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

## 2026-10-12 — CBP report times and fetch labels

- Dashboard and crossing cards distinguish snapshot retrieval from the official
standard-lane report time. Lane details expose each report's original clock
and timezone text, with an explicit missing-time label for numeric reports.
- Replaced fetch-derived Live labels and the detail page's Lanes right now
heading. All crossing detail cards use the actual snapshot retrieval time.
Missing report dates are not inferred from export timestamps or clock text;
collection, identity, wait availability and historical calculations stay intact.
- Added EN/ES regression coverage for raw report text, missing times, retrieval
labels, stale fallback and lane state/navigation behavior. Release evidence
belongs to the pull request.

## 2026-10-12 — Shared FAQ metadata scope

- Removed the homepage FAQPage node from the shared HTML shell. Its southbound
Expand Down
9 changes: 9 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,3 +376,12 @@ schema management. Keep visible homepage answers, route-specific FAQ generation,
other structured data, canonical/hreflang behavior and JSON script escaping.
This scoped correctness fix does not promise search or rich-result improvements;
other prerender/runtime schema lifecycle issues need separate review.


## 2026-10-12 · Retrieval time cannot establish a lane report's age

A fresh CBP fetch can contain older clock-only lane reports. Show the supplied
lane update_time separately from fetched_at, and describe snapshot freshness
as retrieval freshness rather than Live. Do not infer a report date, midnight
rollover or DST occurrence from the feed export date. Existing snapshot stale
thresholds and fallback timestamps remain unchanged.
4 changes: 4 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ cross-border experience; it is a product hypothesis, not a claim of demand.

### Now: earn trust in the free entry point

- [x] Separate CBP lane report times from snapshot retrieval on dashboard and
crossing detail cards. Preserve raw report text and unknown dates; release
verification belongs to the scoped pull request.

- [x] Approved Vercel production cutover: PR #109 merged, Actions publication
succeeded, and IONOS apex/www DNS plus trusted HTTPS were verified. See
issue #101 for publication evidence and exact rollback records.
Expand Down
350 changes: 350 additions & 0 deletions scripts/check-report-times.mjs

Large diffs are not rendered by default.

16 changes: 16 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 { checkWalkDriveAvailability } from './check-walk-drive-availability.mjs';
import { checkComparisonGuide } from './check-comparison-guide.mjs';
import { checkReportTimes } from './check-report-times.mjs';
import { checkAdSlotDiagnostics } from './check-ad-slot-diagnostics.mjs';
import { assertNoGlobalFaq, checkBuiltFaqScope, checkFaqNavigation } from './check-faq-scope.mjs';

Expand Down Expand Up @@ -321,6 +322,21 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) {
failures.push(`${profile.name} growth interactions: ${error.stack || error.message}`);
}
activeErrors = [];
// Bound the entire fixture, including browser protocol promises that are
// outside locator timeouts. A stuck fixture must fail the release gate.
const reportDeadline = setTimeout(() => {
console.error(`[report-times] ${profile.name}: exceeded 60-second fixture deadline`);
page.close().catch(() => {});
}, 60_000);
try {
await checkReportTimes(page, context, baseUrl);
if (activeErrors.length) throw new Error(activeErrors.join(' | '));
} catch (error) {
failures.push(`${profile.name} report times: ${error.stack || error.message}`);
} finally {
clearTimeout(reportDeadline);
}
activeErrors = [];
try {
await checkFaqNavigation(page, baseUrl);
if (activeErrors.length) throw new Error(activeErrors.join(' | '));
Expand Down
33 changes: 25 additions & 8 deletions scripts/test-trust-state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
ageMinutes,
freshnessOf,
formatAge,
liveLabel,
fetchLabel,
reportTimeLabel,
summarize,
} from '../src/lib/trustState.js';
import { availableLaneWait } from '../src/lib/crossingAvailability.js';
Expand Down Expand Up @@ -65,19 +66,35 @@ test('missing or unparseable timestamps are unknown, never zero', () => {
assert.equal(freshnessOf(bad, NOW).state, FRESHNESS.UNKNOWN);
}
// An unknown age must not render as "Live".
assert.notEqual(liveLabel(FRESHNESS.UNKNOWN, null, 'en'), 'Live');
assert.notEqual(fetchLabel(FRESHNESS.UNKNOWN, null, 'en'), 'Live');
});

test('future timestamps clamp to zero rather than going negative', () => {
assert.equal(ageMinutes(new Date(NOW + 60_000).toISOString(), NOW), 0);
});

test('only fresh data is allowed to call itself Live, in both languages', () => {
assert.equal(liveLabel(FRESHNESS.FRESH, 3, 'en'), 'Live');
assert.equal(liveLabel(FRESHNESS.FRESH, 3, 'es'), 'En vivo');
for (const state of [FRESHNESS.AGING, FRESHNESS.STALE, FRESHNESS.UNKNOWN]) {
assert.doesNotMatch(liveLabel(state, 120, 'en'), /^Live$/);
assert.doesNotMatch(liveLabel(state, 120, 'es'), /^En vivo$/);
test('retrieval labels never claim the lane report itself is live', () => {
assert.equal(fetchLabel(FRESHNESS.FRESH, 3, 'en'), 'Fetched 3 min ago');
assert.equal(fetchLabel(FRESHNESS.FRESH, 3, 'es'), 'Consulta: hace 3 min');
assert.equal(fetchLabel(FRESHNESS.STALE, 120, 'en'), 'Stale snapshot · Fetched 2 h ago');
assert.equal(fetchLabel(FRESHNESS.UNKNOWN, null, 'es'), 'Hora de consulta desconocida');
for (const state of Object.values(FRESHNESS)) {
assert.doesNotMatch(fetchLabel(state, 120, 'en'), /Live/);
assert.doesNotMatch(fetchLabel(state, 120, 'es'), /En vivo/);
}
});

test('official report times preserve raw midnight, locale and DST text without inferred dates or ages', () => {
for (const raw of ['At 11:00 pm MST', 'At Midnight PDT', 'At 1:00 am PST', 'At 1:00 am PDT', 'A las 11:00 pm MST']) {
assert.equal(reportTimeLabel(raw, 'en'), `CBP reported: ${raw}`);
assert.equal(reportTimeLabel(raw, 'es'), `Reporte CBP: ${raw}`);
}
});

test('missing report times stay unknown rather than borrowing a fresh fetch or feed export time', () => {
for (const missing of [null, undefined, '', ' ', 0, {}]) {
assert.equal(reportTimeLabel(missing, 'en'), 'CBP report time unavailable');
assert.equal(reportTimeLabel(missing, 'es'), 'Hora del reporte CBP no disponible');
}
});

Expand Down
42 changes: 15 additions & 27 deletions src/components/dashboard/BorderCrossingCard.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { track } from '@/lib/analytics';
import { FRESHNESS, freshnessOf, liveLabel } from '@/lib/trustState';
import { FRESHNESS, freshnessOf, fetchLabel, reportTimeLabel } from '@/lib/trustState';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
Expand All @@ -21,7 +21,6 @@ import LaneRow from '@/components/dashboard/LaneRow';
import { isSparseCell } from '@/lib/aggregates';
import {
getStatusForDirection,
getUpdatedAtForDirection,
getWaitMinutes,
} from '@/components/utils/crossingDirection';
import {
Expand Down Expand Up @@ -96,20 +95,6 @@ function formatHour12(h, lang) {
return `${h % 12 || 12} ${suffix}`;
}

function formatUpdatedAt(value, language) {
if (!value) return '';
if (typeof value === 'string' && /T.*Z$/.test(value)) {
const d = new Date(value);
if (!Number.isNaN(d.getTime())) {
return d.toLocaleTimeString(language === 'en' ? 'en-US' : 'es-MX', {
hour: 'numeric',
minute: '2-digit',
});
}
}
return String(value);
}

function advisoryLabel(type, language) {
const labels = {
closure: { en: 'Closure', es: 'Cierre' },
Expand Down Expand Up @@ -165,10 +150,9 @@ export default function BorderCrossingCard({
const s = STATUS_STYLES[status] || STATUS_STYLES.unknown;
const wait = getWaitMinutes(crossing, selectedDirection);
const isHigh = typeof wait === 'number' && wait >= 45;
const updatedAt = getUpdatedAtForDirection(crossing, selectedDirection);
// The footer dot used to say "Live" unconditionally, even on an hours-old
// snapshot. Freshness now comes from the shared trust model.
const freshness = freshnessOf(snapshotAt ?? updatedAt);
// Retrieval freshness must not be confused with a lane's report time.
// A timezone-less feed export timestamp is not a fallback fetch timestamp.
const freshness = freshnessOf(snapshotAt);
const isSouthbound = selectedDirection === 'southbound';
const portStatus = getPortStatus(crossing);
const portStatusStyle = PORT_STATUS_STYLES[portStatus] || PORT_STATUS_STYLES.unknown;
Expand All @@ -179,7 +163,7 @@ export default function BorderCrossingCard({
? (language === 'en'
? (isSouthbound ? 'Estimate not available yet' : 'No current wait time reported')
: (isSouthbound ? 'La estimación todavía no está disponible' : 'No hay tiempo actual reportado'))
: (isSouthbound ? (language === 'en' ? 'Estimated delay' : 'Demora estimada') : (language === 'en' ? 'Official wait time' : 'Tiempo oficial'));
: (isSouthbound ? (language === 'en' ? 'Estimated delay' : 'Demora estimada') : (language === 'en' ? 'Latest reported wait' : 'Última espera reportada'));
const hoursLabel = getHoursSummary(crossing, language);
const advisoryText = summarizeNotice(getOperationalNotice(crossing));
const hasAdvisory = hasOperationalAdvisory(crossing);
Expand Down Expand Up @@ -259,7 +243,7 @@ export default function BorderCrossingCard({
animate={{ opacity: 1, y: 0 }}
transition={reduceMotion ? { duration: 0 } : { delay: Math.min(index * 0.02, 0.3) }}
>
<Card className="h-full flex flex-col overflow-hidden rounded-lg border-slate-200/90 bg-white shadow-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg dark:border-slate-700 dark:bg-slate-950">
<Card data-testid="border-crossing-card" data-port-number={crossing.port_number} className="h-full flex flex-col overflow-hidden rounded-lg border-slate-200/90 bg-white shadow-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg dark:border-slate-700 dark:bg-slate-950">
<div className={`h-1.5 ${s.bar}`} />
<CardContent className="p-3 flex-1 flex flex-col">
{/* Header */}
Expand Down Expand Up @@ -366,7 +350,7 @@ export default function BorderCrossingCard({
{waitMetaLabel}
</div>
<div className="mt-1 flex items-baseline gap-2">
<span className={`text-4xl font-bold tabular-nums leading-none ${s.waitText}`}>
<span data-testid="primary-wait" className={`text-4xl font-bold tabular-nums leading-none ${s.waitText}`}>
{wait == null ? '—' : wait}
</span>
<span className="text-sm font-medium text-slate-700">
Expand All @@ -390,6 +374,11 @@ export default function BorderCrossingCard({
)}
</div>
</div>
{!isSouthbound && wait != null && (
<p className="mt-2 text-xs text-slate-600 break-words" data-testid="cbp-report-time">
{reportTimeLabel(lanes.passenger_standard?.update_time, language)}
</p>
)}
<div className={`mt-2.5 h-2 overflow-hidden rounded-full ${s.rail}`}>
<div
className={`h-full rounded-full ${s.bar}`}
Expand Down Expand Up @@ -498,19 +487,18 @@ export default function BorderCrossingCard({
)}
</AnimatePresence>

{/* Footer: live dot + updated + warning */}
{/* Footer: snapshot retrieval freshness, separate from official lane reports. */}
<div className="mt-auto mt-2 flex flex-wrap items-center justify-between gap-x-2 gap-y-1 border-t border-slate-200 pt-2 text-xs text-slate-600 dark:border-slate-700 dark:text-slate-300">
<div className="flex items-center gap-1.5">
<span
className={`w-2 h-2 rounded-full ${
freshness.state === FRESHNESS.FRESH ? `${s.dot} motion-safe:animate-pulse` : 'bg-slate-400'
}`}
/>
<span className={freshness.state === FRESHNESS.STALE ? 'text-amber-700 dark:text-amber-400' : undefined}>
{liveLabel(freshness.state, freshness.age, language)}
<span data-testid="snapshot-freshness" className={freshness.state === FRESHNESS.STALE ? 'text-amber-700 dark:text-amber-400' : undefined}>
{fetchLabel(freshness.state, freshness.age, language)}
</span>
</div>
<span className="text-slate-600 dark:text-slate-300 tabular-nums">{formatUpdatedAt(updatedAt, language)}</span>
</div>
{isHigh && (
<div className="mt-2 flex items-center gap-1.5 rounded-md bg-rose-50 px-2 py-1 text-xs text-rose-800">
Expand Down
29 changes: 18 additions & 11 deletions src/components/dashboard/LaneRow.jsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import { reportTimeLabel } from '@/lib/trustState';

// Single-row presentation of one CBP lane's current state. Used both on
// the dashboard card (gated behind "Show lanes") and on /crossing/:slug
// (always-on "Lanes right now" section). Kept light-themed to match the
// (always-on "Latest lane reports" section). Kept light-themed to match the
// dashboard card's existing lane-drawer chrome.
export default function LaneRow({ icon: Icon, label, data, language }) {
if (!data) return null;
return (
<div className="flex items-center justify-between py-1.5 text-xs">
<div className="flex items-center gap-2 text-slate-600 min-w-0">
<Icon className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{label[language] || label.en}</span>
</div>
<div className="flex items-center gap-2 font-medium text-slate-900 tabular-nums whitespace-nowrap">
<span>{data.delay_minutes == null ? '—' : `${data.delay_minutes}m`}</span>
<span className="text-slate-700 font-normal">
· {data.lanes_open} {language === 'en' ? 'open' : 'abiertas'}
</span>
<div className="py-1.5 text-xs" data-testid="lane-row">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-slate-600 min-w-0">
<Icon className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{label[language] || label.en}</span>
</div>
<div className="flex items-center gap-2 font-medium text-slate-900 tabular-nums whitespace-nowrap">
<span>{data.delay_minutes == null ? '—' : `${data.delay_minutes}m`}</span>
<span className="text-slate-700 font-normal">
· {data.lanes_open} {language === 'en' ? 'open' : 'abiertas'}
</span>
</div>
</div>
{typeof data.delay_minutes === 'number' && (
<p data-testid="cbp-report-time" className="mt-1 text-slate-600 break-words">{reportTimeLabel(data.update_time, language)}</p>
)}
</div>
);
}
33 changes: 20 additions & 13 deletions src/lib/trustState.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,22 +70,29 @@ export function formatAge(age, language = 'en') {
}

/**
* The live-state label for a card footer. Only genuinely recent data is
* allowed to call itself "Live"; everything else states its age instead of
* implying a real-time feed.
* Retrieval freshness is not the age of an individual CBP lane report.
* Keep the measured snapshot thresholds, but never label a recent fetch Live.
*/
export function liveLabel(state, age, language = 'en') {
export function fetchLabel(state, age, language = 'en') {
const en = language === 'en';
switch (state) {
case FRESHNESS.FRESH:
return en ? 'Live' : 'En vivo';
case FRESHNESS.AGING:
return formatAge(age, language);
case FRESHNESS.STALE:
return en ? `Stale · ${formatAge(age, language)}` : `Desactualizado · ${formatAge(age, language)}`;
default:
return en ? 'Age unknown' : 'Antigüedad desconocida';
if (state === FRESHNESS.UNKNOWN) return en ? 'Fetch time unknown' : 'Hora de consulta desconocida';
const fetched = en ? `Fetched ${formatAge(age, language)}` : `Consulta: ${formatAge(age, language)}`;
return state === FRESHNESS.STALE
? `${en ? 'Stale snapshot' : 'Consulta desactualizada'} · ${fetched}`
: fetched;
}

/**
* CBP lane update_time is clock-only text, often with a timezone abbreviation.
* Preserve it as supplied; the feed's export date and our fetch time cannot
* establish the lane's report date, midnight rollover or DST occurrence.
*/
export function reportTimeLabel(updateTime, language = 'en') {
const en = language === 'en';
if (typeof updateTime !== 'string' || !updateTime.trim()) {
return en ? 'CBP report time unavailable' : 'Hora del reporte CBP no disponible';
}
return `${en ? 'CBP reported' : 'Reporte CBP'}: ${updateTime}`;
}

/**
Expand Down
10 changes: 5 additions & 5 deletions src/pages/CrossingDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -492,7 +492,7 @@ export default function CrossingDetail() {
language={language}
index={0}
selectedDirection="northbound"
snapshotAt={isSanYsidro ? state.fetchedAt : null}
snapshotAt={state.fetchedAt}
isFavorite={favorites.includes(crossing.port_number)}
onToggleFavorite={toggleFavorite}
/>
Expand Down Expand Up @@ -554,7 +554,7 @@ export default function CrossingDetail() {
)}

{(() => {
// "Lanes right now" — always-on per-lane breakout for this port.
// "Latest lane reports" — always-on per-lane breakout for this port.
// Filters to lanes CBP currently reports with a real delay value;
// hides "Update Pending" stubs and null lanes that don't apply here.
const L = crossing.lanes || {};
Expand All @@ -573,12 +573,12 @@ export default function CrossingDetail() {
return (
<section className="mb-6">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
{language === 'en' ? 'Lanes right now' : 'Carriles ahora mismo'}
{language === 'en' ? 'Latest lane reports' : 'Últimos reportes por carril'}
</h2>
<p className="text-xs text-slate-500 mb-3">
{language === 'en'
? `Per-lane delay and number of lanes open at ${crossing.name}, straight from CBP. Northbound only.`
: `Demora por carril y número de carriles abiertos en ${crossing.name}, directo de CBP. Solo hacia EE.UU.`}
? `Per-lane delay and number of lanes open at ${crossing.name}, as reported by CBP. Report times may differ from the data fetch time. Northbound only.`
: `Demora por carril y número de carriles abiertos en ${crossing.name}, según CBP. La hora del reporte puede diferir de la hora de consulta. Solo hacia EE.UU.`}
</p>
<div className="rounded-md border border-slate-200 bg-slate-50 p-2.5 divide-y divide-slate-200">
{rows.map((r) => (
Expand Down
Loading