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 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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue