Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion scripts/check-bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
];

Expand Down
36 changes: 36 additions & 0 deletions scripts/test-data-refresh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, []);
});
9 changes: 7 additions & 2 deletions src/components/dashboard/CrossingMapExplorer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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.',
Expand All @@ -129,6 +131,7 @@ export default function CrossingMapExplorer({
fetchedAt,
success,
fromFallback,
unavailable,
selectedPort,
onSelect,
onClose,
Expand Down Expand Up @@ -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.',
Expand Down Expand Up @@ -429,7 +434,7 @@ export default function CrossingMapExplorer({
setParams(q, {
replace: true
});
}} />{clock(selectedSnapshot?.fetched_at, language)}</label>}{unavailableInstant && <p>{es ? 'La observación solicitada no está en este archivo. Elige otra hora.' : 'The requested observation is absent from this archive. Choose another time.'}</p>}{lane !== 'passenger_standard' && <p>{t.notRetained}. <button onClick={() => onLaneChange('passenger_standard')}>{es ? 'Elegir auto estándar' : 'Choose Standard passenger'}</button></p>}</div> : !trusted && <div className="explorerNotice" role="status">{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)}</div>}
}} />{clock(selectedSnapshot?.fetched_at, language)}</label>}{unavailableInstant && <p>{es ? 'La observación solicitada no está en este archivo. Elige otra hora.' : 'The requested observation is absent from this archive. Choose another time.'}</p>}{lane !== 'passenger_standard' && <p>{t.notRetained}. <button onClick={() => onLaneChange('passenger_standard')}>{es ? 'Elegir auto estándar' : 'Choose Standard passenger'}</button></p>}</div> : !trusted && <div className="explorerNotice" role="status">{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)}</div>}
<div className="explorerWorkspace">
<div ref={frameRef} className="explorerMap bp-line" tabIndex="0" aria-describedby="map-location-note" aria-label={es ? 'Mapa. Usa los botones para acercar o arrastra para mover.' : 'Map. Use zoom buttons or drag to pan.'} onKeyDown={e => {
if (e.target !== e.currentTarget) return;
Expand Down Expand Up @@ -481,7 +486,7 @@ export default function CrossingMapExplorer({
<p className="locationWarning" data-testid="location-evidence">{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 && <a href={selected.location.locationSource} target="_blank" rel="noopener noreferrer" className="underline">{es ? 'Fuente de ubicación' : 'Location source'}</a>}</p>
{replay ? <Card className="recordedReport"><CardContent className="p-4"><h3>{selected.crossing.name}</h3><p>{t.history} · {laneLabel(lane, language)}</p><strong className="recordedWait">{selected.wait == null ? '—' : selected.wait}<small>{t.minute}</small></strong><p>{selected.wait == null ? stateLabel(selected) : es ? 'Resumen registrado de auto estándar' : 'Recorded Standard summary'}</p><time dateTime={selectedSnapshot?.fetched_at || undefined}>{clock(selectedSnapshot?.fetched_at, language, getPortTimezone(selected.crossing))}</time><p>{t.clockMissing}</p>{selectedSnapshot?.source && <a href={`https://github.com/sbc1-code/borderpulse/blob/${selectedSnapshot.source.slice(7)}/public/data/crossings.json`} target="_blank" rel="noopener noreferrer">{es ? 'Ver observación original' : 'View source observation'}</a>}</CardContent></Card> : active && <BorderCrossingCard crossing={selected.crossing} language={language} selectedLane={lane} selectedDirection="northbound" snapshotAt={fetchedAt} slug={portToSlug[selected.id]} isFavorite={favoritesSet.has(selected.id)} onToggleFavorite={onToggleFavorite} index={0} />}
<Link className="fullReportLink" to={withLane(`/crossing/${portToSlug[selected.id] || selected.crossing.slug}/`, lane)}>{es ? 'Abrir reporte completo y cámaras' : 'Open full report and cameras'}<ArrowUpRight className="w-4 h-4" /></Link>
</> : <p role="status">{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.'}</p>}</> : <><div className="listHeading"><h3>{t.list}</h3><span>{rows.length}</span></div>{!rows.length ? <p className="emptyMap">{t.empty}</p> : <ol className="crossingResultList">{rows.map(row => <li key={row.id}><button id={`map-result-${row.id}`} onClick={e => select(row.id, e)} className={selectedPort === row.id ? 'active' : ''}><span><b>{row.crossing.name}</b><small>{row.crossing.state} · {replay ? t.history : row.report ? reportTimeLabel(row.report, language) : t.noReport}{!row.location && ` · ${t.unlocated}`}</small></span><strong className={trusted || replay ? getStatusForWait(row.wait) : 'retained'}>{row.wait == null ? '—' : row.wait}<small>{row.wait == null ? '' : t.minute}</small></strong></button></li>)}</ol>}</>}
</> : <p role="status">{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.'}</p>}</> : <><div className="listHeading"><h3>{t.list}</h3><span>{rows.length}</span></div>{!rows.length ? <p className="emptyMap">{noData ? t.unavailable : t.empty}</p> : <ol className="crossingResultList">{rows.map(row => <li key={row.id}><button id={`map-result-${row.id}`} onClick={e => select(row.id, e)} className={selectedPort === row.id ? 'active' : ''}><span><b>{row.crossing.name}</b><small>{row.crossing.state} · {replay ? t.history : row.report ? reportTimeLabel(row.report, language) : t.noReport}{!row.location && ` · ${t.unlocated}`}</small></span><strong className={trusted || replay ? getStatusForWait(row.wait) : 'retained'}>{row.wait == null ? '—' : row.wait}<small>{row.wait == null ? '' : t.minute}</small></strong></button></li>)}</ol>}</>}
</div>
</div>
<div className="mapLegend"><span><i className="good" />{es ? 'Menos de 15 min' : 'Under 15 min'}</span><span><i className="moderate" />15–44 min</span><span><i className="heavy" />45+ min</span><span><i className="unknown" />{es ? 'Sin reporte de este carril' : 'No report for this lane'}</span></div>
Expand Down
21 changes: 19 additions & 2 deletions src/components/utils/dataService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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; });
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions src/pages/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export default function Dashboard() {
fetchedAt: null,
success: false,
fromFallback: false,
unavailable: false,
});

const location = useLocation();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -592,7 +594,7 @@ export default function Dashboard() {
</div>}
<div hidden={presentation !== 'map'}>
<Suspense fallback={<p role="status" className="py-8 text-sm text-slate-500">{language === 'es' ? 'Cargando mapa fronterizo…' : 'Loading border map…'}</p>}>
<CrossingMapExplorer crossings={sortedCrossings} allCount={state.crossings.length} language={language} lane={lane} fetchedAt={state.fetchedAt} success={state.success} fromFallback={state.fromFallback} selectedPort={selectedPort} onSelect={selectPort} onClose={closePort} onAll={showWholeBorder} onLaneChange={changeLane} portToSlug={portToSlug} favoritesSet={favoritesSet} onToggleFavorite={handleToggleFavorite} scopeKey={region === 'ALL' && !corridor && !search ? 'ALL' : `${region}|${corridor}|${search}`} active={presentation === 'map'} onDisplayChange={changePresentation} />
<CrossingMapExplorer crossings={sortedCrossings} allCount={state.crossings.length} language={language} lane={lane} fetchedAt={state.fetchedAt} success={state.success} fromFallback={state.fromFallback} unavailable={state.unavailable} selectedPort={selectedPort} onSelect={selectPort} onClose={closePort} onAll={showWholeBorder} onLaneChange={changeLane} portToSlug={portToSlug} favoritesSet={favoritesSet} onToggleFavorite={handleToggleFavorite} scopeKey={region === 'ALL' && !corridor && !search ? 'ALL' : `${region}|${corridor}|${search}`} active={presentation === 'map'} onDisplayChange={changePresentation} />
</Suspense>
</div>
{presentation === 'list' && <>
Expand Down Expand Up @@ -648,7 +650,11 @@ export default function Dashboard() {
{visibleCrossings.length === 0 ? (
<Card id="crossing-results" className="border-dashed">
<CardContent className="p-8 text-center text-sm text-slate-500">
{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.')
Expand Down
Loading
Loading