fix(traffic): the traffic layer reads STALE, not LIVE, on cached flow tiles

Once the TomTom tile budget is spent, or TomTom fails, the proxy serves
cached tiles marked STALE-BUDGET / STALE-ERROR. flowSource works out
`stale`, but the layer fetched flow through fetchFlowForBounds, which
returns only the segments, so getStats() and the sync chip still read
"LIVE · Roads: …". #1239 fixed the get_traffic_flow tool the same way; the
layer now uses fetchFlowDetail too, reports stale and oldestTileAt in its
stats, and the chip reads "STALE · Roads: <source> · Flow: cached TomTom
tiles". A source without fetchFlowDetail keeps the previous behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
quanticsoul4772andClaude Opus 5.5 committed 2026-10-25 18:01:19 -07:00
1 parent 2006c035d9
commit 7981a179ea
11 files changed
+120 -3

No files matched your search

+4 -1
View File
@@ -5651,7 +5651,10 @@ 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, and the sync chip reads
`STALE · Roads: <source> · 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
+1
View File
@@ -857,6 +857,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.",
+1
View File
@@ -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}%",
+1
View File
@@ -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}%",
+15
View File
@@ -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 ||
+11 -2
View File
@@ -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;
+4
View File
@@ -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;
+12
View File
@@ -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 {
+58
View File
@@ -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({
+10
View File
@@ -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".
+3
View File
@@ -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;