From 45802c1a2feefe3eda0dcb4158bb7dc0c46c6110 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Mon, 10 Aug 2026 00:17:40 -0500 Subject: [PATCH] Fix trendline deletion while typing, audio leak, prefs drift, cluster payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/analysis/confluence.py | 18 +++++++--- app/analysis/levels.py | 17 +++++++++ static/app.js | 74 +++++++++++++++++++++++++++++++------- static/index.html | 2 +- tests/test_confluence.py | 22 ++++++++++++ 5 files changed, 115 insertions(+), 18 deletions(-) diff --git a/app/analysis/confluence.py b/app/analysis/confluence.py index 78ee959..3528dae 100644 --- a/app/analysis/confluence.py +++ b/app/analysis/confluence.py @@ -1,4 +1,4 @@ -from dataclasses import asdict, dataclass +from dataclasses import dataclass from hashlib import sha1 from typing import Any @@ -17,10 +17,18 @@ class Cluster: distance: float def to_dict(self) -> dict[str, Any]: - value = asdict(self) - value["side"] = self.side.value - value["members"] = [member.to_dict() for member in self.members] - return value + # Built field by field rather than via asdict(), which would deep-copy + # every member's point history before we replaced it with summaries. + return { + "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( diff --git a/app/analysis/levels.py b/app/analysis/levels.py index 7358796..3475312 100644 --- a/app/analysis/levels.py +++ b/app/analysis/levels.py @@ -50,3 +50,20 @@ class Level: value["tf"] = self.tf.value value["side"] = self.side.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, + } diff --git a/static/app.js b/static/app.js index 08c3f19..770328b 100644 --- a/static/app.js +++ b/static/app.js @@ -40,16 +40,44 @@ const defaultPrefs = { 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({ setup() { const status = ref({ stream: 'disconnected', bars_held: {} }); const price = ref(null); - const storedPrefs = localStorage.getItem('chart-layer-prefs'); - const prefs = ref(storedPrefs ? JSON.parse(storedPrefs) : structuredClone(defaultPrefs)); + const prefs = ref(loadPrefs()); const timeframe = ref(prefs.value.base_tf || '1m'); const levels = ref([]); const clusters = ref([]); const alerts = ref([]); + let alertSequence = 0; const drawMode = ref(false); const drawName = ref(''); const drawColor = ref('#65b7cf'); @@ -107,8 +135,12 @@ createApp({ clusters.value = message.clusters; price.value = message.price; } else if (message.type === 'alert') { - alerts.value.unshift({ at: new Date().toLocaleTimeString(), message: message.message }); - alerts.value = alerts.value.slice(0, 20); + // Keyed on a counter, not the timestamp: two alerts inside the same + // 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(); } }; @@ -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() { - const context = new (window.AudioContext || window.webkitAudioContext)(); - const oscillator = context.createOscillator(); - const gain = context.createGain(); + const Context = window.AudioContext || window.webkitAudioContext; + if (!Context) return; + 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; - gain.gain.setValueAtTime(0.12, context.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.35); - oscillator.connect(gain).connect(context.destination); + gain.gain.setValueAtTime(0.12, audioContext.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.35); + oscillator.connect(gain).connect(audioContext.destination); 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() { @@ -279,8 +321,16 @@ createApp({ 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) { - 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(); deleteSelected(); } diff --git a/static/index.html b/static/index.html index b548930..bacd0dc 100644 --- a/static/index.html +++ b/static/index.html @@ -82,7 +82,7 @@

Alert log

No alerts fired.
-
{{ alert.message }}
+
{{ alert.message }}
diff --git a/tests/test_confluence.py b/tests/test_confluence.py index d68a69c..e88d241 100644 --- a/tests/test_confluence.py +++ b/tests/test_confluence.py @@ -37,3 +37,25 @@ def test_level_ended_before_current_time_is_excluded(): ended = level("ended", 98, 12, Timeframe.D1) ended.cutoff_t = 150 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]