diff --git a/changelog.d/1405-traffic-layer-stale.md b/changelog.d/1405-traffic-layer-stale.md new file mode 100644 index 000000000..dc564ac76 --- /dev/null +++ b/changelog.d/1405-traffic-layer-stale.md @@ -0,0 +1,3 @@ +- The Traffic layer reads STALE instead of LIVE when the TomTom proxy serves + cached flow tiles (budget spent or TomTom failing), and the row dates them by + the oldest cached tile (quanticsoul4772, #1405). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3e7f333a1..6546ea023 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5785,7 +5785,11 @@ easier to meet (detection is now on more often), but does not create it. coverage number. `stats.loading` covers outstanding flow work as well as the road fetch, so a flow failure landing after the first paint still ends the shared loading batch as LOAD FAILED. Harnesses must gate on `!stats.error`, - never on `mode === 'live'` alone. + never on `mode === 'live'` alone. When the drawn flow came from the proxy's + cached STALE-BUDGET / STALE-ERROR tiles, `getStats()` reports `stale: true` + and `oldestTileAt`, the row reads STALE with the oldest tile's age, and the + sync chip reads + `STALE · Roads: · Flow: cached TomTom tiles` instead of LIVE. - Traffic runs in `sim` mode (white dots, hardcoded speeds) unless `TOMTOM_API_KEY` is configured (env or Keychain `tomtom-api`/`api-key`), which enables `live` mode: TomTom flow vector tiles via the budget-governed `/api/tomtom` proxy diff --git a/src/data/traffic.js b/src/data/traffic.js index dbab8cb8f..ad77d92a9 100644 --- a/src/data/traffic.js +++ b/src/data/traffic.js @@ -9,9 +9,18 @@ const sourceSlot = createSourceSlot( { getFlowSessionStats: () => ({ tilesFetched: 0 }), resetFlowTileCache: () => {}, + // The layer reads stale cached tiles through this; a source without it + // answers with fresh segments only. + fetchFlowDetail: async (...args) => ({ + segments: await sourceSlot.source.fetchFlowForBounds(...args), + stale: false, + oldestTileAt: null, + }), }, ); export const configureTrafficSource = sourceSlot.configure; +/** The source the traffic layer reads, as configured. */ +export const trafficSource = sourceSlot.source; const layer = createApplicationTraffic({ source: sourceSlot.source, }); diff --git a/src/data/traffic.test.mjs b/src/data/traffic.test.mjs index 04739e200..23924448a 100644 --- a/src/data/traffic.test.mjs +++ b/src/data/traffic.test.mjs @@ -22,7 +22,10 @@ import { DataLayerManager, layerFeedState } from './manager.js'; const LIVE_CLAIM = /\bLIVE\b|\bGPS\b|\breal[- ]?time\b/; test('a superseded flow fetch is not an outage', () => { - assert.equal(deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), null); + assert.equal( + deriveTrafficFlowError({ name: 'AbortError', message: 'aborted' }), + null, + ); assert.equal(deriveTrafficFlowError(null), null); assert.equal(deriveTrafficFlowError(undefined), null); }); @@ -30,10 +33,22 @@ test('a superseded flow fetch is not an outage', () => { test('flow failures map onto short, specific reasons', () => { const reason = (message) => deriveTrafficFlowError(new Error(message)); assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable'); - assert.equal(reason('flow tile 12/1/1: HTTP 429'), 'TomTom daily budget reached'); - assert.equal(reason('flow tile 12/1/1: HTTP 502'), 'TomTom upstream unreachable'); - assert.equal(reason('flow tile 12/1/1: HTTP 504'), 'TomTom upstream unreachable'); - assert.equal(reason('flow tile 12/1/1: HTTP 418'), 'TomTom flow error (HTTP 418)'); + assert.equal( + reason('flow tile 12/1/1: HTTP 429'), + 'TomTom daily budget reached', + ); + assert.equal( + reason('flow tile 12/1/1: HTTP 502'), + 'TomTom upstream unreachable', + ); + assert.equal( + reason('flow tile 12/1/1: HTTP 504'), + 'TomTom upstream unreachable', + ); + assert.equal( + reason('flow tile 12/1/1: HTTP 418'), + 'TomTom flow error (HTTP 418)', + ); assert.equal(reason('flow fetch failed'), 'TomTom flow unavailable'); }); @@ -56,19 +71,32 @@ test('no keyless label ever implies a live feed', () => { trafficFeedPresentation({ liveMode: false, fetching: false }), trafficFeedPresentation({ liveMode: false, fetching: true }), trafficFeedPresentation({ statusUnavailable: true }), - trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }), - trafficFeedPresentation({ liveMode: true, fetching: true, flowError: 'TomTom flow unavailable' }), + trafficFeedPresentation({ + liveMode: true, + flowError: 'TomTom flow unavailable', + }), + trafficFeedPresentation({ + liveMode: true, + fetching: true, + flowError: 'TomTom flow unavailable', + }), ].map((feed) => feed.loadingLabel); for (const label of labels) { assert.ok(!LIVE_CLAIM.test(label), `label implies live data: ${label}`); - assert.ok(label.startsWith('SIMULATED'), `fallback label must lead with the mode: ${label}`); + assert.ok( + label.startsWith('SIMULATED'), + `fallback label must lead with the mode: ${label}`, + ); } }); test('simulating because the status probe failed reads differently from keyless by design', () => { const probeDown = trafficFeedPresentation({ statusUnavailable: true }); assert.equal(probeDown.mode, 'sim'); - assert.equal(probeDown.loadingLabel, 'SIMULATED — traffic service unreachable'); + assert.equal( + probeDown.loadingLabel, + 'SIMULATED — traffic service unreachable', + ); }); test('a healthy keyed layer reports live flow with its real coverage', () => { @@ -76,7 +104,8 @@ test('a healthy keyed layer reports live flow with its real coverage', () => { assert.deepEqual(idle, { mode: 'live', error: null, - loadingLabel: 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated', + loadingLabel: + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 87% cov · Unmatched: simulated', }); assert.equal( trafficFeedPresentation({ liveMode: true, fetching: true }).loadingLabel, @@ -100,7 +129,11 @@ test('a mid-session flow outage degrades instead of reporting stale live coverag fetching: true, flowError: 'TomTom daily budget reached', }); - assert.deepEqual(busy, down, 'the degraded state reads the same whether or not a load is in flight'); + assert.deepEqual( + busy, + down, + 'the degraded state reads the same whether or not a load is in flight', + ); }); test('the rendered steady-state meta line carries the SIMULATED copy', () => { @@ -116,10 +149,12 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => { assert.equal( mgr._buildMetaText({ source: 'OpenStreetMap', - stats: stats(trafficFeedPresentation({ - liveMode: true, - flowError: 'TomTom daily budget reached', - })), + stats: stats( + trafficFeedPresentation({ + liveMode: true, + flowError: 'TomTom daily budget reached', + }), + ), }), 'DEGRADED · OpenStreetMap · SIMULATED — TomTom daily budget reached', ); @@ -128,17 +163,26 @@ test('the rendered steady-state meta line carries the SIMULATED copy', () => { test('the manager reads keyless as FALLBACK and an outage as DEGRADED', () => { const settled = { count: 4200, lastUpdate: Date.now() }; assert.equal( - layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: false }) }), + layerFeedState({ + ...settled, + ...trafficFeedPresentation({ liveMode: false }), + }), 'fallback', ); assert.equal( - layerFeedState({ ...settled, ...trafficFeedPresentation({ liveMode: true }) }), + layerFeedState({ + ...settled, + ...trafficFeedPresentation({ liveMode: true }), + }), 'nominal', ); assert.equal( layerFeedState({ ...settled, - ...trafficFeedPresentation({ liveMode: true, flowError: 'TomTom flow unavailable' }), + ...trafficFeedPresentation({ + liveMode: true, + flowError: 'TomTom flow unavailable', + }), }), 'degraded', ); @@ -148,7 +192,10 @@ test('the shipped layer boots keyless-honest before any status check', () => { const stats = trafficLayer.getStats(); assert.equal(stats.mode, 'sim'); assert.equal(stats.error, null); - assert.ok(!LIVE_CLAIM.test(stats.loadingLabel), `boot label implies live data: ${stats.loadingLabel}`); + assert.ok( + !LIVE_CLAIM.test(stats.loadingLabel), + `boot label implies live data: ${stats.loadingLabel}`, + ); assert.equal(layerFeedState(stats), 'fallback'); }); @@ -167,3 +214,42 @@ test('zero matched dots never claims live coverage', () => { assert.match(feed.loadingLabel, /TomTom \(no matches\)/); assert.ok(!LIVE_CLAIM.test(feed.loadingLabel)); }); + +test('the traffic layer source passes fetchFlowDetail through, so stale tiles reach the layer', async () => { + const { trafficSource, configureTrafficSource } = + await import('./traffic.js'); + assert.equal(typeof trafficSource.fetchFlowDetail, 'function'); + const base = { + requestRoads: async () => ({}), + getStatus: async () => ({}), + fetchFlowForBounds: async () => ['segment'], + }; + let release = configureTrafficSource({ + ...base, + fetchFlowDetail: async () => ({ + segments: ['segment'], + stale: true, + oldestTileAt: 1000, + }), + }); + try { + assert.deepEqual(await trafficSource.fetchFlowDetail({}), { + segments: ['segment'], + stale: true, + oldestTileAt: 1000, + }); + } finally { + release(); + } + // A source without fetchFlowDetail answers with fresh segments. + release = configureTrafficSource(base); + try { + assert.deepEqual(await trafficSource.fetchFlowDetail({}), { + segments: ['segment'], + stale: false, + oldestTileAt: null, + }); + } finally { + release(); + } +}); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e1e404001..696233f86 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -864,6 +864,7 @@ "LIVE · Roads: {source} · No flow roads in view": "EN VIVO · Vías: {source} · Sin vías con flujo a la vista", "LIVE · Roads: {source} · Flow {percent}%": "EN VIVO · Vías: {source} · Flujo {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULADO · Vías: {source} · Flujo {percent}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "DESACTUALIZADO · Vías: {source} · Flujo: teselas de TomTom en caché", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Sincronizando flujo · Vías: OpenStreetMap · Flujo: TomTom · Sin coincidencia: simulado", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob. · Sin coincidencia: simulado", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "EN VIVO · Vías: OpenStreetMap · Flujo: TomTom · {percent}% cob.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 739f80dac..8764d9d06 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -254,6 +254,7 @@ "LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком", "LIVE · Roads: {source} · Flow {percent}%": "ОНЛАЙН · Дороги: {source} · Поток {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "СИМУЛЯЦИЯ · Дороги: {source} · Поток {percent}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "УСТАРЕЛ · Дороги: {source} · Поток: тайлы TomTom из кэша", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Синхронизация потока · Дороги: OpenStreetMap · Поток: TomTom · Без совпадений: симуляция", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}% · Без совпадений: симуляция", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "ОНЛАЙН · Дороги: OpenStreetMap · Поток: TomTom · охват {percent}%", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c415e3776..2e7db6b8f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1059,6 +1059,7 @@ "LIVE · Roads: {source} · No flow roads in view": "实时 · 道路:{source} · 视野内没有带路况数据的道路", "LIVE · Roads: {source} · Flow {percent}%": "实时 · 道路:{source} · 路况 {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "模拟 · 道路:{source} · 路况 {percent}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "过期 · 道路:{source} · 路况:TomTom 缓存瓦片", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路况 · 道路:OpenStreetMap · 路况:TomTom · 未匹配:模拟", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}% · 未匹配:模拟", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "实时 · 道路:OpenStreetMap · 路况:TomTom · 覆盖 {percent}%", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 3f649178e..1477acd51 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1054,6 +1054,7 @@ "LIVE · Roads: {source} · No flow roads in view": "實時 · 道路:{source} · 視野內沒有帶路況數據的道路", "LIVE · Roads: {source} · Flow {percent}%": "實時 · 道路:{source} · 路況 {percent}%", "SIMULATED · Roads: {source} · Flow {percent}%": "模擬 · 道路:{source} · 路況 {percent}%", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "過期 · 道路:{source} · 路況:TomTom 緩存瓦片", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "正在同步路況 · 道路:OpenStreetMap · 路況:TomTom · 未匹配:模擬", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}% · 未匹配:模擬", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "實時 · 道路:OpenStreetMap · 路況:TomTom · 覆蓋 {percent}%", diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js index d33e5a076..01af66633 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -310,6 +310,7 @@ export function createControls({ state: layerState, services, parts, source }) { liveMode: layerState._liveMode, fetching: loading, flowError: layerState._flowError, + stale: layerState._flowStale, coveragePct: flowCoveragePct, statusUnavailable: layerState._flowStatusUnavailable, roadSource: layerState._roadSource, @@ -321,6 +322,20 @@ export function createControls({ state: layerState, services, parts, source }) { lastUpdate: layerState._lastUpdate, loading, mode: feed.mode, + // Cached proxy tiles (TomTom budget spent or upstream failing): the + // manager reads this as STALE. Any error (flow, roads, detail) reads + // DEGRADED instead. + stale: Boolean( + layerState._liveMode && + layerState._flowStale && + !( + layerState._roadError || + layerState._roadWarning || + layerState._detailError || + feed.error + ), + ), + oldestTileAt: layerState._flowOldestTileAt ?? null, error: layerState._roadError || layerState._roadWarning || diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 7135fd8c6..9b37178ba 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -186,10 +186,19 @@ export function createFlow({ state: layerState, services, parts, source }) { return layerState._warmFlow; layerState._warmFlowGeneration = generation; const signal = layerState._activeFetchAbort?.signal; - layerState._warmFlow = ensureFlowStatus(signal).then(() => { + layerState._warmFlow = ensureFlowStatus(signal).then(async () => { if (!layerState._liveMode || generation !== layerState._loadGeneration) return []; - return fetchFlowForBounds(clamped, { signal }); + if (typeof source.fetchFlowDetail !== 'function') + return fetchFlowForBounds(clamped, { signal }); + // The proxy serves cached tiles once the TomTom budget is spent or the + // upstream fails; carry that mark so getStats() reads STALE, not LIVE. + const detail = await source.fetchFlowDetail(clamped, { signal }); + if (generation === layerState._loadGeneration) { + layerState._flowStale = detail.stale === true; + layerState._flowOldestTileAt = detail.oldestTileAt ?? null; + } + return detail.segments; }); layerState._warmFlow.catch(() => {}); return layerState._warmFlow; diff --git a/src/layers/traffic/lifecycle.js b/src/layers/traffic/lifecycle.js index fc13e205d..1c53e3ebe 100644 --- a/src/layers/traffic/lifecycle.js +++ b/src/layers/traffic/lifecycle.js @@ -42,6 +42,8 @@ export function createLifecycle({ layerState._lastViewCenter = null; layerState._flowRoads = null; layerState._flowError = null; + layerState._flowStale = false; + layerState._flowOldestTileAt = null; if (TRAFFIC_TIMING_ENABLED) { layerState._trafficTimingCurrentAnchor = null; layerState._trafficTimingSequence = 0; @@ -159,6 +161,8 @@ export function createLifecycle({ // A stale outage from the last session would misreport a fresh enable — // the next load re-derives feed health from real evidence. layerState._flowError = null; + layerState._flowStale = false; + layerState._flowOldestTileAt = null; layerState._surfaceFrameRemover?.(); layerState._surfaceFrameRemover = null; diff --git a/src/layers/traffic/model.js b/src/layers/traffic/model.js index 291c06c90..ff1c89ddb 100644 --- a/src/layers/traffic/model.js +++ b/src/layers/traffic/model.js @@ -229,6 +229,7 @@ export function createModel({ state: layerState, services, parts, source }) { * @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key. * @param {boolean} [input.fetching] - A viewport load is in flight. * @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any. + * @param {boolean} [input.stale] - The flow came from cached proxy tiles. * @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100. * @param {boolean} [input.statusUnavailable] - The status probe itself failed. * @param {string} [input.roadSource] - Name of the geometry being drawn. @@ -239,6 +240,7 @@ export function createModel({ state: layerState, services, parts, source }) { liveMode = false, fetching = false, flowError = null, + stale = false, coveragePct = 0, statusUnavailable = false, roadSource = 'OpenStreetMap', @@ -258,6 +260,16 @@ export function createModel({ state: layerState, services, parts, source }) { }); return { mode, error: degraded, loadingLabel: degraded }; } + // Cached proxy tiles (TomTom budget spent or upstream failing) are not live. + if (liveMode && stale && !fetching) { + return { + mode, + error: null, + loadingLabel: t('STALE · Roads: {source} · Flow: cached TomTom tiles', { + source: roadSource, + }), + }; + } // TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy. if (liveMode && roadSource === 'TomTom') { return { diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index a7556e432..1d1cd4986 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -283,6 +283,64 @@ test('flow application preserves direct values and leaves Hybrid fill simulated' assert.equal(state._flowPending, 0); }); +test('stale proxy flow tiles reach the layer stats and never read LIVE', async () => { + const { createFlow } = await import('./flow.js'); + const { createModel } = await import('./model.js'); + const { createControls } = await import('./controls.js'); + const { createState } = await import('./state.js'); + const { layerFeedState } = await import('../../data/feedState.js'); + let detail = { + segments: [flow], + partial: false, + stale: true, + oldestTileAt: 1000, + }; + const source = { + fetchFlowDetail: async () => detail, + fetchFlowForBounds: async () => detail.segments, + getFlowSessionStats: () => ({ tilesFetched: 1 }), + }; + const state = createState({ services: {} }); + Object.assign(state, { + _enabled: true, + _liveMode: true, + _loadGeneration: 1, + _flowStatusPromise: Promise.resolve(), + _bucketCounts: { free: 3, slow: 0, jam: 0, sim: 1 }, + }); + const parts = {}; + const context = { state, services: { credits: {} }, parts, source }; + parts.model = createModel(context); + parts.flow = createFlow(context); + const controls = createControls(context); + + await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 1); + const stale = controls.methods.getStats(); + assert.equal(stale.mode, 'live'); + assert.equal(stale.stale, true); + assert.equal(stale.oldestTileAt, 1000); + assert.doesNotMatch(stale.loadingLabel, /LIVE/); + assert.equal(layerFeedState(stale), 'stale'); + + // A road failure outranks cached flow: the row reads the failure. + state._roadError = 'Road geometry unavailable'; + assert.equal(controls.methods.getStats().stale, false); + state._roadError = null; + + detail = { + segments: [flow], + partial: false, + stale: false, + oldestTileAt: 2000, + }; + state._loadGeneration = 2; + await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 2); + const live = controls.methods.getStats(); + assert.equal(live.stale, false); + assert.match(live.loadingLabel, /^LIVE · Roads: OpenStreetMap/); + assert.equal(layerFeedState(live), 'nominal'); +}); + test('status names the drawn road source for each mode', async () => { const { createModel } = await import('./model.js'); const { trafficFeedPresentation: present } = createModel({ diff --git a/src/layers/traffic/state.js b/src/layers/traffic/state.js index 595bc2a0d..ecd34c9ee 100644 --- a/src/layers/traffic/state.js +++ b/src/layers/traffic/state.js @@ -108,6 +108,16 @@ export function createState({ services }) { layerState._flowError = null; + /** + * True while the drawn flow came from cached proxy tiles (TomTom budget + * spent or upstream failing); `_flowOldestTileAt` is the oldest tile's + * fetch time in ms, or null. + * @type {boolean} + */ + + layerState._flowStale = false; + layerState._flowOldestTileAt = null; + /** * True when `/api/tomtom/status` itself could not be reached, so the layer is * simulating because it could not ask — not because the server said "no key". diff --git a/src/layers/traffic/viewport.js b/src/layers/traffic/viewport.js index f147f5288..077199855 100644 --- a/src/layers/traffic/viewport.js +++ b/src/layers/traffic/viewport.js @@ -183,6 +183,9 @@ export function createViewport({ state: layerState, services, parts, source }) { layerState._fetching = false; layerState._flowPending = 0; layerState._roadError = null; + // Nothing is drawn up here, cached or not. + layerState._flowStale = false; + layerState._flowOldestTileAt = null; parts.animation.clearDots(); layerState._lastBounds = null; layerState._lastViewCenter = null; diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 18b71dabf..24bcad6e6 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -714,18 +714,24 @@ export class LayerPanel { }); } if (feedState === 'stale') { + // A layer drawing cached upstream data (Traffic's proxy tiles) dates the + // row by the oldest of it, not by the load that re-served it. + const dataAgo = + Number(stats.oldestTileAt) > 0 + ? this._timeAgo(stats.oldestTileAt) + : ago; if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) { return t('{state} · {source} · {ago} · retrying in {seconds}s', { state: stateLabel, source, - ago, + ago: dataAgo, seconds: stats.retryInSec, }); } return t('{state} · {source} · {ago}', { state: stateLabel, source, - ago, + ago: dataAgo, }); } if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) { diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index b4b771e1a..bd90f0777 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -93,6 +93,27 @@ test('partial feed controls distinguish incomplete records from stale data and o assert.equal(button.textContent, 'OFF'); }); +test('a stale row dates cached data by its oldest tile, not the last load', async () => { + const { LayerPanel } = await import('./layerPanel.js'); + const panel = LayerPanel.prototype; + const layer = { + id: 'traffic', + name: 'Street Traffic', + source: 'TomTom', + enabled: true, + stats: { + mode: 'live', + stale: true, + count: 4, + lastUpdate: Date.now(), + oldestTileAt: Date.now() - 3 * 3600_000, + }, + }; + assert.match(panel._buildMetaText(layer), /^STALE · TomTom · 3h ago$/); + delete layer.stats.oldestTileAt; + assert.match(panel._buildMetaText(layer), /^STALE · TomTom · just now$/); +}); + test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => { const { LayerPanel } = await import('./layerPanel.js'); const { railFixture } = await import('./railTestFixture.mjs');