diff --git a/CHANGELOG.md b/CHANGELOG.md index d07c0c80b..d5dd56ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 3a79f2a2e..a54f11e89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 017287172..bc6e1e8e3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 @@ -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 diff --git a/public/sw.js b/public/sw.js index 9de26a312..f88007990 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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 = [ @@ -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)); diff --git a/scripts/test-data-refresh.mjs b/scripts/test-data-refresh.mjs index 15c8f25d6..f26cab095 100644 --- a/scripts/test-data-refresh.mjs +++ b/scripts/test-data-refresh.mjs @@ -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(); diff --git a/src/components/utils/dataService.js b/src/components/utils/dataService.js index aad3c2360..293283eb5 100644 --- a/src/components/utils/dataService.js +++ b/src/components/utils/dataService.js @@ -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); } @@ -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 }; @@ -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); diff --git a/src/lib/liveDataSubscription.js b/src/lib/liveDataSubscription.js new file mode 100644 index 000000000..fb3b1c3d6 --- /dev/null +++ b/src/lib/liveDataSubscription.js @@ -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(); + }; +} diff --git a/src/pages/BestTime.jsx b/src/pages/BestTime.jsx index ebbda346b..d0e855789 100644 --- a/src/pages/BestTime.jsx +++ b/src/pages/BestTime.jsx @@ -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'; @@ -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(() => { @@ -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(() => { diff --git a/src/pages/Compare.jsx b/src/pages/Compare.jsx index c80a0ab15..29df4ec34 100644 --- a/src/pages/Compare.jsx +++ b/src/pages/Compare.jsx @@ -1,8 +1,10 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useParams, Link, Navigate } from 'react-router-dom'; -import { ArrowLeft, ArrowRight, Check, Clock, Copy, MessageCircle } from 'lucide-react'; +import { ArrowLeft, ArrowRight, Check, Clock, Copy, MessageCircle, RefreshCw } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; import { dataService } from '@/components/utils/dataService'; +import { subscribeToBorderData } from '@/lib/liveDataSubscription'; import { buildSlugMap } from '@/lib/slugs'; import { standardPassengerWait } from '@/lib/standardPassengerWait'; import { FRESHNESS, freshnessOf, formatAge } from '@/lib/trustState'; @@ -122,7 +124,7 @@ export default function Compare() { const language = usePersistentLanguage(); const parsed = useMemo(() => parsePair(pair), [pair]); - const [state, setState] = useState({ crossings: [], isLoading: true, fetchedAt: null }); + const [state, setState] = useState({ crossings: [], isLoading: true, isRefreshing: false, fetchedAt: null }); const [aggA, setAggA] = useState(null); const [aggB, setAggB] = useState(null); const [feedback, setFeedback] = useState(null); @@ -134,12 +136,16 @@ export default function Compare() { }, [pair]); useEffect(() => { - (async () => { - const data = await dataService.getBorderData(); - setState({ crossings: data.crossings || [], isLoading: false, fetchedAt: data.timestamp }); - })(); + return subscribeToBorderData(data => { + setState({ crossings: data.crossings || [], isLoading: false, isRefreshing: false, fetchedAt: data.timestamp }); + }); }, []); + const refresh = () => { + setState(previous => ({ ...previous, isRefreshing: true })); + dataService.getBorderData(); + }; + const { aSlug, bSlug, crossingA, crossingB } = useMemo(() => { if (!parsed || !state.crossings.length) { return { aSlug: null, bSlug: null, crossingA: null, crossingB: null }; @@ -275,7 +281,8 @@ export default function Compare() { -
+
+

{language === 'en' ? `${crossingA.name} vs ${crossingB.name}` @@ -287,6 +294,11 @@ export default function Compare() { ? 'Reported northbound waits and 30-day patterns side by side.' : 'Esperas reportadas hacia EE. UU. y patrones de 30 días lado a lado.'}

+

+
{liveSummary && ( @@ -386,8 +398,8 @@ export default function Compare() {