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

## 2026-10-22 — Historical pattern confidence (prepared)

- Replace the crossing callout's live-looking generated prose with validated
weekday/hour evidence from the existing 30-day sample. A Sunday pattern
remains Sunday on other days; lowest_today names its evidenced weekday.
Preserve genuine zero medians and show sample counts and port-local source
snapshot/generation dates, rather than labeling first detection Last checked.
- Invalid/future/missing source times, unknown types and malformed numerical
evidence yield a neutral unavailable interpretation. Old but dated historical
evidence remains visible. No detector, feed, threshold or current-wait change.
- Add unit and isolated EN/ES desktop/mobile browser regressions for weekday
scope, zero, malformed/future sources, recovery, dark mode and port navigation.
- Recheck the eight moderate audit entries and actual rendered dependency
graph. No package update or audit exception: parser 6.1.5 is a separately
documented same-major candidate; React Router needs a major migration and
sprintf-js has no published patched version. See the refresh runbook.

## 2026-10-21 — Browser resilience and navigation (published)

- PR #137 merged at `0ec918a897ee356d643afbd86c8957d84369c809`.
Expand Down
9 changes: 9 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# DECISIONS

## 2026-10-22 · Historical weekday medians are not live observations

The anomaly feed's lowest_today scans 30-day weekday buckets, and sunday_spike
always describes Sunday evenings. Render typed evidence with its actual day,
nonempty sample count and source dates; never reuse generated Right now copy
or a retained first-detection date as Last checked. Invalid evidence gets a
neutral unavailable state. Preserve dated history without imposing a new
live-feed expiry rule or changing detector thresholds.

## 2026-10-21 · Browser persistence is optional and saved preferences are untrusted

A blocked localStorage accessor or a parsed non-array favorite value must not
Expand Down
8 changes: 4 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,10 @@ cross-border experience; it is a product hypothesis, not a claim of demand.
Comparison sharing and a fixed-choice usefulness prompt are prepared for the
next release. Read `compare-share` and `compare-feedback` alongside visits;
a feedback click is stated usefulness, not evidence of a completed crossing.
- Follow up on the separately observed Sunday-pattern callout: its "Today vs.
the 30-day pattern" heading can appear on another day. Align the heading and
displayed day with the pattern's actual scope before calling it a today
comparison.
- [~] Correct the historical-pattern callout using validated weekday/hour
evidence, sample counts and source dates. Never present a Sunday pattern or
a 30-day weekday minimum as a live today observation. Source and regressions
are prepared October 22; publication evidence belongs to the scoped PR.
- Sebastian reports that Stripe is connected for BorderPulse. Verify the
account, mode, product/Price, webhook and checkout state in Stripe before
describing any paid offering or accepting a charge. The free, ad-supported
Expand Down
28 changes: 28 additions & 0 deletions docs/REFRESH-OPERATIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Public data refresh

## Bounded dependency review — 2026-10-22 UTC

Official npm audit reports **eight moderate package entries**, zero high or
critical, from four distinct advisories across three dependency chains. This
is a reachability review of the existing app, not a complete security audit.
No dependency, lockfile, exception, permission or security setting changed.

| Installed chain | Scope and observed reachability | Compatible repair assessment |
| --- | --- | --- |
| @tailwindcss/typography 0.5.19 → postcss-selector-parser 6.0.10 (2 entries) | CSS build plugin in src/index.css, parsing repository CSS. Neither package renders in browser chunks or is imported by the public API. No visitor-supplied CSS parsing endpoint was found. | Maintainer advisory lists fixed **6.1.5** as well as 7.1.6; official npm confirms 6.1.5 exists with the same dependency names/engine as 6.0.10. Typography 0.5.20 still pins 6.0.10. A same-major backport is a candidate for a scoped override and CSS-output regression review; it is not automatically installed by a compatible parent update. npm's advisory range still broadly flags <7.1.6, unlike the maintainer's backport range. Do not suppress the advisory to conceal that discrepancy. |
| gray-matter 4.0.3 → js-yaml 3.15.2 → argparse 1.0.10 → sprintf-js 1.0.3 (4 entries) | Frontmatter build/prerender tools only; absent from rendered browser chunks. The vulnerable format-string path is argparse CLI help; js-yaml's argparse import is in bin/js-yaml.js, while repo code uses the YAML/frontmatter library API. No public request accepts such a format string. | sprintf-js latest is 1.1.3 and the advisory lists no patched release. npm suggests gray-matter 2.0.1, a major downgrade; do not treat that as a compatible fix or apply audit fix --force. |
| react-router-dom 6.30.6 → react-router 6.30.6 (2 entries, 2 advisories) | Present in browser chunks. App uses BrowserRouter declarative mode and createRoot, with no SSR/hydration path. Reviewed navigation values are constants or controlled internal path builders; no bare attacker-supplied destination was found. This limits the reviewed paths, not all future usage. | Latest published 6.x is 6.30.6. Maintainer fixes are 7.18.0+, requiring a major migration; do not mix that migration into the data-confidence correction. |

The Vite 6.4.4 rendered module graph independently confirmed zero rendered
modules for the six build-chain packages and one each for react-router and
react-router-dom. The public API imports the CBP parser, not these build tools.
Package classification alone was not used as proof of runtime reachability.
The current historical-pattern misrepresentation was selected as the one
user-facing correction; the parser backport remains an explicitly untested
separate candidate. Existing dependency PRs were inspected and left intact.

Primary references: [parser maintainer advisory](https://github.com/postcss/postcss-selector-parser/security/advisories/GHSA-rj75-hqrm-r3gf),
[sprintf-js disclosure](https://github.com/alexei/sprintf.js/issues/237),
[router redirect advisory](https://github.com/remix-run/react-router/security/advisories/GHSA-wrjc-x8rr-h8h6),
[router SSR advisory](https://github.com/remix-run/react-router/security/advisories/GHSA-337j-9hxr-rhxg).
Versions/chains were read from the unchanged lockfile and official npm registry;
these dated results do not imply zero exploitability or complete security.

## Browser resilience release — 2026-10-21 UTC

- After the owner approved the precise follow-up batch, its single authorized
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"news:classify": "node scripts/build-curation-queue.mjs",
"test:bundle": "node scripts/check-bundle-size.mjs",
"test:audit": "node scripts/check-audit.mjs",
"test:trust": "node --test scripts/test-trust-state.mjs scripts/test-local-comparison.mjs scripts/test-browser-state.mjs",
"test:trust": "node --test scripts/test-trust-state.mjs scripts/test-local-comparison.mjs scripts/test-browser-state.mjs scripts/test-historical-pattern.mjs",
"test:mdx": "node --test scripts/test-mdx-frontmatter.mjs",
"test:writer": "node --test scripts/test-generated-writer.mjs",
"test:refresh": "node --test scripts/test-data-refresh.mjs"
Expand Down
97 changes: 97 additions & 0 deletions scripts/check-historical-pattern.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';

// Isolated page/context fixtures, never public data or the owner's browser.
export async function checkHistoricalPatterns(owner, context, baseUrl) {
const clock = new Date('2026-10-21T20:00:00Z'); // Wednesday in California.
const snapshot = JSON.parse(fs.readFileSync(new URL('../public/data/crossings.json', import.meta.url)));
snapshot.fetched_at = clock.toISOString();
const sunday = { type: 'sunday_spike', port_slug: 'calexico-east', detected_at: '2026-10-18T18:00:00Z', summary_en: 'Right now, choose this crossing.', summary_es: 'Ahora mismo, elige este cruce.', evidence: { peak_hour: 21, peak_median: 120, overall_median: 30, samples: 15, ratio: 4 } };
const original = { generated_at: '2026-10-21T19:50:00Z', snapshot_at: '2026-10-21T19:45:00Z', active: [sunday] };
const feedMatch = url => ['/data/crossings.json', '/api/public/crossings'].includes(url.pathname);
const feedHandler = route => route.fulfill({ contentType: 'application/json', body: JSON.stringify(snapshot) });
const errors = [];
for (const language of ['en', 'es']) {
// Clock installation affects a browser context. Isolate it from the
// existing suite, including its owner page and refresh timers.
const qa = await context.browser().newContext({ viewport: owner.viewportSize(), serviceWorkers: 'block', timezoneId: 'Asia/Tokyo' });
await qa.route('**/*', route => new URL(route.request().url()).origin === baseUrl.origin ? route.continue() : route.abort('blockedbyclient'));
await qa.route(feedMatch, feedHandler);
const page = await qa.newPage();
page.setDefaultTimeout(12_000);
page.on('pageerror', error => errors.push(error.message));
page.on('console', msg => { if (msg.type() === 'error' && /ErrorBoundary|Uncaught|TypeError|ReferenceError/.test(msg.text())) errors.push(msg.text()); });
let current = structuredClone(original);
try {
await page.clock.install({ time: clock });
await page.addInitScript(language => {
localStorage.setItem('borderPulse_language', language);
localStorage.setItem('borderPulse_theme', 'light');
}, language);
await page.route('**/data/anomalies.json', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify(current) }));
const reload = async () => {
await page.goto(new URL('/crossing/calexico-east/', baseUrl).href);
await page.getByTestId('historical-pattern').waitFor();
assert.equal(await page.locator('html').getAttribute('lang'), language);
return page.getByTestId('historical-pattern');
};
let panel = await reload();
let text = await panel.innerText();
assert.match(text, language === 'en' ? /Sunday.*120 min/ : /domingo.*120 min/);
assert.doesNotMatch(text, /Today vs|Hoy vs|Right now|Ahora mismo|Last checked|Última verificación/);
assert.match(text, /30 min/);
assert.match(text, /15 (samples|muestras)/);
assert.match(text, /not a live wait|no una espera en vivo/);
assert.deepEqual(await panel.locator('time').evaluateAll(nodes => nodes.map(node => node.dateTime)), [original.snapshot_at, original.generated_at]);
const expectedDate = await page.evaluate(({ stamp, language }) => new Date(stamp).toLocaleString(language === 'es' ? 'es-MX' : 'en-US', { timeZone: 'America/Los_Angeles', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }), { stamp: original.snapshot_at, language });
assert.equal(await panel.locator('time').first().innerText(), expectedDate, 'source dates use the port timezone');
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
if (process.env.BP_PATTERN_CAPTURE_DIR) {
fs.mkdirSync(process.env.BP_PATTERN_CAPTURE_DIR, { recursive: true });
await panel.screenshot({ path: path.join(process.env.BP_PATTERN_CAPTURE_DIR, `pattern-${page.viewportSize().width}-${language}.png`) });
}

current = { ...original, active: [{ ...sunday, type: 'lowest_today', evidence: { day_of_week: 1, best_hour: 0, best_median: 0, overall_median: 30, samples: 2 } }] };
panel = await reload();
text = await panel.innerText();
assert.match(text, language === 'en' ? /Monday/ : /lunes/);
assert.match(text, /0 min/);
assert.doesNotMatch(text, /Right now|Ahora mismo|Today vs|Hoy vs/);

const badEvidence = structuredClone(original); badEvidence.active[0].evidence.peak_median = null;
const unknownType = structuredClone(original); unknownType.active[0].type = 'unknown';
const broken = [badEvidence, unknownType, { ...original, snapshot_at: null }, { ...original, generated_at: 'invalid' }, { ...original, snapshot_at: '2026-10-22T20:00:00Z' }];
for (current of broken) {
panel = await reload();
text = await panel.innerText();
assert.match(text, /cannot be verified|No se puede verificar/);
assert.doesNotMatch(text, /120 min|Right now|Ahora mismo|Invalid Date/);
assert.equal(await panel.locator('time').count(), 0);
assert.equal(await panel.getByRole('link').getAttribute('href'), 'https://bwt.cbp.gov/');
}
// Restore evidence without a new API/schema and keep the card neutral
// in dark mode; old historical data is still useful when dated.
current = { ...structuredClone(original), generated_at: '2026-10-01T12:00:00Z' };
panel = await reload();
assert.match(await panel.innerText(), /120 min/);
await page.getByRole('button', { name: 'Switch to dark mode', exact: true }).filter({ visible: true }).click();
assert.ok(await page.locator('html').evaluate(node => node.classList.contains('dark')));
assert.doesNotMatch(await panel.getAttribute('class'), /emerald|green/);
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();
assert.equal(await page.getByTestId('historical-pattern').count(), 0, 'another port cannot inherit the pattern');
await page.goBack();
await page.getByTestId('historical-pattern').waitFor();
assert.match(await page.getByTestId('historical-pattern').innerText(), /120 min/);
current = null;
await page.reload();
await page.getByRole('heading', { level: 1 }).waitFor();
assert.equal(await page.getByTestId('historical-pattern').count(), 0, 'missing feed leaves latest reports usable');
} finally { await qa.close(); }
}
assert.deepEqual(errors, [], 'pattern fixtures must not crash the application');
console.log('[historical-pattern] PASS: EN/ES weekday scope, zero wait, source timestamps, invalid/future/evidence fallback, recovery, dark mode and port navigation');
}
2 changes: 2 additions & 0 deletions scripts/smoke-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright-core';
import { checkGrowthInteractions } from './check-growth-interactions.mjs';
import { checkBrowserResilience } from './check-browser-resilience.mjs';
import { checkHistoricalPatterns } from './check-historical-pattern.mjs';
import { checkLocalCrossings } from './check-local-crossings.mjs';
import { checkNoJsReports } from './check-nojs-reports.mjs';
import { checkWalkDriveFreshness } from './check-walk-drive-freshness.mjs';
Expand Down Expand Up @@ -323,6 +324,7 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) {
try {
await checkGrowthInteractions(page, context, baseUrl);
await checkBrowserResilience(page, context, baseUrl);
await checkHistoricalPatterns(page, context, baseUrl);
await checkLocalCrossings(page, context, baseUrl);
await checkWalkDriveAvailability(page, context, baseUrl);
await checkWalkDriveFreshness(context, baseUrl);
Expand Down
52 changes: 52 additions & 0 deletions scripts/test-historical-pattern.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { historicalPatternFor } from '../src/lib/historicalPattern.js';
const now = Date.parse('2026-10-21T20:00:00Z'); // Wednesday, including California.
const sunday = { type: 'sunday_spike', port_slug: 'calexico-east', detected_at: '2026-10-18T18:00:00Z', summary_en: 'Right now, choose this crossing', evidence: { peak_hour: 21, peak_median: 120, overall_median: 30, samples: 15 } };
const feed = (...entries) => ({ snapshot_at: '2026-10-21T19:45:00Z', generated_at: '2026-10-21T19:50:00Z', active: entries.length ? entries : [structuredClone(sunday)] });
const select = value => historicalPatternFor(value, 'calexico-east', now);

test('Sunday evidence stays Sunday on Wednesday; generated prose and first-detection time are not observations', () => {
const p = select(feed());
assert.deepEqual(p, { state: 'historical', type: 'sunday_spike', day: 0, hour: 21, median: 120, baseline: 30, samples: 15, snapshotAt: '2026-10-21T19:45:00Z', generatedAt: '2026-10-21T19:50:00Z' });
assert.ok(!JSON.stringify(p).includes('Right now'));
assert.ok(!JSON.stringify(p).includes('2026-10-18'));
});
test('lowest_today describes its evidenced weekday and preserves a genuine zero', () => {
const p = select(feed({ ...sunday, type: 'lowest_today', evidence: { day_of_week: 1, best_hour: 0, best_median: 0, overall_median: 30, samples: 2 } }));
assert.equal(p.day, 1);
assert.equal(p.median, 0);
assert.equal(p.hour, 0);
assert.equal(p.state, 'historical');
});
test('missing, invalid or future source timing withdraws the numerical interpretation', () => {
for (const key of ['snapshot_at', 'generated_at']) for (const value of [null, '', 'invalid', '1', '2026-10-21T12:00:00', '2026-02-30T12:00:00Z', '2026-10-22T20:00:00Z']) {
assert.deepEqual(select({ ...feed(), [key]: value }), { state: 'unverified' }, `${key}: ${value}`);
}
});
test('dated historical evidence remains available; unchanged generation time may precede a newer source snapshot', () => {
const p = select({ ...feed(), generated_at: '2026-10-01T12:00:00Z' });
assert.equal(p.state, 'historical');
assert.equal(p.generatedAt, '2026-10-01T12:00:00Z');
});
test('non-numeric, negative, non-finite and missing evidence cannot become a historical fact', () => {
for (const key of ['peak_median', 'overall_median']) for (const value of [undefined, null, '120', -1, Infinity, NaN]) {
const item = structuredClone(sunday); item.evidence[key] = value;
assert.deepEqual(select(feed(item)), { state: 'unverified' });
}
for (const samples of [0, -1, 1.5, '15', NaN, Infinity]) assert.deepEqual(select(feed({ ...sunday, evidence: { ...sunday.evidence, samples } })), { state: 'unverified' });
});
test('weekday/hour evidence follows the detector domains without changing detection thresholds', () => {
for (const peak_hour of [-1, 0, 15, 23, 24, 21.5, '21']) assert.deepEqual(select(feed({ ...sunday, evidence: { ...sunday.evidence, peak_hour } })), { state: 'unverified' });
for (const day_of_week of [-1, 7, 1.5, '1']) assert.deepEqual(select(feed({ ...sunday, type: 'lowest_today', evidence: { day_of_week, best_hour: 3, best_median: 5, overall_median: 30, samples: 2 } })), { state: 'unverified' });
});
test('wrong port and absent feed do not create a card; malformed first item does not hide later valid evidence', () => {
for (const value of [null, {}, { active: {} }, { active: [null] }, feed({ ...sunday, port_slug: 'another-port' })]) assert.equal(select(value), null);
assert.equal(select(feed({ ...sunday, type: 'unknown' }, sunday)).state, 'historical');
assert.deepEqual(select(feed({ ...sunday, type: 'unknown' })), { state: 'unverified' });
});
test('a corrected source response restores verified historical evidence', () => {
const broken = feed(); broken.active[0].evidence.peak_median = null;
assert.equal(select(broken).state, 'unverified');
assert.equal(select(feed()).state, 'historical');
});
Loading
Loading