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

## 2026-11-10 - Crossing titles and descriptions match how people search (draft)

- Crossing page titles now follow the query shape from Search Console: "<crossing> border wait times today" (EN) and "Tiempos de espera en <crossing> hoy" (ES). Bridges and ports of entry use the name people type: "Anzalduas International Bridge (Hidalgo/Pharr)", "Santa Teresa Port of Entry", "Andrade (Los Algodones)", "Eagle Pass International Bridge II" and others.
- Descriptions are built per crossing instead of one shared template: the search name, the hour with the shortest typical wait over the last 30 days, and cameras where they exist. One function feeds the prerendered page and the client.
- Why: Google Search Console, last 28 days: 51.1K impressions, 1.2% click rate, average position 9.6. "santa teresa port of entry" had 342 impressions and no clicks under the old "Santa Teresa · San Jerónimo" title.
- New `src/lib/crossingSeo.js`, loaded only by crossing pages and prerender, so the entry bundle stays at 61.89 KB gzip (budget 62.25).
- Checks: full `npm test` passes after a prerendered build. Measure: click rate per crossing page in Search Console 2 to 4 weeks after deploy.

## 2026-11-10 - Spanish and accessibility polish (Phase 4, draft)

- Spanish: the mobile menu button is announced as "Abrir menú" / "Cerrar menú" instead of English. The blog index title, "Todos" tab, filter label and empty state follow the site language; all posts stay listed by default so Spanish readers can still open English guides. FAQ wording "job programado" is now "tarea programada".
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-faq-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,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: CBP wait times', types: ['Place', 'FAQPage', 'BreadcrumbList'] },
{ route: '/crossing/san-ysidro/', title: 'San Ysidro border wait times today', 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) },
Expand Down
4 changes: 2 additions & 2 deletions scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { COMPARE_PAIRS } from '../src/lib/comparePairs.js';
import { pageHreflangs, blogHreflangs } from '../src/lib/hreflang.js';
import { snapshotHtml, snapshotDocument, injectSnapshot } from './lib/crossing-snapshot-html.mjs';
import { SPANISH_HOME } from '../src/lib/homeMeta.js';
import { crossingSearchTitle } from '../src/lib/crossingNames.js';
import { crossingSearchDescription, crossingSearchTitle } from '../src/lib/crossingSeo.js';
import { cameraSourceFor } from '../src/lib/cameras.js';
import { googlePrivacyNotice } from '../src/lib/googlePrivacyNotice.js';
import { privacyParagraphs } from '../src/lib/privacyParagraphs.js';
Expand Down Expand Up @@ -56,7 +56,7 @@ 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 = crossingSearchTitle(crossing, 'en', cameraSourceFor(crossing.port_number)?.kind);
const desc = `Reported ${crossing.name} border wait time from U.S. Customs and Border Protection, plus 30-day hour-by-hour patterns, port hours, and historical standard-passenger wait patterns.`;
const desc = crossingSearchDescription(crossing, 'en', { bestHour: aggregate?.overall_best_hour ?? null, hasCameras: Boolean(cameraSourceFor(crossing.port_number)?.kind) });
const canonical = `${BASE}/crossing/${slug}/`;
const ogImage = `${BASE}/og/${slug}.png`;

Expand Down
7 changes: 4 additions & 3 deletions scripts/test-crossing-snapshot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { snapshotHtml, snapshotDocument, injectSnapshot } from './lib/crossing-snapshot-html.mjs';
import { crossingDisplayName, crossingSearchTerms, crossingSearchTitle } from '../src/lib/crossingNames.js';
import { crossingDisplayName, crossingSearchTerms } from '../src/lib/crossingNames.js';
import { crossingSearchTitle } from '../src/lib/crossingSeo.js';

const crossing = { port_number: '240202', name: 'El Paso - Paso Del Norte (PDN)',
hours: '24 hrs/day', port_status: 'Open', lanes: { passenger_standard: { status: 'No Delay', lanes_open: 1, delay_minutes: 0, update_time: 'At 6:00 pm MDT' } } };
Expand Down Expand Up @@ -79,8 +80,8 @@ test('local naming helps search without changing official identity or canonical
assert.match(crossingDisplayName(crossing), /Santa Fe/);
assert.ok(crossingSearchTerms({ port_number: '240201', name: 'BOTA' }).includes('puente libre'));
assert.ok(crossingSearchTerms({ port_number: '240801', name: 'Santa Teresa' }).includes('san jeronimo'));
assert.match(crossingSearchTitle(crossing, 'en', true), /CBP wait times & cameras/);
assert.match(crossingSearchTitle(crossing, 'es', true), /esperas CBP y cámaras/);
assert.match(crossingSearchTitle(crossing, 'en', true), /wait times today & cameras/);
assert.match(crossingSearchTitle(crossing, 'es', true), /hoy y cámaras/);
});
test('built homepage and every crossing page contain their own useful visible snapshot', () => {
if (!fs.existsSync('dist/index.html')) return;
Expand Down
9 changes: 0 additions & 9 deletions src/lib/crossingNames.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,3 @@ export function crossingSearchTerms(crossing) {
LOCAL_NAMES[String(crossing?.port_number)]?.terms].filter(Boolean).join(' ')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
}

export function crossingSearchTitle(crossing, language = 'en', hasCameras = false) {
const name = crossingDisplayName(crossing, language);
const enCamera = hasCameras === 'approach' ? ' & road cameras' : hasCameras === 'external' ? ' & camera sources' : hasCameras ? ' & cameras' : '';
const esCamera = hasCameras === 'approach' ? ' y cámaras viales' : hasCameras === 'external' ? ' y fuentes de cámaras' : hasCameras ? ' y cámaras' : '';
return language === 'es'
? `${name}: esperas CBP${esCamera} | Border Pulse`
: `${name}: CBP wait times${enCamera} | Border Pulse`;
}
47 changes: 47 additions & 0 deletions src/lib/crossingSeo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { crossingDisplayName } from './crossingNames.js';

// How people actually search for a crossing (Search Console queries), used only
// in titles and descriptions. Unlisted crossings fall back to the display name.
const SEARCH_NAMES = {
'250201': 'Andrade (Los Algodones)',
'230503': 'Anzalduas International Bridge (Hidalgo/Pharr)',
'230902': 'Donna International Bridge (Progreso)',
'230901': 'Progreso International Bridge',
'535503': 'Los Indios International Bridge (Brownsville)',
'535502': 'Veterans International Bridge (Brownsville)',
'535504': 'Gateway International Bridge (Brownsville)',
'535501': 'B&M Bridge (Brownsville)',
'230301': 'Eagle Pass International Bridge I',
'230302': 'Eagle Pass International Bridge II',
'240801': 'Santa Teresa Port of Entry',
'240401': 'Tornillo Port of Entry (Marcelino Serna)',
'240301': 'Presidio Port of Entry',
'260401': 'Nogales DeConcini',
'260402': 'Nogales Mariposa',
'260403': 'Nogales Morley Gate',
};
export function crossingSearchName(crossing, language = 'en') {
return SEARCH_NAMES[String(crossing?.port_number)] || crossingDisplayName(crossing, language);
}
const hasPlaceWord = (name) => /bridge|port of entry|puente|garita/i.test(name);

// Title mirrors the query shape: "<crossing> border wait times today".
export function crossingSearchTitle(crossing, language = 'en', hasCameras = false) {
const name = crossingSearchName(crossing, language);
const enCamera = hasCameras === 'approach' ? ' & road cameras' : hasCameras === 'external' ? ' & camera sources' : hasCameras ? ' & cameras' : '';
const esCamera = hasCameras === 'approach' ? ' y cámaras viales' : hasCameras === 'external' ? ' y fuentes de cámaras' : hasCameras ? ' y cámaras' : '';
return language === 'es'
? `Tiempos de espera en ${name} hoy${esCamera} | Border Pulse`
: `${name}${hasPlaceWord(name) ? '' : ' border'} wait times today${enCamera} | Border Pulse`;
}

// One description for the prerendered page and the client, built from the
// crossing's own data: its search name, its historical quietest hour, cameras.
export function crossingSearchDescription(crossing, language = 'en', { bestHour = null, hasCameras = false } = {}) {
const name = crossingSearchName(crossing, language);
const hour = Number.isInteger(bestHour) ? `${bestHour % 12 || 12} ${bestHour >= 12 ? 'PM' : 'AM'}` : null;
if (language === 'es') {
return `Tiempo de espera actual en ${name} según CBP, por carril.${hour ? ` En los últimos 30 días, la espera típica más corta fue cerca de las ${hour}.` : ''}${hasCameras ? ' Incluye cámaras de la zona.' : ''} Horarios del puerto y tendencias históricas por hora.`;
}
return `Current ${name} wait time from CBP, by lane.${hour ? ` Over the last 30 days, the shortest typical wait came around ${hour}.` : ''}${hasCameras ? ' Includes nearby road cameras.' : ''} Port hours and historical trends by hour.`;
}
11 changes: 5 additions & 6 deletions src/pages/CrossingDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import EmbedSnippetModal from '@/components/dashboard/EmbedSnippetModal';
import CrossingShare from '@/components/dashboard/CrossingShare';
import CrossingCameras from '@/components/dashboard/CrossingCameras';
import { cameraSourceFor } from '@/lib/cameras';
import { crossingDisplayName, crossingSearchTitle } from '@/lib/crossingNames';
import { crossingDisplayName } from '@/lib/crossingNames';
import { crossingSearchDescription, crossingSearchTitle } from '@/lib/crossingSeo';
import SantaTeresaActions from '@/components/dashboard/SantaTeresaActions';
import SanYsidroDecision from '@/components/dashboard/SanYsidroDecision';
import { subscribeToBorderData } from '@/lib/liveDataSubscription';
Expand Down Expand Up @@ -392,11 +393,9 @@ export default function CrossingDetail() {

useEffect(() => {
if (!crossing) return;
const hasHistoricalPattern = Array.isArray(aggregate?.by_hour) && aggregate.by_hour.length > 0;
const title = crossingSearchTitle(crossing, language, cameraSourceFor(crossing.port_number)?.kind);
const desc = language === 'en'
? `Reported ${crossing.name} border wait times, refreshed regularly. Official CBP data${hasHistoricalPattern ? ', historical trends, and best crossing times' : ', lane status, and port hours'}.`
: `Tiempos de espera en ${crossing.name}, actualizados con regularidad. Datos oficiales de CBP${hasHistoricalPattern ? ', tendencias históricas y mejores horarios para cruzar' : ', estado de carriles y horarios del puerto'}.`;
const cameraKind = cameraSourceFor(crossing.port_number)?.kind;
const title = crossingSearchTitle(crossing, language, cameraKind);
const desc = crossingSearchDescription(crossing, language, { bestHour: aggregate?.overall_best_hour ?? null, hasCameras: Boolean(cameraKind) });
const url = `https://borderpulse.com/crossing/${canonicalSlug}/`;
updatePageMeta({ title, description: desc, ogTitle: title, ogDescription: desc, ogUrl: url, canonical: url });
return () => resetPageMeta();
Expand Down
Loading