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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 11 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
48 changes: 48 additions & 0 deletions docs/REFRESH-OPERATIONS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
132 changes: 132 additions & 0 deletions scripts/check-dashboard-summary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
5 changes: 4 additions & 1 deletion scripts/check-historical-pattern.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
18 changes: 12 additions & 6 deletions src/components/dashboard/BorderLine.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export default function BorderLine({
typicalByPort,
focusedPort = null,
onSelect,
trusted = true,
}) {
const t = STRINGS[language] || STRINGS.en;
const isWide = useIsWide();
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
Expand All @@ -173,7 +177,7 @@ export default function BorderLine({
>
<title id="bp-line-title">{t.title}</title>
<desc id="bp-line-desc">
{t.desc(ticks.length, reportingCount)}
{description}
</desc>

{/* drafting grid */}
Expand Down Expand Up @@ -314,7 +318,7 @@ export default function BorderLine({
>
<title id="bp-line-title-m">{t.title}</title>
<desc id="bp-line-desc-m">
{t.desc(ticks.length, reportingCount)}
{description}
</desc>

<line className="bp-axis" x1={SPINE_X} y1={TOP - 10} x2={SPINE_X} y2={MH - 12} />
Expand Down Expand Up @@ -397,13 +401,15 @@ export default function BorderLine({
>
<div className="flex items-baseline justify-between gap-3 px-4 pt-3">
<h2 className="bp-line-h">{t.title}</h2>
<span className="bp-line-legend">
{trusted && <span className="bp-line-legend">
<i className="bp-sw bp-sw-good" /> {t.quick}
<i className="bp-sw bp-sw-mod" /> {t.moderate}
<i className="bp-sw bp-sw-heavy" /> {t.heavy}
</span>
</span>}
</div>
<p className="bp-line-sub px-4">{showTypical ? t.subtitle : t.subtitleSb}</p>
<p className="bp-line-sub px-4">{!trusted
? (language === 'en' ? 'Retained reported waits, not current conditions.' : 'Esperas reportadas conservadas, no condiciones actuales.')
: showTypical ? t.subtitle : t.subtitleSb}</p>
<div className="px-1 pb-2">{isWide ? desktop : mobile}</div>

{/* Accessible equivalent. Also the only copy a text-mode crawler sees,
Expand Down
Loading
Loading