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 &&{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 &&{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 ?{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'}}{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.empty}
:{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.'}
}> : <>{noData ? t.unavailable : t.empty}
: