diff --git a/docs/NEXT_STEPS.md b/docs/NEXT_STEPS.md index 9a03c10..b181b1c 100644 --- a/docs/NEXT_STEPS.md +++ b/docs/NEXT_STEPS.md @@ -1,46 +1,19 @@ # Current recommendations -Last reviewed: 2026-08-11 03:39 CDT. +Last reviewed: 2026-08-11 04:13 CDT. This file is the short list of work worth considering next. Verified history, measurements and completed work remain in `docs/IMPLEMENTATION_PLAN.md`. ## Fix next -### Show unambiguous market freshness +### Deepen freshness telemetry -The best location is the status strip directly below the chart. Keep the header -for coarse transport state (`LIVE · SCHWAB`, `DELAYED · YAHOO 10m`) and replace -the ambiguous `LAST BAR` age with browser-observed receipt time: - -```text -UPDATED 3s AGO -``` - -On mobile use a compact form: - -```text -Updated 3s ago · Live -``` - -No additional request is needed. Record `Date.now()` when an existing WebSocket -`bar` message arrives. A snapshot should initially say `LOADED JUST NOW`; level, -alert and preference messages are not market updates and must not refresh it. - -Do not derive update age from `bar.t`: it is the bucket start, and selected -timeframe bar messages currently overwrite `status.last_bar_t`, making an hourly -bar appear stale even when just received. - -A later, more complete implementation can add these fields to the existing -WebSocket rather than add polling: - -- exact `last_trade_t` where the source supplies it; -- `last_settled_m1_t`, measured from the end of the minute; -- `last_source_received_at`; -- an application heartbeat for transport health. - -Stale warnings must distinguish market closure from transport failure and must -subtract Yahoo's declared delay before calling delayed data overdue. +The status bar now reports when the browser received its latest snapshot or bar. +A later server-side implementation can distinguish market closure, source delay +and transport failure by carrying exact trade time, the last settled minute, +source receipt time and an application heartbeat over the existing WebSocket. +Any stale threshold must account for Yahoo's declared delay. ## Mobile authoring diff --git a/static/app.js b/static/app.js index 8f28c47..96fc487 100644 --- a/static/app.js +++ b/static/app.js @@ -72,6 +72,8 @@ createApp({ setup() { const status = ref({ stream: 'disconnected', bars_held: {} }); const deployment = ref(null); + const dataReceivedAt = ref(null); + const dataReceiptVerb = ref('LOADED'); const price = ref(null); const prefs = ref(loadPrefs()); const timeframe = ref(prefs.value.base_tf || '1m'); @@ -111,17 +113,29 @@ createApp({ const seconds = Math.max(0, Math.floor(now.value / 1000 - status.value.last_bar_t)); return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`; }); - const updatedAt = computed(() => { + const deployedAt = computed(() => { if (!deployment.value?.started_at) return '—'; return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }).format(new Date(deployment.value.started_at)); }); + const deployMeta = computed(() => { + if (!deployment.value) return '—'; + return `${deployment.value.commit.slice(0, 8)} · ${deployedAt.value}`; + }); const deploymentTitle = computed(() => { if (!deployment.value?.started_at) return ''; const timestamp = new Date(deployment.value.started_at).toLocaleString(); return `Running since ${timestamp} · ${deployment.value.commit}`; }); + const dataAge = computed(() => { + if (dataReceivedAt.value == null) return 'WAITING'; + const seconds = Math.max(0, Math.floor((now.value - dataReceivedAt.value) / 1000)); + if (seconds < 5) return 'JUST NOW'; + if (seconds < 60) return `${seconds}s AGO`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m AGO`; + return `${Math.floor(seconds / 3600)}h AGO`; + }); const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual')); const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null); const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); @@ -179,6 +193,8 @@ createApp({ socket.onmessage = ({ data }) => { const message = JSON.parse(data); if (message.type === 'snapshot') { + dataReceivedAt.value = Date.now(); + dataReceiptVerb.value = 'LOADED'; chartApi.setBars(message.bars); levels.value = message.levels || []; syncVisibleLevels(); @@ -190,6 +206,8 @@ createApp({ // series is older than everything in it, which the chart rejects // outright. The event carries its timeframe, so honour it. if (message.tf && message.tf !== timeframe.value) return; + dataReceivedAt.value = Date.now(); + dataReceiptVerb.value = 'UPDATED'; chartApi.updateBar(message.bar); price.value = message.bar.c; status.value.last_bar_t = message.bar.t; @@ -569,6 +587,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, updatedAt, deploymentTitle, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned }; + return { status, price, barAge, dataAge, dataReceiptVerb, deployMeta, deploymentTitle, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index f9d1e22..7f18db1 100644 --- a/static/chart.js +++ b/static/chart.js @@ -96,11 +96,16 @@ class ConfluenceChart { // the label is localised, via the browser's own zone. tickMarkFormatter: (time, tickMarkType) => { const at = new Date(time * 1000); - if (tickMarkType === LightweightCharts.TickMarkType.Year) return String(at.getFullYear()); - if (tickMarkType === LightweightCharts.TickMarkType.Month) { - return at.toLocaleString(undefined, { month: 'short' }); + const daily = this.bars[0]?.tf === '1d'; + if (tickMarkType === LightweightCharts.TickMarkType.Year) { + return String(daily ? at.getUTCFullYear() : at.getFullYear()); + } + if (tickMarkType === LightweightCharts.TickMarkType.Month) { + return at.toLocaleString(undefined, { month: 'short', ...(daily ? { timeZone: 'UTC' } : {}) }); + } + if (tickMarkType === LightweightCharts.TickMarkType.DayOfMonth) { + return String(daily ? at.getUTCDate() : at.getDate()); } - if (tickMarkType === LightweightCharts.TickMarkType.DayOfMonth) return String(at.getDate()); return at.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit', ...(tickMarkType === LightweightCharts.TickMarkType.TimeWithSeconds @@ -110,9 +115,12 @@ class ConfluenceChart { }, localization: { // The crosshair readout, which would otherwise disagree with the axis. - timeFormatter: time => new Date(time * 1000).toLocaleString(undefined, { - hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', - }), + timeFormatter: time => { + const daily = this.bars[0]?.tf === '1d'; + return new Date(time * 1000).toLocaleString(undefined, daily + ? { timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric' } + : { hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + }, }, rightPriceScale: { borderVisible: false }, // Every price-bearing series shares this scale. A second autoscaled price diff --git a/static/index.html b/static/index.html index 46d22d5..2c0538b 100644 --- a/static/index.html +++ b/static/index.html @@ -32,7 +32,8 @@ Drag on the chart from one point to the other Click or drag on the chart to set the price Line selected — Delete removes it - UPDATED {{ updatedAt }} + {{ dataReceiptVerb }} {{ dataAge }} + DEPLOY {{ deployMeta }}