diff --git a/CHANGELOG.md b/CHANGELOG.md index d8f7eeddb..962ad5bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DECISIONS.md b/DECISIONS.md index 23be2ef6b..d39c16dd1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md index 256255dc7..76b825964 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/scripts/check-report-times.mjs b/scripts/check-report-times.mjs new file mode 100644 index 000000000..b38c2677a --- /dev/null +++ b/scripts/check-report-times.mjs @@ -0,0 +1,350 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const PORT = '260401'; +const DETAIL_PATH = '/crossing/nogales-deconcini/'; +const FEED_PATHS = ['/data/crossings.json', '/api/public/crossings']; +const FIXTURE_HEADER = 'x-borderpulse-report-time-fixture'; +const STORAGE_KEYS = [ + 'borderPulse_language', 'borderPulse_region', 'borderPulse_geoConsent', + 'borderPulse_waitHistory_v1', 'borderPulse_theme', +]; +const LANE_LABELS = { + passenger_standard: { en: 'Standard', es: 'Estándar' }, + passenger_ready: { en: 'Ready Lane', es: 'Ready Lane' }, + passenger_sentri: { en: 'SENTRI', es: 'SENTRI' }, + pedestrian_standard: { en: 'Pedestrian', es: 'Peatones' }, + pedestrian_ready: { en: 'Pedestrian Ready', es: 'Peatones Ready' }, + commercial_standard: { en: 'Commercial', es: 'Comercial' }, + commercial_fast: { en: 'FAST', es: 'FAST' }, +}; + +const reportLabel = (language, raw) => raw + ? `${language === 'en' ? 'CBP reported: ' : 'Reporte CBP: '}${raw}` + : (language === 'en' ? 'CBP report time unavailable' : 'Hora del reporte CBP no disponible'); +const freshLabel = language => language === 'en' ? 'Fetched just now' : 'Consulta: ahora mismo'; +const lane = (delay_minutes, update_time, status = 'delay') => ({ + delay_minutes, update_time, status, lanes_open: 1, +}); +const escapeRegex = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +async function expectText(locator, expected, message) { + const pattern = expected instanceof RegExp ? expected : new RegExp(`^\\s*${escapeRegex(expected)}\\s*$`); + await locator.filter({ hasText: pattern }).waitFor({ timeout: 5_000 }); + const actual = (await locator.innerText()).trim(); + if (expected instanceof RegExp) assert.match(actual, expected, message); + else assert.equal(actual, expected, message); +} + +async function assertFits(page, label) { + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), + `${label}: page must fit the current viewport`); + const overflowing = await page.getByTestId('cbp-report-time').evaluateAll(nodes => nodes + .filter(node => node.getClientRects().length) + .filter(node => { + const rect = node.getBoundingClientRect(); + return rect.left < -1 || rect.right > innerWidth + 1 + || (node.clientWidth > 0 && node.scrollWidth > node.clientWidth + 1); + }).map(node => node.textContent)); + assert.deepEqual(overflowing, [], `${label}: report labels must wrap without clipping`); +} + +async function assertDarkReadability(page) { + await page.getByRole('button', { name: 'Switch to dark mode', exact: true }).click(); + try { + await page.waitForFunction(() => document.documentElement.classList.contains('dark')); + const contrasts = await page.getByTestId('cbp-report-time').evaluateAll(nodes => { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + const rgba = color => { + ctx.clearRect(0, 0, 1, 1); + ctx.fillStyle = color; + ctx.fillRect(0, 0, 1, 1); + return [...ctx.getImageData(0, 0, 1, 1).data].map(value => value / 255); + }; + const over = (top, bottom) => top.slice(0, 3).map((value, i) => value * top[3] + bottom[i] * (1 - top[3])); + const luminance = color => color.map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4) + .reduce((sum, value, i) => sum + value * [0.2126, 0.7152, 0.0722][i], 0); + return nodes.filter(node => node.getClientRects().length).map(node => { + const ancestors = []; + for (let element = node; element; element = element.parentElement) ancestors.unshift(element); + const background = ancestors.reduce((color, element) => over(rgba(getComputedStyle(element).backgroundColor), color), [1, 1, 1]); + const foreground = over(rgba(getComputedStyle(node).color), background); + const levels = [luminance(background), luminance(foreground)].sort((a, b) => a - b); + return { text: node.textContent, ratio: (levels[1] + 0.05) / (levels[0] + 0.05) }; + }); + }); + assert.ok(contrasts.length > 0, 'dark-mode check must see report labels'); + for (const { text, ratio } of contrasts) { + assert.ok(ratio >= 4.5, `dark-mode report text contrast ${ratio.toFixed(2)}: ${text}`); + } + await assertFits(page, 'dark-mode report labels'); + } finally { + await page.getByRole('button', { name: 'Switch to light mode', exact: true }).click(); + await page.waitForFunction(() => !document.documentElement.classList.contains('dark')); + } +} + +async function assertPrimary(card, language, wait, raw, fetched = freshLabel(language)) { + await expectText(card.getByTestId('primary-wait'), wait == null ? '—' : String(wait), + 'report-time copy must not change the primary wait'); + const labels = card.getByTestId('cbp-report-time'); + if (wait == null) { + assert.equal(await labels.count(), 0, 'a pending/closed primary wait must not gain a numeric report label'); + } else { + await expectText(labels, reportLabel(language, raw), 'primary report must preserve the exact source clock'); + assert.equal(await labels.count(), 1, 'collapsed card has exactly one primary report label'); + } + await expectText(card.getByTestId('snapshot-freshness'), fetched, 'footer must describe fetch freshness'); + assert.equal(await card.getByText(/^(Live|En vivo)$/).count(), 0, 'no card freshness badge may claim Live'); +} + +async function assertLanes(scope, language, lanes, keys) { + assert.equal(await scope.getByTestId('lane-row').count(), keys.length, 'expected numeric lane rows'); + for (const key of keys) { + const row = scope.getByTestId('lane-row').filter({ + has: scope.page().getByText(LANE_LABELS[key][language], { exact: true }), + }); + assert.equal(await row.count(), 1, `${key}: one correctly identified lane`); + await row.getByText(`${lanes[key].delay_minutes}m`, { exact: true }).waitFor(); + await expectText(row.getByTestId('cbp-report-time'), reportLabel(language, lanes[key].update_time), + `${key}: use its own raw clock/timezone, never another lane's time`); + } +} + +// Headless browsers do not consistently background tabs. Dispatch the actual +// subscription events with a temporary visibility override, then restore it. +async function returnToTab(page) { + await page.evaluate(() => { + const original = Object.getOwnPropertyDescriptor(document, 'visibilityState'); + try { + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' }); + document.dispatchEvent(new Event('visibilitychange')); + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); + document.dispatchEvent(new Event('visibilitychange')); + } finally { + if (original) Object.defineProperty(document, 'visibilityState', original); + else delete document.visibilityState; + } + }); +} + +// Called once per existing smoke-routes desktop/mobile profile. That context +// blocks external origins and service workers; both CBP paths are intercepted +// here, so neither the real server endpoint nor an ad provider is exercised. +export async function checkReportTimes(page, context, baseUrl) { + const origin = new URL(baseUrl).origin; + assert.equal(new URL(page.url()).origin, origin, 'caller must first open the app origin'); + const savedStorage = await page.evaluate(keys => Object.fromEntries( + keys.map(key => [key, localStorage.getItem(key)])), STORAGE_KEYS); + const snapshot = JSON.parse(fs.readFileSync(new URL('../public/data/crossings.json', import.meta.url), 'utf8')); + const crossing = snapshot.crossings.find(row => row.port_number === PORT); + assert.ok(crossing, 'Nogales DeConcini fixture must exist'); + let mode = 'normal'; + let feedRequests = 0; + const responses = []; + const recordResponse = response => responses.push(response); + const feedMatcher = url => url.origin === origin && FEED_PATHS.includes(url.pathname); + const feedHandler = route => { + feedRequests += 1; + const isLive = new URL(route.request().url()).pathname === '/api/public/crossings'; + const failed = mode === 'fail-all' || (mode === 'static-fallback' && isLive); + return route.fulfill({ + status: failed ? 503 : 200, + contentType: 'application/json', + headers: { [FIXTURE_HEADER]: 'true', 'cache-control': 'no-store' }, + body: JSON.stringify(failed ? { error: 'Synthetic feed unavailable' } : snapshot), + }); + }; + const card = () => page.locator(`[data-testid="border-crossing-card"][data-port-number="${PORT}"]`); + const section = language => page.locator('section').filter({ has: page.getByRole('heading', { + level: 2, name: language === 'en' ? 'Latest lane reports' : 'Últimos reportes por carril', exact: true, + }) }); + const search = async language => { + await page.getByRole('searchbox', { + name: language === 'en' ? 'Search crossings by name or city' : 'Buscar cruces por nombre o ciudad', exact: true, + }).fill('Deconcini'); + await card().waitFor(); + }; + const waitForFeed = async action => { + await Promise.all([ + page.waitForResponse(response => { + const url = new URL(response.url()); + return feedMatcher(url) && (mode === 'normal' || url.pathname === '/data/crossings.json'); + }, { timeout: 5_000 }), + action(), + ]); + // Do not await Response.finished(): a rejected 503 body may never emit + // requestfinished. The UI assertions below wait for the applied payload. + // Give response parsing and subscriptions a bounded event-loop turn. + // Parallel headless profiles may pause animation frames in a background tab. + await page.waitForTimeout(50); + }; + const setStandard = (wait, raw, status = wait === 0 ? 'no delay' : 'delay') => { + if (mode === 'normal') snapshot.fetched_at = new Date().toISOString(); + crossing.current_wait_time = wait; + crossing.status = wait == null ? 'unknown' : wait < 15 ? 'good' : wait < 45 ? 'moderate' : 'heavy'; + crossing.lanes.passenger_standard = lane(wait, raw, status); + }; + + // Playwright Core has no default action timeout. Bound fixture failures so + // a missing selector reports a failure instead of hanging the release gate. + page.setDefaultTimeout(10_000); + await context.route(feedMatcher, feedHandler); + page.on('response', recordResponse); + try { + for (const language of ['en', 'es']) { + console.log(`[report-times] ${language}: dashboard and lane drawer`); + mode = 'normal'; + snapshot.fetched_at = new Date().toISOString(); + crossing.port_status = 'Open'; + // A conflicting old aggregate row timestamp must never replace fetched_at. + crossing.updated_at = '2001-01-02T03:04:05.000Z'; + crossing.lanes = { + passenger_standard: lane(37, 'At 11:00 pm MST'), + passenger_ready: lane(13, 'At 12:05 am MDT'), + passenger_sentri: lane(0, 'At 1:15 am PST', 'no delay'), + pedestrian_standard: lane(4, 'At 1:25 am PDT'), + pedestrian_ready: lane(8, 'At 2:35 am EST'), + commercial_standard: lane(22, 'At 3:45 am CDT'), + commercial_fast: lane(7, 'At 4:55 am CST'), + }; + setStandard(37, 'At 11:00 pm MST'); + await page.evaluate(lang => { + localStorage.setItem('borderPulse_language', lang); + localStorage.setItem('borderPulse_region', 'ALL'); + localStorage.setItem('borderPulse_theme', 'light'); + localStorage.setItem('borderPulse_geoConsent', 'denied'); + }, language); + await page.goto(new URL('/', baseUrl).href); + await search(language); + await assertPrimary(card(), language, 37, 'At 11:00 pm MST'); + await assertFits(page, `${language} dashboard`); + + // Drawer and always-visible detail share LaneRow but must retain each + // lane's own time, including zero waits and differing DST abbreviations. + await card().getByRole('button', { name: language === 'en' ? 'More options' : 'Más opciones', exact: true }).click(); + await page.getByRole('menuitem', { name: language === 'en' ? 'Show lanes' : 'Ver carriles', exact: true }).click(); + await card().getByTestId('lane-row').first().waitFor(); + await assertLanes(card(), language, crossing.lanes, Object.keys(LANE_LABELS).filter(key => key !== 'pedestrian_ready')); + await assertFits(page, `${language} expanded drawer`); + await assertDarkReadability(page); + await card().getByRole('button', { name: language === 'en' ? 'More options' : 'Más opciones', exact: true }).click(); + await page.getByRole('menuitem', { name: language === 'en' ? 'Hide lanes' : 'Ocultar carriles', exact: true }).click(); + await card().getByTestId('lane-row').first().waitFor({ state: 'detached' }); + await assertPrimary(card(), language, 37, 'At 11:00 pm MST'); + await waitForFeed(() => card().getByRole('link', { name: crossing.name, exact: true }).click()); + await page.waitForURL(url => url.pathname === DETAIL_PATH); + await section(language).waitFor(); + await assertPrimary(card(), language, 37, 'At 11:00 pm MST'); + await assertLanes(section(language), language, crossing.lanes, Object.keys(LANE_LABELS)); + assert.equal(await page.getByRole('heading', { name: /^(Lanes right now|Carriles ahora mismo)$/ }).count(), 0); + await assertFits(page, `${language} detail`); + await waitForFeed(() => page.goBack()); + await page.waitForURL(url => url.pathname === '/'); + await search(language); + await assertPrimary(card(), language, 37, 'At 11:00 pm MST'); + + console.log(`[report-times] ${language}: refresh and source-time states`); + setStandard(41, 'At 12:15 am MDT'); + snapshot.fetched_at = new Date().toISOString(); + await waitForFeed(() => page.getByRole('button', { name: language === 'en' ? 'Refresh' : 'Actualizar', exact: true }).click()); + await assertPrimary(card(), language, 41, 'At 12:15 am MDT'); + setStandard(43, 'At 1:05 am PDT'); + snapshot.fetched_at = new Date().toISOString(); + await waitForFeed(() => returnToTab(page)); + await assertPrimary(card(), language, 43, 'At 1:05 am PDT'); + + setStandard(31, null); + await page.goto(new URL(DETAIL_PATH, baseUrl).href); + await section(language).waitFor(); + await assertPrimary(card(), language, 31, null); + await assertLanes(section(language), language, crossing.lanes, Object.keys(LANE_LABELS)); + await assertFits(page, `${language} missing source time`); + + setStandard(0, 'At 1:15 am PST'); + await page.reload(); + await section(language).waitFor(); + await assertPrimary(card(), language, 0, 'At 1:15 am PST'); + await assertLanes(section(language), language, crossing.lanes, Object.keys(LANE_LABELS)); + + // Keep CBP's pending zero stub: it must not create a zero primary wait. + setStandard(null, 'At 11:00 pm MST', 'Update Pending'); + crossing.lanes.passenger_standard.delay_minutes = 0; + await page.reload(); + await section(language).waitFor(); + await assertPrimary(card(), language, null, null); + await card().getByText(language === 'en' ? 'No current wait time reported' : 'No hay tiempo actual reportado', { exact: true }).waitFor(); + await assertLanes(section(language), language, crossing.lanes, Object.keys(LANE_LABELS).filter(key => key !== 'passenger_standard')); + + // A retained numeric lane does not override an official port closure. + crossing.port_status = 'Closed'; + setStandard(90, 'At 11:00 pm MST'); + await page.reload(); + await card().waitFor(); + await assertPrimary(card(), language, null, null); + await card().getByText(language === 'en' ? 'Closed now' : 'Cerrado ahora', { exact: true }).waitFor(); + await assertFits(page, `${language} closed primary state`); + + // A new document resets the in-memory cache, allowing an intentionally + // older static snapshot. Live-API builds first receive a synthetic 503. + console.log(`[report-times] ${language}: stale fallback and recovery`); + mode = 'static-fallback'; + snapshot.fetched_at = new Date(Date.now() - 3 * 60 * 60_000).toISOString(); + crossing.updated_at = new Date().toISOString(); + crossing.port_status = 'Open'; + setStandard(17, 'At 1:15 am PST'); + await page.goto(new URL('/', baseUrl).href); + await search(language); + const stale = language === 'en' + ? /^Stale snapshot · Fetched 3 h(?: \d+ min)? ago$/ + : /^Consulta desactualizada · Consulta: hace 3 h(?: \d+ min)?$/; + await assertPrimary(card(), language, 17, 'At 1:15 am PST', stale); + await waitForFeed(() => card().getByRole('link', { name: crossing.name, exact: true }).click()); + await page.waitForURL(url => url.pathname === DETAIL_PATH); + await section(language).waitFor(); + await assertPrimary(card(), language, 17, 'At 1:15 am PST', stale); + await assertFits(page, `${language} stale detail`); + + mode = 'fail-all'; + await waitForFeed(() => returnToTab(page)); + await assertPrimary(card(), language, 17, 'At 1:15 am PST', stale); + mode = 'normal'; + snapshot.fetched_at = new Date().toISOString(); + setStandard(29, 'At 2:05 am EDT'); + await waitForFeed(() => page.evaluate(() => window.dispatchEvent(new Event('online')))); + await assertPrimary(card(), language, 29, 'At 2:05 am EDT'); + await assertLanes(section(language), language, crossing.lanes, Object.keys(LANE_LABELS)); + await assertFits(page, `${language} recovered detail`); + } + + assert.ok(feedRequests > 0, 'the synthetic feed must actually be requested'); + for (const response of responses) { + const url = new URL(response.url()); + assert.equal(url.origin, origin, `unexpected provider response: ${url.href}`); + if (FEED_PATHS.includes(url.pathname)) { + assert.equal(await response.headerValue(FIXTURE_HEADER), 'true', `feed bypassed its mock: ${url.href}`); + } else { + assert.ok(!url.pathname.startsWith('/api/'), `unexpected real server response: ${url.href}`); + } + } + } finally { + // Finish on a non-data page so the singleton cannot refresh synthetic data + // after the route mocks are removed. A following navigation remounts it. + try { + await page.goto(new URL('/about/', baseUrl).href); + await page.evaluate(values => { + for (const [key, value] of Object.entries(values)) { + if (value == null) localStorage.removeItem(key); + else localStorage.setItem(key, value); + } + }, savedStorage); + } finally { + page.off('response', recordResponse); + await context.unroute(feedMatcher, feedHandler); + page.setDefaultTimeout(0); + } + } + console.log('[report-times] PASS: EN/ES raw lane clocks, missing/zero/pending/closed waits, drawer/detail/Back, refresh/tab return, stale fallback/recovery, dark contrast and viewport fit'); +} diff --git a/scripts/smoke-routes.mjs b/scripts/smoke-routes.mjs index 9859cd1e4..0384d1536 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 { 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'; @@ -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(' | ')); diff --git a/scripts/test-trust-state.mjs b/scripts/test-trust-state.mjs index 2bd5ec070..55d8cb5bb 100644 --- a/scripts/test-trust-state.mjs +++ b/scripts/test-trust-state.mjs @@ -7,7 +7,8 @@ import { ageMinutes, freshnessOf, formatAge, - liveLabel, + fetchLabel, + reportTimeLabel, summarize, } from '../src/lib/trustState.js'; import { availableLaneWait } from '../src/lib/crossingAvailability.js'; @@ -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'); } }); diff --git a/src/components/dashboard/BorderCrossingCard.jsx b/src/components/dashboard/BorderCrossingCard.jsx index 4293d9e0c..f83b5d8a5 100644 --- a/src/components/dashboard/BorderCrossingCard.jsx +++ b/src/components/dashboard/BorderCrossingCard.jsx @@ -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'; @@ -21,7 +21,6 @@ import LaneRow from '@/components/dashboard/LaneRow'; import { isSparseCell } from '@/lib/aggregates'; import { getStatusForDirection, - getUpdatedAtForDirection, getWaitMinutes, } from '@/components/utils/crossingDirection'; import { @@ -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' }, @@ -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; @@ -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); @@ -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) }} > - +
{/* Header */} @@ -366,7 +350,7 @@ export default function BorderCrossingCard({ {waitMetaLabel}
- + {wait == null ? '—' : wait} @@ -390,6 +374,11 @@ export default function BorderCrossingCard({ )}
+ {!isSouthbound && wait != null && ( +

+ {reportTimeLabel(lanes.passenger_standard?.update_time, language)} +

+ )}
- {/* Footer: live dot + updated + warning */} + {/* Footer: snapshot retrieval freshness, separate from official lane reports. */}
- - {liveLabel(freshness.state, freshness.age, language)} + + {fetchLabel(freshness.state, freshness.age, language)}
- {formatUpdatedAt(updatedAt, language)}
{isHigh && (
diff --git a/src/components/dashboard/LaneRow.jsx b/src/components/dashboard/LaneRow.jsx index db1476d59..eeb5a2a09 100644 --- a/src/components/dashboard/LaneRow.jsx +++ b/src/components/dashboard/LaneRow.jsx @@ -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 ( -
-
- - {label[language] || label.en} -
-
- {data.delay_minutes == null ? '—' : `${data.delay_minutes}m`} - - · {data.lanes_open} {language === 'en' ? 'open' : 'abiertas'} - +
+
+
+ + {label[language] || label.en} +
+
+ {data.delay_minutes == null ? '—' : `${data.delay_minutes}m`} + + · {data.lanes_open} {language === 'en' ? 'open' : 'abiertas'} + +
+ {typeof data.delay_minutes === 'number' && ( +

{reportTimeLabel(data.update_time, language)}

+ )}
); } diff --git a/src/lib/trustState.js b/src/lib/trustState.js index 2a55b012f..086dbcafb 100644 --- a/src/lib/trustState.js +++ b/src/lib/trustState.js @@ -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}`; } /** diff --git a/src/pages/CrossingDetail.jsx b/src/pages/CrossingDetail.jsx index 4fdb13e34..721473abb 100644 --- a/src/pages/CrossingDetail.jsx +++ b/src/pages/CrossingDetail.jsx @@ -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} /> @@ -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 || {}; @@ -573,12 +573,12 @@ export default function CrossingDetail() { return (

- {language === 'en' ? 'Lanes right now' : 'Carriles ahora mismo'} + {language === 'en' ? 'Latest lane reports' : 'Últimos reportes por carril'}

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

{rows.map((r) => (