Fix freshness and daily date labels

This commit is contained in:
Chris Amow 2026-08-11 04:23:19 -05:00
parent 91677127a0
commit 4488c7d4b9
6 changed files with 77 additions and 52 deletions

View file

@ -1,46 +1,19 @@
# Current recommendations # 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, This file is the short list of work worth considering next. Verified history,
measurements and completed work remain in `docs/IMPLEMENTATION_PLAN.md`. measurements and completed work remain in `docs/IMPLEMENTATION_PLAN.md`.
## Fix next ## Fix next
### Show unambiguous market freshness ### Deepen freshness telemetry
The best location is the status strip directly below the chart. Keep the header The status bar now reports when the browser received its latest snapshot or bar.
for coarse transport state (`LIVE · SCHWAB`, `DELAYED · YAHOO 10m`) and replace A later server-side implementation can distinguish market closure, source delay
the ambiguous `LAST BAR` age with browser-observed receipt time: and transport failure by carrying exact trade time, the last settled minute,
source receipt time and an application heartbeat over the existing WebSocket.
```text Any stale threshold must account for Yahoo's declared delay.
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.
## Mobile authoring ## Mobile authoring

View file

@ -72,6 +72,8 @@ createApp({
setup() { setup() {
const status = ref({ stream: 'disconnected', bars_held: {} }); const status = ref({ stream: 'disconnected', bars_held: {} });
const deployment = ref(null); const deployment = ref(null);
const dataReceivedAt = ref(null);
const dataReceiptVerb = ref('LOADED');
const price = ref(null); const price = ref(null);
const prefs = ref(loadPrefs()); const prefs = ref(loadPrefs());
const timeframe = ref(prefs.value.base_tf || '1m'); 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)); 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`; return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`;
}); });
const updatedAt = computed(() => { const deployedAt = computed(() => {
if (!deployment.value?.started_at) return '—'; if (!deployment.value?.started_at) return '—';
return new Intl.DateTimeFormat(undefined, { return new Intl.DateTimeFormat(undefined, {
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
}).format(new Date(deployment.value.started_at)); }).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(() => { const deploymentTitle = computed(() => {
if (!deployment.value?.started_at) return ''; if (!deployment.value?.started_at) return '';
const timestamp = new Date(deployment.value.started_at).toLocaleString(); const timestamp = new Date(deployment.value.started_at).toLocaleString();
return `Running since ${timestamp} · ${deployment.value.commit}`; 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 manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null); const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length);
@ -179,6 +193,8 @@ createApp({
socket.onmessage = ({ data }) => { socket.onmessage = ({ data }) => {
const message = JSON.parse(data); const message = JSON.parse(data);
if (message.type === 'snapshot') { if (message.type === 'snapshot') {
dataReceivedAt.value = Date.now();
dataReceiptVerb.value = 'LOADED';
chartApi.setBars(message.bars); chartApi.setBars(message.bars);
levels.value = message.levels || []; levels.value = message.levels || [];
syncVisibleLevels(); syncVisibleLevels();
@ -190,6 +206,8 @@ createApp({
// series is older than everything in it, which the chart rejects // series is older than everything in it, which the chart rejects
// outright. The event carries its timeframe, so honour it. // outright. The event carries its timeframe, so honour it.
if (message.tf && message.tf !== timeframe.value) return; if (message.tf && message.tf !== timeframe.value) return;
dataReceivedAt.value = Date.now();
dataReceiptVerb.value = 'UPDATED';
chartApi.updateBar(message.bar); chartApi.updateBar(message.bar);
price.value = message.bar.c; price.value = message.bar.c;
status.value.last_bar_t = message.bar.t; status.value.last_bar_t = message.bar.t;
@ -569,6 +587,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); 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'); }).mount('#app');

View file

@ -96,11 +96,16 @@ class ConfluenceChart {
// the label is localised, via the browser's own zone. // the label is localised, via the browser's own zone.
tickMarkFormatter: (time, tickMarkType) => { tickMarkFormatter: (time, tickMarkType) => {
const at = new Date(time * 1000); const at = new Date(time * 1000);
if (tickMarkType === LightweightCharts.TickMarkType.Year) return String(at.getFullYear()); const daily = this.bars[0]?.tf === '1d';
if (tickMarkType === LightweightCharts.TickMarkType.Month) { if (tickMarkType === LightweightCharts.TickMarkType.Year) {
return at.toLocaleString(undefined, { month: 'short' }); 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, { return at.toLocaleTimeString(undefined, {
hour12: false, hour: '2-digit', minute: '2-digit', hour12: false, hour: '2-digit', minute: '2-digit',
...(tickMarkType === LightweightCharts.TickMarkType.TimeWithSeconds ...(tickMarkType === LightweightCharts.TickMarkType.TimeWithSeconds
@ -110,9 +115,12 @@ class ConfluenceChart {
}, },
localization: { localization: {
// The crosshair readout, which would otherwise disagree with the axis. // The crosshair readout, which would otherwise disagree with the axis.
timeFormatter: time => new Date(time * 1000).toLocaleString(undefined, { timeFormatter: time => {
hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', 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 }, rightPriceScale: { borderVisible: false },
// Every price-bearing series shares this scale. A second autoscaled price // Every price-bearing series shares this scale. A second autoscaled price

View file

@ -32,7 +32,8 @@
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span> <span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span> <span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
<span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span> <span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span>
<span class="app-updated" :title="deploymentTitle">UPDATED <b>{{ updatedAt }}</b></span> <span class="data-freshness">{{ dataReceiptVerb }} <b>{{ dataAge }}</b></span>
<span class="app-meta" :title="deploymentTitle">DEPLOY <b>{{ deployMeta }}</b></span>
</div> </div>
</section> </section>
<aside> <aside>

View file

@ -15,7 +15,7 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview { z-index:4; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden] { display:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview { z-index:4; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden] { display:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; } .chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.app-updated { margin-left:auto; white-space:nowrap; } .statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; }.data-freshness,.app-meta { white-space:nowrap; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; } .sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span:not(.drawing-icon) { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row>.drawing-icon { grid-column:1; align-self:center; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; } .trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span:not(.drawing-icon) { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row>.drawing-icon { grid-column:1; align-self:center; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }

View file

@ -25,28 +25,53 @@ test('the viewport opens on the live edge, not in the past', { timeout: 180000 }
}); });
}); });
test('the running deployment time is visible without making the status bar taller', test('data freshness and deployment metadata share the existing status row',
{ timeout: 180000 }, async () => { { timeout: 180000 }, async () => {
await withChart(async page => { await withChart(async page => {
await page.waitForFunction(() => { await page.waitForFunction(() => {
const node = document.querySelector('.app-updated b'); const freshness = document.querySelector('.data-freshness b');
return node && node.textContent.trim() !== '—'; const deployment = document.querySelector('.app-meta b');
return freshness?.textContent.trim() !== 'WAITING'
&& deployment?.textContent.trim() !== '—';
}); });
const state = await page.evaluate(() => { const state = await page.evaluate(() => {
const node = document.querySelector('.app-updated'); const freshness = document.querySelector('.data-freshness');
const deployment = document.querySelector('.app-meta');
return { return {
text: node.textContent.trim(), freshness: freshness.textContent.trim(),
title: node.title, deployment: deployment.textContent.trim(),
title: deployment.title,
barHeight: document.querySelector('.statusbar').getBoundingClientRect().height, barHeight: document.querySelector('.statusbar').getBoundingClientRect().height,
}; };
}); });
assert.match(state.text, /^UPDATED\s+\S/); assert.match(state.freshness, /^(LOADED|UPDATED)\s+(JUST NOW|\d+[smh] AGO)$/);
assert.match(state.deployment, /^DEPLOY\s+(dev|[0-9a-f]{8}) · \S/);
assert.match(state.title, /^Running since .+ · (dev|[0-9a-f]{7,40})$/); assert.match(state.title, /^Running since .+ · (dev|[0-9a-f]{7,40})$/);
assert.ok(state.barHeight <= 40, `status bar grew to ${state.barHeight}px`); assert.ok(state.barHeight <= 40, `status bar grew to ${state.barHeight}px`);
assertNoPageErrors(page, assert); assertNoPageErrors(page, assert);
}); });
}); });
test('a daily crosshair shows the session date, not the previous evening',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await selectTimeframe(page, '1d');
const state = await page.evaluate(() => {
const c = window.__chart;
const time = c.bars[c.bars.length - 1].t;
return {
actual: c.chart.options().localization.timeFormatter(time),
expected: new Date(time * 1000).toLocaleString(undefined, {
timeZone: 'UTC', year: 'numeric', month: 'short', day: 'numeric',
}),
};
});
assert.equal(state.actual, state.expected);
assert.doesNotMatch(state.actual, /\d:\d/);
assertNoPageErrors(page, assert);
});
});
test('every timeframe gives one slot per candle', { timeout: 300000 }, async () => { test('every timeframe gives one slot per candle', { timeout: 300000 }, async () => {
await withChart(async page => { await withChart(async page => {
for (const tf of TIMEFRAMES) { for (const tf of TIMEFRAMES) {