From db4d61e2c4dc01855c02949d76a9cd33909b9697 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 19:46:14 -0800 Subject: [PATCH] fix(data): fresher fallback feed and honest unavailable state When the live CBP endpoint fails, read the collector's committed feed via a same-origin rewrite and keep the newer of it and the bundled copy (which only refreshes on daily Vercel deploys). When nothing loads, stop labeling the empty state as saved fallback data or a filter result (EN/ES). Entry budget 62 -> 62.25 KB gzip (+0.19 KB measured) for this fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JB1Z5Jn58ixqZtx1YGzvmC --- CHANGELOG.md | 9 +++++ DECISIONS.md | 4 +++ scripts/check-bundle-size.mjs | 3 +- scripts/test-data-refresh.mjs | 36 +++++++++++++++++++ .../dashboard/CrossingMapExplorer.jsx | 9 +++-- src/components/utils/dataService.js | 21 +++++++++-- src/pages/Dashboard.jsx | 10 ++++-- vercel.json | 3 +- 8 files changed, 87 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78f66f756..253408155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # CHANGELOG +## 2026-11-09 - Fresher backup data and an honest "could not load" state + +- Fallback freshness: when the live CBP endpoint fails, the site now also reads the collector's committed feed through a same-origin rewrite (`/data/latest/crossings.json` -> `main/public/data/crossings.json` on GitHub). It keeps whichever valid backup is newer, the collector feed or the bundled copy. The bundled copy only changes when Vercel deploys (daily), so it could be most of a day old; the collector commits every 15 minutes. Backup readings are still labeled as fallback with their original clock. +- Both sources down: with nothing loaded and nothing retained, the map no longer says "Saved fallback data" and the lists no longer say no crossings match the filters or region. EN/ES now say current reports could not load and no waits are shown. `getFallbackData()` returns `unavailable: true` and `fromFallback: false`. +- Tests: two new regression tests in `test-data-refresh.mjs` (newest-backup selection with collector up, older or down; unavailable state). Both fail on the previous code. Full `npm test` passes after a prerendered build. Headless Chrome check with all three sources returning 503: EN 1280px and ES 390px show the unavailable copy, no saved-data or filter wording, no horizontal overflow. +- Entry budget 62 -> 62.25 KB gzip (+0.19 KB measured) for this fix. +- Rebuilt from current main. The Nov 5 candidate lived in a cloud workspace that was never pushed and is not reachable from here. +- Not verified: the Vercel external rewrite in production (needs a preview deploy), native Safari, physical devices. + ## 2026-11-07 - Make visible controls do what they look like (Phase 2) - "View trends" in a crossing card's menu now opens `#trends`, the hourly pattern section, and scrolls to it. Before, it reloaded the same crossing page at the top. The menu skips its focus return for this link so the page does not jump back up. diff --git a/DECISIONS.md b/DECISIONS.md index 602271aec..a30427e89 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -555,3 +555,7 @@ The first corridor console is a lazy, reversible route for San Ysidro/Otay, not ## 2026-11-02 · Co-occurring CBP rows keep the reported lane The raw `bwtpublicmod` feed saved 2026-11-02 contains both port numbers for Ysleta (`240104` and `240203`) and for the other pinned substitution pairs. Identity stays pinned. When both rows are present, a reported wait outranks an empty or Update Pending row. `Lanes Closed` and `N/A` stay on the lane so a closed lane is not stored as a blank one. San Ysidro still does not take PedWest or Cross Border Express waits. This supersedes the co-occurrence claim in the 2026-08-19 identity note. + +## 2026-11-09 · Backup data reads the collector feed through a same-origin rewrite + +The bundled `/data/crossings.json` only refreshes on Vercel deploys, so as a fallback it could be most of a day old. `vercel.json` rewrites `/data/latest/crossings.json` to the collector's committed file on GitHub `main`, and the client keeps whichever valid backup is newer. Same origin, so no CORS and no new client-side host; GitHub already hosts the code and the Pages rollback. If GitHub is unreachable the bundled copy still serves. "Nothing loaded" is a separate `unavailable` state, never labeled as saved data. diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs index ec3b4ab50..c190bdd38 100644 --- a/scripts/check-bundle-size.mjs +++ b/scripts/check-bundle-size.mjs @@ -19,7 +19,8 @@ const ASSETS = path.resolve(process.cwd(), 'dist', 'assets'); // entry chunk (index-*.js) and the single stylesheet const BUDGETS = [ - { label: 'entry js', pattern: /^index-.*\.js$/, maxGzipKb: 62 }, + // 62 -> 62.25 (2026-11-09): fresher fallback feed + honest "could not load" state, +0.19 KB. + { label: 'entry js', pattern: /^index-.*\.js$/, maxGzipKb: 62.25 }, { label: 'css', pattern: /^index-.*\.css$/, maxGzipKb: 20 }, ]; diff --git a/scripts/test-data-refresh.mjs b/scripts/test-data-refresh.mjs index 6ee2aba5a..5753335a4 100644 --- a/scripts/test-data-refresh.mjs +++ b/scripts/test-data-refresh.mjs @@ -217,3 +217,39 @@ test('malformed live response falls back to the static timestamp; missing timest service.cache = null; assert.equal((await service.getBorderData()).timestamp, null); }); + +test('live failure uses the newer of the collector feed and the bundled copy', async t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('upstream down'); }); + const doc = fetched_at => ({ fetched_at, crossings: [{ port_number: '250401', current_wait_time: 5 }] }); + const collectorTime = '2026-11-05T01:15:00Z'; + const bundledTime = '2026-11-04T16:22:00Z'; + + const fresher = new DataService({ liveApi: true }); + fresher.fetchJson = async path => path === '/data/latest/crossings.json' ? doc(collectorTime) : doc(bundledTime); + const fromCollector = await fresher.getBorderData(); + assert.equal(fromCollector.timestamp, collectorTime); + assert.equal(fromCollector.fromFallback, true, 'a backup reading is still labeled as fallback'); + + const olderCollector = new DataService({ liveApi: true }); + olderCollector.fetchJson = async path => path === '/data/latest/crossings.json' ? doc(bundledTime) : doc(collectorTime); + assert.equal((await olderCollector.getBorderData()).timestamp, collectorTime, 'the bundled copy wins when it is newer'); + + const collectorDown = new DataService({ liveApi: true }); + collectorDown.fetchJson = async path => { + if (path === '/data/latest/crossings.json') throw new Error('HTTP 502'); + return doc(bundledTime); + }; + assert.equal((await collectorDown.getBorderData()).timestamp, bundledTime); +}); + +test('when no source loads and nothing is retained, the result is unavailable, not saved fallback data', async t => { + t.mock.method(globalThis, 'fetch', async () => { throw new Error('upstream down'); }); + const service = new DataService({ liveApi: true }); + service.fetchJson = async () => { throw new Error('offline'); }; + const result = await service.getBorderData(); + assert.equal(result.success, false); + assert.equal(result.unavailable, true); + assert.equal(result.fromFallback, false); + assert.equal(result.timestamp, null); + assert.deepEqual(result.crossings, []); +}); diff --git a/src/components/dashboard/CrossingMapExplorer.jsx b/src/components/dashboard/CrossingMapExplorer.jsx index 493bc6989..4a947235b 100644 --- a/src/components/dashboard/CrossingMapExplorer.jsx +++ b/src/components/dashboard/CrossingMapExplorer.jsx @@ -91,6 +91,7 @@ const labels = { notRetained: 'Not recorded for this lane', minute: 'min', empty: 'No crossings match these filters.', + unavailable: 'Current reports could not be loaded. No waits are shown.', historyNotice: 'Recorded observations · Not current conditions. Only Standard summary waits were retained; gaps remain missing.', clockMissing: 'The archive does not retain lane report clocks or lane counts.', failed: 'Recorded history unavailable.', @@ -114,6 +115,7 @@ const labels = { notRetained: 'No se registró este carril', minute: 'min', empty: 'No hay cruces con estos filtros.', + unavailable: 'No se pudieron cargar los reportes actuales. No se muestran esperas.', historyNotice: 'Observaciones registradas · No son condiciones actuales. Solo se conservaron resúmenes de auto estándar; los datos faltantes siguen sin datos.', clockMissing: 'El archivo no conserva horas de reporte por carril ni número de carriles.', failed: 'Historial registrado no disponible.', @@ -129,6 +131,7 @@ export default function CrossingMapExplorer({ fetchedAt, success, fromFallback, + unavailable, selectedPort, onSelect, onClose, @@ -237,6 +240,8 @@ export default function CrossingMapExplorer({ reporting = rows.filter(row => row.wait != null).length; const freshness = walkDriveFreshness(fetchedAt); const trusted = !replay && freshness.usable && !offline && success !== false && !fromFallback; + // Nothing loaded at all (unfiltered count is zero): not saved data, not a filter result. + const noData = !replay && unavailable && !allCount; const locationLabel = location => ({ 'directory-target': es ? 'Punto aproximado de un enlace del directorio público; no es una entrada.' : 'Approximate target linked by the public directory; not an entrance.', 'federal-facility': es ? 'Punto del recinto publicado en el Federal Register; no es una entrada.' : 'Facility point published in the Federal Register; not an entrance.', @@ -429,7 +434,7 @@ export default function CrossingMapExplorer({ setParams(q, { replace: true }); - }} />{clock(selectedSnapshot?.fetched_at, language)}}{unavailableInstant &&

{es ? 'La observación solicitada no está en este archivo. Elige otra hora.' : 'The requested observation is absent from this archive. Choose another time.'}

}{lane !== 'passenger_standard' &&

{t.notRetained}.

} : !trusted &&
{offline ? es ? 'Sin conexión. Conservamos reportes anteriores.' : 'Offline. Retained reports are shown.' : fromFallback ? es ? 'Datos de respaldo guardados. Las condiciones actuales no están verificadas.' : 'Saved fallback data. Current conditions are not verified.' : success === false ? es ? 'No se pudo actualizar. Conservamos la hora y los valores del último reporte.' : 'Refresh unavailable. The last report values and original time are retained.' : es ? 'Consulta desactualizada o de hora desconocida.' : 'Stale or unknown retrieval time.'} {clock(fetchedAt, language)}
} + }} />{clock(selectedSnapshot?.fetched_at, language)}}{unavailableInstant &&

{es ? 'La observación solicitada no está en este archivo. Elige otra hora.' : 'The requested observation is absent from this archive. Choose another time.'}

}{lane !== 'passenger_standard' &&

{t.notRetained}.

} : !trusted &&
{noData ? t.unavailable : offline ? es ? 'Sin conexión. Conservamos reportes anteriores.' : 'Offline. Retained reports are shown.' : fromFallback ? es ? 'Datos de respaldo guardados. Las condiciones actuales no están verificadas.' : 'Saved fallback data. Current conditions are not verified.' : success === false ? es ? 'No se pudo actualizar. Conservamos la hora y los valores del último reporte.' : 'Refresh unavailable. The last report values and original time are retained.' : es ? 'Consulta desactualizada o de hora desconocida.' : 'Stale or unknown retrieval time.'} {clock(fetchedAt, language)}
}
{ if (e.target !== e.currentTarget) return; @@ -481,7 +486,7 @@ export default function CrossingMapExplorer({

{selected.location ? locationLabel(selected.location) : `${t.unlocated}. ${es ? 'Sin punto en el mapa mientras se revisa. El reporte sigue disponible.' : 'No map point while location review is pending. The report remains available.'}`} {selected.location?.locationSource && {es ? 'Fuente de ubicación' : 'Location source'}}

{replay ?

{selected.crossing.name}

{t.history} · {laneLabel(lane, language)}

{selected.wait == null ? '—' : selected.wait}{t.minute}

{selected.wait == null ? stateLabel(selected) : es ? 'Resumen registrado de auto estándar' : 'Recorded Standard summary'}

{t.clockMissing}

{selectedSnapshot?.source && {es ? 'Ver observación original' : 'View source observation'}}
: active && } {es ? 'Abrir reporte completo y cámaras' : 'Open full report and cameras'} - :

{es ? 'Este cruce no aparece en la respuesta actual. No se muestra una espera supuesta.' : 'This crossing is absent from the current response. No wait is assumed.'}

} : <>

{t.list}

{rows.length}
{!rows.length ?

{t.empty}

:
    {rows.map(row =>
  1. )}
}} + :

{es ? 'Este cruce no aparece en la respuesta actual. No se muestra una espera supuesta.' : 'This crossing is absent from the current response. No wait is assumed.'}

} : <>

{t.list}

{rows.length}
{!rows.length ?

{noData ? t.unavailable : t.empty}

:
    {rows.map(row =>
  1. )}
}}
{es ? 'Menos de 15 min' : 'Under 15 min'}15–44 min45+ min{es ? 'Sin reporte de este carril' : 'No report for this lane'}
diff --git a/src/components/utils/dataService.js b/src/components/utils/dataService.js index 4168011b1..142d5840e 100644 --- a/src/components/utils/dataService.js +++ b/src/components/utils/dataService.js @@ -6,6 +6,10 @@ import { buildSlugMap } from '../../lib/slugs.js'; const DATA_PATH = '/data/crossings.json'; const LIVE_DATA_PATH = '/api/public/crossings'; +// Same-origin rewrite (vercel.json) to the collector's committed feed on main. +// The bundled DATA_PATH only changes when Vercel deploys (daily), so it can be +// most of a day old; the collector commits every 15 minutes. +export const COLLECTOR_DATA_PATH = '/data/latest/crossings.json'; const FX_PATH = '/data/exchange-rate.json'; const SB_PATH = '/data/crossings-sb.json'; @@ -86,12 +90,23 @@ export class DataService { return { doc: validateSnapshot(await res.json()), fromFallback: false }; } catch (error) { console.warn('[dataService] CBP fallback', error); - return { doc: validateSnapshot(await this.fetchJson(DATA_PATH)), fromFallback: true }; + return { doc: await this.fetchNewestBackup(), fromFallback: true }; } } return { doc: validateSnapshot(await this.fetchJson(DATA_PATH)), fromFallback: false }; } + // Try the collector feed and the bundled copy together and keep whichever + // valid snapshot is newer. Either one alone is enough; if neither validates, + // throw so the caller keeps the browser's last reading or reports unavailable. + async fetchNewestBackup() { + const docs = (await Promise.allSettled([COLLECTOR_DATA_PATH, DATA_PATH] + .map(path => this.fetchJson(path).then(validateSnapshot)))) + .filter(result => result.value).map(result => result.value); + if (!docs.length) throw new Error('No valid backup snapshot'); + return docs.sort((a, b) => Date.parse(b.fetched_at) - Date.parse(a.fetched_at))[0]; + } + getBorderData() { if (!this.inFlight) { this.inFlight = this.loadBorderData().finally(() => { this.inFlight = null; }); @@ -157,7 +172,9 @@ export class DataService { } async refreshBorderData() { return this.getBorderData(); } - getFallbackData() { return { success: false, crossings: [], exchange_rate: null, timestamp: null, source: 'fallback', fromFallback: true }; } + // Nothing loaded and nothing retained. This is not saved data, so it must not + // be labeled as a fallback reading. + getFallbackData() { return { success: false, crossings: [], exchange_rate: null, timestamp: null, source: 'unavailable', fromFallback: false, unavailable: true }; } startAutoRefresh(intervalMs = 15 * 60 * 1000) { if (this.refreshTimer) return; diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index e2f215c02..f34e9c7a6 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -78,6 +78,7 @@ export default function Dashboard() { fetchedAt: null, success: false, fromFallback: false, + unavailable: false, }); const location = useLocation(); @@ -158,6 +159,7 @@ export default function Dashboard() { fetchedAt: data.timestamp, success: data.success, fromFallback: Boolean(data.fromFallback), + unavailable: Boolean(data.unavailable), }); if (data.success) { recordSnapshot(data.crossings, 'northbound', data.timestamp); @@ -592,7 +594,7 @@ export default function Dashboard() { } {presentation === 'list' && <> @@ -648,7 +650,11 @@ export default function Dashboard() { {visibleCrossings.length === 0 ? ( - {offlineCrossings.length > 0 + {state.unavailable && state.crossings.length === 0 + ? (language === 'en' + ? 'Reports could not load. No waits are shown.' + : 'No cargaron los reportes. No se muestran esperas.') + : offlineCrossings.length > 0 ? (language === 'en' ? 'Matching crossings have no current published wait times. Show them to open their reports.' : 'Los cruces encontrados no tienen tiempos actuales publicados. Muéstralos para abrir sus reportes.') diff --git a/vercel.json b/vercel.json index d4bd7628f..7f5b36cb3 100644 --- a/vercel.json +++ b/vercel.json @@ -7,6 +7,7 @@ { "source": "/snapshot", "destination": "/api/public/snapshot" }, { "source": "/snapshot/", "destination": "/api/public/snapshot" }, { "source": "/snapshot/es", "destination": "/api/public/snapshot?lang=es" }, - { "source": "/snapshot/es/", "destination": "/api/public/snapshot?lang=es" } + { "source": "/snapshot/es/", "destination": "/api/public/snapshot?lang=es" }, + { "source": "/data/latest/crossings.json", "destination": "https://raw.githubusercontent.com/sbc1-code/borderpulse/main/public/data/crossings.json" } ] }