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

## 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
10 changes: 9 additions & 1 deletion DECISIONS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# DECISIONS

## 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 +148,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
13 changes: 8 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 Down Expand Up @@ -223,10 +223,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>');
}
}
6 changes: 3 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@
<meta name="twitter:description" content="Available northbound reports at US-Mexico border crossings. Official CBP data, refreshed regularly." />
<meta name="twitter:image" content="https://borderpulse.com/og-card.png" />

<!-- No es hreflang here: the language toggle is client-side and there is no
distinct Spanish URL for the dashboard yet. Pointing es at the same URL
tells Google a Spanish document exists where it does not. -->
<!-- /es/ is the indexable Spanish home. The in-page toggle still changes
this shared URL for people; it does not replace that document. -->
<link rel="alternate" hreflang="en" href="https://borderpulse.com/" />
<link rel="alternate" hreflang="es" href="https://borderpulse.com/es/" />
<link rel="alternate" hreflang="x-default" href="https://borderpulse.com/" />

<script type="application/ld+json">
Expand Down
2 changes: 2 additions & 0 deletions public/robots.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Disallow: /category/
# Embed widgets are meant for iframe use on third-party sites, not search.
# They also have <meta name="robots" content="noindex"> as a second guard.
Disallow: /embed/
# Snapshot frames are noindex supplements for the home page, not extra landing pages.
Disallow: /snapshot/

# AI training crawlers — explicitly welcome
User-agent: GPTBot
Expand Down
12 changes: 12 additions & 0 deletions public/sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@
<lastmod>2026-08-20</lastmod>
<changefreq>hourly</changefreq>
<priority>1.0</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://borderpulse.com/" />
<xhtml:link rel="alternate" hreflang="es" href="https://borderpulse.com/es/" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://borderpulse.com/" />
</url>
<url>
<loc>https://borderpulse.com/es/</loc>
<lastmod>2026-11-02</lastmod>
<changefreq>hourly</changefreq>
<priority>0.9</priority>
<xhtml:link rel="alternate" hreflang="en" href="https://borderpulse.com/" />
<xhtml:link rel="alternate" hreflang="es" href="https://borderpulse.com/es/" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://borderpulse.com/" />
</url>
<url>
<loc>https://borderpulse.com/about/</loc>
Expand Down
4 changes: 3 additions & 1 deletion scripts/build-sitemap.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { hasPedestrianLane } from '../src/lib/crossingAvailability.js';
import { COMPARE_PAIRS } from '../src/lib/comparePairs.js';
import { homeHreflangs } from '../src/lib/hreflang.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
Expand Down Expand Up @@ -59,7 +60,8 @@ async function main() {
{ lang: 'x-default', href: `${BASE}/privacy/` },
];
const urls = [
{ loc: `${BASE}/`, changefreq: 'hourly', priority: '1.0', lastmod: today },
{ loc: `${BASE}/`, changefreq: 'hourly', priority: '1.0', lastmod: today, alternates: homeHreflangs() },
{ loc: `${BASE}/es/`, changefreq: 'hourly', priority: '0.9', lastmod: today, alternates: homeHreflangs() },
{ loc: `${BASE}/about/`, changefreq: 'monthly', priority: '0.5', lastmod: today },
{ loc: `${BASE}/best-time/`, changefreq: 'daily', priority: '0.7', lastmod: today },
{ loc: `${BASE}/privacy/`, changefreq: 'yearly', priority: '0.2', lastmod: today, alternates: privacyAlternates },
Expand Down
1 change: 1 addition & 0 deletions scripts/check-border-map.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export async function checkBorderMap(browser, baseUrl, viewport) {
await openResult();
await page.getByRole('searchbox').fill('San');
await page.waitForFunction(() => !new URL(location.href).searchParams.has('port'));
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
assert.equal(await page.getByRole('searchbox').evaluate(n => n === document.activeElement), true, 'filtering keeps search focus');
await page.getByRole('searchbox').fill('');
await page.waitForURL(url => !url.searchParams.has('q'));
Expand Down
18 changes: 18 additions & 0 deletions scripts/check-document-language.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,24 @@ export async function checkDocumentLanguage(page, context, baseUrl) {
localStorage.setItem('borderPulse_language', 'es');
});
}
for (const preference of [null, 'en']) {
await page.evaluate(value => {
if (value === null) localStorage.removeItem('borderPulse_language');
else localStorage.setItem('borderPulse_language', value);
}, preference);
await page.goto(new URL('/es/?area=all&lane=sentri&display=list', baseUrl).href);
await inspect('/es/', 'es');
await page.locator('footer').getByRole('link', { name: 'Metodología', exact: true }).waitFor();
assert.equal(await page.locator('footer a[href="/methodology/"]').count(), 0);
const menu = page.getByRole('button', { name: 'Open menu', exact: true });
if (await menu.isVisible()) await menu.click();
await page.getByRole('navigation', { name: 'Navegación principal', exact: true }).filter({ visible: true })
.getByRole('link', { name: 'Cruces', exact: true }).waitFor();
await switchTo('en');
await inspect('/', 'en');
assert.equal(new URL(page.url()).search, '?area=all&lane=sentri&display=list');
await page.locator('footer').getByRole('link', { name: 'Methodology', exact: true }).waitFor();
}
assert.ok(feedRequests > 1, 'must exercise a genuine feed refresh');
assert.ok(aggregateRequests > 0, 'must exercise delayed aggregate arrival');
console.log('[document-language] PASS: delayed aggregate, feed refresh, switching, Back/Forward, shared pages and EN/ES content-owned pages');
Expand Down
3 changes: 2 additions & 1 deletion scripts/check-faq-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ export function checkBuiltFaqScope(distDir = path.join(root, 'dist')) {

const twins = (en, es) => ({ en, es, 'x-default': en });
const representatives = [
{ route: '/', title: 'Border Pulse |', hreflangs: { en: '/', 'x-default': '/' } },
{ route: '/', title: 'Border Pulse |', hreflangs: twins('/', '/es/') },
{ route: '/es/', title: 'Border Pulse |', lang: 'es', hreflangs: twins('/', '/es/') },
{ route: '/methodology/', title: 'Methodology |', types: ['BreadcrumbList'], hreflangs: twins('/methodology/', '/metodologia/') },
{ route: '/metodologia/', title: 'Metodología |', lang: 'es', types: ['BreadcrumbList'], hreflangs: twins('/methodology/', '/metodologia/') },
{ route: '/privacy/', title: 'Privacy and advertising |', hreflangs: twins('/privacy/', '/privacidad/') },
Expand Down
13 changes: 9 additions & 4 deletions scripts/check-hreflang.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const alternates = (html) => links(html).filter((a) => a.rel === 'alternate' &&
export function checkBuiltHreflangs(dist = path.join(root, 'dist')) {
const posts = JSON.parse(fs.readFileSync(path.join(dist, 'data/blog/index.json'), 'utf8')).posts;
const expected = new Map([
['/', pair('/', '/es/')],
['/es/', pair('/', '/es/')],
['/snapshot/', single('/snapshot/')],
['/snapshot/es/', single('/snapshot/es/', 'es')],
['/methodology/', pair('/methodology/', '/metodologia/')],
['/metodologia/', pair('/methodology/', '/metodologia/')],
['/privacy/', pair('/privacy/', '/privacidad/')],
Expand Down Expand Up @@ -74,11 +78,12 @@ export async function checkHreflangNavigation(page, baseUrl) {
assert.equal(await page.evaluate(() => window.hreflangDocument), marker, 'must stay in same SPA document');
assert.deepEqual(await page.locator('link[rel="canonical"]').evaluateAll((nodes) => nodes.map((node) => node.href)), [origin + route]);
};
const homePair = pair('/', '/es/');
const click = async (route) => page.locator(`#root a[href="${route}"]:visible`).first().click();
await inspect('/');
await inspect('/', homePair);
await page.getByRole('button', { name: 'Cambiar a español', exact: true }).filter({ visible: true }).click();
await page.waitForFunction(() => document.documentElement.lang === 'es', null, { timeout: 10_000 });
await inspect('/'); // Shared URL; no invented Spanish route.
await inspect('/', homePair); // Shared URL keeps this canonical. Spanish points at /es/.
await page.getByRole('button', { name: 'Switch to English', exact: true }).filter({ visible: true }).click();
await page.getByRole('searchbox', { name: 'Search crossings by name or city', exact: true }).fill('San Ysidro');
await click('/crossing/san-ysidro/');
Expand Down Expand Up @@ -109,15 +114,15 @@ export async function checkHreflangNavigation(page, baseUrl) {
await page.goBack();
await inspect(enArticle, pair(enArticle, esArticle));
await click('/');
await inspect('/');
await inspect('/', homePair);
// A fresh translated entry also has to clear its prerendered pair on exit.
await page.goto(new URL(esArticle, baseUrl).href);
await page.locator('#root article[lang="es"]').waitFor();
const entryExpected = pair(enArticle, esArticle);
assert.deepEqual(await page.locator('link[rel="alternate"][hreflang]').evaluateAll((nodes) => nodes.map((node) => ({ lang: node.hreflang, href: node.href }))), entryExpected);
await click('/');
await page.getByRole('searchbox', { name: 'Search crossings by name or city', exact: true }).waitFor();
assert.deepEqual(await page.locator('link[rel="alternate"][hreflang]').evaluateAll((nodes) => nodes.map((node) => ({ lang: node.hreflang, href: node.href }))), single('/'));
assert.deepEqual(await page.locator('link[rel="alternate"][hreflang]').evaluateAll((nodes) => nodes.map((node) => ({ lang: node.hreflang, href: node.href }))), homePair);
console.log('[hreflang] PASS: SPA home/language switch → crossing → best-time → Back → compare → blog → EN/ES article → Back → home');
}

Expand Down
Loading
Loading