From c00af01c8efcf0a86c436a3e4b157e43c75d3821 Mon Sep 17 00:00:00 2001 From: Sebastian Becerra <207095575+sbc1-code@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:38:00 -0700 Subject: [PATCH 1/7] Add local crossing comparisons and public camera views --- CHANGELOG.md | 18 ++++ DECISIONS.md | 10 ++ ROADMAP.md | 11 +++ docs/CROSSING-FEATURES.md | 94 +++++++++++++++++++ package.json | 2 +- scripts/check-local-crossings.mjs | 85 +++++++++++++++++ scripts/smoke-routes.mjs | 2 + scripts/test-local-comparison.mjs | 76 +++++++++++++++ .../dashboard/BorderCrossingCard.jsx | 7 ++ src/components/dashboard/CrossingCameras.jsx | 33 +++++++ .../dashboard/LocalCrossingComparison.jsx | 48 ++++++++++ src/lib/cameras.js | 17 ++++ src/lib/corridors.js | 57 +++++++++++ src/pages/CrossingDetail.jsx | 14 ++- src/pages/Dashboard.jsx | 40 +++++++- src/pages/Privacy.jsx | 7 +- 16 files changed, 514 insertions(+), 7 deletions(-) create mode 100644 docs/CROSSING-FEATURES.md create mode 100644 scripts/check-local-crossings.mjs create mode 100644 scripts/test-local-comparison.mjs create mode 100644 src/components/dashboard/CrossingCameras.jsx create mode 100644 src/components/dashboard/LocalCrossingComparison.jsx create mode 100644 src/lib/cameras.js create mode 100644 src/lib/corridors.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0e8ffce..877dfefdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # CHANGELOG +## 2026-10-13 — Local comparison and camera feature batch (prepared) + +- Added nine named crossing corridors, remembered in browser storage and + compatible with existing state/search filters. El Paso–Juárez includes + Santa Teresa in New Mexico without treating the entire state as local. +- Added northbound comparisons for standard passenger, Ready Lane, SENTRI + and pedestrian lanes. Missing reports, closed ports/lanes and unverifiable + operating hours are excluded. Ties, fewer than two available reports and + stale/unknown/future snapshot times suppress a shortest-wait claim. Report + clocks remain separate from retrieval time; extra driving is not estimated. +- Added ten click-loaded camera views at five Juárez–El Paso bridges from + the official Fideicomiso directory, with view switching, closing, source and + playback fallback links, EN/ES provider disclosure and privacy copy. +- Added comparison eligibility tests and desktop/mobile EN/ES interactions + to the existing release gate. No new npm dependency, account, database, + subscription, email service or production workflow change. Publication and + validation evidence belongs to the scoped pull request. + ## 2026-10-13 — Document language ownership - Metadata cleanup preserves the document language during crossing data and diff --git a/DECISIONS.md b/DECISIONS.md index d39c16dd1..4685aeeca 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,15 @@ # DECISIONS +## 2026-10-13 · Local comparisons use eligible lanes; cameras load on request + +The owner requested competitor-inspired crossing features. Compare named +corridors and the same northbound lane type without estimating total trip time. +Suppress comparative winners on stale/unknown retrievals or unavailable hours; +camera views are orientation, not proof of lane-specific conditions. Load the +public YouTube player only after a user selects a camera; use official-source +and provider fallback links. Real alerts/community reports remain separate +backend work and do not override the ban on non-delivering notification CTAs. + Short log of *why* something is the way it is. Append-only. Future-you (or the AI you're collaborating with) reads this when "wait, why did we…" comes up. diff --git a/ROADMAP.md b/ROADMAP.md index 9333e154e..ff203e28f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,17 @@ cross-border experience; it is a product hypothesis, not a claim of demand. ### Now: earn trust in the free entry point +- [~] October 13 competitor feature batch: nine remembered local corridor + filters, comparisons of the selected eligible lane and click-loaded public + camera views at five Juárez–El Paso bridges. Prepared on the feature branch; + production publication is not implied. Keep raw CBP report times visible and + exclude closed/unverifiable hours, missing reports and stale winner claims. +- [ ] Real email threshold alerts and community crossing reports: implement + only with a working delivery channel and shared storage. See + `docs/CROSSING-FEATURES.md` for the concrete follow-up contract, source receipts, + operating costs and validation requirements. No signup/report form until the + service actually accepts and delivers it. + - [x] Correct route hreflangs and bound metadata-navigation checks (PR129/130). Production was verified on October 12 at `2b40cb2b`, including reciprocal pairs and exact served assets. Those releases are complete; preserve them. diff --git a/docs/CROSSING-FEATURES.md b/docs/CROSSING-FEATURES.md new file mode 100644 index 000000000..301913785 --- /dev/null +++ b/docs/CROSSING-FEATURES.md @@ -0,0 +1,94 @@ +# Crossing feature batch — October 13, 2026 + +## Prepared implementation + +The dashboard's crossing-area selector remembers one of nine named corridors. +Selecting a state clears that corridor; selecting a corridor clears the search +and state scope. Existing public routes, source data, collection, ads and +publication gates stay intact. Local comparisons include the selected lane +only; a shorter reported queue is not a shorter total journey. + +Public camera players are available on Paso del Norte, Stanton-Lerdo, +Ysleta-Zaragoza, Tornillo and Bridge of the Americas detail pages. The regional +panel links directly to each page's camera section. No video, thumbnail or +provider request is made by this feature before a view is selected. Selecting +another view replaces the iframe; closing removes it. There is no video relay, +recording, paid routing call, new SDK or new secret. Availability is controlled +by the source and is not inferred from iframe load success. + +## Camera source receipts + +Official directory: https://www.puentesfronterizos.gob.mx/camaras-en-vivo.php + +The directory lists each orientation separately. On review, its CamStreamer +links resolve to the following YouTube player IDs; BOTA links point to YouTube +directly. These are public-source embeds, not scraped competitor video assets. +The displayed orientation is the official heading, not inferred travel direction. + +| Canonical port | Official views | Player IDs | +| --- | --- | --- | +| 240202 Paso del Norte | North to south / South to north | IcvugJWPXz8 / 0Pg3S6s76IE | +| 240204 Stanton-Lerdo | North to south / South to north | CYhISA_wFQ8 / sOHFG6_3zAs | +| 240104 Ysleta-Zaragoza | North to south / South to north | GC5RY3zipa4 / KX-bm-Lhy3w | +| 240401 Tornillo | North to south / South to north | 0gT7jvaLCkg / 8u-hBawdG8c | +| 240201 BOTA | North / South | mp3RS0y77tY / CZM5TpXLzE8 | + +Review the IDs against the directory when playback stops: livestream IDs can +change. The official directory remains the fallback. Santa Teresa has no +verified stream in this registry. City of El Paso's own directory reported +camera maintenance during review, so its streams are not advertised as working. +Playback itself could not be established by text retrieval; browser tests mock +or block providers and test the controls, not third-party stream uptime. + +## Next: real email threshold alerts + +The existing product has no database or email delivery provider. Do not collect +addresses or show a signup success until the full path exists. Prepare an +explicit provider/storage choice and cost cap before provisioning anything. + +- Pilot one corridor. Subscription specifies canonical port, eligible lane, + threshold, language and email. Confirm the email before activation. +- Durable storage needs subscription ID, email, confirmed status, port/lane, + threshold, confirmation/unsubscribe token hashes, and last delivery time. +- Reuse the current validated CBP collector; do not add a per-subscriber polling + loop. A scheduled evaluator compares only open, reporting eligible lanes. +- Trigger on crossing from above to below the threshold, at most one email per + subscriber per day. On stale/missing data, send nothing. Recovery must not + manufacture a threshold crossing from an unknown previous observation. +- Idempotent delivery ledger prevents retry duplicates. Unsubscribe is present + in each email; remove/suppress addresses after bounce or complaint. +- Validation: closed browser receives the confirmed test email; threshold + transition, stale feeds, duplicate cron runs, unsubscribe and provider errors + are tested. Never call an open-tab-only banner an alert. +- Costs are unknown until the provider and expected volume are selected. + Calculate confirmed subscriptions × maximum deliveries/day, storage volume, + cron frequency and provider rate limits. Target no new monthly spend for the + pilot; alert at a reviewed cap and fail closed before paid overages. + +## Next: community crossing reports + +A public community feed needs shared storage and an abuse-control path; a +local browser timer alone cannot provide community reports. + +- Collect canonical port, direction, lane, actual duration, completion time and + submission time. Do not require precise GPS, document data or free text. +- Optional timer uses absolute start/end timestamps, survives reload in local + browser storage, and stops only on explicit completion. A saved local record + is labeled as such until a server confirms publication. +- Validate numeric bounds, allowed port/lane combinations and recency on the + server. Rate-limit and deduplicate submissions; cap retention and volume. +- Public read model shows report count, lane, time range and a summary with a + sample floor. Keep community observations separate from official CBP waits. +- Do not claim improved estimates until held-out completed crossings show it. + Compare error by bridge/lane and quiet/busy periods; avoid validating on the + observations used to calibrate the model. +- Validation: submission appears in a second browser; expired, implausible, + duplicate and rejected reports never create a misleading public estimate. + +## Competitor operating-cost observations + +Fila de Puentes credits Fideicomiso, CBP, TxDOT and public streams on its About +page, and Fili's explanation states a daily message limit. Public embeds and +message limits can reduce bandwidth/inference cost. Revenue, funding, actual +traffic, model/provider and operating expenses remain unknown. Absence of a +visible paid plan or ad unit does not establish that its costs are high. diff --git a/package.json b/package.json index ca1023408..7a0b93088 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", + "test:trust": "node --test scripts/test-trust-state.mjs scripts/test-local-comparison.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-local-crossings.mjs b/scripts/check-local-crossings.mjs new file mode 100644 index 000000000..301d70b0c --- /dev/null +++ b/scripts/check-local-crossings.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +export async function checkLocalCrossings(page, context, baseUrl) { + const fixture = JSON.parse(fs.readFileSync(new URL('../public/data/crossings.json', import.meta.url))); + fixture.fetched_at = new Date().toISOString(); + for (const crossing of fixture.crossings) { + if (!['240202', '240201', '240104', '240204', '240801', '240401'].includes(crossing.port_number)) continue; + crossing.hours = '24 hrs/day'; + crossing.port_status = 'Open'; + crossing.lanes = { + passenger_standard: { status: 'Delay', lanes_open: 1, delay_minutes: crossing.port_number === '240801' ? 0 : 30, update_time: '12:00 PM MDT' }, + passenger_sentri: { status: 'No Delay', lanes_open: 1, delay_minutes: 0, update_time: '12:00 PM MDT' }, + }; + } + const dataPattern = '**/data/crossings.json'; + const apiPattern = '**/api/public/crossings'; + const feed = route => route.fulfill({ contentType: 'application/json', body: JSON.stringify(fixture) }); + await page.route(dataPattern, feed); + await page.route(apiPattern, feed); + try { + for (const lang of ['en', 'es']) { + await page.goto(new URL('/', baseUrl).href); + await page.evaluate(value => { + localStorage.setItem('borderPulse_language', value); + localStorage.setItem('borderPulse_region', 'ALL'); + localStorage.removeItem('borderPulse_corridor'); + }, lang); + await page.reload(); + const chooser = page.getByLabel(lang === 'es' ? 'Tu zona de cruce' : 'Your crossing area'); + await chooser.selectOption('el-paso-juarez'); + const panel = page.getByRole('region', { name: lang === 'es' ? 'Comparación local' : 'Local comparison', exact: true }); + await panel.waitFor(); + await panel.getByText(/Santa Teresa/).first().waitFor(); + await page.waitForFunction(() => document.querySelector('[data-testid="local-comparison-summary"]')?.textContent.includes('0 min'), null, { timeout: 5_000 }); + assert.equal(await panel.getByRole('link', { name: /San Ysidro/ }).count(), 0); + assert.equal(await panel.getByRole('link', { name: lang === 'es' ? 'Ver cámaras' : 'View cameras', exact: true }).count(), 5); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'regional view must fit viewport'); + await page.reload(); + assert.equal(await chooser.inputValue(), 'el-paso-juarez'); + const lane = panel.getByLabel(lang === 'es' ? 'Tu carril' : 'Your lane'); + await lane.selectOption('passenger_sentri'); + await panel.getByText(lang === 'es' ? 'Solo compara SENTRI si cumples los requisitos de ese carril.' : 'Compare SENTRI only if you meet that lane’s requirements.', { exact: true }).waitFor(); + assert.ok(!(await panel.locator('[data-testid="local-comparison-summary"]').innerText()).includes('0 min'), 'ties cannot claim a shortest option'); + await lane.selectOption('pedestrian_standard'); + assert.equal(await panel.getByText(lang === 'es' ? 'No comparable' : 'Not comparable', { exact: true }).count(), 6); + await page.evaluate(() => { + const original = Storage.prototype.setItem; + Storage.prototype.setItem = function(key, value) { + if (key === 'borderPulse_corridor') throw new Error('storage denied'); + return original.call(this, key, value); + }; + }); + await chooser.selectOption('san-diego-tijuana'); + await panel.getByRole('heading', { name: 'San Diego–Tijuana', exact: true }).waitFor(); + await page.reload(); + assert.equal(await chooser.inputValue(), 'el-paso-juarez', 'blocked write must not claim persistence'); + const cameraLink = panel.getByRole('link', { name: lang === 'es' ? 'Ver cámaras' : 'View cameras', exact: true }).first(); + await cameraLink.click(); + await page.waitForURL(url => url.hash === '#cameras'); + const cameraPanel = page.getByRole('region', { name: lang === 'es' ? 'Cámaras del cruce' : 'Crossing cameras', exact: true }); + await cameraPanel.waitFor(); + assert.equal(await cameraPanel.locator('iframe').count(), 0, 'no player loaded before selection'); + await cameraPanel.getByRole('button', { name: lang === 'es' ? 'Vista de norte a sur' : 'North to south view', exact: true }).click(); + const frame = cameraPanel.locator('iframe'); + await frame.waitFor(); + assert.match(await frame.getAttribute('src'), /^https:\/\/www\.youtube-nocookie\.com\/embed\/IcvugJWPXz8\?/); + await cameraPanel.getByRole('button', { name: lang === 'es' ? 'Vista de sur a norte' : 'South to north view', exact: true }).click(); + assert.equal(await cameraPanel.locator('iframe').count(), 1); + assert.match(await frame.getAttribute('src'), /0Pg3S6s76IE/); + await cameraPanel.getByRole('button', { name: lang === 'es' ? 'Cerrar cámara' : 'Close camera', exact: true }).click(); + assert.equal(await cameraPanel.locator('iframe').count(), 0); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'camera panel must fit viewport'); + await page.goto(new URL('/', baseUrl).href); + await page.getByRole('button', { name: 'California', exact: true }).click(); + assert.equal(await chooser.inputValue(), ''); + assert.equal(await page.getByRole('region', { name: lang === 'es' ? 'Comparación local' : 'Local comparison', exact: true }).count(), 0); + } + } finally { + await page.unroute(dataPattern, feed); + await page.unroute(apiPattern, feed); + await page.evaluate(() => localStorage.removeItem('borderPulse_corridor')); + } + console.log('[local-crossings] PASS: EN/ES corridor persistence, lane eligibility, storage failure, camera navigation, deferred player, view switching and close'); +} diff --git a/scripts/smoke-routes.mjs b/scripts/smoke-routes.mjs index 93453733b..50df2b5f0 100644 --- a/scripts/smoke-routes.mjs +++ b/scripts/smoke-routes.mjs @@ -4,6 +4,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright-core'; import { checkGrowthInteractions } from './check-growth-interactions.mjs'; +import { checkLocalCrossings } from './check-local-crossings.mjs'; import { checkWalkDriveAvailability } from './check-walk-drive-availability.mjs'; import { checkComparisonGuide } from './check-comparison-guide.mjs'; import { checkReportTimes } from './check-report-times.mjs'; @@ -318,6 +319,7 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) { try { await checkGrowthInteractions(page, context, baseUrl); + await checkLocalCrossings(page, context, baseUrl); await checkWalkDriveAvailability(page, context, baseUrl); await checkComparisonGuide(page, context, baseUrl); } catch (error) { diff --git a/scripts/test-local-comparison.mjs b/scripts/test-local-comparison.mjs new file mode 100644 index 000000000..8092beafc --- /dev/null +++ b/scripts/test-local-comparison.mjs @@ -0,0 +1,76 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CORRIDORS, localComparison, isOperatingNow } from '../src/lib/corridors.js'; +import { camerasFor } from '../src/lib/cameras.js'; + +const now = Date.parse('2026-10-13T18:00:00Z'); +const fresh = new Date(now - 5 * 60_000).toISOString(); +const make = (port, wait, overrides = {}) => ({ port_number: port, name: port, + hours: '24 hrs/day', port_status: 'Open', + lanes: { passenger_standard: { status: 'Delay', lanes_open: 1, delay_minutes: wait }, + passenger_sentri: { status: 'No Delay', lanes_open: 1, delay_minutes: 0 } }, ...overrides }); +const compare = (rows, lane = 'passenger_standard', timestamp = fresh) => localComparison(rows, 'el-paso-juarez', lane, timestamp, now); + +test('compare only canonical ports in one corridor, across TX/NM', () => { + const rows = [make('240202', 30), make('240801', 10, { state: 'NM' }), make('250401', 0)]; + const result = compare(rows); + assert.equal(result.rows.length, 2); + assert.equal(result.shortest.crossing.port_number, '240801'); + assert.equal(CORRIDORS.find(c => c.id === 'el-paso-juarez').ports.includes('240221'), false); +}); +test('genuine zero remains valid; ties do not invent a winner', () => { + assert.equal(compare([make('240202', 20), make('240201', 0)]).shortest.wait, 0); + assert.equal(compare([make('240202', 0), make('240201', 0)]).shortest, null); +}); +test('one available report cannot establish a comparative winner', () => { + assert.equal(compare([make('240202', 0), make('240201', null)]).shortest, null); +}); +test('closed ports, zero open lanes, unknown status and missing/pending reports cannot win', () => { + for (const crossing of [make('240201', 0, { port_status: 'Closed' }), make('240201', 0, { port_status: '' }), + make('240201', 0, { lanes: { passenger_standard: { status: 'Delay', lanes_open: 0, delay_minutes: 0 } } }), + make('240201', 0, { lanes: { passenger_standard: { status: 'Update Pending', lanes_open: 1, delay_minutes: 0 } } })]) { + const result = compare([make('240202', 20), crossing]); + assert.equal(result.rows[1].wait, null); + assert.equal(result.shortest, null); + } +}); +test('negative and malformed waits are unavailable, not fastest', () => { + for (const wait of [-1, '0', NaN, Infinity]) assert.equal(compare([make('240202', 30), make('240201', wait)]).shortest, null); +}); +test('no substitution between lane types', () => { + const row = make('240202', 20); + delete row.lanes.passenger_sentri; + const result = compare([row, make('240201', 30)], 'passenger_sentri'); + assert.equal(result.rows[0].wait, null); + assert.equal(result.shortest, null); +}); +test('stale, unknown, invalid and future snapshots suppress the comparative claim', () => { + const rows = [make('240202', 20), make('240201', 0)]; + for (const timestamp of [null, '', 'bad', new Date(now - 91 * 60_000).toISOString(), new Date(now + 60_000).toISOString()]) { + const result = compare(rows, 'passenger_standard', timestamp); + assert.equal(result.shortest, null); + assert.equal(result.rows[1].wait, 0); // retain last reports, with retrieval label + } +}); +test('hours eligibility uses port local time and exact opening/closing minutes', () => { + const port = make('240801', 0, { hours: '6:30 AM - 10:00 PM', state: 'NM' }); + assert.equal(isOperatingNow(port, Date.parse('2026-10-13T12:29:00Z')), false); + assert.equal(isOperatingNow(port, Date.parse('2026-10-13T12:30:00Z')), true); + assert.equal(isOperatingNow(port, Date.parse('2026-10-14T04:00:00Z')), false); + assert.equal(isOperatingNow({ ...port, hours: 'Seasonal hours' }, now), false); +}); +test('unknown corridor and lane fail closed', () => { + assert.deepEqual(localComparison([], 'not-a-region', 'passenger_standard', fresh, now), { rows: [], shortest: null }); + assert.deepEqual(compare([make('240202', 0)], 'not-a-lane'), { rows: [], shortest: null }); +}); +test('camera registry has only reviewed ports and valid public player URLs', () => { + assert.equal(camerasFor('240801').length, 0); + for (const port of ['240202', '240204', '240104', '240401', '240201']) { + assert.equal(camerasFor(port).length, 2); + for (const view of camerasFor(port)) { + assert.match(view.id, /^[\w-]{11}$/); + assert.equal(new URL(view.embedUrl).hostname, 'www.youtube-nocookie.com'); + assert.equal(new URL(view.embedUrl).searchParams.has('autoplay'), false); + } + } +}); diff --git a/src/components/dashboard/BorderCrossingCard.jsx b/src/components/dashboard/BorderCrossingCard.jsx index f83b5d8a5..82751411b 100644 --- a/src/components/dashboard/BorderCrossingCard.jsx +++ b/src/components/dashboard/BorderCrossingCard.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { camerasFor } from '@/lib/cameras'; import { track } from '@/lib/analytics'; import { FRESHNESS, freshnessOf, fetchLabel, reportTimeLabel } from '@/lib/trustState'; import { Card, CardContent } from '@/components/ui/card'; @@ -437,6 +438,12 @@ export default function BorderCrossingCard({ )} + {cardSlug && camerasFor(crossing.port_number).length > 0 && ( + + {language === 'es' ? 'Ver cámaras' : 'View cameras'} + + )} + {/* Lane details */} {showLanes && ( diff --git a/src/components/dashboard/CrossingCameras.jsx b/src/components/dashboard/CrossingCameras.jsx new file mode 100644 index 000000000..2fa4c5765 --- /dev/null +++ b/src/components/dashboard/CrossingCameras.jsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; +import { Camera } from 'lucide-react'; +import { CAMERA_SOURCE, camerasFor } from '@/lib/cameras'; +import { track } from '@/lib/analytics'; + +export default function CrossingCameras({ crossing, language }) { + const [active, setActive] = useState(null); + const cameras = camerasFor(crossing.port_number); + if (!cameras.length) return null; + const es = language === 'es'; + const selected = cameras.find(c => c.id === active); + return ( +
+

{es ? 'Mira la fila' : 'See the line'}

+

{es ? 'Vistas públicas del Fideicomiso de Puentes Fronterizos de Chihuahua. La orientación de la cámara no identifica tu carril; verifica la imagen y la hora en el reproductor. La transmisión puede estar fuera de servicio.' : 'Public views from Fideicomiso de Puentes Fronterizos de Chihuahua. Camera orientation does not identify your lane; check the image and time in the player. Streams may be unavailable.'}

+
+ {cameras.map(camera => ( + + ))} + {selected && } +
+

{es ? 'Seleccionar una vista carga el reproductor de YouTube y conecta tu navegador con ese servicio.' : 'Selecting a view loads the YouTube player and connects your browser to that service.'}

+ {selected &&
+