Fix trendline deletion while typing, audio leak, prefs drift, cluster payload
Five defects found by exercising the running app rather than reading it: - Backspace inside the sidebar rename field deleted the trendline instead of a character. The window keydown handler never checked what was focused, so correcting a typo in a line's name destroyed the line. - playAlert() built a new AudioContext per alert and never closed it. Browsers cap a document at roughly six, after which alerts stop making any sound. One shared context now, with nodes released on end and a resume() for the autoplay policy. - Stored layer preferences were used verbatim, so any key added to defaultPrefs later would be missing for existing visitors. A missing enabled.ma is a crash rather than a cosmetic gap. They are now deep-merged onto the defaults, and unparseable state falls back instead of throwing. - The alert log keyed rows on a second-resolution timestamp, so two alerts in the same second collided. - Clusters embedded whole Level objects, including a moving average's entire point history — hundreds of entries reaching back years. Because clusters are re-sent on every closed 1m bar, this shipped the whole levels payload once a minute. Members are now compact summaries and the client joins on id; Cluster.to_dict() also stops round-tripping through asdict(), which was deep-copying those arrays before discarding them. /api/confluence drops from 61,838 to 1,245 bytes with five clusters live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
821f0a0a8f
commit
45802c1a2f
5 changed files with 115 additions and 18 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import dataclass
|
||||||
from hashlib import sha1
|
from hashlib import sha1
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -17,10 +17,18 @@ class Cluster:
|
||||||
distance: float
|
distance: float
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
value = asdict(self)
|
# Built field by field rather than via asdict(), which would deep-copy
|
||||||
value["side"] = self.side.value
|
# every member's point history before we replaced it with summaries.
|
||||||
value["members"] = [member.to_dict() for member in self.members]
|
return {
|
||||||
return value
|
"id": self.id,
|
||||||
|
"side": self.side.value,
|
||||||
|
"low": self.low,
|
||||||
|
"high": self.high,
|
||||||
|
"center": self.center,
|
||||||
|
"score": self.score,
|
||||||
|
"members": [member.summary() for member in self.members],
|
||||||
|
"distance": self.distance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def cluster_levels(
|
def cluster_levels(
|
||||||
|
|
|
||||||
|
|
@ -50,3 +50,20 @@ class Level:
|
||||||
value["tf"] = self.tf.value
|
value["tf"] = self.tf.value
|
||||||
value["side"] = self.side.value
|
value["side"] = self.side.value
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, Any]:
|
||||||
|
"""Compact form for embedding inside a cluster.
|
||||||
|
|
||||||
|
Clusters go out on every closed 1m bar, and a moving average carries its
|
||||||
|
whole point history — hundreds of entries reaching back years. Embedding
|
||||||
|
the full level duplicated the entire levels payload once a minute. The
|
||||||
|
client already holds the full levels and joins on id.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"kind": self.kind.value,
|
||||||
|
"tf": self.tf.value,
|
||||||
|
"side": self.side.value,
|
||||||
|
"weight": self.weight,
|
||||||
|
"label": self.label,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,16 +40,44 @@ const defaultPrefs = {
|
||||||
hidden_levels_score: false,
|
hidden_levels_score: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Stored preferences outlive the shape they were written in. Anything added to
|
||||||
|
// defaultPrefs later would otherwise be missing for every existing visitor —
|
||||||
|
// and a missing `enabled.ma` is a crash, not a cosmetic gap.
|
||||||
|
function mergePrefs(defaults, stored) {
|
||||||
|
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return structuredClone(defaults);
|
||||||
|
const merged = structuredClone(defaults);
|
||||||
|
for (const [key, value] of Object.entries(stored)) {
|
||||||
|
if (!(key in merged)) continue;
|
||||||
|
const fallback = merged[key];
|
||||||
|
if (fallback && typeof fallback === 'object' && !Array.isArray(fallback)) {
|
||||||
|
merged[key] = mergePrefs(fallback, value);
|
||||||
|
} else if (Array.isArray(fallback)) {
|
||||||
|
if (Array.isArray(value)) merged[key] = value;
|
||||||
|
} else if (value !== null && typeof value !== 'object') {
|
||||||
|
merged[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPrefs() {
|
||||||
|
try {
|
||||||
|
return mergePrefs(defaultPrefs, JSON.parse(localStorage.getItem('chart-layer-prefs')));
|
||||||
|
} catch {
|
||||||
|
return structuredClone(defaultPrefs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createApp({
|
createApp({
|
||||||
setup() {
|
setup() {
|
||||||
const status = ref({ stream: 'disconnected', bars_held: {} });
|
const status = ref({ stream: 'disconnected', bars_held: {} });
|
||||||
const price = ref(null);
|
const price = ref(null);
|
||||||
const storedPrefs = localStorage.getItem('chart-layer-prefs');
|
const prefs = ref(loadPrefs());
|
||||||
const prefs = ref(storedPrefs ? JSON.parse(storedPrefs) : structuredClone(defaultPrefs));
|
|
||||||
const timeframe = ref(prefs.value.base_tf || '1m');
|
const timeframe = ref(prefs.value.base_tf || '1m');
|
||||||
const levels = ref([]);
|
const levels = ref([]);
|
||||||
const clusters = ref([]);
|
const clusters = ref([]);
|
||||||
const alerts = ref([]);
|
const alerts = ref([]);
|
||||||
|
let alertSequence = 0;
|
||||||
const drawMode = ref(false);
|
const drawMode = ref(false);
|
||||||
const drawName = ref('');
|
const drawName = ref('');
|
||||||
const drawColor = ref('#65b7cf');
|
const drawColor = ref('#65b7cf');
|
||||||
|
|
@ -107,8 +135,12 @@ createApp({
|
||||||
clusters.value = message.clusters;
|
clusters.value = message.clusters;
|
||||||
price.value = message.price;
|
price.value = message.price;
|
||||||
} else if (message.type === 'alert') {
|
} else if (message.type === 'alert') {
|
||||||
alerts.value.unshift({ at: new Date().toLocaleTimeString(), message: message.message });
|
// Keyed on a counter, not the timestamp: two alerts inside the same
|
||||||
alerts.value = alerts.value.slice(0, 20);
|
// second would collide and Vue would reuse the wrong row.
|
||||||
|
alerts.value = [
|
||||||
|
{ key: ++alertSequence, at: new Date().toLocaleTimeString(), message: message.message },
|
||||||
|
...alerts.value,
|
||||||
|
].slice(0, 20);
|
||||||
playAlert();
|
playAlert();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -126,16 +158,26 @@ createApp({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One context for the page, not one per alert. Browsers cap how many a
|
||||||
|
// document may hold (~6), after which alerts silently stop making a sound.
|
||||||
|
let audioContext = null;
|
||||||
|
|
||||||
function playAlert() {
|
function playAlert() {
|
||||||
const context = new (window.AudioContext || window.webkitAudioContext)();
|
const Context = window.AudioContext || window.webkitAudioContext;
|
||||||
const oscillator = context.createOscillator();
|
if (!Context) return;
|
||||||
const gain = context.createGain();
|
if (!audioContext) audioContext = new Context();
|
||||||
|
// Autoplay policy suspends a context created before any user gesture.
|
||||||
|
if (audioContext.state === 'suspended') audioContext.resume();
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gain = audioContext.createGain();
|
||||||
oscillator.frequency.value = 740;
|
oscillator.frequency.value = 740;
|
||||||
gain.gain.setValueAtTime(0.12, context.currentTime);
|
gain.gain.setValueAtTime(0.12, audioContext.currentTime);
|
||||||
gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.35);
|
gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.35);
|
||||||
oscillator.connect(gain).connect(context.destination);
|
oscillator.connect(gain).connect(audioContext.destination);
|
||||||
oscillator.start();
|
oscillator.start();
|
||||||
oscillator.stop(context.currentTime + 0.35);
|
oscillator.stop(audioContext.currentTime + 0.35);
|
||||||
|
// Nodes are single-use; release them rather than letting them pile up.
|
||||||
|
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDraw() {
|
function toggleDraw() {
|
||||||
|
|
@ -279,8 +321,16 @@ createApp({
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backspace is a normal editing key inside a field. Without this guard,
|
||||||
|
// fixing a typo in a trendline's name deletes the trendline.
|
||||||
|
function isEditing(target) {
|
||||||
|
if (!target) return false;
|
||||||
|
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(event) {
|
function handleKeydown(event) {
|
||||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedLine.value) {
|
if (isEditing(event.target)) return;
|
||||||
|
if ((event.key === 'Delete' || event.key === 'Backspace') && hasLineSelection.value) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
deleteSelected();
|
deleteSelected();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
</details>
|
</details>
|
||||||
<h2>Alert log</h2>
|
<h2>Alert log</h2>
|
||||||
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
|
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
|
||||||
<div v-for="alert in alerts" :key="alert.at" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
|
<div v-for="alert in alerts" :key="alert.key" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,3 +37,25 @@ def test_level_ended_before_current_time_is_excluded():
|
||||||
ended = level("ended", 98, 12, Timeframe.D1)
|
ended = level("ended", 98, 12, Timeframe.D1)
|
||||||
ended.cutoff_t = 150
|
ended.cutoff_t = 150
|
||||||
assert cluster_levels([ended], 200, 100, 1) == []
|
assert cluster_levels([ended], 200, 100, 1) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cluster_members_omit_point_history():
|
||||||
|
# Clusters are re-sent on every closed 1m bar. A moving average's point
|
||||||
|
# history is hundreds of entries, so embedding whole levels here shipped the
|
||||||
|
# entire levels payload once a minute.
|
||||||
|
heavy = level("ma", 98, 12, Timeframe.D1)
|
||||||
|
heavy.points = [(t, 1.0) for t in range(600)]
|
||||||
|
|
||||||
|
payload = cluster_levels([heavy], 200, 100, 1)[0].to_dict()
|
||||||
|
|
||||||
|
assert payload["members"] == [
|
||||||
|
{
|
||||||
|
"id": "ma",
|
||||||
|
"kind": "ma",
|
||||||
|
"tf": "1d",
|
||||||
|
"side": "resistance",
|
||||||
|
"weight": 12,
|
||||||
|
"label": "ma",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert "points" not in payload["members"][0]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue