From b911a0fc52656597ebd56ee5a475dd97148fd640 Mon Sep 17 00:00:00 2001 From: Sebastian Becerra <207095575+sbc1-code@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:57:51 -0700 Subject: [PATCH] fix: gate walk-or-drive advice on retrieval freshness --- CHANGELOG.md | 14 +++++ scripts/check-walk-drive-availability.mjs | 1 + scripts/check-walk-drive-freshness.mjs | 75 +++++++++++++++++++++++ scripts/prerender.mjs | 4 +- scripts/smoke-routes.mjs | 2 + scripts/test-trust-state.mjs | 14 +++++ src/lib/walkDriveFreshness.js | 13 ++++ src/pages/WalkOrDrive.jsx | 54 +++++++++++----- 8 files changed, 161 insertions(+), 16 deletions(-) create mode 100644 scripts/check-walk-drive-freshness.mjs create mode 100644 src/lib/walkDriveFreshness.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f69e7544..aa568e95a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/check-walk-drive-availability.mjs b/scripts/check-walk-drive-availability.mjs index 09f136813..0d6125dd2 100644 --- a/scripts/check-walk-drive-availability.mjs +++ b/scripts/check-walk-drive-availability.mjs @@ -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'; diff --git a/scripts/check-walk-drive-freshness.mjs b/scripts/check-walk-drive-freshness.mjs new file mode 100644 index 000000000..723ad690d --- /dev/null +++ b/scripts/check-walk-drive-freshness.mjs @@ -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'); +} diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index e39ead539..2da22fb0e 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -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 = { diff --git a/scripts/smoke-routes.mjs b/scripts/smoke-routes.mjs index 0374badf7..f2549be6c 100644 --- a/scripts/smoke-routes.mjs +++ b/scripts/smoke-routes.mjs @@ -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'; @@ -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}`); diff --git a/scripts/test-trust-state.mjs b/scripts/test-trust-state.mjs index 55d8cb5bb..b21fd7d9d 100644 --- a/scripts/test-trust-state.mjs +++ b/scripts/test-trust-state.mjs @@ -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); +}); diff --git a/src/lib/walkDriveFreshness.js b/src/lib/walkDriveFreshness.js new file mode 100644 index 000000000..f22d47cab --- /dev/null +++ b/src/lib/walkDriveFreshness.js @@ -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), + }; +} diff --git a/src/pages/WalkOrDrive.jsx b/src/pages/WalkOrDrive.jsx index 15120b308..22e50ddc6 100644 --- a/src/pages/WalkOrDrive.jsx +++ b/src/pages/WalkOrDrive.jsx @@ -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 / @@ -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 ( @@ -41,6 +43,7 @@ function PanelCard({ icon: Icon, title, wait, sub, lang, recommendedLabel }) { {wait == null ? '—' : `${wait} min`}

{sub}

+

{reportTimeLabel(reportTime, lang)}

); @@ -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()); }); }, []); @@ -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) { @@ -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(); @@ -137,7 +151,7 @@ export default function WalkOrDrive() {

{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`)}

@@ -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.`)}

+

{retrievalLabel}

{closed && crossing.hours && (

{language === 'en' ? 'Posted CBP hours: ' : 'Horario publicado por CBP: '}{crossing.hours} @@ -185,11 +200,19 @@ export default function WalkOrDrive() {

{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.`}

+

{retrievalLabel}

+

{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.'}

+ {!usableSnapshot &&

{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.'}

} + {recommendation && (
- {language === 'en' ? 'Right now' : 'Ahora mismo'} + {language === 'en' ? 'Based on reported waits' : 'Según las esperas reportadas'}

{(() => { @@ -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} @@ -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`} @@ -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`}