diff --git a/CHANGELOG.md b/CHANGELOG.md index ab40d8a5..fcff05e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # CHANGELOG +## 2026-10-22 — Summary snapshot confidence (validated locally) + +- Reproduce PR141's expanded summary saying Moving well / Median wait now for + synthetic 120-minute-old and future retrievals in EN/ES desktop/mobile. +- Pass the existing fetch timestamp into the deferred summary. Reuse the same + 45/90-minute retrieval policy and future guard as Compare/walk-or-drive; + reevaluate each minute, at render after a delayed import/new response, and on + offline/online changes. Untrusted values remain dated reference readings: + neutral labels/ticks/metrics replace current-condition interpretations. +- Preserve reported numbers, map selection, filters and zero readings. Keep the + existing empty-data validator behavior for missing/invalid source timestamps. + A stable polite status announces confidence changes, without announcing every + age-minute update. No loader, feed, threshold, dependency or configuration change. +- Fresh build, 130 unit tests and full npm test PASS (420 local navigations), + with isolated EN/ES desktop/mobile delayed-open expiry, loaded expiry without + response, offline/online, stale/missing/invalid/future time, missing values, + zero recovery, original chunk failure/recovery, keyboard/menu and metadata QA. +- The first PR CI exposed a historical-fixture race: URL changed before React + committed the next port, and a generic h1 wait matched the previous page. + Wait for the target canonical before asserting no inherited pattern; retain + the absence assertion and all existing scenarios. No crossing-page code change. +- Entry 61.69 KiB local, unchanged 62 KiB cap. Paired full initial JS measurement + 280,511 → 280,514 gzip bytes (+3); summary own chunk 5,861 → 6,520 bytes and + one additional small shared retrieval-guard request on opening, not initial + loading. Simulated opening remains a diagnostic 0.30–1.32s; no production + speed, usage or conversion gain is established. Publication awaits remote CI. + ## 2026-10-22 — Patched Router release (PR #141) - Merge/source `5a681bad045eb4153830928021350844069cef98` published by existing diff --git a/DECISIONS.md b/DECISIONS.md index d0daeed8..c382f972 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,16 @@ # DECISIONS +## 2026-10-22 · Deferred content must evaluate confidence when it actually opens + +A usable snapshot can expire while an optional chunk is downloading, while a +page remains open without a successful response, or while the browser is offline. +The expanded dashboard summary must evaluate the existing retrieval policy on +its current render clock, then keep dated readings and controls available with +neutral interpretation when confidence is lost. Keep the per-minute reevaluation +local to this optional view and announce the confidence transition, not every +minute of age. A fresh response restores interpretation immediately. Do not +change feed validation, retrieval thresholds or gate budgets for this correction. + ## 2026-10-22 · A patched dependency candidate must also pass the performance gate The first Router 7.18.4 candidate cleared both runtime audit entries but diff --git a/docs/REFRESH-OPERATIONS.md b/docs/REFRESH-OPERATIONS.md index ed59e7e7..819493bb 100644 --- a/docs/REFRESH-OPERATIONS.md +++ b/docs/REFRESH-OPERATIONS.md @@ -1,5 +1,53 @@ # Public data refresh +## Expanded-summary confidence follow-up — 2026-10-22 UTC + +- Reproduced against the served PR141 app, using synthetic input only: 120-minute + and future snapshots still rendered Moving well / Median wait now and colored + chart ticks after opening the deferred summary. The top-level old-data warning + did not alter the opened summary. This is not evidence of a failing live feed. +- Scope: pass existing fetchedAt into DashboardSummary, reuse walkDriveFreshness + (45/90 minutes plus invalid/future guard), and track browser connectivity and + per-minute rerenders inside the existing optional chunk. Evaluate Date.now on + each render so an import or new response is not judged against an old timer. +- Untrusted retrievals/offline views keep raw wait values and valid recorded fetch + dates, chart ticks, keyboard selection and filters. Current-condition labels, + categorical colors and legends become neutral reference readings; accessible + SVG descriptions also stop claiming current conditions. A stable status region + announces confidence changes; the changing age/date line is not a live region. +- Missing/invalid timestamps are rejected upstream by the existing dataService + validator, so a cold load correctly has no rows/map rather than invented + history. The summary still explains unknown fetch time and missing readings. + Tests preserve that guard and prove a corrected zero-valued feed restores the + actual summary immediately. This change does not independently validate CBP + observation clocks, lane statistics or the numerical truth of the source. +- Local verification: fresh build and 130 unit tests PASS; full npm test PASS, + 420 navigations for its 181 canonical/28-alias input-derived inventory. Existing + loader failure/reload recovery, storage, sharing/feedback, keyboard/menu, + metadata and freshness fixtures pass. New independent-clock/context fixtures + cover expiry during held chunk download and after mounting without a new + response, offline/online, stale/missing/invalid/future times, dated numbers, + missing rows and genuine-zero recovery in EN/ES desktop/mobile. +- Entry remains 61.69 KiB under the unchanged 62 KiB gate. Paired full initially + required JS is 280,511 → 280,514 gzip bytes (+3). The summary own chunk grows + 5,861 → 6,520 bytes; the existing shared retrieval helper adds a small parallel + request when opening. No vendor chunk or mandatory payload is excluded from + measurement, and no budget/build/audit configuration changed. Simulated + 150ms/200KiB/s cold opening was about 0.30–1.32s; samples are diagnostic, not + evidence of production speed or conversion improvement. +- The first PR CI (39729934526) failed only at the existing historical-pattern + navigation assertion, while push CI passed. Its generic h1 wait could match + the old crossing during React's transition after the URL changed. The fixture + now waits for the target canonical before checking the same no-inherited-card + assertion. Preserve the failed log; no product crossing-page code or check was + removed. Rerun the complete local suite and both CI on the final test head. +- Release only after remote CI passes on the reviewed head and concurrent + source changes are rechecked; use the existing production workflow once. + Check the exact source/manifest, EN/ES rendered states and unchanged served + assets in a supported localhost preview for the language fixture. Roll back + only this scoped src/script diff through a normal tested PR, retaining collector + inputs, earlier Router mitigation, existing budgets and release history. + ## Router release — 2026-10-22 UTC (PR #141) - Reviewed head `e0bac72fd4d978dfbbec7f3c68d804025d3ac45c`, dependency-only diff --git a/scripts/check-dashboard-summary.mjs b/scripts/check-dashboard-summary.mjs index f5d3648c..73de3da4 100644 --- a/scripts/check-dashboard-summary.mjs +++ b/scripts/check-dashboard-summary.mjs @@ -103,5 +103,137 @@ export async function checkDashboardSummary(owner, context, baseUrl) { } } assert.deepEqual(errors, [], 'summary loading/failure/recovery must not crash the app'); + await checkSummaryConfidence(owner, context, baseUrl); console.log('[dashboard-summary] PASS: EN/ES closed-load budget, delayed first open, keyboard selection, retained state, current filters, menu actions/focus, failed asset isolation and recovery'); } + +// Independent clocks/contexts keep expiry and offline fixtures out of the +// existing navigation checks. Numbers and source dates must survive the guard. +export async function checkSummaryConfidence(owner, context, baseUrl) { + const source = JSON.parse(fs.readFileSync(new URL('../public/data/crossings.json', import.meta.url))); + source.crossings = source.crossings.filter(c => ['250401', '250601'].includes(c.port_number)); + const waits = value => source.crossings.forEach((c, index) => { + const wait = value === undefined ? [5, 10][index] : value; + c.port_status = 'Open'; + c.current_wait_time = c.northbound_wait_time = wait; + c.status = wait == null ? 'unknown' : 'good'; + c.lanes.passenger_standard = { status: wait == null ? 'Update Pending' : 'Delay', delay_minutes: wait, lanes_open: 1 }; + }); + for (const language of ['en', 'es']) { + waits(); + const now = Date.now(); + source.fetched_at = new Date(now - 88 * 60_000).toISOString(); + const qa = await context.browser().newContext({ viewport: owner.viewportSize(), serviceWorkers: 'block', reducedMotion: 'reduce' }); + await qa.addInitScript(value => localStorage.setItem('borderPulse_language', value), language); + await qa.route('**/*', route => new URL(route.request().url()).origin === baseUrl.origin ? route.continue() : route.abort('blockedbyclient')); + let fail = false, release; + const assetGate = new Promise(resolve => { release = resolve; }); + await qa.route(/\/assets\/DashboardSummary-[^/]+\.js$/, async route => { + const response = await route.fetch(); + await assetGate; + await route.fulfill({ response }); + }); + const feed = url => ['/data/crossings.json', '/api/public/crossings'].includes(url.pathname); + await qa.route(feed, route => fail ? route.fulfill({ status: 503, body: 'fixture unavailable' }) + : route.fulfill({ contentType: 'application/json', body: JSON.stringify(source) })); + const page = await qa.newPage(); + page.setDefaultTimeout(10_000); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.clock.install({ time: now }); + const summary = page.getByTestId('dashboard-summary'); + const notice = page.getByTestId('summary-freshness'); + const panel = page.getByRole('region', { name: language === 'en' ? 'Commuter snapshot' : 'Resumen para commuters', exact: true }); + const chart = page.getByRole('region', { name: language === 'en' ? 'The border, west to east' : 'La frontera, de oeste a este', exact: true }); + const open = async () => { + await page.getByRole('searchbox').waitFor(); + const disclosure = page.locator('summary').filter({ hasText: language === 'en' ? 'Show wait summary and border chart' : 'Ver resumen y mapa de esperas' }); + await disclosure.focus(); + await page.keyboard.press('Enter'); + }; + const trust = async value => page.waitForFunction(value => document.querySelector('[data-testid="dashboard-summary"]')?.dataset.trusted === String(value), value, { timeout: 2_000 }); + const neutral = async (hasMap = true) => { + await trust(false); + assert.doesNotMatch(await panel.innerText(), /Moving well|Heavy right now|Median wait now|Fluye bien|Pesado ahora|Espera mediana ahora/i); + if (hasMap) { + await chart.waitFor(); + assert.equal(await chart.locator('[stroke="var(--bp-good)"], [stroke="var(--bp-moderate)"], [stroke="var(--bp-heavy)"]').count(), 0, 'untrusted waits have neutral ticks'); + assert.doesNotMatch(await chart.locator('desc').textContent(), /currently reporting/i); + assert.equal(await chart.getByRole('button').count(), 2, 'map interactions remain available'); + } else { + assert.equal(await chart.count(), 0, 'rejected source data must not invent map readings'); + } + if (typeof source.fetched_at === 'string' && Number.isFinite(Date.parse(source.fetched_at))) { + await page.waitForFunction(value => document.querySelector('[data-testid="summary-freshness"] time')?.getAttribute('datetime') === value, source.fetched_at); + assert.equal(await notice.locator('time').getAttribute('datetime'), source.fetched_at, 'retain the exact fetch timestamp'); + } else { + await page.waitForFunction(() => !document.querySelector('[data-testid="summary-freshness"] time')); + } + }; + const refresh = async () => { + source.fetched_at = new Date(await page.evaluate(() => Date.now())).toISOString(); + fail = false; + await Promise.all([ + page.waitForResponse(response => feed(new URL(response.url()))), + page.evaluate(() => window.dispatchEvent(new Event('online'))), + ]); + await trust(true); + await panel.getByText(language === 'en' ? 'Moving well' : 'Fluye bien', { exact: true }).waitFor(); + assert.ok(await chart.locator('[stroke="var(--bp-good)"]').count() > 0, 'valid observations restore the colored chart'); + }; + try { + await page.goto(baseUrl.href); + await open(); + await page.getByRole('status').getByText(language === 'en' ? 'Loading…' : 'Cargando…', { exact: true }).waitFor(); + fail = true; + await page.clock.fastForward(3 * 60_000); + release(); + await neutral(); + await panel.getByText('8 min', { exact: true }).waitFor(); + await panel.getByText('5 min', { exact: true }).waitFor(); + await panel.getByText('10 min', { exact: true }).waitFor(); + await refresh(); + + await qa.setOffline(true); + await neutral(); + await notice.getByText(language === 'en' ? 'Offline. Reported values are retained for reference.' : 'Sin conexión. Conservamos los valores reportados como referencia.', { exact: true }).waitFor(); + const tick = chart.getByRole('button', { name: /^San Ysidro:/ }); + await tick.focus(); + await page.keyboard.press('Space'); + source.fetched_at = new Date(await page.evaluate(() => Date.now())).toISOString(); + await qa.setOffline(false); + await trust(true); + + // Loaded content must age without a successful new response as well. + source.fetched_at = new Date(await page.evaluate(() => Date.now()) - 89 * 60_000).toISOString(); + await page.goto(baseUrl.href); await open(); await trust(true); + await page.waitForFunction(value => document.querySelector('[data-testid="summary-freshness"] time')?.getAttribute('datetime') === value, source.fetched_at); + fail = true; + await page.clock.fastForward(2 * 60_000); + await neutral(); + await refresh(); + + for (const value of [null, 'invalid', new Date(await page.evaluate(() => Date.now()) + 5 * 60_000).toISOString(), new Date(await page.evaluate(() => Date.now()) - 120 * 60_000).toISOString()]) { + source.fetched_at = value; + // Dashboard's existing validator rejects missing/invalid timestamps + // before the summary sees any rows. Keep that guard and empty state. + const accepted = typeof value === 'string' && Number.isFinite(Date.parse(value)); + await page.goto(baseUrl.href); await open(); await neutral(accepted); + if (accepted) await panel.getByText('8 min', { exact: true }).waitFor(); + } + waits(null); source.fetched_at = null; + await page.goto(baseUrl.href); await open(); await neutral(false); + await notice.getByText(language === 'en' ? 'Fetch time unknown' : 'Hora de consulta desconocida', { exact: true }).waitFor(); + assert.ok(await panel.getByText('—', { exact: true }).count() >= 3, 'missing waits remain missing'); + waits(0); + await refresh(); + assert.ok(await panel.getByText('0 min', { exact: true }).count() >= 3, 'valid zero restores a genuine reading'); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + assert.deepEqual(errors, [], 'confidence fixtures must not crash the app'); + } finally { + release(); + await qa.close(); + } + } + console.log('[summary-confidence] PASS: EN/ES delayed-open expiry, loaded expiry without response, offline/online, stale/missing/invalid/future timestamps, retained values/dates, missing waits and zero recovery'); +} diff --git a/scripts/check-historical-pattern.mjs b/scripts/check-historical-pattern.mjs index 5fcbe4b0..3f7c0dc2 100644 --- a/scripts/check-historical-pattern.mjs +++ b/scripts/check-historical-pattern.mjs @@ -81,7 +81,10 @@ export async function checkHistoricalPatterns(owner, context, baseUrl) { assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); await page.locator('#root a[href="/crossing/calexico-west/"]').first().click(); await page.waitForFunction(() => location.pathname === '/crossing/calexico-west/'); - await page.getByRole('heading', { level: 1 }).waitFor(); + // History updates before React commits the new crossing. The previous + // port's h1 is still visible during that transition; verify the target + // identity before checking that its pattern is absent. + await page.waitForFunction(() => document.querySelector('link[rel="canonical"]')?.href === 'https://borderpulse.com/crossing/calexico-west/'); assert.equal(await page.getByTestId('historical-pattern').count(), 0, 'another port cannot inherit the pattern'); await page.goBack(); await page.getByTestId('historical-pattern').waitFor(); diff --git a/src/components/dashboard/BorderLine.jsx b/src/components/dashboard/BorderLine.jsx index d71a6855..4873b175 100644 --- a/src/components/dashboard/BorderLine.jsx +++ b/src/components/dashboard/BorderLine.jsx @@ -97,6 +97,7 @@ export default function BorderLine({ typicalByPort, focusedPort = null, onSelect, + trusted = true, }) { const t = STRINGS[language] || STRINGS.en; const isWide = useIsWide(); @@ -125,6 +126,9 @@ export default function BorderLine({ const anchors = useMemo(() => (isWide ? resolveAnchors(ticks) : []), [ticks, isWide]); const reportingCount = ticks.filter((k) => k.reporting).length; + const description = trusted ? t.desc(ticks.length, reportingCount) : (language === 'en' + ? `${ticks.length} crossings. Retained reported values; current conditions cannot be verified.` + : `${ticks.length} cruces. Valores reportados conservados; no se pueden verificar las condiciones actuales.`); const handleSelect = useCallback( (tick) => { @@ -150,7 +154,7 @@ export default function BorderLine({ if (!ticks.length) return null; const strokeFor = (tick) => - STATUS_STROKE[getStatusForDirection(tick.crossing, direction)] || STATUS_STROKE.unknown; + (trusted && STATUS_STROKE[getStatusForDirection(tick.crossing, direction)]) || STATUS_STROKE.unknown; // ---------------------------------------------------------------- desktop const W = 1000; @@ -173,7 +177,7 @@ export default function BorderLine({ > {t.title} - {t.desc(ticks.length, reportingCount)} + {description} {/* drafting grid */} @@ -314,7 +318,7 @@ export default function BorderLine({ > {t.title} - {t.desc(ticks.length, reportingCount)} + {description} @@ -397,13 +401,15 @@ export default function BorderLine({ >

{t.title}

- + {trusted && {t.quick} {t.moderate} {t.heavy} - + }
-

{showTypical ? t.subtitle : t.subtitleSb}

+

{!trusted + ? (language === 'en' ? 'Retained reported waits, not current conditions.' : 'Esperas reportadas conservadas, no condiciones actuales.') + : showTypical ? t.subtitle : t.subtitleSb}

{isWide ? desktop : mobile}
{/* Accessible equivalent. Also the only copy a text-mode crawler sees, diff --git a/src/components/dashboard/CommuterSnapshot.jsx b/src/components/dashboard/CommuterSnapshot.jsx index 79175dd7..703e8130 100644 --- a/src/components/dashboard/CommuterSnapshot.jsx +++ b/src/components/dashboard/CommuterSnapshot.jsx @@ -65,6 +65,7 @@ export default function CommuterSnapshot({ selectedDirection = 'northbound', language = 'en', regionLabel, + trusted = true, }) { const reduceMotion = useReducedMotion(); const stats = useMemo(() => { @@ -112,7 +113,10 @@ export default function CommuterSnapshot({ }; }, [crossings, selectedDirection]); - const tier = TIER_META[stats.overallTier] || TIER_META.unknown; + const tier = trusted ? (TIER_META[stats.overallTier] || TIER_META.unknown) : { + ...TIER_META.unknown, + label: { en: 'Reported values', es: 'Valores reportados' }, + }; const hasRegion = Boolean(regionLabel) && !['All', 'Todos'].includes(regionLabel); const scopeLabel = hasRegion ? regionLabel @@ -143,7 +147,9 @@ export default function CommuterSnapshot({

- {language === 'en' ? 'Median wait now' : 'Espera mediana ahora'} + {trusted + ? (language === 'en' ? 'Median wait now' : 'Espera mediana ahora') + : (language === 'en' ? 'Reported median wait' : 'Espera mediana reportada')}

{formatWait(stats.median)} @@ -155,9 +161,9 @@ export default function CommuterSnapshot({

-
-
-
+
+
+
{stats.reportingCount}/{stats.total} {language === 'en' ? 'reporting' : 'reportando'} @@ -171,7 +177,7 @@ export default function CommuterSnapshot({ label={language === 'en' ? 'Longest wait' : 'Mayor espera'} value={formatWait(stats.slowest?.wait)} detail={stats.slowest?.crossing?.name || (language === 'en' ? 'No current report' : 'Sin reporte actual')} - tone={stats.slowest?.wait >= 45 ? 'rose' : stats.slowest?.wait >= 15 ? 'amber' : 'slate'} + tone={!trusted ? 'slate' : stats.slowest?.wait >= 45 ? 'rose' : stats.slowest?.wait >= 15 ? 'amber' : 'slate'} />
-
+ {trusted &&
{language === 'en' ? 'Quick under 15m' : 'Rápido bajo 15m'} @@ -211,7 +217,7 @@ export default function CommuterSnapshot({ {language === 'en' ? 'Heavy 45m+' : 'Pesado 45m+'} -
+
} ); } diff --git a/src/components/dashboard/DashboardSummary.jsx b/src/components/dashboard/DashboardSummary.jsx index f4c9dd5f..e85f8aab 100644 --- a/src/components/dashboard/DashboardSummary.jsx +++ b/src/components/dashboard/DashboardSummary.jsx @@ -1,12 +1,41 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import BorderLine from './BorderLine'; import CommuterSnapshot from './CommuterSnapshot'; +import { walkDriveFreshness } from '@/lib/walkDriveFreshness'; +import { fetchLabel } from '@/lib/trustState'; -export default function DashboardSummary({ direction, regionLabel, ...props }) { +export default function DashboardSummary({ direction, regionLabel, fetchedAt, ...props }) { + const [offline, setOffline] = useState(() => !navigator.onLine); + const [, tick] = useState(0); + useEffect(() => { + const network = () => setOffline(!navigator.onLine); + const timer = setInterval(() => tick(n => n + 1), 60_000); + window.addEventListener('online', network); + window.addEventListener('offline', network); + return () => { + clearInterval(timer); + window.removeEventListener('online', network); + window.removeEventListener('offline', network); + }; + }, []); + // Read the clock on every render: a new response must not be judged against + // the previous minute's timer value, including after a delayed chunk load. + const freshness = walkDriveFreshness(fetchedAt); + const trusted = freshness.usable && !offline; + const en = props.language === 'en'; + const timestamp = typeof fetchedAt === 'string' && Number.isFinite(Date.parse(fetchedAt)) + ? new Date(fetchedAt).toLocaleString(en ? 'en-US' : 'es-MX', { timeZoneName: 'short' }) + : null; return ( - <> - - - +
+
+

{!trusted && (offline + ? (en ? 'Offline. Reported values are retained for reference.' : 'Sin conexión. Conservamos los valores reportados como referencia.') + : (en ? 'Current conditions cannot be verified. Reported values are retained for reference.' : 'No se pueden verificar las condiciones actuales. Conservamos los valores reportados como referencia.'))}

+

{fetchLabel(freshness.state, freshness.age, props.language)}{timestamp && <> · }

+
+ + +
); } diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 87c5e392..af15f5d5 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -541,6 +541,7 @@ export default function Dashboard() {