Fix freshness and daily date labels
This commit is contained in:
parent
91677127a0
commit
4488c7d4b9
6 changed files with 77 additions and 52 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-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 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>
|
||||
</section>
|
||||
<aside>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
#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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
await withChart(async page => {
|
||||
await page.waitForFunction(() => {
|
||||
const node = document.querySelector('.app-updated b');
|
||||
return node && node.textContent.trim() !== '—';
|
||||
const freshness = document.querySelector('.data-freshness b');
|
||||
const deployment = document.querySelector('.app-meta b');
|
||||
return freshness?.textContent.trim() !== 'WAITING'
|
||||
&& deployment?.textContent.trim() !== '—';
|
||||
});
|
||||
const state = await page.evaluate(() => {
|
||||
const node = document.querySelector('.app-updated');
|
||||
const freshness = document.querySelector('.data-freshness');
|
||||
const deployment = document.querySelector('.app-meta');
|
||||
return {
|
||||
text: node.textContent.trim(),
|
||||
title: node.title,
|
||||
freshness: freshness.textContent.trim(),
|
||||
deployment: deployment.textContent.trim(),
|
||||
title: deployment.title,
|
||||
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.ok(state.barHeight <= 40, `status bar grew to ${state.barHeight}px`);
|
||||
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 () => {
|
||||
await withChart(async page => {
|
||||
for (const tf of TIMEFRAMES) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue