diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0e8ffce..8f69e7544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # CHANGELOG +## 2026-10-13 — Camera coverage beyond El Paso (prepared) + +- Added a camera-area selector for all nine corridors. The camera area follows + an explicit dashboard area change, while its own picker does not change the + wait-time filter. Changing areas/crossings and collapsing remove the player. +- Added San Ysidro and Otay Mesa Caltrans U.S. road approach viewers, explicitly + distinguished from Mexico-to-U.S. inspection queues in visible copy, search + titles and no-JavaScript snapshots. Added four officially published Eagle + Pass Bridge II player views, without inferring their lane/orientation. +- Added external official camera-source paths for Nogales, Laredo and Pharr. + Pharr's source belongs only to port 230502, not nearby Anzalduas. Calexico, + Brownsville and Progreso show an explicit unverified-coverage state. +- Caltrans uses its published still images with manual refresh, original-image + timestamp guidance and error fallback. Both San Diego sources showed the + provider's Temporarily Unavailable placeholder during the browser check. +- Generalized source/disclosure/privacy copy for YouTube, Caltrans and + IPCamLive; retained source/fallback links. No paid camera license, video relay, + new dependency or production release. Provider playback is not established + by mocked tests; exact verification evidence belongs to PR #132. + +## 2026-10-13 — Homepage camera access (prepared) + +- Added a visible Camera feeds section beside the local-area controls. Visitors + can select one of five verified El Paso–Juárez bridges and open the existing + camera player directly on the homepage, without finding a detail-page link. +- Opening the section makes no player request. Selecting a view loads one + player; changing bridges or collapsing the section removes it. Coverage is + labeled explicitly, and English/Spanish copy and provider fallbacks are shared + with the crossing-detail component. +- Added desktop/mobile EN/ES release checks for discovery, bridge switching, + deferred loading, cleanup and viewport fit. Direct inspection found both Paso + del Norte players rendered but buffering in the cloud browser; live playback + and source uptime remain unverified. Final release evidence belongs to PR #132. + +## 2026-10-13 — UX and crawlable-report QC (prepared) + +- Captured the live competitor and BorderPulse homepage in the cloud browser. + The observed first-screen gap is local decisions versus border-wide charts. + Moved the summary/chart behind an accessible disclosure, added quick local + choices, and made corridor selection a shareable `?area=` URL with Back + navigation and canonical homepage identity preserved. +- Added familiar El Paso–Juárez crossing names and accent-insensitive aliases + for local search, headings and search titles without changing port identity. +- Homepage and all 42 canonical crossing pages now deliver visible report HTML + before JavaScript. The original retrieval timestamp, raw lane clocks, missing + reports and closure state stay explicit. Other routes do not inherit this + homepage snapshot; no new page inventory or guessed rich-result schema. +- Added snapshot/no-JavaScript assertions and expanded local-area browser + checks. No ranking, Core Web Vitals or acquisition improvement is claimed; + current Search Console reads were unavailable because the connected provider + reported an ended trial. Exact release evidence belongs to the pull request. + +## 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..c84d4ed08 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,41 @@ # DECISIONS +## 2026-10-13 · Camera coverage cannot substitute nearby roads for inspection queues + +Use an explicit camera-area picker and distinguish embedded crossing views, +Caltrans U.S. road approach views, and external official source pages. Areas +with no verified source show that state instead of defaulting to El Paso. +Keep canonical port identity: the Pharr source is not Anzalduas. Camera source +review establishes provenance, not current playback or lane-specific conditions. + +## 2026-10-13 · Camera access belongs on the homepage + +The owner could not find feeds from the preview homepage. Give cameras their +own visible entry beside local-area selection, with explicit El Paso–Juárez +coverage and an inline bridge/view picker. Reuse the existing player and +disclosures; selecting a view remains the only action that loads the provider, +and bridge changes/collapse remove playback. + +## 2026-10-13 · Local decisions lead; published reports remain useful without JS + +Use familiar names as display/search aliases, preserving canonical CBP identity. +Keep the border-wide chart available behind a disclosure and make local area +selection shareable without creating duplicate indexable URLs. Deliver visible, +timestamped HTML inside the React root on the homepage and crossing pages; +createRoot replaces it with the refreshing app. Do not hide different report +content for crawlers, imply a published snapshot is live, or expand thin SEO +routes while current query/ranking evidence is unavailable. + +## 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..d1bb53438 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,30 @@ cross-border experience; it is a product hypothesis, not a claim of demand. ### Now: earn trust in the free entry point +- [~] QC the October 13 locality-first flow, shareable area URLs and familiar + crossing names, with a visible inline camera entry on the homepage. Deliver + useful visible homepage/crossing snapshots without + JavaScript, preserving timestamps and canonical identity. Validation and + preview evidence belong to PR #132; not published yet. +- [ ] Reconnect an existing authorized Search Console data path to measure + query/page impressions, clicks, CTR and area-to-crossing actions after release. + GSC Wizard reported an ended trial during QC; no subscription was started. + +- [~] October 13 competitor feature batch: nine remembered local corridor + filters, comparisons of the selected eligible lane and click-loaded public + camera views across El Paso–Juárez, San Diego–Tijuana and Eagle Pass, with + external official source paths for Nogales, Laredo and Pharr. 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. +- [ ] Verify permissible camera sources for Calexico–Mexicali, Brownsville and + Progreso, and real playback of the prepared viewers. Public source directories + and road approaches do not establish inspection-queue or live-stream coverage. +- [ ] 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..b2f36c494 --- /dev/null +++ b/docs/CROSSING-FEATURES.md @@ -0,0 +1,143 @@ +# 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. + +The homepage also has a visible Camera feeds section with explicit El Paso–Juárez +coverage. Opening it exposes the bridge/view controls without creating a player. +Selecting a view loads the same component used on crossing pages. Changing the +bridge or collapsing the section removes the player. This path addresses the +owner's difficulty finding the feeds in the preview. + +## 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. + +## Expanded camera coverage — October 13 + +The homepage camera-area picker includes all nine corridors. Six have reviewed +source paths: 14 video-player views and two camera snapshots at eight crossings +in three corridors, plus +official external-source links at five crossings in three additional corridors. +Calexico–Mexicali, Brownsville and Progreso have no source verified in this batch +and show that explicitly. A reviewed source is not a stream-uptime guarantee. + +| Area | Canonical ports | Type and source receipt | +| --- | --- | --- | +| San Diego–Tijuana | 250401 San Ysidro; 250601 Otay Mesa | Caltrans U.S. road approaches; not the northbound inspection queue in Mexico. Official list: https://cwwp2.dot.ca.gov/vm/streamlist.htm | +| Eagle Pass | 230302 Bridge II | Four iframe players published at https://www.eaglepasstx.gov/335/International-Bridge-2-Cameras ; alias IDs: 67231a475ead1, 639ba5d96b3f6, 639ba81bb4b49, 68f3f8b846277. Views are numbered because lane/orientation is unconfirmed. | +| Nogales | 260401 DeConcini; 260402 Mariposa | External municipal directory: https://heroicanogales.gob.mx/webcams-garitas ; municipal source publication: https://heroicanogales.gob.mx/acciones/fortalecen-acceso-ciudadano-a-camaras-de-garita-mariposa | +| Laredo | 230401 Bridge I; 230402 Bridge II | External municipal directory: https://www.cityoflaredo.com/services/bridge-cameras . Indexed official listing was retrieved; page fetch returned 403 during review, so no embed/playback is claimed. | +| McAllen–Reynosa | 230502 Pharr | External official bridge site: https://bridge.pharr-tx.gov/ advertises its Live Bridge Camera link. The viewer fetch timed out during review; no embed/playback is claimed. This source must not be assigned to Anzalduas (230503). | + +Caltrans viewer pages inspected in the browser: +- San Ysidro: https://cwwp2.dot.ca.gov/vm/loc/d11/c214sb5viadesanysidro.htm +- Otay Mesa: https://cwwp2.dot.ca.gov/vm/loc/d11/c100i905siemprevivat.htm + +The viewer's video poster fields publish the snapshot URLs used in the app: +- https://cwwp2.dot.ca.gov/data/d11/cctv/image/c214sb5viadesanysidro/c214sb5viadesanysidro.jpg +- https://cwwp2.dot.ca.gov/data/d11/cctv/image/c100i905siemprevivat/c100i905siemprevivat.jpg + +These images load after view selection and refresh only on explicit request; +request time is not displayed as capture time. Failed loads expose a visible +error and an official video-viewer link. The provider's own Temporarily +Unavailable placeholder remains visible when returned as an image. Both sources +showed that placeholder during this review; playback/uptime is not claimed. + +Caltrans publishes these viewers through its official live-camera list and +describes its own information as generally public domain in +https://dot.ca.gov/conditions-of-use . Keep attribution and provider links. +BorderTraffic's public page requires licensing for commercial use of its +content; its free personal-use views are not copied or embedded here. No +license was purchased and no provider was contacted. + +Area/crossing selection alone creates no provider iframe. Selecting one of the +reviewed embedded views loads that provider's page; source-only options are +ordinary outbound links. Existing detail cards, titles and visible static +snapshots use the correct source and distinguish road approaches from queues. + +## 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..0d98883d4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "test": "npm run test:ads && npm run test:writer && npm run test:refresh && npm run test:publish && npm run test:hours && npm run test:trust && npm run test:mdx && npm run test:metadata && npm run test:audit && npm run test:bundle && npm run test:routes", - "test:metadata": "node --test scripts/test-faq-scope.mjs scripts/test-hreflang.mjs", + "test:metadata": "node --test scripts/test-faq-scope.mjs scripts/test-hreflang.mjs scripts/test-crossing-snapshot.mjs", "test:ads": "node --test scripts/test-ad-slot-diagnostics.mjs", "test:publish": "node --test scripts/test-publish-data.mjs scripts/test-public-crossings.mjs", "publish:validate": "node scripts/validate-publish-data.mjs", @@ -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-faq-scope.mjs b/scripts/check-faq-scope.mjs index 38087db0b..f07495bf9 100644 --- a/scripts/check-faq-scope.mjs +++ b/scripts/check-faq-scope.mjs @@ -105,7 +105,7 @@ export function checkBuiltFaqScope(distDir = path.join(root, 'dist')) { { route: '/metodologia/', title: 'Metodología |', lang: 'es', types: ['BreadcrumbList'], hreflangs: twins('/methodology/', '/metodologia/') }, { route: '/privacy/', title: 'Privacy and advertising |', hreflangs: twins('/privacy/', '/privacidad/') }, { route: '/privacidad/', title: 'Privacidad y publicidad |', lang: 'es', hreflangs: twins('/privacy/', '/privacidad/') }, - { route: '/crossing/san-ysidro/', title: 'San Ysidro Border Wait Time', types: ['Place', 'FAQPage', 'BreadcrumbList'] }, + { route: '/crossing/san-ysidro/', title: 'San Ysidro: CBP wait times', types: ['Place', 'FAQPage', 'BreadcrumbList'] }, { route: '/blog/', title: 'Border Pulse Blog |', types: ['Blog', 'BreadcrumbList'] }, { route: enArticle, title: 'San Ysidro vs Otay Mesa:', types: ['BlogPosting', 'BreadcrumbList'], hreflangs: twins(enArticle, esArticle) }, { route: esArticle, title: 'San Ysidro vs Otay Mesa:', lang: 'es', types: ['BlogPosting', 'BreadcrumbList'], hreflangs: twins(enArticle, esArticle) }, diff --git a/scripts/check-local-crossings.mjs b/scripts/check-local-crossings.mjs new file mode 100644 index 000000000..1e510d9e0 --- /dev/null +++ b/scripts/check-local-crossings.mjs @@ -0,0 +1,154 @@ +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.current_wait_time = crossing.port_number === '240801' ? 0 : 30; + 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); + const cameraImagePattern = 'https://cwwp2.dot.ca.gov/data/d11/cctv/image/**'; + const cameraImage = route => route.fulfill({ contentType: 'image/png', body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC', 'base64') }); + await page.route(cameraImagePattern, cameraImage); + 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'); + await page.waitForURL(url => url.searchParams.get('area') === 'el-paso-juarez'); + const cameraHub = page.getByRole('region', { name: lang === 'es' ? 'Cámaras fronterizas' : 'Border cameras', exact: true }); + const cameraToggle = cameraHub.getByRole('button', { name: lang === 'es' ? 'Cámaras' : 'Camera feeds', exact: true }); + assert.equal(await cameraHub.locator('iframe').count(), 0); + await cameraToggle.click(); + const bridgeChooser = cameraHub.getByRole('group', { name: lang === 'es' ? 'Elegir cruce' : 'Choose a crossing', exact: true }); + assert.equal(await bridgeChooser.getByRole('button').count(), 5); + assert.equal(await cameraHub.locator('iframe').count(), 0, 'opening the camera hub alone must not load a provider'); + await cameraHub.getByRole('button', { name: lang === 'es' ? 'Vista de norte a sur' : 'North to south view', exact: true }).click(); + const inlineCamera = cameraHub.locator('iframe'); + await inlineCamera.waitFor(); + assert.match(await inlineCamera.getAttribute('src'), /IcvugJWPXz8/); + await bridgeChooser.getByRole('button', { name: 'Zaragoza · Ysleta', exact: true }).click(); + assert.equal(await cameraHub.locator('iframe').count(), 0, 'switching bridges closes the previous player'); + await cameraHub.getByRole('button', { name: lang === 'es' ? 'Vista de norte a sur' : 'North to south view', exact: true }).click(); + assert.equal(await cameraHub.locator('iframe').count(), 1); + assert.match(await inlineCamera.getAttribute('src'), /GC5RY3zipa4/); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'inline cameras must fit the viewport'); + const cameraArea = cameraHub.getByLabel(lang === 'es' ? 'Zona de cámaras' : 'Camera area'); + await cameraArea.selectOption('san-diego-tijuana'); + assert.equal(await cameraHub.locator('iframe').count(), 0, 'changing areas removes the previous player'); + assert.equal(await bridgeChooser.getByRole('button').count(), 2); + await bridgeChooser.getByRole('button', { name: 'San Ysidro', exact: true }).click(); + await cameraHub.getByText(lang === 'es' ? /no muestran la fila de inspección/ : /do not show the inspection queue/).waitFor(); + await cameraHub.getByRole('button', { name: lang === 'es' ? 'I-5 · Via de San Ysidro (lado EE. UU.)' : 'I-5 · Via de San Ysidro (U.S. road)', exact: true }).click(); + const cameraSnapshot = cameraHub.getByRole('img'); + await cameraSnapshot.waitFor(); + const originalImageUrl = await cameraSnapshot.getAttribute('src'); + assert.equal(new URL(originalImageUrl).hostname, 'cwwp2.dot.ca.gov'); + assert.equal(await cameraHub.locator('iframe').count(), 0, 'Caltrans snapshots do not load a video player'); + await cameraHub.getByRole('button', { name: lang === 'es' ? 'Actualizar imagen' : 'Refresh image', exact: true }).click(); + assert.notEqual(await cameraSnapshot.getAttribute('src'), originalImageUrl, 'manual refresh requests a new image without claiming a new capture time'); + await page.route(cameraImagePattern, route => route.abort('blockedbyclient')); + await cameraHub.getByRole('button', { name: lang === 'es' ? 'Actualizar imagen' : 'Refresh image', exact: true }).click(); + await cameraHub.getByText(lang === 'es' ? /Imagen no disponible/ : /Camera image unavailable/).waitFor(); + assert.equal(await cameraHub.getByRole('link', { name: lang === 'es' ? 'Abrir visor de video en Caltrans' : 'Open the Caltrans video viewer', exact: true }).getAttribute('href'), 'https://cwwp2.dot.ca.gov/vm/loc/d11/c214sb5viadesanysidro.htm'); + await page.unroute(cameraImagePattern); + await page.route(cameraImagePattern, cameraImage); + assert.equal(new URL(page.url()).searchParams.get('area'), 'el-paso-juarez', 'camera selection must not silently change the wait-time area'); + await cameraArea.selectOption('eagle-pass'); + assert.equal(await cameraHub.locator('iframe').count(), 0); + await cameraHub.getByRole('button', { name: lang === 'es' ? 'Vista 1 del Puente II' : 'Bridge II view 1', exact: true }).click(); + assert.match(await inlineCamera.getAttribute('src'), /^https:\/\/g1\.ipcamlive\.com\/player\/player.php\?alias=67231a475ead1$/); + await cameraArea.selectOption('nogales'); + assert.equal(await cameraHub.locator('iframe').count(), 0); + assert.equal(await cameraHub.getByRole('link', { name: lang === 'es' ? 'Abrir fuente oficial de cámaras' : 'Open official camera source', exact: true }).getAttribute('href'), 'https://heroicanogales.gob.mx/webcams-garitas'); + await cameraArea.selectOption('calexico-mexicali'); + await cameraHub.getByText(lang === 'es' ? /Aún no hay una cámara pública verificada/ : /No public camera has been verified/).waitFor(); + assert.equal(await cameraHub.locator('iframe').count(), 0); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'area selector and missing-camera state must fit the viewport'); + await cameraToggle.click(); + assert.equal(await cameraHub.locator('iframe').count(), 0, 'collapsing the camera hub stops the player'); + assert.equal(await bridgeChooser.count(), 0); + await page.getByRole('button', { name: lang === 'es' ? 'Compartir' : 'Share', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: lang === 'es' ? 'Copiar para WhatsApp' : 'Copy for WhatsApp', exact: true }).click(); + const shareText = await page.evaluate(() => window.copiedLink); + assert.ok(shareText.includes('https://borderpulse.com/?area=el-paso-juarez')); + assert.ok(!/\d+\s*min/.test(shareText), 'local sharing must not carry a cached wait claim'); + await page.keyboard.press('Escape'); + 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' ? 'Sin reporte: Peatones' : 'No pedestrian report', { 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(), 'san-diego-tijuana', 'the URL remains usable when browser storage is blocked'); + assert.equal(await page.evaluate(() => localStorage.getItem('borderPulse_corridor')), 'el-paso-juarez', 'blocked storage does not claim a saved preference'); + await chooser.selectOption('el-paso-juarez'); + await panel.getByRole('heading', { name: 'El Paso–Juárez', exact: true }).waitFor(); + 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); + assert.equal(new URL(page.url()).searchParams.has('area'), false); + } + } finally { + await page.unroute(dataPattern, feed); + await page.unroute(apiPattern, feed); + await page.unroute(cameraImagePattern); + 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/check-nojs-reports.mjs b/scripts/check-nojs-reports.mjs new file mode 100644 index 000000000..62f1ca596 --- /dev/null +++ b/scripts/check-nojs-reports.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; + +// This is a no-JavaScript visitor, not a special Googlebot response. +export async function checkNoJsReports(browser, baseUrl) { + const context = await browser.newContext({ javaScriptEnabled: false, + viewport: { width: 375, height: 812 }, serviceWorkers: 'block' }); + await context.route('**/*', route => new URL(route.request().url()).origin === baseUrl.origin + ? route.continue() : route.abort('blockedbyclient')); + const page = await context.newPage(); + try { + await page.goto(new URL('/', baseUrl).href); + await page.getByRole('heading', { name: 'US–Mexico border wait times', exact: true }).waitFor(); + const report = page.locator('[data-prerender="crossing-snapshot"]'); + assert.equal(await report.locator('tbody tr').count(), 42); + await report.getByText(/published snapshot, not a live feed/).waitFor(); + assert.ok(await report.getByRole('link', { name: 'El Paso–Juárez', exact: true }).isVisible()); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'no-JS homepage must fit mobile viewport'); + await report.getByRole('link', { name: 'Santa Fe · Paso del Norte', exact: true }).click(); + await page.getByRole('heading', { name: 'Santa Fe · Paso del Norte border wait times', exact: true }).waitFor(); + assert.equal(await report.locator('tbody tr').count(), 1); + assert.ok(await report.getByRole('link', { name: 'View official camera sources: Fideicomiso de Puentes Fronterizos de Chihuahua', exact: true }).isVisible()); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'no-JS crossing must fit mobile viewport'); + } finally { await context.close(); } + console.log('[nojs-reports] PASS: mobile homepage → crossing → official camera path, visible reports, original timestamps and viewport fit without app JavaScript'); +} diff --git a/scripts/lib/crossing-snapshot-html.mjs b/scripts/lib/crossing-snapshot-html.mjs new file mode 100644 index 000000000..228e7c8c8 --- /dev/null +++ b/scripts/lib/crossing-snapshot-html.mjs @@ -0,0 +1,42 @@ +import { availableLaneWait } from '../../src/lib/crossingAvailability.js'; +import { crossingDisplayName } from '../../src/lib/crossingNames.js'; +import { CORRIDORS } from '../../src/lib/corridors.js'; +import { cameraSourceFor } from '../../src/lib/cameras.js'; + +const esc = s => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +// Visible, useful HTML for every visitor before the app loads, including when +// JavaScript is unavailable. createRoot replaces it with the updating app. +// The original snapshot timestamp is never rewritten into a "live" promise. +export function snapshotHtml(crossings, portToSlug, fetchedAt, single = false) { + const heading = single + ? `${crossingDisplayName(crossings[0])} border wait times` + : 'US–Mexico border wait times'; + const areas = single ? '' : ``; + const rows = crossings.map(c => { + const slug = portToSlug[c.port_number]; + const lane = c.lanes?.passenger_standard; + const wait = availableLaneWait(c, 'passenger_standard'); + const closed = (c.port_status || '').trim().toLowerCase() === 'closed'; + const value = closed ? 'Closed in this snapshot' : wait == null ? 'No standard-lane report' : `${wait} min`; + return `${esc(crossingDisplayName(c))}${esc(value)}${esc(lane?.update_time || 'CBP report time unavailable')}${esc(c.hours || 'Official hours unavailable')}`; + }).join(''); + const cameraSource = single && cameraSourceFor(crossings[0]?.port_number); + const cameraLink = cameraSource + ? `

View official camera sources: ${esc(cameraSource.owner)}. ${cameraSource.kind === 'approach' ? 'These are U.S. road approaches, not the inspection queue from Mexico into the U.S.' : 'Camera access and availability are controlled by the provider.'}

` : ''; + return `
+

${esc(heading)}

+

Official CBP northbound reports, crossing hours, local comparisons and historical planning. English and Spanish are available in the interactive app.

+

Published snapshot fetched: . This is a published snapshot, not a live feed. Individual lane report times can be older. Check the interactive report or CBP before leaving.

+ ${areas} +
${rows}
Reported standard passenger waits to the U.S.
CrossingReported waitCBP report clockPosted hours
+ ${cameraLink} +

Consider your eligible lane, extra driving and destination. These numbers do not compare total trip time. Data methodology.

+
`; +} + +export function injectSnapshot(html, content) { + const target = /
<\/div>/; + if (!target.test(html)) throw new Error('Expected one empty React root for snapshot injection'); + return html.replace(target, `
${content}
`); +} diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index a8040f37b..e39ead539 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -6,6 +6,9 @@ import { mdxBodyToHtml } from './lib/mdx-to-html.mjs'; import { hasPedestrianLane } from '../src/lib/crossingAvailability.js'; import { COMPARE_PAIRS } from '../src/lib/comparePairs.js'; import { pageHreflangs, blogHreflangs } from '../src/lib/hreflang.js'; +import { snapshotHtml, injectSnapshot } from './lib/crossing-snapshot-html.mjs'; +import { crossingSearchTitle } from '../src/lib/crossingNames.js'; +import { cameraSourceFor } from '../src/lib/cameras.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '..'); @@ -43,7 +46,7 @@ function faqItems(crossing, aggregate) { function renderCrossingHead(crossing, slug, aggregate) { // Title pattern matches what wins these SERPs: keyword-first, "today"/live // freshness signal, current year. Brand goes last where truncation is cheap. - const title = `${crossing.name} Border Wait Time Today: Live CBP Data (2026) | Border Pulse`; + const title = crossingSearchTitle(crossing, 'en', cameraSourceFor(crossing.port_number)?.kind); const desc = `Live ${crossing.name} border wait time from U.S. Customs and Border Protection, plus 30-day hour-by-hour patterns, port hours, and the best time to cross today.`; const canonical = `${BASE}/crossing/${slug}/`; const ogImage = `${BASE}/og/${slug}.png`; @@ -400,7 +403,7 @@ function injectCrawlableLinks(html, crossings, portToSlug, posts) { .join('\n'); const block = ` -