From 4ac65c1ffc92f72a0118f0e4ab2d1f34d5c39db5 Mon Sep 17 00:00:00 2001 From: sbc1-code <207095575+sbc1-code@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:37:05 -0700 Subject: [PATCH] fix: interpret crossing patterns as validated historical evidence --- CHANGELOG.md | 17 ++++ DECISIONS.md | 9 ++ ROADMAP.md | 8 +- docs/REFRESH-OPERATIONS.md | 28 ++++++ package.json | 2 +- scripts/check-historical-pattern.mjs | 97 +++++++++++++++++++ scripts/smoke-routes.mjs | 2 + scripts/test-historical-pattern.mjs | 52 ++++++++++ .../dashboard/HistoricalPattern.jsx | 63 ++++++++++++ src/lib/historicalPattern.js | 45 +++++++++ src/pages/CrossingDetail.jsx | 45 +-------- 11 files changed, 321 insertions(+), 47 deletions(-) create mode 100644 scripts/check-historical-pattern.mjs create mode 100644 scripts/test-historical-pattern.mjs create mode 100644 src/components/dashboard/HistoricalPattern.jsx create mode 100644 src/lib/historicalPattern.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ebba0a308..eca2e0b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/DECISIONS.md b/DECISIONS.md index c2a32c1e4..edb08b81e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 5b2194075..5022158bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/docs/REFRESH-OPERATIONS.md b/docs/REFRESH-OPERATIONS.md index 83d41a688..ad3a3c9a6 100644 --- a/docs/REFRESH-OPERATIONS.md +++ b/docs/REFRESH-OPERATIONS.md @@ -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 diff --git a/package.json b/package.json index 6c8106470..0ea12c407 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/check-historical-pattern.mjs b/scripts/check-historical-pattern.mjs new file mode 100644 index 000000000..5fcbe4b06 --- /dev/null +++ b/scripts/check-historical-pattern.mjs @@ -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'); +} diff --git a/scripts/smoke-routes.mjs b/scripts/smoke-routes.mjs index 17d595982..ef7cb483d 100644 --- a/scripts/smoke-routes.mjs +++ b/scripts/smoke-routes.mjs @@ -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'; @@ -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); diff --git a/scripts/test-historical-pattern.mjs b/scripts/test-historical-pattern.mjs new file mode 100644 index 000000000..53f66e2e0 --- /dev/null +++ b/scripts/test-historical-pattern.mjs @@ -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'); +}); diff --git a/src/components/dashboard/HistoricalPattern.jsx b/src/components/dashboard/HistoricalPattern.jsx new file mode 100644 index 000000000..276a690b9 --- /dev/null +++ b/src/components/dashboard/HistoricalPattern.jsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Clock3 } from 'lucide-react'; +import { historicalPatternFor } from '@/lib/historicalPattern'; +import { getPortTimezone } from '@/components/utils/crossingMeta'; + +const DAYS = { + en: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], + es: ['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado'], +}; + +export default function HistoricalPattern({ feed, slug, crossing, language }) { + const pattern = historicalPatternFor(feed, slug); + if (!pattern) return null; + const es = language === 'es'; + const verified = pattern.state === 'historical'; + const locale = es ? 'es-MX' : 'en-US'; + const timeZone = getPortTimezone(crossing); + const dateLabel = stamp => new Date(stamp).toLocaleString(locale, { + timeZone, year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short', + }); + const day = DAYS[es ? 'es' : 'en'][pattern.day]; + // Format the port-local bucket's clock number without converting its hour. + const hour = verified ? new Intl.DateTimeFormat(locale, { hour: 'numeric', hour12: true, timeZone: 'UTC' }) + .format(new Date(Date.UTC(2000, 0, 1, pattern.hour))) : null; + const sampleLabel = es ? (pattern.samples === 1 ? 'muestra' : 'muestras') : (pattern.samples === 1 ? 'sample' : 'samples'); + const summary = !verified ? null : pattern.type === 'sunday_spike' + ? (es + ? `En la muestra de 30 días, el pico del domingo tiene una mediana de ${pattern.median} min a las ${hour}, frente a la mediana general de ${pattern.baseline} min (${pattern.samples} ${sampleLabel}).` + : `In the 30-day sample, Sunday's evening peak has a median of ${pattern.median} min at ${hour}, compared with the overall median of ${pattern.baseline} min (${pattern.samples} ${sampleLabel}).`) + : (es + ? `En la muestra de 30 días, la hora con menor mediana del ${day} es a las ${hour}: ${pattern.median} min, frente a la mediana general de ${pattern.baseline} min (${pattern.samples} ${sampleLabel}).` + : `In the 30-day sample, ${day}'s lightest sampled hour is ${hour}: median ${pattern.median} min, compared with the overall median of ${pattern.baseline} min (${pattern.samples} ${sampleLabel}).`); + + return ( +
+
+
+
+ ); +} diff --git a/src/lib/historicalPattern.js b/src/lib/historicalPattern.js new file mode 100644 index 000000000..23329874d --- /dev/null +++ b/src/lib/historicalPattern.js @@ -0,0 +1,45 @@ +const nonnegative = value => typeof value === 'number' && Number.isFinite(value) && value >= 0; +const integerIn = (value, min, max) => Number.isInteger(value) && value >= min && value <= max; +// The publisher emits UTC ISO timestamps. Reject timezone-less strings and +// calendar rollovers rather than interpreting them in the visitor's timezone. +const sourceTime = (value, now) => { + if (typeof value !== 'string') return false; + const parts = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/.exec(value); + const stamp = Date.parse(value); + return Boolean(parts) && Number.isFinite(stamp) && stamp <= now + && new Date(stamp).toISOString() === `${parts[1]}.${(parts[2] || '').padEnd(3, '0')}Z`; +}; + +// These records describe weekday/hour medians in a 30-day sample. Their +// generated prose and retained first-detection date are not live observations. +export function historicalPatternFor(feed, slug, now = Date.now()) { + if (!Array.isArray(feed?.active) || !slug) return null; + const entries = feed.active.filter(entry => entry?.port_slug === slug); + if (!entries.length) return null; + const unverified = { state: 'unverified' }; + if (!sourceTime(feed.snapshot_at, now) || !sourceTime(feed.generated_at, now)) return unverified; + + for (const entry of entries) { + const e = entry.evidence; + if (!e || !integerIn(e.samples, 1, Number.MAX_SAFE_INTEGER) || !nonnegative(e.overall_median)) continue; + let day, hour, median; + if (entry.type === 'sunday_spike') { + day = 0; + hour = e.peak_hour; + median = e.peak_median; + if (!integerIn(hour, 16, 22) || e.overall_median === 0) continue; + } else if (entry.type === 'lowest_today') { + day = e.day_of_week; + hour = e.best_hour; + median = e.best_median; + if (!integerIn(day, 0, 6) || !integerIn(hour, 0, 23)) continue; + } else continue; + if (!nonnegative(median)) continue; + return { + state: 'historical', type: entry.type, day, hour, median, + baseline: e.overall_median, samples: e.samples, + snapshotAt: feed.snapshot_at, generatedAt: feed.generated_at, + }; + } + return unverified; +} diff --git a/src/pages/CrossingDetail.jsx b/src/pages/CrossingDetail.jsx index 27467b051..d8b41c872 100644 --- a/src/pages/CrossingDetail.jsx +++ b/src/pages/CrossingDetail.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useParams, useLocation, Link, Navigate } from 'react-router-dom'; -import { ArrowLeft, Clock, MapPin, RefreshCw, ArrowRight, Code, Car, User, Truck, AlertTriangle } from 'lucide-react'; +import { ArrowLeft, Clock, MapPin, RefreshCw, ArrowRight, Code, Car, User, Truck } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import BorderCrossingCard from '@/components/dashboard/BorderCrossingCard'; @@ -20,6 +20,7 @@ import { nearestCrossings, kmToMiles } from '@/lib/geo'; import { comparePairsFor } from '@/lib/comparePairs'; import { usePersistentLanguage } from '@/lib/useLanguage'; import { readFavorites } from '@/lib/browserStorage'; +import HistoricalPattern from '@/components/dashboard/HistoricalPattern'; import { isSparseCell } from '@/lib/aggregates'; import { hasPedestrianLane } from '@/lib/crossingAvailability'; import { pickLightestHour } from '@/lib/recommendations'; @@ -290,17 +291,6 @@ export default function CrossingDetail() { [crossing], ); - const activeAnomaly = useMemo(() => { - if (!anomalies || !canonicalSlug) return null; - const list = Array.isArray(anomalies.active) ? anomalies.active : []; - const match = list.find((a) => a && a.port_slug === canonicalSlug); - if (!match) return null; - // Only render when the strings we display are actually strings — guards - // against a malformed feed crashing the page. - if (typeof match.summary_en !== 'string' || typeof match.summary_es !== 'string') return null; - return match; - }, [anomalies, canonicalSlug]); - // Reset selected day to today's day-of-week whenever the crossing changes. useEffect(() => { setSelectedDay(todayIdx); @@ -528,36 +518,7 @@ export default function CrossingDetail() { - {activeAnomaly && ( -
-
- -
-

- {language === 'es' ? 'Hoy vs. el patrón de 30 días' : 'Today vs. the 30-day pattern'} -

-

- {language === 'es' ? activeAnomaly.summary_es : activeAnomaly.summary_en} -

-

- {language === 'es' ? 'Última verificación' : 'Last checked'}{' '} - {new Date(activeAnomaly.detected_at).toLocaleString(language === 'es' ? 'es-MX' : 'en-US')}{' · '} - - {language === 'es' ? 'Verificar en bwt.cbp.gov' : 'Verify on bwt.cbp.gov'} - -

-
-
-
- )} + {(() => { // "Latest lane reports" — always-on per-lane breakout for this port.