Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 49 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,52 @@
# CHANGELOG

## 2026-11-03 — Prepare the reviewed fixes as an ordered branch stack

- Preserve current main data while reconciling the Spanish/snapshot, duplicate-lane and Lane Flip Watch branches in that order. Keep both endpoint regression groups, metadata imports and reviewed source-clock safeguards.
- Refresh the retained lane archive from committed normalized snapshots; retain the explicit missing-raw-data caveat and actual coverage dates.
- Add annotation-only Button prop and optional SEO field contracts for the configured JavaScript typecheck; no runtime, gate or dependency change. Remaining diagnostics are inherited from untouched main.

## 2026-11-02 — Scope Lane Flip Watch to retained evidence

- Keep concise EN/ES eligibility and non-simultaneity cautions in copied, native-shared and WhatsApp text, alongside the source clocks and canonical URL.

- Replace usual/never/right-now claims with descriptive counts and percentages from retained fetches. Repeated fetches can repeat one CBP report and are not independent observations or forecasts.
- Show actual archive and per-lane intervals, an explicit stale state after a day without a retained fetch, both compared source clocks and lane eligibility reminders in EN/ES.
- Rebuild coverage metadata from committed normalized snapshots only; raw historical CBP responses are unavailable, so clearly disclose earlier duplicate-row omissions. This does not retroactively apply the new normalizer to old data.

## 2026-11-02 — Lane Flip Watch (review candidate)

- Add `/lane-flip/` and `/lane-flip/:slug/`: reported general, Ready Lane, SENTRI, and Ready pedestrian waits side by side, with a plain verdict and a tappable 24-hour strip.
- Count flips from the same 30-day `crossings.json` history the collector already walks. `snapshot-history.json` still stores only standard waits. A paired report needs both lanes open, with a numeric delay or no-delay status. Missing, pending, and closed lanes stay missing.
- Ship the summary as `public/data/lane-flips.json`. The collector writes it beside the standard archive and can skip it without blocking a CBP refresh. No new data source.
- English and Spanish, sitemap, navigation, and share text that keeps the CBP report clock. Not published.
- A null or missing open-lane count is not zero. `Lanes Closed` and `N/A` stay out of comparisons and flips.

## 2026-11-02 — Normalize primary waits and retain per-lane source clocks

- Apply the same status-aware scalar normalization to the San Ysidro main-row path as other ports. Hypothetical retained-number tests cover closed, N/A and pending states, actual zero, and reversed source order; they do not describe an observed feed incident.
- Preserve each lane’s originating CBP row date/time when duplicate rows contribute different lanes, alongside the original lane update clock.

## 2026-11-02 — Keep reported waits when CBP publishes two rows for one crossing

- When two CBP rows map to the same crossing, a reported lane wait is kept ahead of an empty or Update Pending row. Ysleta keeps the reported Ready Lane, SENTRI, pedestrian, and commercial waits from port 240203. Standard stays Lanes Closed, with no minute invented for it.
- Store CBP's lane status (`Lanes Closed`, `Update Pending`, `N/A`) on the lane. Closed and N/A lanes are no longer dropped. Current wait wording is unchanged.
- The same feed also dropped Otay Mesa's reported commercial waits, and let Update Pending replace an explicit status at BOTA, Paso Del Norte, Naco, Roma, and both Brownsville legacy crossings.
- Correct the port-identity note that said those substitute rows never appear together.

## 2026-11-02 — Bound snapshot recovery and keep the Spanish home consistent

- Share an 8.5-second total fetch budget between the HTML endpoint’s upstream and known-host bundled recovery, leaving headroom under the existing ten-second function limit. Preserve fallback retrieval/report clocks and no-store errors.
- Let `/es/` own the shell, footer and shared language preference; switching to English retains the query and hash. Cover fresh and saved-English visitors, mobile/desktop and both no-JS language entries.
- Clarify that CDN reuse includes five minutes of cache plus up to ten minutes of stale revalidation; it is not a strict five-minute freshness guarantee.

## 2026-11-02 - Deploy focus race, Spanish home, fresher no-JS snapshot

- Keep map selection focus from taking the search field when its callback arrives after the visitor has started filtering. This was the only failure in the Pages deploy at 06:23 UTC.
- Add `/es/` as the indexable Spanish home, with lang, title, description, reciprocal hreflang and a sitemap entry. Shared crossing URLs stay one address.
- Serve the no-JS home snapshot from the cached official fetch on Vercel, and from the data-deploy file on Pages, still labeled as a published snapshot.
- Describe DIGITO as an independent creative studio. No home city added.

## 2026-11-01 — Correct map report focus and selected-lane summaries

- Reveal the selected report's focused Back control clear of the native header and nested pane clipping; restore the original list/document position and focus on Escape, button and browser Back, with immediate reduced-motion-safe scrolling.
Expand Down Expand Up @@ -772,7 +819,6 @@ a single session. Pull from `git log` if you ever need raw commit detail.

## 2026-08-19

<<<<<<< HEAD
### Added
- **Blueprint Border Line on the dashboard.** The border drawn as an
architect's elevation: one line from the Pacific to the Gulf, all 42 ports
Expand All @@ -789,8 +835,7 @@ a single session. Pull from `git log` if you ever need raw commit detail.
`npm test`). There was no size check and the entry chunk had already
drifted from a documented ~161 KB to 177 KB with nothing to catch it.

=======
>>>>>>> origin/main

### Fixed
- **Port identity is pinned; CBP renames can no longer move a URL.** CBP's feed
intermittently substitutes a different row for the same physical crossing
Expand All @@ -812,15 +857,13 @@ a single session. Pull from `git log` if you ever need raw commit detail.
exactly to 42.
- **Southbound restored for Ysleta**, which was failing silently on a stale
`240203` key in `fetch-sb.mjs`. Coordinate coverage 40/44 -> 42/42.
<<<<<<< HEAD
- **Border Line i18n and tap-target gaps caught in verification.** The SVG
title block rendered "42 CROSSINGS · 18 CON DATOS" in Spanish (half
translated) and the `<desc>` was English-only; both are localised now.
Mobile tick rows measured 23 CSS px at a 375px viewport (iPhone SE/mini),
just under the 24x24 AA floor, because the SVG scales down inside a
narrower screen — row height raised so it clears at 375px too.
=======
>>>>>>> origin/main

- **Accessibility: single `<h1>` and WCAG 2.2 tap targets.** The sidebar brand
was an `<h1>` inside `hidden lg:flex`, so every page had two h1s at every
width. Controls raised to a 24x24 floor with 44x44 on primary actions, via a
Expand Down
31 changes: 26 additions & 5 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# DECISIONS

## 2026-11-02 · Lane comparisons describe retained fetches

Do not infer a usual lane advantage from repeated fetches or call values current just because retrieval is fresh. Display both source clocks and actual historical coverage; mark an archive stale after 24 hours without a retained fetch. Earlier snapshots were normalized before duplicate-row repair and raw responses are unavailable: retain an explicit incompleteness caveat rather than claim they were reconstructed. Counts and percentages describe those stored fetches only.

## 2026-11-02 · Lane flip counts need normalized lane snapshots, not the Standard-only archive

`snapshot-history.json` keeps only Standard `current_wait_time`, so Ready, SENTRI, and Ready pedestrian comparisons use the existing full-history walk of normalized `public/data/crossings.json`. These are not raw CBP bodies and cannot recover values omitted by the older duplicate-row normalizer. Each fetch counts once if both usable lane values exist; repeated fetches may repeat the same underlying source report. Hours group port-local retrieval time. Show actual retained dates and descriptive counts/percentages, never a usual/never or probability claim. The collector requests a 30-day lookback, but a retained artifact may be older: its coverage and stale state must remain visible. Failure to write the summary must not block the CBP snapshot.

## 2026-11-02 · Spanish home is one indexable entry, not a translated URL tree

Search engines need a Spanish document for the home page. `/es/` is that entry: its own lang, title, description, canonical and reciprocal hreflang with `/`. Shared crossing URLs stay one address with the in-page language preference. This does not open per-crossing or best-time Spanish duplicates while query evidence is still thin.

## 2026-11-02 · The no-JS home snapshot is not frozen to the last site build

Vercel publishes the app daily and on application changes, so HTML baked at build time can lag the live CBP fetch by hours. The no-JS home frame reads `/snapshot/`, which on Vercel is the existing cached CBP function rendered as HTML. GitHub Pages, the rollback host, keeps the snapshot file written by that data deploy. The frame is labeled as a published snapshot and keeps the retrieval time. It does not add a collector or rebuild the site on every data commit.

## 2026-10-30 · The crossing map uses the existing BorderPulse design

The map is an ordinary BorderPulse page. Its presentation inherits the existing shell, type, surfaces, controls and blueprint map language; a separate cinematic visual identity is no longer the target. Keep useful geography, explicit clocks, lane comparisons, honest replay and user-entered driving arithmetic. Entry points belong in the existing navigation/dashboard and relevant crossing pages. This correction awaits exact-head review before release.
Expand Down Expand Up @@ -140,7 +156,7 @@ additional monthly spend; do not pause the shared Vercel team and DIGITO.
## 2026-09-29 · Evaluate Vercel with a public-app preview

Keep the existing GitHub Pages site and automatic ads while testing Vercel.
Vercel builds use the same Vite app plus a five-minute CDN-cached CBP endpoint;
Vercel builds use the same Vite app plus a CBP endpoint with five minutes of CDN cache and up to ten further minutes of stale revalidation;
the client retains the last successful reading and falls back to the published
static snapshot with its original timestamp when the endpoint fails. A compact
30-day snapshot artifact makes historical aggregates independent of build-host
Expand Down Expand Up @@ -222,9 +238,11 @@ gate.
## 2026-08-19 · Crossing identity is pinned in code, not read from the CBP feed

CBP substitutes whole rows for the same bridge: `240202` "El Paso - Paso Del
Norte (PDN)" becomes `202401` "Paso Del Norte", and back. The rows never
co-occur (0/200 snapshots), so this is not something dedupe can arbitrate.
Since slugs come from names, the crossing's URL moved on its own.
Norte (PDN)" becomes `202401` "Paso Del Norte", and back. A 200-snapshot
sample did not catch them together, so this was treated as something dedupe
could not arbitrate. The 2026-11-02 feed shows that sample was incomplete;
see the later note. Identity pinning still stands. Since slugs come from
names, the crossing's URL moved on its own.

`SLUG_ALIASES` was the wrong layer: it aliases *slugs* while the instability is
in *port numbers*, so every rename needed a new manual entry, and
Expand Down Expand Up @@ -446,7 +464,6 @@ is the implementation record.

*Append a new section the next time a non-obvious decision lands.*


## 2026-10-06 · Accept Tailwind 4 browser floor to clear publication blockers

The owner approved the tested Tailwind 4 migration and production release with
Expand Down Expand Up @@ -526,3 +543,7 @@ Activation waits for the actual cloud source/tool/connector dry run.
## 2026-10-30 · Add the crossing console alongside the proven dashboard

The first corridor console is a lazy, reversible route for San Ysidro/Otay, not a dashboard replacement. Current data uses existing trust/availability guards; Standard-only historical observations are a separate, on-request replay. User-entered driving time supports explicit arithmetic, not routing or forecasting. Preserve existing editorial and publication paths; release follows exact-head review and QC.

## 2026-11-02 · Co-occurring CBP rows keep the reported lane

The raw `bwtpublicmod` feed saved 2026-11-02 contains both port numbers for Ysleta (`240104` and `240203`) and for the other pinned substitution pairs. Identity stays pinned. When both rows are present, a reported wait outranks an empty or Update Pending row. `Lanes Closed` and `N/A` stay on the lane so a closed lane is not stored as a blank one. San Ysidro still does not take PedWest or Cross Border Express waits. This supersedes the co-occurrence claim in the 2026-08-19 identity note.
16 changes: 11 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Status legend: `[ ]` open · `[~]` in flight · `[x]` shipped · `[-]` won't do
BorderPulse is an independently branded product built by DIGITO. Its own domain,
codebase, operating record and product decisions stay here; DIGITO may describe
the product as proof of its work without folding the crossing experience into
the trades-focused DIGITO site.
the DIGITO site. DIGITO is an independent creative studio: Make more of technology.

The long-term opportunity is practical, bilingual cross-border decision support
for people who cross for work, family and everyday life. Wait times are the
Expand All @@ -21,6 +21,7 @@ cross-border experience; it is a product hypothesis, not a claim of demand.

### Now: earn trust in the free entry point

- [x] Keep a reported CBP lane wait ahead of an empty duplicate row, and store `Lanes Closed` / `N/A` instead of a blank lane. Ysleta 240104/240203 co-occur in the 2026-11-02 feed. Review-only until the collector publishes a new snapshot.
- [~] Review the additive bilingual San Ysidro/Otay crossing console with geographic reports, meaningful motion, observed replay and user-entered detour arithmetic. Draft PR, exact-head QC/CI and a protected preview precede any merge; the existing dashboard and editorial remain.

- [x] Preserve chosen lane through local comparison, crossing detail and sharing
Expand Down Expand Up @@ -140,6 +141,8 @@ cross-border experience; it is a product hypothesis, not a claim of demand.
describing any paid offering or accepting a charge. The free, ad-supported
crossing experience remains the current offer.

- [~] Lane Flip Watch (`/lane-flip/`): reported Ready Lane, SENTRI, and Ready pedestrian versus general, plus a 30-day hour strip. Review candidate only. No production release.

### Next: test one adjacent crossing job

- Interview or observe repeat crossers to find a specific unresolved decision.
Expand Down Expand Up @@ -223,10 +226,13 @@ Work top to bottom; restore product trust before expanding the URL inventory.
- [x] **Programmatic SEO `/compare/:slugA-vs-:slugB`** — shipped
2026-05-09. 15 seed pairs prerendered + sitemap'd. Add more
via the single `COMPARE_PAIRS` inventory in `src/lib/comparePairs.js`.
- [ ] **Hreflang for ES variants** — currently EN/ES share the same URL
and toggle in localStorage. For better ES SEO, consider
`/mejor-hora/:slug` parallel routes with proper `<link
rel="alternate" hreflang="es">` pointing.
- [~] **Spanish home entry:** `/es/` is prepared as the indexable Spanish
homepage, with its own lang, title, description and reciprocal hreflang.
The in-page toggle still changes shared URLs such as `/`. Not published.
- [ ] **Hreflang for other ES variants:** crossing, best-time and compare
URLs still share one address and toggle in the browser. Parallel
routes such as `/mejor-hora/:slug` stay deferred while current
query evidence is thin.
- [ ] **Refactor: extract `useTypicalDelta` hook** — duplicated in
`BorderCrossingCard.jsx` and `Embed.jsx`. Pull into `src/lib/`
once a third caller emerges. Low priority, no user impact.
Expand Down
4 changes: 3 additions & 1 deletion api/public/crossings.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { CBP_URL, createCbpPayload } from '../../scripts/fetch-cbp.mjs';

// Serve one official snapshot from the CDN for five minutes. The frontend
// The CDN reuses a response for five minutes, then may serve it for another
// ten minutes while revalidating. This is not a five-minute freshness bound.
// The frontend
// keeps its last good response and can fall back to the static Pages feed.
export const config = { maxDuration: 10 };

Expand Down
65 changes: 65 additions & 0 deletions api/public/snapshot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { CBP_URL, createCbpPayload } from '../../scripts/fetch-cbp.mjs';
import { snapshotDocument } from '../../scripts/lib/crossing-snapshot-html.mjs';

// HTML form of the cached official snapshot. The no-JS home frame uses it so
// the published table is not stuck on the last full site build. CDN responses
// may be reused for five minutes plus ten minutes of stale revalidation.
// Retrieval and lane report clocks remain visible; this is not a freshness SLA.
export const config = { maxDuration: 10 };

function send(res, status, body, cache = false) {
res.status(status);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', cache ? 'public, max-age=0, must-revalidate' : 'no-store');
res.setHeader('X-Robots-Tag', 'noindex');
if (cache) {
res.setHeader('Vercel-CDN-Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600');
res.setHeader('CDN-Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600');
}
res.end(body);
}

function requestedLang(req) {
const fromQuery = req.query?.lang;
if (fromQuery === 'es' || fromQuery === 'en') return fromQuery;
try {
return new URL(req.url || '/', 'https://borderpulse.com').searchParams.get('lang') === 'es' ? 'es' : 'en';
} catch {
return 'en';
}
}

export default async function handler(req, res) {
if (req.method !== 'GET') return send(res, 405, 'Method not allowed');
const lang = requestedLang(req);
// Leave 1.5 seconds for rendering/response within maxDuration. The fallback
// shares this deadline; it cannot start a second eight-second request.
const deadline = Date.now() + 8_500;
try {
const upstream = await fetch(CBP_URL, {
headers: { 'User-Agent': 'borderpulse.com/1.0 (snapshot-cache)' },
signal: AbortSignal.timeout(6_000),
});
if (!upstream.ok) throw new Error(`CBP source returned ${upstream.status}`);
const payload = createCbpPayload(await upstream.json());
return send(res, 200, snapshotDocument(payload, { lang, framed: true }), true);
} catch (error) {
console.error('[public-snapshot]', error);
const host = req.headers?.host;
const allowedHosts = ['borderpulse.com', 'www.borderpulse.com', process.env.VERCEL_URL, process.env.VERCEL_PROJECT_PRODUCTION_URL].filter(Boolean);
if (allowedHosts.includes(host) && Date.now() < deadline) {
try {
const bundled = await fetch(`https://${host}/data/crossings.json`, { signal: AbortSignal.timeout(Math.max(1, Math.min(2_500, deadline - Date.now()))) });
if (bundled.ok) {
const payload = await bundled.json();
return send(res, 200, snapshotDocument(payload, { lang, framed: true }), false);
}
} catch (fallbackError) {
console.error('[public-snapshot] bundled fallback', fallbackError);
}
}
return send(res, 502, lang === 'es'
? '<p>Los datos de CBP no están disponibles temporalmente. <a href="https://bwt.cbp.gov/" target="_top">Consultar CBP</a></p>'
: '<p>Official CBP data is temporarily unavailable. <a href="https://bwt.cbp.gov/" target="_top">Check CBP</a></p>');
}
}
Loading
Loading