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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# CHANGELOG

## 2026-10-14 — Walk-or-drive retrieval freshness (prepared)

- Preserve the actual snapshot retrieval time and suppress walk/drive/tie advice
and recommendation badges for stale, missing, invalid or future timestamps.
Re-evaluate age while the page stays open, using the existing 45/90-minute
thresholds; failed refreshes retain the original timestamp.
- Show retrieval age separately from raw CBP lane report clocks, including
unavailable-clock labels. Replace unconditional live/current decision wording
in the comparison and its metadata with reported-wait wording.
- Add EN/ES desktop/mobile regression coverage for zero waits, expiry without a
new feed, offline refresh, recovery, future timestamps and report clocks.
Retain closure, pending-report and Back-navigation checks. No feed, threshold,
route-inventory, camera or ad changes; publication awaits independent review.

## 2026-10-13 — Camera coverage beyond El Paso (prepared)

- Added a camera-area selector for all nine corridors. The camera area follows
Expand Down
1 change: 1 addition & 0 deletions scripts/check-walk-drive-availability.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import fs from 'node:fs';
// This fixture tests retained CBP lane values without hitting CBP or ad networks.
export async function checkWalkDriveAvailability(page, context, baseUrl) {
const snapshot = JSON.parse(fs.readFileSync(new URL('../public/data/crossings.json', import.meta.url)));
snapshot.fetched_at = new Date().toISOString();
const crossing = snapshot.crossings.find(row => row.port_number === '250201');
assert.ok(crossing, 'Andrade fixture must exist');
crossing.hours = '6 am-10 pm';
Expand Down
75 changes: 75 additions & 0 deletions scripts/check-walk-drive-freshness.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';

// Reuse the smoke context's external-request blocker; no CBP/ad traffic.
export async function checkWalkDriveFreshness(context, baseUrl) {
const snapshot = JSON.parse(fs.readFileSync(new URL('../public/data/crossings.json', import.meta.url)));
const crossing = snapshot.crossings.find(row => row.port_number === '250201');
crossing.port_status = 'Open';
const lane = (delay_minutes, update_time) => ({ delay_minutes, status: 'delay', lanes_open: 1, update_time });
crossing.lanes = {
passenger_standard: lane(90, 'At Midnight PDT'),
passenger_ready: lane(15, 'At 1:00 am PST'),
pedestrian_standard: lane(0, 'At 11:00 pm MST'),
pedestrian_ready: lane(10, null),
};
const feedMatcher = url => ['/data/crossings.json', '/api/public/crossings'].includes(url.pathname);
let fail = false;
let requests = 0;
const feedHandler = route => {
requests++;
return fail ? route.fulfill({ status: 503, body: 'fixture unavailable' })
: route.fulfill({ contentType: 'application/json', body: JSON.stringify(snapshot) });
};
await context.route(feedMatcher, feedHandler);
const page = await context.newPage();
const now = Date.now();
await page.clock.install({ time: now });
try {
for (const lang of ['en', 'es']) {
await page.goto(baseUrl.href);
await page.evaluate(value => localStorage.setItem('borderPulse_language', value), lang);
await page.clock.setSystemTime(now);
fail = false;
snapshot.fetched_at = new Date(now - 90 * 60_000).toISOString();
await page.goto(new URL('/walk-or-drive/andrade/', baseUrl).href);
await page.getByText(lang === 'en' ? 'Recommended' : 'Recomendado', { exact: true }).waitFor();
let content = await page.locator('#root').innerText();
for (const text of ['0 min', 'At Midnight PDT', 'At 1:00 am PST', 'At 11:00 pm MST',
lang === 'en' ? 'CBP report time unavailable' : 'Hora del reporte CBP no disponible']) assert.ok(content.includes(text), text);
assert.ok(!/Right now|Ahora mismo|Live decision|Decisión en vivo/.test(content));
// No new feed is required to expire advice while the page remains open.
fail = true;
await page.clock.runFor(61_000);
await page.getByTestId('walk-drive-unavailable').waitFor();
content = await page.locator('#root').innerText();
assert.ok(!/Recommended|Recomendado|Walk\.|Camina\.|Drive\.|Maneja\.|It's a wash\.|Está parejo\./.test(content));
const previousLabel = await page.getByTestId('walk-drive-fetch-time').innerText();
const before = requests;
const refreshFailed = page.waitForEvent('console', {
predicate: message => message.text().includes('[dataService] fetch failed'),
timeout: 10_000,
});
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await refreshFailed;
assert.ok(requests > before, 'online refresh attempted and failed');
assert.equal(await page.getByTestId('walk-drive-fetch-time').innerText(), previousLabel, 'failed refresh must not renew retrieval time');
assert.ok(await page.getByTestId('walk-drive-unavailable').isVisible());
// Recovery restores the recommendation using the new actual timestamp.
fail = false;
snapshot.fetched_at = new Date(now + 61_000).toISOString();
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await page.getByText(lang === 'en' ? 'Recommended' : 'Recomendado', { exact: true }).waitFor();
// Upstream allows small clock skew, but the page must not recommend from it.
snapshot.fetched_at = new Date(now + 180_000).toISOString();
await page.reload();
await page.getByTestId('walk-drive-unavailable').waitFor();
assert.equal(await page.getByTestId('walk-drive-fetch-time').innerText(), lang === 'en' ? 'Fetch time unknown' : 'Hora de consulta desconocida');
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
}
} finally {
await page.close();
await context.unroute(feedMatcher, feedHandler);
}
console.log('[walk-drive] PASS: EN/ES raw clocks, aging, expiry, failed refresh, recovery and future retrievals');
}
4 changes: 2 additions & 2 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -781,8 +781,8 @@ async function main() {
for (const c of crossings.filter(hasPedestrianLane)) {
const slug = portToSlug[c.port_number];
if (!slug) continue;
const title = `Walk or Drive Across ${c.name}? Live Wait Comparison (2026) | Border Pulse`;
const desc = `Compare live pedestrian and vehicle wait times at ${c.name} from CBP. See whether walking saves enough minutes to be worth parking.`;
const title = `Walk or Drive Across ${c.name}? CBP Wait Comparison | Border Pulse`;
const desc = `Compare reported pedestrian and vehicle wait times at ${c.name} from CBP. See whether walking saves enough minutes to be worth parking.`;
const canonical = `${BASE}/walk-or-drive/${slug}/`;
const ogImage = `${BASE}/og/${slug}.png`;
const breadcrumb = {
Expand Down
2 changes: 2 additions & 0 deletions scripts/smoke-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { chromium } from 'playwright-core';
import { checkGrowthInteractions } from './check-growth-interactions.mjs';
import { checkLocalCrossings } from './check-local-crossings.mjs';
import { checkNoJsReports } from './check-nojs-reports.mjs';
import { checkWalkDriveFreshness } from './check-walk-drive-freshness.mjs';
import { checkWalkDriveAvailability } from './check-walk-drive-availability.mjs';
import { checkComparisonGuide } from './check-comparison-guide.mjs';
import { checkReportTimes } from './check-report-times.mjs';
Expand Down Expand Up @@ -322,6 +323,7 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) {
await checkGrowthInteractions(page, context, baseUrl);
await checkLocalCrossings(page, context, baseUrl);
await checkWalkDriveAvailability(page, context, baseUrl);
await checkWalkDriveFreshness(context, baseUrl);
await checkComparisonGuide(page, context, baseUrl);
} catch (error) {
failures.push(`${profile.name} growth interactions: ${error.stack || error.message}`);
Expand Down
14 changes: 14 additions & 0 deletions scripts/test-trust-state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,17 @@ test('walk-or-drive requires a usable lane report rather than an old numeric val
assert.equal(wait(null), null);
assert.equal(wait({ status: ' No Delay ', delay_minutes: 0, lanes_open: 1 }), 0);
});

// Walk/drive uses the same measured thresholds without treating clock skew as fresh.
test('walk-drive retrieval gate handles fresh, aging, stale and inconsistent clocks', async () => {
const { walkDriveFreshness } = await import('../src/lib/walkDriveFreshness.js');
for (const minutes of [0, 45, 46, 90]) {
assert.equal(walkDriveFreshness(minutesAgo(minutes), NOW).usable, true);
}
for (const stamp of [minutesAgo(91), null, undefined, '', 'invalid', 0, {}, minutesAgo(-1)]) {
assert.equal(walkDriveFreshness(stamp, NOW).usable, false);
}
const stamp = minutesAgo(90);
assert.equal(walkDriveFreshness(stamp, NOW + 60_000).state, FRESHNESS.STALE);
assert.equal(walkDriveFreshness(stamp, NOW + 60_000).usable, false);
});
13 changes: 13 additions & 0 deletions src/lib/walkDriveFreshness.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { freshnessOf, FRESHNESS } from './trustState.js';

// Retrieval freshness is separate from CBP's clock-only lane report times.
// Missing, malformed and future retrievals cannot support a recommendation.
export function walkDriveFreshness(fetchedAt, now = Date.now()) {
const freshness = typeof fetchedAt === 'string' && Date.parse(fetchedAt) <= now
? freshnessOf(fetchedAt, now)
: { state: FRESHNESS.UNKNOWN, age: null };
return {
...freshness,
usable: [FRESHNESS.FRESH, FRESHNESS.AGING].includes(freshness.state),
};
}
54 changes: 40 additions & 14 deletions src/pages/WalkOrDrive.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { buildSlugMap } from '@/lib/slugs';
import { updatePageMeta, resetPageMeta } from '@/lib/seo';
import { usePersistentLanguage } from '@/lib/useLanguage';
import { availableLaneWait } from '@/lib/crossingAvailability';
import { fetchLabel, reportTimeLabel } from '@/lib/trustState';
import { walkDriveFreshness } from '@/lib/walkDriveFreshness';
import { getPortStatus } from '@/components/utils/crossingMeta';

// Time it typically takes to park, walk the bridge, and reach a rideshare /
Expand All @@ -22,7 +24,7 @@ const FOOT_OVERHEAD_MIN = 20;
// dominates the wait-time savings.
const RECOMMEND_GAP_MIN = 15;

function PanelCard({ icon: Icon, title, wait, sub, lang, recommendedLabel }) {
function PanelCard({ icon: Icon, title, wait, sub, reportTime, lang, recommendedLabel }) {
return (
<Card className={`h-full ${recommendedLabel ? 'border-emerald-400 dark:border-emerald-700 ring-1 ring-emerald-400 dark:ring-emerald-700' : ''}`}>
<CardContent className="p-4 sm:p-5">
Expand All @@ -41,6 +43,7 @@ function PanelCard({ icon: Icon, title, wait, sub, lang, recommendedLabel }) {
{wait == null ? '—' : `${wait} min`}
</div>
<p className="mt-1 text-xs text-slate-600 dark:text-slate-300">{sub}</p>
<p className="mt-1 break-words text-xs text-slate-500 dark:text-slate-400">{reportTimeLabel(reportTime, lang)}</p>
</CardContent>
</Card>
);
Expand All @@ -49,11 +52,22 @@ function PanelCard({ icon: Icon, title, wait, sub, lang, recommendedLabel }) {
export default function WalkOrDrive() {
const { slug } = useParams();
const language = usePersistentLanguage();
const [state, setState] = useState({ crossings: [], isLoading: true });
const [state, setState] = useState({ crossings: [], fetchedAt: null, isLoading: true });

const [now, setNow] = useState(Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(timer);
}, []);

const freshness = walkDriveFreshness(state.fetchedAt, now);
const usableSnapshot = freshness.usable;
const retrievalLabel = fetchLabel(freshness.state, freshness.age, language);

useEffect(() => {
return subscribeToBorderData(data => {
setState({ crossings: data.crossings || [], isLoading: false });
setState({ crossings: data.crossings || [], fetchedAt: data.timestamp, isLoading: false });
setNow(Date.now());
});
}, []);

Expand Down Expand Up @@ -84,7 +98,7 @@ export default function WalkOrDrive() {
// against best foot option PLUS the foot overhead. Walking only wins if the
// adjusted foot total still beats the vehicle by more than the threshold.
const recommendation = useMemo(() => {
if (veh == null || bestFootWait == null) return null;
if (!usableSnapshot || veh == null || bestFootWait == null) return null;
const footTotal = bestFootWait.minutes + FOOT_OVERHEAD_MIN;
const gap = veh - footTotal;
if (gap >= RECOMMEND_GAP_MIN) {
Expand All @@ -104,16 +118,16 @@ export default function WalkOrDrive() {
};
}
return { verdict: 'wash', gap, footTotal, via: bestFootWait.via };
}, [veh, bestFootWait]);
}, [veh, bestFootWait, usableSnapshot]);

useEffect(() => {
if (!crossing || !canonicalSlug) return;
const title = language === 'en'
? `Walk or drive across ${crossing.name}? Live decision based on CBP data | Border Pulse`
: `¿Cruzar a pie o en auto en ${crossing.name}? Decisión en vivo con datos de CBP | Border Pulse`;
? `Walk or drive across ${crossing.name}? CBP wait comparison | Border Pulse`
: `¿Cruzar a pie o en auto en ${crossing.name}? Comparación de esperas de CBP | Border Pulse`;
const desc = language === 'en'
? `Compare live pedestrian and vehicle wait times at ${crossing.name} from CBP. See whether walking saves enough minutes to be worth parking.`
: `Compara tiempos en vivo a pie y en vehículo en ${crossing.name} con datos de CBP. Ve si caminar te ahorra suficiente tiempo para que valga estacionar.`;
? `Compare reported pedestrian and vehicle wait times at ${crossing.name} from CBP. See whether walking saves enough minutes to be worth parking.`
: `Compara tiempos reportados a pie y en vehículo en ${crossing.name} con datos de CBP. Ve si caminar te ahorra suficiente tiempo para que valga estacionar.`;
const url = `https://borderpulse.com/walk-or-drive/${canonicalSlug}/`;
updatePageMeta({ title, description: desc, ogTitle: title, ogDescription: desc, ogUrl: url, canonical: url });
return () => resetPageMeta();
Expand All @@ -137,7 +151,7 @@ export default function WalkOrDrive() {
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white mb-3">
{closed
? (language === 'en' ? `${crossing.name}: closed now` : `${crossing.name}: cerrado ahora`)
? (language === 'en' ? `${crossing.name}: ${usableSnapshot ? 'closed now' : 'reported closed'}` : `${crossing.name}: ${usableSnapshot ? 'cerrado ahora' : 'reportado cerrado'}`)
: (language === 'en' ? `${crossing.name}: no current pedestrian wait reported` : `${crossing.name}: sin espera peatonal actual reportada`)}
</h1>
<p className="text-sm text-slate-700 dark:text-slate-300 mb-4">
Expand All @@ -149,6 +163,7 @@ export default function WalkOrDrive() {
? `CBP has no current pedestrian wait report for ${crossing.name}, so a walk-vs-drive recommendation is unavailable. A missing report does not mean zero wait or prove that there is no pedestrian lane.`
: `CBP no tiene un reporte actual de espera peatonal para ${crossing.name}, así que no hay recomendación entre caminar o manejar. La falta de un reporte no significa espera cero ni demuestra que no haya carril peatonal.`)}
</p>
<p className="mb-4 text-xs text-slate-500 dark:text-slate-400" data-testid="walk-drive-fetch-time">{retrievalLabel}</p>
{closed && crossing.hours && (
<p className="mb-4 text-sm text-slate-700 dark:text-slate-300">
{language === 'en' ? 'Posted CBP hours: ' : 'Horario publicado por CBP: '}{crossing.hours}
Expand Down Expand Up @@ -185,11 +200,19 @@ export default function WalkOrDrive() {
<p className="text-xs sm:text-sm text-slate-500 mt-1 inline-flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{language === 'en'
? `Live wait times from CBP, plus ${FOOT_OVERHEAD_MIN} min added to the foot total to cover parking and walking the bridge. Northbound only.`
: `Tiempos en vivo de CBP, más ${FOOT_OVERHEAD_MIN} min al total a pie para cubrir estacionar y cruzar el puente. Solo hacia EE.UU.`}
? `Reported wait times from CBP, plus ${FOOT_OVERHEAD_MIN} min added to the foot total to cover parking and walking the bridge. Northbound only.`
: `Tiempos reportados por CBP, más ${FOOT_OVERHEAD_MIN} min al total a pie para cubrir estacionar y cruzar el puente. Solo hacia EE.UU.`}
</p>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400" data-testid="walk-drive-fetch-time">{retrievalLabel}</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{language === 'en'
? 'Lane report times may differ. A recent fetch does not establish how recent each CBP report is.'
: 'Las horas de reporte pueden variar por carril. Una consulta reciente no confirma la antigüedad de cada reporte de CBP.'}</p>
</header>

{!usableSnapshot && <p className="mb-4 rounded-lg border border-slate-300 p-4 text-sm dark:border-gray-700" data-testid="walk-drive-unavailable">{language === 'en'
? 'Walk-or-drive recommendation unavailable: the snapshot is stale or its fetch time cannot be verified. These are retained reports; check CBP before leaving.'
: 'Recomendación entre caminar o manejar no disponible: la consulta está desactualizada o no se puede verificar su hora. Estos son reportes guardados; consulta CBP antes de salir.'}</p>}

{recommendation && (
<div className={`mb-4 rounded-lg border px-4 py-3 ${
recommendation.verdict === 'walk'
Expand All @@ -203,7 +226,7 @@ export default function WalkOrDrive() {
: recommendation.verdict === 'drive' ? 'text-sky-800 dark:text-sky-300'
: 'text-slate-600 dark:text-slate-400'
}`}>
{language === 'en' ? 'Right now' : 'Ahora mismo'}
{language === 'en' ? 'Based on reported waits' : 'Según las esperas reportadas'}
</div>
<p className="text-sm text-slate-900 dark:text-white">
{(() => {
Expand Down Expand Up @@ -233,8 +256,9 @@ export default function WalkOrDrive() {
icon={Car}
title={language === 'en' ? 'Drive (standard)' : 'En auto (estándar)'}
wait={veh}
reportTime={crossing.lanes?.passenger_standard?.update_time}
sub={vehReady != null
? (language === 'en' ? `Ready Lane: ${vehReady} min · SENTRI: ${vehSentri ?? '—'} min` : `Ready Lane: ${vehReady} min · SENTRI: ${vehSentri ?? '—'} min`)
? `Ready Lane: ${vehReady} min (${reportTimeLabel(crossing.lanes?.passenger_ready?.update_time, language)}) · SENTRI: ${vehSentri ?? '—'} min (${reportTimeLabel(crossing.lanes?.passenger_sentri?.update_time, language)})`
: (language === 'en' ? 'CBP standard passenger lane' : 'Carril estándar de pasajero CBP')}
lang={language}
recommendedLabel={recommendation?.verdict === 'drive' ? (language === 'en' ? 'Recommended' : 'Recomendado') : null}
Expand All @@ -243,6 +267,7 @@ export default function WalkOrDrive() {
icon={User}
title={language === 'en' ? 'Walk (standard)' : 'A pie (estándar)'}
wait={ped}
reportTime={crossing.lanes?.pedestrian_standard?.update_time}
sub={language === 'en'
? `Add ~${FOOT_OVERHEAD_MIN} min for parking + bridge`
: `Agrega ~${FOOT_OVERHEAD_MIN} min por estacionar + puente`}
Expand All @@ -253,6 +278,7 @@ export default function WalkOrDrive() {
icon={User}
title={language === 'en' ? 'Walk (Ready Lane)' : 'A pie (Ready Lane)'}
wait={pedReady}
reportTime={crossing.lanes?.pedestrian_ready?.update_time}
sub={language === 'en'
? `Requires RFID document. Add ~${FOOT_OVERHEAD_MIN} min for parking + bridge`
: `Requiere documento con RFID. Agrega ~${FOOT_OVERHEAD_MIN} min por estacionar + puente`}
Expand Down
Loading