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