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

## 2026-09-30 — Vercel cutover and live-wait refresh

- Approved production publication and IONOS apex/www website DNS cutover
completed; both domains have trusted HTTPS, and www redirects to the apex.
The site runs on Vercel, code/data collection and prebuilt daily publication
on GitHub Actions, and the domain/DNS on IONOS. Exact receipts and rollback
records are in issue #101. Two scheduled publication observations and actual
first-week cost validation remain pending, without a second automation.
- Fixed current-waits views freezing after their initial load. Comparison,
crossing, best-time, walk-or-drive and embed pages now receive background
refreshes and refresh on tab return/reconnection. Comparison has a bilingual
manual refresh control. Concurrent refresh requests are deduplicated.
- Current waits render independently of optional FX/southbound requests.
Removed the service worker's extra offline cache for the live API so the
shared Vercel CDN and timestamped client/static fallback remain authoritative.
Added regressions for slow optional feeds, concurrent requests, timer/tab/
reconnection updates, cleanup and stale service-worker response handling.

## Unreleased — controlled daily Vercel publication

- Added a disabled-by-default daily/application-change GitHub publisher that builds Vercel output locally, runs release checks and uploads prebuilt artifacts. Data-only commits do not trigger it. Preview is the default; production needs an explicit target change after approval.
Expand Down
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ If the user just says **"what's new"** or **"catch me up"** or

## Quick repo facts

- **Hosted on GitHub Pages** at borderpulse.com.
- **Hosted on Vercel** at borderpulse.com; IONOS remains registrar/DNS.
The apex and www cutover is verified in issue #101. Actions builds and
uploads prebuilt output daily at 08:17 UTC and on application changes;
data-only commits do not deploy to Vercel. Current waits use its cached CBP
endpoint. Two scheduled production observations and first-week cost review
remain pending; no separate monitoring automation was created.
**GitHub Pages is preserved for rollback.**
`.github/workflows/deploy.yml` is the SOLE Pages deployer
(`concurrency: pages`). It runs on push to `main` AND on
`workflow_dispatch`.
Expand All @@ -54,10 +60,13 @@ If the user just says **"what's new"** or **"catch me up"** or
which is how it drifted 16 KB unnoticed.
- **Public JSON feeds** at `/data/crossings.json`, `/data/aggregates/{slug}.json`,
`/data/timelines/...`, `/data/blog/...`, `/data/stats.json`.
- **Vercel previews add one cached official-CBP function.** No auth, database,
- **Vercel production adds one cached official-CBP function.** No auth, database,
SMS or email. Anything that
needs persistence lives in browser localStorage. Anything that needs
cron lives in GitHub Actions.
- Current-waits pages share `subscribeToBorderData`: refresh while open and
on tab return/reconnection. The service worker leaves the live endpoint
to the CDN; timestamped static/browser fallback belongs to `dataService`.

## Constraints to respect

Expand Down
20 changes: 15 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,17 @@ cross-border experience; it is a product hypothesis, not a claim of demand.

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

- [~] Review the cost-controlled Vercel preview from the current public app:
verify automatic ads, live CBP endpoint, static fallback, 30-day history,
and measured usage before any domain move. The public site stays on Pages
until Sebastian approves a production cutover.
- [x] Approved Vercel production cutover: PR #109 merged, Actions publication
succeeded, and IONOS apex/www DNS plus trusted HTTPS were verified. See
issue #101 for publication evidence and exact rollback records.
- [ ] Observe two scheduled Vercel production publications and first-week
usage/cost. The manual cutover publish does not count toward the two runs.
No publication-monitoring automation exists; the October pilot readout is
separate. Preserve Pages rollback and the 15-minute collector meanwhile.
- [x] Current-waits views refresh while open and on tab return/reconnection,
including comparison, crossing, best-time, walk-or-drive and embed routes.
Slow optional feeds no longer delay waits, and the service worker does not
silently substitute an old response for the live API.
- Keep official northbound CBP data, source timestamps, honest stale states and
the static fallback reliable. Keep the free experience supported by ads.
- Measure whether people complete a crossing decision and return. Review
Expand Down Expand Up @@ -227,7 +234,10 @@ Snapshot of features live on borderpulse.com today.
- `/data/stats.json` — Public aggregate stats (drives `/about`)

### Infra
- GitHub Pages deploy via `.github/workflows/deploy.yml`
- Vercel production via `.github/workflows/publish-vercel.yml`; daily prebuilt
Actions publication plus application changes. IONOS owns domain/DNS.
- GitHub Pages deploy via `.github/workflows/deploy.yml` retained for rollback;
recent jobs are pending/canceled, so verify a successful build before rollback.
- Scheduled CBP refresh via `.github/workflows/fetch-cbp.yml`; changed,
validated snapshots explicitly dispatch the sole deployer with their SHA
- Sitemap (175 URLs) + RSS (36 entries) regenerated each prebuild
Expand Down
6 changes: 5 additions & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const CACHE_NAME = 'borderpulse-v3';
const CACHE_NAME = 'borderpulse-v4';

// App shell files to pre-cache on install
const APP_SHELL = [
Expand Down Expand Up @@ -38,6 +38,10 @@ self.addEventListener('fetch', (event) => {
// Only handle same-origin requests
if (url.origin !== location.origin) return;

// The live endpoint already has a shared Vercel CDN cache. Do not add an
// unbounded offline cache here: dataService owns the timestamped fallback.
if (url.pathname === '/api/public/crossings') return;

// Data files: network-first with 3s timeout, fall back to cache
if (url.pathname.startsWith('/data/') && url.pathname.endsWith('.json')) {
event.respondWith(networkFirstWithTimeout(request, 3000));
Expand Down
99 changes: 99 additions & 0 deletions scripts/test-data-refresh.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,105 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import { DataService } from '../src/components/utils/dataService.js';
import { subscribeToBorderData } from '../src/lib/liveDataSubscription.js';

test('the service worker leaves live CBP responses to the CDN and client fallback', () => {
const handlers = {};
let cached = 0;
vm.runInNewContext(fs.readFileSync(new URL('../public/sw.js', import.meta.url), 'utf8'), {
self: { addEventListener: (name, handler) => { handlers[name] = handler; } },
location: { origin: 'https://borderpulse.com' }, URL,
caches: { match: () => { cached++; return Promise.resolve({ stale: true }); } },
});
let intercepted = false;
handlers.fetch({ request: { url: 'https://borderpulse.com/api/public/crossings', mode: 'cors' },
respondWith: () => { intercepted = true; } });
assert.equal(intercepted, false, 'a stale offline response must not hide a live endpoint failure');
assert.equal(cached, 0);
});

test('current waits arrive before a slow optional FX feed', async () => {
const service = new DataService();
let finishFx;
const fx = new Promise(resolve => { finishFx = resolve; });
service.fetchJson = async path => path === '/data/crossings.json'
? { fetched_at: '2026-09-28T00:00:00Z', crossings: [{ port_number: '250401', current_wait_time: 20 }] }
: path === '/data/exchange-rate.json' ? fx : null;
const readings = [];
service.addListener(data => readings.push(data));
const first = await service.getBorderData();
assert.equal(first.crossings[0].current_wait_time, 20);
assert.equal(first.exchange_rate, null);
const enriched = new Promise(resolve => service.addListener(data => { if (data.exchange_rate) resolve(data); }));
finishFx({ rate: 18, fetched_at: '2026-09-28T00:00:00Z' });
assert.equal((await enriched).timestamp, first.timestamp);
assert.equal(readings.length, 2);
});

test('concurrent refreshes share one request and preserve the latest reading', async () => {
const service = new DataService();
let finish;
let requests = 0;
service.fetchCrossingsDoc = () => { requests++; return new Promise(resolve => { finish = resolve; }); };
service.fetchJson = async () => null;
const first = service.getBorderData();
const second = service.getBorderData();
assert.equal(first, second);
assert.equal(requests, 1);
finish({ doc: { fetched_at: '2026-09-28T00:15:00Z', crossings: [{ port_number: '250401', current_wait_time: 0 }] }, fromFallback: false });
assert.equal((await second).crossings[0].current_wait_time, 0);
});

test('current-waits views refresh while open, on tab return and online; cleanup retains other subscribers', async t => {
t.mock.timers.enable({ apis: ['setInterval'] });
const page = new EventTarget();
page.visibilityState = 'visible';
const connection = new EventTarget();
const service = new DataService();
let wait = 10;
let calls = 0;
service.fetchJson = async path => {
if (path !== '/data/crossings.json') return null;
calls++;
return { fetched_at: '2026-09-28T00:00:00Z', crossings: [{ port_number: '250401', current_wait_time: wait }] };
};
const readings = [];
const unsubscribe = subscribeToBorderData(data => readings.push(data), { service, page, connection });
await service.inFlight;
const stopOther = subscribeToBorderData(() => {}, { service, page, connection });
await service.inFlight;
wait = 30;
t.mock.timers.tick(5 * 60_000);
await service.inFlight;
assert.equal(readings.at(-1).crossings[0].current_wait_time, 30);
const beforeHidden = calls;
page.visibilityState = 'hidden';
page.dispatchEvent(new Event('visibilitychange'));
assert.equal(calls, beforeHidden);
wait = 45;
page.visibilityState = 'visible';
page.dispatchEvent(new Event('visibilitychange'));
await service.inFlight;
assert.equal(calls, beforeHidden + 1, 'tab return is deduplicated across views');
assert.equal(readings.at(-1).crossings[0].current_wait_time, 45);
connection.dispatchEvent(new Event('online'));
await service.inFlight;
unsubscribe();
assert.ok(service.refreshTimer);
const beforeCleanup = readings.length;
t.mock.timers.tick(5 * 60_000);
await service.inFlight;
assert.equal(readings.length, beforeCleanup);
stopOther();
assert.equal(service.refreshTimer, null);
const finalCalls = calls;
page.dispatchEvent(new Event('visibilitychange'));
connection.dispatchEvent(new Event('online'));
t.mock.timers.tick(5 * 60_000);
assert.equal(calls, finalCalls);
});

test('subscribers receive new snapshots; network failure preserves the last real timestamp', async () => {
const service = new DataService();
Expand Down
35 changes: 31 additions & 4 deletions src/components/utils/dataService.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export class DataService {
this.listeners = new Set();
this.cache = null;
this.refreshTimer = null;
this.inFlight = null;
}

addListener(fn) { this.listeners.add(fn); }
Expand Down Expand Up @@ -88,13 +89,22 @@ export class DataService {
return { doc: validateSnapshot(await this.fetchJson(DATA_PATH)), fromFallback: false };
}

async getBorderData() {
getBorderData() {
if (!this.inFlight) {
this.inFlight = this.loadBorderData().finally(() => { this.inFlight = null; });
}
return this.inFlight;
}

async loadBorderData() {
try {
const [crossingsResult, fxDoc, sbDoc] = await Promise.all([
this.fetchCrossingsDoc(),
let auxiliaryData = null;
const auxiliary = Promise.all([
this.fetchJson(FX_PATH).catch(() => null),
this.fetchJson(SB_PATH).catch(() => null),
]);
]).then(data => { auxiliaryData = data; return data; });
const crossingsResult = await this.fetchCrossingsDoc();
const [fxDoc, sbDoc] = auxiliaryData || [this.cache?.exchange_rate, null];
const { doc: crossingsDoc, fromFallback } = crossingsResult;
if (this.cache && Date.parse(crossingsDoc.fetched_at) < Date.parse(this.cache.timestamp)) {
const retained = { ...this.cache, success: false, fromFallback: true };
Expand All @@ -115,6 +125,23 @@ export class DataService {
};
this.cache = payload;
this.notify(payload);
// Current waits do not depend on the optional feeds. Enrich the same
// reading later, without overwriting a newer refresh or its timestamp.
if (!auxiliaryData) {
auxiliary.then(([fx, sb]) => {
if (this.cache !== payload || (!fx && !sb)) return;
const enriched = {
...payload,
crossings: mergeSouthbound(crossingsDoc, sb),
exchange_rate: fx || payload.exchange_rate,
southbound_source: sb?.source || null,
southbound_timestamp: sb?.fetched_at || null,
southbound_note: sb?.note || null,
};
this.cache = enriched;
this.notify(enriched);
}).catch(error => console.warn('[dataService] optional feed failed', error));
}
return payload;
} catch (err) {
console.warn('[dataService] fetch failed', err);
Expand Down
26 changes: 26 additions & 0 deletions src/lib/liveDataSubscription.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { dataService } from '../components/utils/dataService.js';

// Every current-waits view uses the same refresh lifecycle, including embeds.
export function subscribeToBorderData(listener, {
service = dataService,
page = globalThis.document,
connection = globalThis.window,
} = {}) {
service.addListener(listener);
if (service.cache) listener(service.cache);
service.startAutoRefresh(5 * 60_000);
const refresh = () => service.getBorderData();
const onVisible = () => {
if (page.visibilityState === 'visible') refresh();
};
page?.addEventListener('visibilitychange', onVisible);
connection?.addEventListener('online', refresh);
refresh();

return () => {
service.removeListener(listener);
page?.removeEventListener('visibilitychange', onVisible);
connection?.removeEventListener('online', refresh);
if (!service.listeners.size) service.stopAutoRefresh();
};
}
25 changes: 9 additions & 16 deletions src/pages/BestTime.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useParams, Link, Navigate } from 'react-router-dom';
import { ArrowLeft, ArrowRight, Clock, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { dataService } from '@/components/utils/dataService';
import { subscribeToBorderData } from '@/lib/liveDataSubscription';
import { buildSlugMap } from '@/lib/slugs';
import { updatePageMeta, resetPageMeta } from '@/lib/seo';
import { nearestCrossings } from '@/lib/geo';
Expand Down Expand Up @@ -76,11 +76,10 @@ export function BestTimeIndex() {
const [aggregates, setAggregates] = useState({}); // port_number -> aggregate

useEffect(() => {
let cancelled = false;
dataService.getBorderData()
.then((doc) => { if (!cancelled) { setCrossings(doc?.crossings || []); setLoaded(true); } })
.catch(() => { if (!cancelled) setLoaded(true); });
return () => { cancelled = true; };
return subscribeToBorderData(doc => {
setCrossings(doc.crossings || []);
setLoaded(true);
});
}, []);

const portToSlug = useMemo(() => {
Expand Down Expand Up @@ -279,16 +278,10 @@ export default function BestTime() {
const [loaded, setLoaded] = useState(false);

useEffect(() => {
let cancelled = false;
dataService.getBorderData()
.then((doc) => {
if (!cancelled) {
setCrossings(doc?.crossings || []);
setLoaded(true);
}
})
.catch(() => { if (!cancelled) setLoaded(true); });
return () => { cancelled = true; };
return subscribeToBorderData(doc => {
setCrossings(doc.crossings || []);
setLoaded(true);
});
}, []);

const { crossing, portToSlug, canonicalSlug } = useMemo(() => {
Expand Down
Loading
Loading