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:
Chris Amow 2026-08-10 00:17:40 -05:00
parent 821f0a0a8f
commit 45802c1a2f
5 changed files with 115 additions and 18 deletions

View file

@ -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(

View file

@ -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,
}

View file

@ -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();
}

View file

@ -82,7 +82,7 @@
</details>
<h2>Alert log</h2>
<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>
</main>
</div>

View file

@ -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]