Compare commits

...

10 Commits

Author SHA1 Message Date
jubnl 1ed00b67ad fix(pwa): persist offline storage + Mapbox offline policy (H8, H9) (#1184)
H8: prefetched tiles and file blobs could be evicted under storage pressure
(worsened by opaque tile responses inflating the quota ~7MB each), blanking the
offline map right when a traveler needs it. Request persistent storage at app
init so the browser exempts our caches from eviction. We deliberately keep tile
requests no-cors (a cors switch would break self-hosted/custom tile providers
without CORS headers), so persistence is the safe mitigation rather than
de-opaquing responses.

H9: Mapbox GL users had no offline map at all — no runtimeCaching matched the
Mapbox hosts. Add a StaleWhileRevalidate rule for api.mapbox.com /
*.tiles.mapbox.com so visited areas are available offline (best-effort; full
pre-download still requires the Leaflet renderer, now documented).

- new sync/persistentStorage.ts requestPersistentStorage(), called from main.tsx
- vite.config: mapbox-tiles SW cache rule
- MapViewAuto / tilePrefetcher comments document the offline-maps policy
- tests for the persist helper (granted / already-persisted / absent / rejects)
2026-06-15 09:33:35 +02:00
jubnl 4d072b4cb8 fix(realtime): correct assignment:created echo dedup (H11) (#1183)
When X-Idempotency/X-Socket-Id let an own-echo through, the assignment:created
dedup had two bugs: it keyed on place id, so (1) a legitimate second assignment
of a place already on the day was silently dropped, and (2) the temp-version
reconciliation matched place?.id === placeId, letting undefined === undefined
collapse place-less rows onto each other.

- dedup now keys on assignment id (exact-id duplicate -> no-op)
- temp (negative-id) optimistic rows are reconciled only when a real placeId
  matches, replacing just that row; a sibling temp of another place is untouched
- everything else appends, including a genuine 2nd assignment of the same place
- tests: 2nd-of-same-place kept, correct temp picked among siblings, place-less
  rows don't collapse

Note: the broader own-echo suppression relies on X-Socket-Id being sent; this
fixes the client-side fallback when an echo slips through.
2026-06-15 09:33:12 +02:00
jubnl 028e3e0a84 fix(server): lengthen idempotency key TTL to survive multi-day offline (H6) (#1182)
The nightly cleanup deleted idempotency keys older than 24h. The TREK client
replays queued mutations with their X-Idempotency-Key on reconnect, so a device
offline longer than a day had its keys GC'd before it returned — the replayed
POST was then treated as new and created a duplicate.

- raise the TTL to 30 days (DEFAULT_IDEMPOTENCY_TTL_SECONDS), overridable via
  IDEMPOTENCY_TTL_SECONDS
- extract purgeExpiredIdempotencyKeys(now, ttl, db) (mirrors cleanupOldBackups)
  with an injectable db, and have the cron job call it
- tests: 30-day default eviction, 25-day key retained (was dropped at 24h),
  env override

H7 (exactly-once across the lost-response window) is deferred: a correct fix
must store the response in the same DB transaction as the entity write. Doing
it in the generic interceptor (reserve-before-handler) cannot store the real
response body for the crash case, which would break the client's temp->real id
remapping on replay (mutationQueue.flush relies on the entity in the body). It
needs a per-service change and is tracked separately.
2026-06-15 09:32:42 +02:00
jubnl 39b5af790e fix(sync): re-hydrate active trip store on reconnect/online (H1) (#1181)
setRefetchCallback was dead code, so on reconnect the queue flushed and Dexie
re-seeded but the open trip's Zustand store was never refreshed — a
collaborator's edits made while we were offline didn't appear until navigating
away and back.

- new tripStore.hydrateActiveTrip(): silent refresh of the active trip's
  collaborative state (days/places/packing/todo/budget/reservations/files),
  no resetTrip and no isLoading toggle so there's no splash on reconnect
- syncTriggers wires setRefetchCallback to it (WS layer awaits the flush hook
  first) and re-hydrates open trips after the online-event syncAll; cleared on
  unregister
- websocket exposes getActiveTrips() for the online-event path
- tests: refetch wiring + ordering, silent hydrate without reset/splash
2026-06-15 09:32:28 +02:00
jubnl 1eb2cb8eb2 fix(store): reset and uniformly hydrate trip-scoped slices in loadTrip (H4, H5) (#1180)
loadTrip only replaced the first slice group, so budget/reservations/files
from a previous trip stayed visible after switching trips (data exposure on a
shared screen). Those three also loaded via separate tab-gated effects, so they
never hydrated offline for an unopened tab.

- resetTrip() clears every trip-scoped slice (keeps global tags/categories) and
  runs at the top of loadTrip, so a switch can't leak the prior trip's data
- loadTrip now hydrates budget/reservations/files through their repos alongside
  the rest (non-fatal catches), making offline hydration uniform
- useTripPlanner drops the redundant loadFiles + reservations/budget effects;
  tab-gated lazy reloads stay as on-demand refresh
- tests: cross-trip no-leak, uniform hydration, resetTrip
2026-06-15 09:25:28 +02:00
jubnl bcd2c8c959 fix(repo): fall back to Dexie when a network read fails (H2) (#1179)
Repos gated reads on raw navigator.onLine and the online branch had no
try/catch, so a captive portal or connected-but-no-internet (navigator.onLine
lying "true") threw a network error instead of serving the good cached copy —
blanking the trip even though Dexie held it.

- new onlineThenCache(onlineFn, cacheFn) helper: reads the cache when offline,
  and on a network-level failure (Axios error with no HTTP response). A genuine
  HTTP error (4xx/5xx — the server responded) is rethrown so callers still set
  error state / navigate, not masked by a stale cache.
- gates only on navigator.onLine, NOT the connectivity probe: the probe is a
  coarse global flag and one failed health check would otherwise divert every
  read to the (possibly empty) cache even when the request would succeed.
- every repo list/get read path routed through it (reads only — writes still
  go through the mutation queue so failures surface)
- tests: captive-portal fallback, HTTP-error rethrow, non-Axios rethrow
2026-06-15 09:25:11 +02:00
jubnl 5a9c14fc8e fix(db): scope, evict, and cap the offline blob cache (H3) (#1178)
Blob cache previously leaked forever: clearTripData omitted it, entries had
no trip discriminator, and there was no size/count bound, so file blobs
survived trip eviction and could starve the map-tile cache for quota.

- BlobCacheEntry gains tripId + bytes; Dexie v3 adds a tripId index with a
  backfill upgrade (legacy rows -> tripId -1, bytes from blob.size)
- clearTripData purges the trip's blobs in-transaction
- enforceBlobBudget() evicts oldest-by-cachedAt past 200 entries / 100 MB
- tripSyncManager threads tripId/bytes into puts and enforces the budget
2026-06-15 09:24:52 +02:00
jubnl 5500405f2f fix(security): stop cross-user offline data leak on shared devices (#1176)
Closes BLOCKER B4 — three reinforcing paths could serve one account's
cached data to the next user on a shared device:

- The Workbox 'api-data' cache keyed trip/user-scoped GETs by URL only
  (cookie-blind). Changed to NetworkOnly; offline reads come from the
  per-user IndexedDB cache via the repo layer instead.
- IndexedDB had no per-user scoping. The Dexie connection is now scoped
  per user (trek-offline-u<id>) behind a Proxy so the ~19 importers keep a
  stable binding; login opens the user DB, logout deletes it and returns
  to the anonymous DB.
- logout() was fire-and-forget and racy: background flush/syncAll could
  re-seed the DB after the wipe. It is now async and ordered — close an
  auth gate, unregister sync triggers, disconnect, clear caches, delete
  the user DB — and flush()/syncAll() bail when the gate is closed.
2026-06-15 07:58:20 +02:00
jubnl 0a794583d7 fix(maps): make offline tiles cover real trips (cap coherence + zoom-clamp) (#1177)
Closes BLOCKER B5 — the offline map was blank for most real trips:

- The Workbox 'map-tiles' cache held only 1000 entries while the prefetcher
  budgeted ~3413, so prefetched tiles were evicted on arrival. Both caps are
  now a coherent 12288 (~180 MB), kept in sync with cross-referencing comments.
- prefetchTilesForTrip skipped a trip entirely when its all-zooms estimate
  exceeded the cap, so region/road-trip bboxes got no tiles. Removed the
  all-or-nothing guard; prefetchTiles already fills zooms low→high and stops at
  the budget, so large trips now cache the zooms that fit instead of nothing.
2026-06-15 07:53:12 +02:00
jubnl 4188f67ab7 fix(sync): remap temp ids, prevent id collisions, surface failed mutations (#1175)
Closes three offline BLOCKERs from the PWA audit:

- B1: offline edits/deletes of an offline-created entity were lost. The
  negative temp id was baked into the PUT/DELETE url and never rewritten
  after the CREATE returned a real id, so dependents 404'd and were dropped.
  Dependents now carry a {id} placeholder + tempEntityId; flush builds a
  tempId->realId map and durably rewrites still-queued dependents on CREATE
  success (survives flush boundaries / reloads).
- B2: tempId = -(Date.now()) collided within a millisecond, overwriting an
  optimistic row. Replaced with a monotonic nextTempId() minter.
- B3: any 4xx marked the mutation failed with no rollback and no signal, and
  the badge ignored failed rows. Terminal failures now roll back the phantom
  optimistic CREATE; 401/408/425/429 are treated as retryable; failedCount()
  is surfaced in OfflineBanner (red pill) and OfflineTab.
2026-06-15 07:51:52 +02:00
41 changed files with 1562 additions and 218 deletions
+6
View File
@@ -20,6 +20,12 @@ export function getSocketId(): string | null {
return mySocketId
}
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
* trip's store after the network comes back via the `online` event. */
export function getActiveTrips(): string[] {
return Array.from(activeTrips)
}
export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn
}
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { render } from '../../../tests/helpers/render'
import OfflineBanner from './OfflineBanner'
vi.mock('../../sync/mutationQueue', () => ({
mutationQueue: {
pendingCount: vi.fn(),
failedCount: vi.fn(),
},
}))
import { mutationQueue } from '../../sync/mutationQueue'
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
afterEach(() => {
vi.clearAllMocks()
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
})
describe('OfflineBanner (B3 surface)', () => {
it('shows the failed pill when failedCount > 0 while online', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(2)
render(<OfflineBanner />)
expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
})
it('stays hidden when online with nothing pending or failed', async () => {
pendingCount.mockResolvedValue(0)
failedCount.mockResolvedValue(0)
const { container } = render(<OfflineBanner />)
// Give the async poll a tick to resolve.
await waitFor(() => expect(failedCount).toHaveBeenCalled())
expect(container.querySelector('[role="status"]')).toBeNull()
})
})
+27 -13
View File
@@ -2,6 +2,7 @@
* OfflineBanner — connectivity + sync state indicator.
*
* States:
* N failed → red pill "N changes failed to sync" (takes priority)
* offline + N queued → amber pill "Offline · N queued"
* offline + 0 queued → amber pill "Offline"
* online + N pending → blue pill "Syncing N…"
@@ -12,7 +13,7 @@
* headers. On mobile it hovers just above the bottom tab bar.
*/
import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from 'lucide-react'
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
import { mutationQueue } from '../../sync/mutationQueue'
const POLL_MS = 3_000
@@ -20,6 +21,7 @@ const POLL_MS = 3_000
export default function OfflineBanner(): React.ReactElement | null {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [pendingCount, setPendingCount] = useState(0)
const [failedCount, setFailedCount] = useState(0)
useEffect(() => {
const onOnline = () => setIsOnline(true)
@@ -35,26 +37,36 @@ export default function OfflineBanner(): React.ReactElement | null {
useEffect(() => {
let cancelled = false
async function poll() {
const n = await mutationQueue.pendingCount()
if (!cancelled) setPendingCount(n)
const [n, failed] = await Promise.all([
mutationQueue.pendingCount(),
mutationQueue.failedCount(),
])
if (!cancelled) {
setPendingCount(n)
setFailedCount(failed)
}
}
poll()
const id = setInterval(poll, POLL_MS)
return () => { cancelled = true; clearInterval(id) }
}, [])
const hidden = isOnline && pendingCount === 0
const hidden = isOnline && pendingCount === 0 && failedCount === 0
if (hidden) return null
const offline = !isOnline
const bg = offline ? '#92400e' : '#1e40af'
// Failed mutations are the most important signal — they mean data was dropped.
const failed = failedCount > 0
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
const text = '#fff'
const label = offline
? pendingCount > 0
? `Offline · ${pendingCount} queued`
: 'Offline'
: `Syncing ${pendingCount}`
const label = failed
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync`
: offline
? pendingCount > 0
? `Offline · ${pendingCount} queued`
: 'Offline'
: `Syncing ${pendingCount}`
return (
<div
@@ -82,9 +94,11 @@ export default function OfflineBanner(): React.ReactElement | null {
pointerEvents: 'none',
}}
>
{offline
? <WifiOff size={12} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
{failed
? <AlertTriangle size={12} />
: offline
? <WifiOff size={12} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
}
{label}
</div>
@@ -5,6 +5,11 @@ import { MapViewGL } from './MapViewGL'
// Auto-selects the map renderer based on user settings. Keeps the existing
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
//
// Offline maps: only the Leaflet renderer supports full pre-download (raster
// tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its
// vector tiles are cached opportunistically by the Service Worker as you view
// them online (see the mapbox-tiles rule in vite.config.js), not prefetched.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function MapViewAuto(props: any) {
const provider = useSettingsStore(s => s.settings.map_provider)
@@ -21,6 +21,7 @@ interface CachedTripRow {
export default function OfflineTab(): React.ReactElement {
const [rows, setRows] = useState<CachedTripRow[]>([])
const [pendingCount, setPendingCount] = useState(0)
const [failedCount, setFailedCount] = useState(0)
const [syncing, setSyncing] = useState(false)
const [clearing, setClearing] = useState(false)
const [loading, setLoading] = useState(true)
@@ -28,11 +29,13 @@ export default function OfflineTab(): React.ReactElement {
const load = useCallback(async () => {
setLoading(true)
try {
const [metas, pending] = await Promise.all([
const [metas, pending, failed] = await Promise.all([
offlineDb.syncMeta.toArray(),
mutationQueue.pendingCount(),
mutationQueue.failedCount(),
])
setPendingCount(pending)
setFailedCount(failed)
const result: CachedTripRow[] = []
for (const meta of metas) {
@@ -85,6 +88,7 @@ export default function OfflineTab(): React.ReactElement {
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Stat label="Cached trips" value={rows.length} />
<Stat label="Pending changes" value={pendingCount} />
{failedCount > 0 && <Stat label="Failed changes" value={failedCount} danger />}
</div>
{/* Actions */}
@@ -165,13 +169,14 @@ export default function OfflineTab(): React.ReactElement {
)
}
function Stat({ label, value }: { label: string; value: number }) {
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
return (
<div className="border border-edge bg-surface-secondary" style={{
padding: '8px 14px', borderRadius: 8,
minWidth: 100,
}}>
<div className="text-content" style={{ fontSize: 20, fontWeight: 700 }}>{value}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: danger ? '#ef4444' : undefined }}
className={danger ? undefined : 'text-content'}>{value}</div>
<div className="text-content-muted" style={{ fontSize: 11 }}>{label}</div>
</div>
)
+137 -3
View File
@@ -27,6 +27,12 @@ export interface QueuedMutation {
tempId?: number;
/** For DELETE mutations: the entity id to remove from Dexie on flush */
entityId?: number;
/**
* For PUT/DELETE enqueued offline against a still-unsynced (negative-id) entity:
* the temp id of the target. The url carries an `{id}` placeholder that the
* mutation queue rewrites to the real server id once the dependent CREATE flushes.
*/
tempEntityId?: number;
}
export interface SyncMeta {
@@ -41,13 +47,48 @@ export interface SyncMeta {
export interface BlobCacheEntry {
/** Relative URL, e.g. "/api/files/42/download" */
url: string;
/**
* Trip this blob belongs to, so it is evicted together with the trip in
* clearTripData. Legacy rows cached before v3 carry the sentinel -1.
*/
tripId: number;
blob: Blob;
/** Byte size captured at insert time — Blob.size is not reliably preserved
* across IndexedDB round-trips, so the LRU budget reads this instead. */
bytes: number;
mime: string;
cachedAt: number;
}
// ── Dexie class ────────────────────────────────────────────────────────────────
/**
* The offline DB is scoped per user so that one account can never read another
* account's cached data on a shared device. Anonymous (logged-out) state uses
* the base name; a logged-in user uses `trek-offline-u<userId>`.
*/
const ANON_DB_NAME = 'trek-offline';
function userDbName(userId: number | string): string {
return `trek-offline-u${userId}`;
}
/**
* Best-effort read of the persisted auth snapshot so the very first DB opened on
* app load (before loadUser resolves) is already the correct per-user one — the
* PWA can render cached data offline without leaking across users.
*/
function initialDbName(): string {
try {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('trek_auth_snapshot') : null;
if (!raw) return ANON_DB_NAME;
const id = JSON.parse(raw)?.state?.user?.id;
return id != null ? userDbName(id) : ANON_DB_NAME;
} catch {
return ANON_DB_NAME;
}
}
class TrekOfflineDb extends Dexie {
trips!: Table<Trip, number>;
days!: Table<Day, number>;
@@ -65,8 +106,8 @@ class TrekOfflineDb extends Dexie {
syncMeta!: Table<SyncMeta, number>;
blobCache!: Table<BlobCacheEntry, string>;
constructor() {
super('trek-offline');
constructor(name: string = ANON_DB_NAME) {
super(name);
this.version(1).stores({
trips: 'id',
@@ -88,10 +129,67 @@ class TrekOfflineDb extends Dexie {
tags: 'id',
categories: 'id',
});
// v3: scope the blob cache by trip so it can be evicted with the trip and
// bounded by an LRU budget (see enforceBlobBudget).
this.version(3).stores({
blobCache: 'url, cachedAt, tripId',
}).upgrade(async (tx) => {
await tx.table('blobCache').toCollection().modify((row: Partial<BlobCacheEntry>) => {
if (row.tripId == null) row.tripId = -1;
if (row.bytes == null) row.bytes = row.blob?.size ?? 0;
});
});
}
}
export const offlineDb = new TrekOfflineDb();
// The live instance is swapped on login/logout via reopenForUser/reopenAnonymous.
// A Proxy keeps the exported `offlineDb` binding stable for the ~19 modules that
// import it directly, while every access forwards to the current connection.
let _db = new TrekOfflineDb(initialDbName());
export const offlineDb = new Proxy({} as TrekOfflineDb, {
get(_target, prop) {
const value = (_db as unknown as Record<string | symbol, unknown>)[prop];
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(_db) : value;
},
set(_target, prop, value) {
(_db as unknown as Record<string | symbol, unknown>)[prop] = value;
return true;
},
}) as TrekOfflineDb;
async function switchTo(name: string): Promise<void> {
if (_db.name === name) {
if (!_db.isOpen()) await _db.open();
return;
}
if (_db.isOpen()) _db.close();
_db = new TrekOfflineDb(name);
await _db.open();
}
/** Point the offline DB at a specific user's scoped database (call on login). */
export async function reopenForUser(userId: number | string): Promise<void> {
await switchTo(userDbName(userId));
}
/** Point the offline DB at the anonymous database (call on logout). */
export async function reopenAnonymous(): Promise<void> {
await switchTo(ANON_DB_NAME);
}
/**
* Delete the current user's scoped database entirely and return to the anonymous
* DB. Used on logout so no trace of the account's data remains on the device.
*/
export async function deleteCurrentUserDb(): Promise<void> {
if (_db.name !== ANON_DB_NAME) {
try { await _db.delete(); } catch { /* ignore — fall through to anon */ }
}
_db = new TrekOfflineDb(ANON_DB_NAME);
await _db.open();
}
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
@@ -166,6 +264,40 @@ export async function getCachedBlob(url: string): Promise<Blob | null> {
}
}
// ── Blob-cache budget ───────────────────────────────────────────────────────
/**
* Upper bounds for the offline file-blob cache. Kept conservative so trip
* documents never starve the map-tile cache (sized at MAX_TILES in
* tilePrefetcher.ts) for the origin's storage quota.
*/
export const BLOB_CACHE_MAX_ENTRIES = 200;
export const BLOB_CACHE_MAX_BYTES = 100 * 1024 * 1024; // 100 MB
/**
* Evict oldest-by-cachedAt blobs until the cache is under both the entry-count
* and byte budget. Call after inserting new blobs. LRU on insertion time, which
* is a reasonable proxy for access for write-once document blobs.
*/
export async function enforceBlobBudget(
maxCount = BLOB_CACHE_MAX_ENTRIES,
maxBytes = BLOB_CACHE_MAX_BYTES,
): Promise<void> {
const entries = await offlineDb.blobCache.orderBy('cachedAt').toArray();
let count = entries.length;
let totalBytes = entries.reduce((sum, e) => sum + (e.bytes ?? 0), 0);
if (count <= maxCount && totalBytes <= maxBytes) return;
const toDelete: string[] = [];
for (const e of entries) {
if (count <= maxCount && totalBytes <= maxBytes) break;
toDelete.push(e.url);
totalBytes -= e.bytes ?? 0;
count -= 1;
}
if (toDelete.length) await offlineDb.blobCache.bulkDelete(toDelete);
}
// ── Eviction / cleanup ────────────────────────────────────────────────────────
/** Delete all cached data for one trip (eviction or explicit clear). */
@@ -184,6 +316,7 @@ export async function clearTripData(tripId: number): Promise<void> {
offlineDb.tripMembers,
offlineDb.mutationQueue,
offlineDb.syncMeta,
offlineDb.blobCache,
],
async () => {
await offlineDb.days.where('trip_id').equals(tripId).delete();
@@ -197,6 +330,7 @@ export async function clearTripData(tripId: number): Promise<void> {
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
},
);
// Remove the trip row itself outside the transaction since it's a separate table
+3
View File
@@ -15,8 +15,11 @@ import '@fontsource/geist-sans/500.css'
import '@fontsource/geist-sans/600.css'
import './index.css'
import { startConnectivityProbe } from './sync/connectivity'
import { requestPersistentStorage } from './sync/persistentStorage'
startConnectivityProbe()
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
requestPersistentStorage()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
@@ -221,11 +221,12 @@ export function useTripPlanner() {
}
}, [isLoading, places])
// Load trip + files (needed for place inspector file section)
// Load the trip. loadTrip hydrates every trip-scoped slice (days, places,
// packing, todo, budget, reservations, files) so offline hydration is uniform
// and there's no cross-trip bleed; members/accommodations load alongside.
useEffect(() => {
if (tripId) {
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripActions.loadFiles(tripId)
loadAccommodations()
if (!navigator.onLine) {
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
@@ -240,13 +241,6 @@ export function useTripPlanner() {
}
}, [tripId])
useEffect(() => {
if (tripId) {
tripActions.loadReservations(tripId)
tripActions.loadBudgetItems?.(tripId)
}
}, [tripId])
useTripWebSocket(tripId)
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
+12 -8
View File
@@ -1,16 +1,20 @@
import { accommodationsApi } from '../api/client'
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { Accommodation } from '../types'
export const accommodationRepo = {
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
if (!navigator.onLine) {
const accommodations = await offlineDb.accommodations
.where('trip_id').equals(Number(tripId)).toArray()
return { accommodations }
}
const result = await accommodationsApi.list(tripId)
upsertAccommodations(result.accommodations || []).catch(() => {})
return result
return onlineThenCache(
async () => {
const result = await accommodationsApi.list(tripId)
upsertAccommodations(result.accommodations || []).catch(() => {})
return result
},
async () => ({
accommodations: await offlineDb.accommodations
.where('trip_id').equals(Number(tripId)).toArray(),
}),
)
},
}
+12 -10
View File
@@ -1,18 +1,20 @@
import { budgetApi } from '../api/client'
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { BudgetItem } from '../types'
export const budgetRepo = {
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.budgetItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { items: cached }
}
const result = await budgetApi.list(tripId)
upsertBudgetItems(result.items)
return result
return onlineThenCache(
async () => {
const result = await budgetApi.list(tripId)
upsertBudgetItems(result.items)
return result
},
async () => ({
items: await offlineDb.budgetItems
.where('trip_id').equals(Number(tripId)).toArray(),
}),
)
},
}
+14 -10
View File
@@ -1,18 +1,22 @@
import { daysApi } from '../api/client'
import { offlineDb, upsertDays } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { Day } from '../types'
export const dayRepo = {
async list(tripId: number | string): Promise<{ days: Day[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.days
.where('trip_id')
.equals(Number(tripId))
.sortBy('day_number' as keyof Day)
return { days: cached as Day[] }
}
const result = await daysApi.list(tripId)
upsertDays(result.days)
return result
return onlineThenCache(
async () => {
const result = await daysApi.list(tripId)
upsertDays(result.days)
return result
},
async () => ({
days: (await offlineDb.days
.where('trip_id')
.equals(Number(tripId))
.sortBy('day_number' as keyof Day)) as Day[],
}),
)
},
}
+12 -10
View File
@@ -1,18 +1,20 @@
import { filesApi } from '../api/client'
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { TripFile } from '../types'
export const fileRepo = {
async list(tripId: number | string): Promise<{ files: TripFile[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.tripFiles
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { files: cached }
}
const result = await filesApi.list(tripId)
upsertTripFiles(result.files)
return result
return onlineThenCache(
async () => {
const result = await filesApi.list(tripId)
upsertTripFiles(result.files)
return result
},
async () => ({
files: await offlineDb.tripFiles
.where('trip_id').equals(Number(tripId)).toArray(),
}),
)
},
}
+21 -14
View File
@@ -1,25 +1,27 @@
import { packingApi } from '../api/client'
import { offlineDb, upsertPackingItems } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
import { onlineThenCache } from './withOfflineFallback'
import type { PackingItem } from '../types'
export const packingRepo = {
async list(tripId: number | string): Promise<{ items: PackingItem[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.packingItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { items: cached }
}
const result = await packingApi.list(tripId)
upsertPackingItems(result.items)
return result
return onlineThenCache(
async () => {
const result = await packingApi.list(tripId)
upsertPackingItems(result.items)
return result
},
async () => ({
items: await offlineDb.packingItems
.where('trip_id').equals(Number(tripId)).toArray(),
}),
)
},
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ item: PackingItem }> {
if (!navigator.onLine) {
const tempId = -(Date.now())
const tempId = nextTempId()
const tempItem: PackingItem = {
...(data as Partial<PackingItem>),
id: tempId,
@@ -51,13 +53,16 @@ export const packingRepo = {
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
await offlineDb.packingItems.put(optimistic)
const mutId = generateUUID()
const isTemp = id < 0
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/packing/${id}`,
url: isTemp ? `/trips/${tripId}/packing/{id}` : `/trips/${tripId}/packing/${id}`,
body: data,
resource: 'packingItems',
entityId: id,
...(isTemp ? { tempEntityId: id } : {}),
})
return { item: optimistic }
}
@@ -70,14 +75,16 @@ export const packingRepo = {
if (!navigator.onLine) {
await offlineDb.packingItems.delete(id)
const mutId = generateUUID()
const isTemp = id < 0
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/packing/${id}`,
url: isTemp ? `/trips/${tripId}/packing/{id}` : `/trips/${tripId}/packing/${id}`,
body: undefined,
resource: 'packingItems',
entityId: id,
...(isTemp ? { tempEntityId: id } : {}),
})
return { success: true }
}
+24 -15
View File
@@ -1,25 +1,27 @@
import { placesApi } from '../api/client'
import { offlineDb, upsertPlaces } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
import { onlineThenCache } from './withOfflineFallback'
import type { Place } from '../types'
export const placeRepo = {
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.places
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { places: cached }
}
const result = await placesApi.list(tripId, params)
upsertPlaces(result.places)
return result
return onlineThenCache(
async () => {
const result = await placesApi.list(tripId, params)
upsertPlaces(result.places)
return result
},
async () => ({
places: await offlineDb.places
.where('trip_id').equals(Number(tripId)).toArray(),
}),
)
},
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ place: Place }> {
if (!navigator.onLine) {
const tempId = -(Date.now())
const tempId = nextTempId()
const tempPlace: Place = {
...(data as Partial<Place>),
id: tempId,
@@ -50,13 +52,16 @@ export const placeRepo = {
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
await offlineDb.places.put(optimistic)
const mutId = generateUUID()
const isTemp = Number(id) < 0
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/places/${id}`,
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
body: data,
resource: 'places',
entityId: Number(id),
...(isTemp ? { tempEntityId: Number(id) } : {}),
})
return { place: optimistic }
}
@@ -69,14 +74,16 @@ export const placeRepo = {
if (!navigator.onLine) {
await offlineDb.places.delete(Number(id))
const mutId = generateUUID()
const isTemp = Number(id) < 0
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: Number(id),
...(isTemp ? { tempEntityId: Number(id) } : {}),
})
return { success: true }
}
@@ -90,14 +97,16 @@ export const placeRepo = {
await offlineDb.places.bulkDelete(ids)
for (const id of ids) {
const mutId = generateUUID()
const isTemp = id < 0
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: id,
...(isTemp ? { tempEntityId: id } : {}),
})
}
return { deleted: ids, count: ids.length }
+12 -10
View File
@@ -1,18 +1,20 @@
import { reservationsApi } from '../api/client'
import { offlineDb, upsertReservations } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { Reservation } from '../types'
export const reservationRepo = {
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.reservations
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { reservations: cached }
}
const result = await reservationsApi.list(tripId)
upsertReservations(result.reservations)
return result
return onlineThenCache(
async () => {
const result = await reservationsApi.list(tripId)
upsertReservations(result.reservations)
return result
},
async () => ({
reservations: await offlineDb.reservations
.where('trip_id').equals(Number(tripId)).toArray(),
}),
)
},
}
+12 -10
View File
@@ -1,18 +1,20 @@
import { todoApi } from '../api/client'
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { TodoItem } from '../types'
export const todoRepo = {
async list(tripId: number | string): Promise<{ items: TodoItem[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.todoItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { items: cached }
}
const result = await todoApi.list(tripId)
upsertTodoItems(result.items)
return result
return onlineThenCache(
async () => {
const result = await todoApi.list(tripId)
upsertTodoItems(result.items)
return result
},
async () => ({
items: await offlineDb.todoItems
.where('trip_id').equals(Number(tripId)).toArray(),
}),
)
},
}
+31 -22
View File
@@ -1,33 +1,42 @@
import { tripsApi } from '../api/client'
import { offlineDb, upsertTrip } from '../db/offlineDb'
import { onlineThenCache } from './withOfflineFallback'
import type { Trip } from '../types'
export const tripRepo = {
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> {
if (!navigator.onLine) {
const all = await offlineDb.trips.toArray()
return {
trips: all.filter(t => !t.is_archived),
archivedTrips: all.filter(t => t.is_archived),
}
}
const [active, archived] = await Promise.all([
tripsApi.list(),
tripsApi.list({ archived: 1 }),
])
active.trips.forEach(t => upsertTrip(t))
archived.trips.forEach(t => upsertTrip(t))
return { trips: active.trips, archivedTrips: archived.trips }
return onlineThenCache(
async () => {
const [active, archived] = await Promise.all([
tripsApi.list(),
tripsApi.list({ archived: 1 }),
])
active.trips.forEach(t => upsertTrip(t))
archived.trips.forEach(t => upsertTrip(t))
return { trips: active.trips, archivedTrips: archived.trips }
},
async () => {
const all = await offlineDb.trips.toArray()
return {
trips: all.filter(t => !t.is_archived),
archivedTrips: all.filter(t => t.is_archived),
}
},
)
},
async get(tripId: number | string): Promise<{ trip: Trip }> {
if (!navigator.onLine) {
const cached = await offlineDb.trips.get(Number(tripId))
if (cached) return { trip: cached }
throw new Error('No cached trip data available offline')
}
const result = await tripsApi.get(tripId)
upsertTrip(result.trip)
return result
return onlineThenCache(
async () => {
const result = await tripsApi.get(tripId)
upsertTrip(result.trip)
return result
},
async () => {
const cached = await offlineDb.trips.get(Number(tripId))
if (cached) return { trip: cached }
throw new Error('No cached trip data available offline')
},
)
},
}
+48
View File
@@ -0,0 +1,48 @@
/**
* True when an error means the request never reached the server — a network-level
* failure (offline, captive portal, proxy auth wall, dropped connection, CORS).
* Axios sets `response` only when the server actually replied; its absence (on an
* Axios error) means we never got one. A real HTTP error (4xx/5xx) HAS a response
* and must NOT be treated as a network failure — the server spoke, so the caller
* needs to see it. Non-Axios errors are surfaced too.
*/
function isNetworkError(err: unknown): boolean {
const e = err as { isAxiosError?: boolean; response?: unknown } | null
return !!e && e.isAxiosError === true && e.response == null
}
/**
* Read-through cache pattern shared by every repo's read methods.
*
* Reads degrade to the local Dexie cache in two situations:
* 1. The browser reports it is offline (`navigator.onLine` false) — skip the
* doomed request entirely.
* 2. The browser *thinks* it is online but the request fails at the network
* level — a lying `navigator.onLine` on a captive portal, a dropped
* connection (H2). Rather than surfacing that (which blanks the trip even
* though a good cached copy exists), we fall back to the cache.
*
* We intentionally gate only on `navigator.onLine`, NOT the connectivity probe:
* the probe is a coarse global flag, and a single failed health check would
* otherwise force every read to the (possibly empty) cache even when the request
* itself would succeed. The network-error catch below covers the captive-portal
* case the probe was meant to.
*
* A genuine HTTP error (404/403/500 — the server responded) is NOT swallowed: it
* is rethrown so callers can set error state, navigate away, etc.
*
* Writes must NOT use this — they go through the mutation queue so failures are
* surfaced and retried, not silently swallowed.
*/
export async function onlineThenCache<T>(
onlineFn: () => Promise<T>,
cacheFn: () => Promise<T>,
): Promise<T> {
if (!navigator.onLine) return cacheFn()
try {
return await onlineFn()
} catch (err) {
if (isNetworkError(err)) return cacheFn()
throw err
}
}
+39 -10
View File
@@ -5,7 +5,9 @@ import { connect, disconnect } from '../api/websocket'
import type { User } from '../types'
import { getApiErrorMessage } from '../types'
import { tripSyncManager } from '../sync/tripSyncManager'
import { clearAll } from '../db/offlineDb'
import { reopenForUser, deleteCurrentUserDb } from '../db/offlineDb'
import { setAuthed } from '../sync/authGate'
import { unregisterSyncTriggers } from '../sync/syncTriggers'
import { useSystemNoticeStore } from './systemNoticeStore.js'
interface AuthResponse {
@@ -40,7 +42,7 @@ interface AuthState {
login: (email: string, password: string) => Promise<LoginResult>
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
logout: () => void
logout: () => Promise<void>
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
loadUser: (opts?: { silent?: boolean }) => Promise<void>
updateMapsKey: (key: string | null) => Promise<void>
@@ -65,6 +67,19 @@ interface AuthState {
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
let authSequence = 0
/**
* Mark the session authenticated and point the offline DB at this user's scoped
* database before any background sync runs, so cached data never crosses users.
*/
async function onAuthSuccess(userId: number): Promise<void> {
setAuthed(true)
try {
await reopenForUser(userId)
} catch (err) {
console.error('[auth] failed to open user-scoped offline DB', err)
}
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
@@ -99,6 +114,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
error: null,
})
await onAuthSuccess(data.user.id)
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
@@ -123,6 +139,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
error: null,
})
await onAuthSuccess(data.user.id)
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
@@ -147,6 +164,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false,
error: null,
})
await onAuthSuccess(data.user.id)
connect()
tripSyncManager.syncAll().catch(console.error)
useSystemNoticeStore.getState().fetch()
@@ -158,18 +176,27 @@ export const useAuthStore = create<AuthState>()(
}
},
logout: () => {
logout: async () => {
// 1. Gate first so any in-flight flush/syncAll bails before we wipe the DB.
setAuthed(false)
set({ isAuthenticated: false })
// 2. Stop background sync triggers (30s interval, WS pre-reconnect hook, listeners).
unregisterSyncTriggers()
// 3. Tear down the live connection.
disconnect()
useSystemNoticeStore.getState().reset()
// Tell server to clear the httpOnly cookie
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
// Clear service worker caches containing sensitive data
// 4. Tell server to clear the httpOnly cookie (best-effort).
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
// 5. Clear service worker caches containing sensitive data.
if ('caches' in window) {
caches.delete('api-data').catch(() => {})
caches.delete('user-uploads').catch(() => {})
await Promise.all([
caches.delete('api-data').catch(() => {}),
caches.delete('user-uploads').catch(() => {}),
])
}
// Purge all cached trip data from IndexedDB
clearAll().catch(console.error)
// 6. Delete this user's scoped IndexedDB and return to the anonymous DB.
await deleteCurrentUserDb().catch(console.error)
// 7. Finish clearing auth state.
set({
user: null,
isAuthenticated: false,
@@ -189,6 +216,7 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: true,
isLoading: false,
})
await onAuthSuccess(data.user.id)
connect()
} catch (err: unknown) {
if (seq !== authSequence) return // stale response — ignore
@@ -282,6 +310,7 @@ export const useAuthStore = create<AuthState>()(
demoMode: true,
error: null,
})
await onAuthSuccess(data.user.id)
connect()
return data
} catch (err: unknown) {
+23 -14
View File
@@ -193,25 +193,34 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
// Assignments
case 'assignment:created': {
const dayKey = String((payload.assignment as Assignment).day_id)
const existing = (state.assignments[dayKey] || [])
const placeId = (payload.assignment as Assignment).place?.id || (payload.assignment as Assignment).place_id
if (existing.some(a => a.id === (payload.assignment as Assignment).id || (placeId && a.place?.id === placeId))) {
const hasTempVersion = existing.some(a => a.id < 0 && a.place?.id === placeId)
if (hasTempVersion) {
return {
assignments: {
...state.assignments,
[dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment as Assignment : a),
}
}
const incoming = payload.assignment as Assignment
const dayKey = String(incoming.day_id)
const existing = state.assignments[dayKey] || []
const placeId = incoming.place?.id ?? incoming.place_id
// Already have this exact assignment id → duplicate broadcast or the
// echo of an already-committed assignment. No-op.
if (existing.some(a => a.id === incoming.id)) return {}
// Reconcile our own optimistic create: replace the temp (negative-id)
// assignment of the same place on this day with the real one. Guarded on
// a real placeId so an assignment with no place can never collapse onto
// another place-less one (undefined === undefined).
if (placeId != null) {
const tempIdx = existing.findIndex(a => a.id < 0 && a.place?.id === placeId)
if (tempIdx !== -1) {
const next = existing.slice()
next[tempIdx] = incoming
return { assignments: { ...state.assignments, [dayKey]: next } }
}
return {}
}
// Genuinely new — including a legitimate second assignment of a place
// already on this day (no temp version to reconcile). Append.
return {
assignments: {
...state.assignments,
[dayKey]: [...existing, payload.assignment as Assignment],
[dayKey]: [...existing, incoming],
}
}
}
+50 -1
View File
@@ -7,6 +7,9 @@ import { dayRepo } from '../repo/dayRepo'
import { placeRepo } from '../repo/placeRepo'
import { packingRepo } from '../repo/packingRepo'
import { todoRepo } from '../repo/todoRepo'
import { budgetRepo } from '../repo/budgetRepo'
import { reservationRepo } from '../repo/reservationRepo'
import { fileRepo } from '../repo/fileRepo'
import { createPlacesSlice } from './slices/placesSlice'
import { createAssignmentsSlice } from './slices/assignmentsSlice'
import { createDaysSlice } from './slices/daysSlice'
@@ -61,7 +64,9 @@ export interface TripStoreState
setSelectedDay: (dayId: number | null) => void
handleRemoteEvent: (event: WebSocketEvent) => void
resetTrip: () => void
loadTrip: (tripId: number | string) => Promise<void>
hydrateActiveTrip: (tripId: number | string) => Promise<void>
refreshDays: (tripId: number | string) => Promise<void>
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag>
@@ -89,15 +94,40 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
// Clear every trip-scoped slice so switching trips (or losing access to one)
// can never leave a previous trip's data visible. Global tags/categories are
// left intact. Called at the top of loadTrip.
resetTrip: () => set({
trip: null,
days: [],
places: [],
assignments: {},
dayNotes: {},
packingItems: [],
todoItems: [],
budgetItems: [],
files: [],
reservations: [],
selectedDayId: null,
error: null,
}),
loadTrip: async (tripId: number | string) => {
get().resetTrip()
set({ isLoading: true, error: null })
try {
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
const [tripData, daysData, placesData, packingData, todoData, budgetData, reservationsData, filesData, tagsData, categoriesData] = await Promise.all([
tripRepo.get(tripId),
dayRepo.list(tripId),
placeRepo.list(tripId),
packingRepo.list(tripId),
todoRepo.list(tripId),
// Budget / reservations / files are hydrated here too so the offline
// path is uniform (no separate tab-gated effects). Non-fatal: a failure
// in any of these must not blank the whole trip.
budgetRepo.list(tripId).catch(() => ({ items: [] as BudgetItem[] })),
reservationRepo.list(tripId).catch(() => ({ reservations: [] as Reservation[] })),
fileRepo.list(tripId).catch(() => ({ files: [] as TripFile[] })),
navigator.onLine
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
: offlineDb.tags.toArray().then(tags => ({ tags })),
@@ -121,6 +151,9 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
dayNotes: dayNotesMap,
packingItems: packingData.items,
todoItems: todoData.items,
budgetItems: budgetData.items,
reservations: reservationsData.reservations,
files: filesData.files,
tags: tagsData.tags,
categories: categoriesData.categories,
isLoading: false,
@@ -132,6 +165,22 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
}
},
// Silently re-fetch the active trip's collaborative state into the store after
// the network comes back (WS reconnect or `online` event) so edits missed while
// offline appear in place — no splash, no resetTrip. Each resource is
// best-effort; a failure on one must not wipe the others.
hydrateActiveTrip: async (tripId: number | string) => {
await Promise.all([
get().refreshDays(tripId),
placeRepo.list(tripId).then(d => set({ places: d.places })).catch(() => {}),
packingRepo.list(tripId).then(d => set({ packingItems: d.items })).catch(() => {}),
todoRepo.list(tripId).then(d => set({ todoItems: d.items })).catch(() => {}),
get().loadBudgetItems(tripId),
get().loadReservations(tripId),
get().loadFiles(tripId),
])
},
refreshDays: async (tripId: number | string) => {
try {
const daysData = await dayRepo.list(tripId)
+18
View File
@@ -0,0 +1,18 @@
/**
* Auth gate — a single boolean the sync layer checks before touching the
* offline DB. It lets logout disable all background sync (flush / syncAll /
* periodic triggers) *before* awaiting the DB swap, so an in-flight loop can't
* re-seed the database after the user has logged out.
*
* Kept separate from authStore to avoid an import cycle
* (authStore → tripSyncManager → authStore).
*/
let _authed = false
export function setAuthed(value: boolean): void {
_authed = value
}
export function isAuthed(): boolean {
return _authed
}
+88 -10
View File
@@ -7,6 +7,7 @@
*/
import { offlineDb } from '../db/offlineDb'
import { apiClient } from '../api/client'
import { isAuthed } from './authGate'
import type { QueuedMutation } from '../db/offlineDb'
import type { Table } from 'dexie'
@@ -39,6 +40,27 @@ let _flushing = false
// Monotonically increasing timestamp so same-millisecond enqueues
// still get a deterministic FIFO order when sorted by createdAt.
let _lastTs = 0
// Monotonic counter for offline temp ids. Date.now() alone collides when two
// creates land in the same millisecond (bulk import, rapid tapping), which would
// overwrite one optimistic Dexie row. This guarantees distinct negative ids.
let _lastTempId = 0
/**
* Mint a collision-free temporary (negative) id for an offline-created entity.
* Monotonic across the session so same-millisecond creates never collide.
*/
export function nextTempId(): number {
const now = Date.now()
_lastTempId = now > _lastTempId ? now : _lastTempId + 1
return -_lastTempId
}
/** HTTP statuses that should be retried later rather than treated as terminal. */
function isRetryableStatus(status: number | undefined): boolean {
// 401: token expired mid-flush (offline window) — retry after re-auth.
// 408/425/429: timeout / too-early / rate-limited — transient.
return status === 401 || status === 408 || status === 425 || status === 429
}
export const mutationQueue = {
/**
@@ -67,8 +89,12 @@ export const mutationQueue = {
* 4xx responses are marked failed and skipped.
*/
async flush(): Promise<void> {
if (_flushing || !navigator.onLine) return
if (_flushing || !navigator.onLine || !isAuthed()) return
_flushing = true
// tempId → realId learned during this flush, so a dependent edit/delete
// queued against an offline-created entity (still holding the negative id)
// can be rewritten to the server id before it is replayed.
const idMap = new Map<number, number>()
try {
const pending = await offlineDb.mutationQueue
.where('status')
@@ -79,10 +105,32 @@ export const mutationQueue = {
// Mark as syncing so UI can show progress
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
// Resolve a temp-id reference now that earlier CREATEs in this flush
// may have completed (FIFO order guarantees the CREATE ran first).
let reqUrl = mutation.url
let reqEntityId = mutation.entityId
if (mutation.tempEntityId !== undefined) {
const realId = idMap.get(mutation.tempEntityId)
if (realId !== undefined) {
reqUrl = reqUrl.replace('{id}', String(realId))
reqEntityId = realId
}
}
// Placeholder still unresolved → the create it depended on is gone
// (failed or missing). Surface it as failed rather than firing a 404.
if (reqUrl.includes('{id}')) {
await offlineDb.mutationQueue.update(mutation.id, {
status: 'failed',
attempts: mutation.attempts + 1,
lastError: 'unresolved temp id (dependent create did not sync)',
})
continue
}
try {
const response = await apiClient.request({
method: mutation.method,
url: mutation.url,
url: reqUrl,
data: mutation.body,
headers: { 'X-Idempotency-Key': mutation.id },
})
@@ -95,31 +143,51 @@ export const mutationQueue = {
const values = Object.values(response.data as Record<string, unknown>)
const entity = values[0]
if (entity && typeof entity === 'object' && 'id' in entity) {
// Remove temp optimistic entry if id changed (CREATE case)
if (mutation.tempId !== undefined && mutation.tempId !== (entity as { id: number }).id) {
const realId = (entity as { id: number }).id
// Remove temp optimistic entry if id changed (CREATE case) and
// remap any queued mutations that still target the negative id.
if (mutation.tempId !== undefined && mutation.tempId !== realId) {
await table.delete(mutation.tempId)
idMap.set(mutation.tempId, realId)
// Durable rewrite so dependents survive a flush boundary / reload.
await offlineDb.mutationQueue
.where('tripId')
.equals(mutation.tripId)
.filter(m => m.tempEntityId === mutation.tempId)
.modify(m => {
m.url = m.url.replace('{id}', String(realId))
m.entityId = realId
m.tempEntityId = undefined
})
}
await table.put(entity)
}
}
} else if (mutation.method === 'DELETE' && mutation.resource && mutation.entityId !== undefined) {
} else if (mutation.method === 'DELETE' && mutation.resource && reqEntityId !== undefined) {
// DELETE was already applied optimistically; ensure it's gone
const table = getTable(mutation.resource)
if (table) await table.delete(mutation.entityId)
if (table) await table.delete(reqEntityId)
}
await offlineDb.mutationQueue.delete(mutation.id)
} catch (err: unknown) {
const httpStatus = (err as { response?: { status: number } })?.response?.status
if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
// Permanent client error — mark failed, continue with next
const isTerminal =
httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500 && !isRetryableStatus(httpStatus)
if (isTerminal) {
// Permanent client error — roll back the phantom optimistic CREATE so
// it can't masquerade as synced, then mark failed and continue.
if (mutation.method !== 'DELETE' && mutation.tempId !== undefined && mutation.resource) {
const table = getTable(mutation.resource)
if (table) await table.delete(mutation.tempId)
}
await offlineDb.mutationQueue.update(mutation.id, {
status: 'failed',
attempts: mutation.attempts + 1,
lastError: String(err),
})
} else {
// Network error — reset to pending, abort flush (retry on next trigger)
// Network / transient error — reset to pending, abort flush (retry next trigger)
await offlineDb.mutationQueue.update(mutation.id, {
status: 'pending',
attempts: mutation.attempts + 1,
@@ -160,9 +228,19 @@ export const mutationQueue = {
.count()
},
/** Reset internal flushing flag and timestamp counter — useful in tests. */
/** Count permanently-failed mutations (surfaced separately so the user knows
* changes were dropped — they are NOT folded into pendingCount). */
async failedCount(): Promise<number> {
return offlineDb.mutationQueue
.where('status')
.equals('failed')
.count()
},
/** Reset internal flushing flag and timestamp counters — useful in tests. */
_resetFlushing(): void {
_flushing = false
_lastTs = 0
_lastTempId = 0
},
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Ask the browser for persistent storage so our offline data — prefetched map
* tiles, cached file blobs, the IndexedDB caches — is exempt from eviction under
* storage pressure. Without this the browser may purge tiles right when a
* traveler goes offline and needs them (audit H8 / M6).
*
* Best-effort and idempotent: returns whether persistence is (now) granted.
*/
export async function requestPersistentStorage(): Promise<boolean> {
try {
if (typeof navigator === 'undefined' || !navigator.storage?.persist) return false
// Already persisted? Avoid re-prompting where the API distinguishes.
if (navigator.storage.persisted && (await navigator.storage.persisted())) return true
return await navigator.storage.persist()
} catch {
return false
}
}
+27 -4
View File
@@ -14,17 +14,34 @@
*/
import { mutationQueue } from './mutationQueue'
import { tripSyncManager } from './tripSyncManager'
import { setPreReconnectHook } from '../api/websocket'
import { setPreReconnectHook, setRefetchCallback, getActiveTrips } from '../api/websocket'
import { useTripStore } from '../store/tripStore'
const PERIODIC_MS = 30_000
let _intervalId: ReturnType<typeof setInterval> | null = null
let _registered = false
/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */
/** Pull the latest server state for every open trip into the Zustand store. */
function rehydrateActiveTrips() {
const store = useTripStore.getState()
for (const tripId of getActiveTrips()) {
store.hydrateActiveTrip(tripId).catch(console.error)
}
}
/**
* Network came back — flush local writes first, then re-seed Dexie for all
* cacheable trips and re-hydrate the open trip's store so a collaborator's
* edits made while we were offline appear without navigating away.
*/
function onOnline() {
mutationQueue.flush().catch(console.error)
tripSyncManager.syncAll().catch(console.error)
mutationQueue.flush()
.catch(console.error)
.finally(() => {
tripSyncManager.syncAll().catch(console.error)
rehydrateActiveTrips()
})
}
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
@@ -48,6 +65,11 @@ export function registerSyncTriggers(): void {
// WS reconnect: flush mutations only — no syncAll to avoid triggering rate
// limiters when the socket drops and reconnects while the device is online.
setPreReconnectHook(() => mutationQueue.flush())
// After the reconnect flush, pull canonical state for the open trip back into
// the store (the WS layer awaits the flush hook before invoking this).
setRefetchCallback(tripId => {
useTripStore.getState().hydrateActiveTrip(tripId).catch(console.error)
})
window.addEventListener('online', onOnline)
document.addEventListener('visibilitychange', onVisibility)
@@ -59,6 +81,7 @@ export function unregisterSyncTriggers(): void {
_registered = false
setPreReconnectHook(null)
setRefetchCallback(null)
window.removeEventListener('online', onOnline)
document.removeEventListener('visibilitychange', onVisibility)
if (_intervalId !== null) {
+20 -12
View File
@@ -17,11 +17,18 @@ import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
// ── Constants ─────────────────────────────────────────────────────────────────
/** Estimated average tile size in KB (road/transit tiles ~15 KB). */
/** Estimated average tile size in KB (raster basemap tiles ~15 KB). */
const AVG_TILE_KB = 15
/** Hard cap: ~50 MB worth of tiles. */
export const MAX_TILES = Math.floor((50 * 1024) / AVG_TILE_KB) // ≈ 3413
/**
* Hard cap on prefetched tiles (~180 MB).
*
* MUST stay in sync with the Workbox 'map-tiles' `maxEntries` in
* client/vite.config.js (kept equal). If this budget exceeds the SW cache size,
* the LRU evicts freshly-prefetched tiles on arrival and the offline map goes
* blank — which is exactly the bug this value was raised (from ~3413) to fix.
*/
export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288
const DEFAULT_TILE_URL =
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
@@ -177,15 +184,16 @@ export async function prefetchTilesForTrip(
const bbox = computeBbox(places)
if (!bbox) return
// Size guard: if total tile count across all zooms exceeds cap, skip
const estimated = countTiles(bbox, 10, 16)
if (estimated > MAX_TILES) {
console.warn(
`[tilePrefetch] trip ${tripId}: estimated ${estimated} tiles exceeds cap (${MAX_TILES}), skipping`,
)
return
}
// Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops
// once MAX_TILES is reached, so large (region / road-trip) bboxes still get
// their lower zooms cached instead of being skipped entirely.
//
// NOTE: opaque (no-cors) tile responses are padded by Chromium to ~7 MB each
// for quota accounting, so the real on-disk budget is far below 180 MB. We
// keep no-cors deliberately: switching to cors would break self-hosted/custom
// tile providers that don't send CORS headers. To stop the browser evicting
// these tiles under the inflated quota, we request persistent storage at app
// init instead (sync/persistentStorage.ts).
const fetched = await prefetchTiles(bbox, template)
// Update syncMeta with bbox and tile count
+7 -2
View File
@@ -27,8 +27,10 @@ import {
upsertCategories,
upsertSyncMeta,
clearTripData,
enforceBlobBudget,
} from '../db/offlineDb'
import { prefetchTilesForTrip } from './tilePrefetcher'
import { isAuthed } from './authGate'
import { useSettingsStore } from '../store/settingsStore'
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
@@ -108,13 +110,16 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
const resp = await fetch(file.url!, { credentials: 'include' })
if (!resp.ok) continue
const blob = await resp.blob()
await offlineDb.blobCache.put({ url: file.url!, blob, mime: file.mime_type, cachedAt: Date.now() })
await offlineDb.blobCache.put({ url: file.url!, tripId: file.trip_id, blob, bytes: blob.size, mime: file.mime_type, cachedAt: Date.now() })
cached++
} catch {
// Network failure — skip this file, will retry next sync
}
}
// Keep the blob cache within its size/count budget after adding new files.
if (cached > 0) await enforceBlobBudget().catch(() => {})
// Update filesCachedCount in syncMeta
const tripId = files[0]?.trip_id
if (tripId) {
@@ -134,7 +139,7 @@ export const tripSyncManager = {
* No-ops when offline.
*/
async syncAll(): Promise<void> {
if (_syncing || !navigator.onLine) return
if (_syncing || !navigator.onLine || !isAuthed()) return
_syncing = true
try {
const { trips } = await tripsApi.list() as { trips: Trip[] }
+97
View File
@@ -23,6 +23,10 @@ import {
upsertReservations,
upsertTripFiles,
upsertSyncMeta,
reopenForUser,
reopenAnonymous,
deleteCurrentUserDb,
enforceBlobBudget,
type QueuedMutation,
type SyncMeta,
type BlobCacheEntry,
@@ -81,6 +85,15 @@ const makePlace = (id: number, tripId = 1): Place => ({
created_at: '2026-01-01T00:00:00Z',
});
const makeBlob = (url: string, tripId = 1, bytes = 10, cachedAt = 1): BlobCacheEntry => ({
url,
tripId,
blob: new Blob(['x'.repeat(bytes)], { type: 'application/pdf' }),
bytes,
mime: 'application/pdf',
cachedAt,
});
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(async () => {
@@ -220,7 +233,9 @@ describe('offlineDb — blobCache', () => {
const blob = new Blob(['%PDF-1.4 test'], { type: 'application/pdf' });
const entry: BlobCacheEntry = {
url: '/api/files/99/download',
tripId: 1,
blob,
bytes: blob.size,
mime: 'application/pdf',
cachedAt: Date.now(),
};
@@ -231,6 +246,49 @@ describe('offlineDb — blobCache', () => {
expect(stored!.mime).toBe('application/pdf');
expect(stored!.blob).toBeDefined();
});
it('queries blobs by tripId index', async () => {
await offlineDb.blobCache.bulkPut([
makeBlob('/api/files/1/download', 1),
makeBlob('/api/files/2/download', 1),
makeBlob('/api/files/3/download', 2),
]);
const trip1 = await offlineDb.blobCache.where('tripId').equals(1).toArray();
expect(trip1).toHaveLength(2);
});
});
describe('offlineDb — enforceBlobBudget', () => {
it('evicts oldest-by-cachedAt entries past the count budget', async () => {
// 5 entries with strictly increasing cachedAt; cap to 3.
for (let i = 0; i < 5; i++) {
await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 10, i + 1));
}
await enforceBlobBudget(3, Infinity);
expect(await offlineDb.blobCache.count()).toBe(3);
// Oldest two (cachedAt 1 and 2) are gone; newest survive.
expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined();
expect(await offlineDb.blobCache.get('/api/files/1/download')).toBeUndefined();
expect(await offlineDb.blobCache.get('/api/files/4/download')).toBeDefined();
});
it('evicts oldest entries past the byte budget', async () => {
// 3 entries of 100 bytes each; cap to 250 bytes → newest two (200) survive.
for (let i = 0; i < 3; i++) {
await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 100, i + 1));
}
await enforceBlobBudget(Infinity, 250);
expect(await offlineDb.blobCache.count()).toBe(2);
expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined();
});
it('is a no-op when already within budget', async () => {
await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1));
await enforceBlobBudget(10, Infinity);
expect(await offlineDb.blobCache.count()).toBe(1);
});
});
describe('offlineDb — clearTripData', () => {
@@ -241,9 +299,12 @@ describe('offlineDb — clearTripData', () => {
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, sort_order: 0, quantity: 1 };
await upsertPackingItems([item]);
await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1));
// Also add data for a different trip — should NOT be removed
await upsertTrip(makeTrip(2));
await upsertDays([makeDay(99, 2)]);
await offlineDb.blobCache.put(makeBlob('/api/files/2/download', 2));
await clearTripData(1);
@@ -251,10 +312,12 @@ describe('offlineDb — clearTripData', () => {
expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0);
expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0);
expect(await offlineDb.packingItems.where('trip_id').equals(1).count()).toBe(0);
expect(await offlineDb.blobCache.where('tripId').equals(1).count()).toBe(0);
// Trip 2 intact
expect(await offlineDb.trips.get(2)).toBeDefined();
expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1);
expect(await offlineDb.blobCache.get('/api/files/2/download')).toBeDefined();
});
});
@@ -271,3 +334,37 @@ describe('offlineDb — clearAll', () => {
expect(await offlineDb.places.count()).toBe(0);
});
});
describe('offlineDb — per-user scoping (B4)', () => {
afterEach(async () => {
// Leave the suite on the anonymous DB so other tests are unaffected.
await reopenAnonymous();
});
it('isolates one user\'s cached data from another', async () => {
await reopenForUser(1);
await upsertPlaces([makePlace(10, 1)]);
expect(await offlineDb.places.count()).toBe(1);
// Switching users must not expose user 1's rows.
await reopenForUser(2);
expect(await offlineDb.places.count()).toBe(0);
// Switching back restores user 1's data (different physical DB).
await reopenForUser(1);
expect(await offlineDb.places.get(10)).toBeDefined();
});
it('deleteCurrentUserDb wipes the user DB and returns to anonymous', async () => {
await reopenForUser(5);
await upsertPlaces([makePlace(20, 1)]);
await deleteCurrentUserDb();
// Now on the anonymous DB — no user data.
expect(await offlineDb.places.count()).toBe(0);
// Re-opening user 5 starts empty (DB was deleted, not just detached).
await reopenForUser(5);
expect(await offlineDb.places.count()).toBe(0);
});
});
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores } from '../../helpers/store';
import { buildDay, buildAssignment, buildPlace } from '../../helpers/factories';
import type { Assignment } from '../../../src/types';
beforeEach(() => {
resetAllStores();
@@ -50,6 +51,58 @@ describe('remoteEventHandler > assignments', () => {
expect(assignments['10'][0].id).toBe(500);
});
it('FE-WSEVT-ASSIGN-003b: a second assignment of an already-present place is NOT suppressed (H11)', () => {
const place = buildPlace({ id: 55 });
useTripStore.setState({
days: [buildDay({ id: 10 })],
// A committed (positive-id) assignment of place 55 already on the day.
assignments: { '10': [buildAssignment({ id: 100, day_id: 10, place, place_id: place.id })] },
});
// A legitimately new, distinct assignment of the same place arrives.
const second = buildAssignment({ id: 300, day_id: 10, place, place_id: place.id });
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: second });
const { assignments } = useTripStore.getState();
expect(assignments['10']).toHaveLength(2);
expect(assignments['10'].map(a => a.id).sort((x, y) => x - y)).toEqual([100, 300]);
});
it('FE-WSEVT-ASSIGN-003c: temp reconciliation replaces only the matching place, not a sibling temp (H11)', () => {
const place55 = buildPlace({ id: 55 });
const place66 = buildPlace({ id: 66 });
useTripStore.setState({
days: [buildDay({ id: 10 })],
assignments: {
'10': [
buildAssignment({ id: -1, day_id: 10, place: place55, place_id: 55 }),
buildAssignment({ id: -2, day_id: 10, place: place66, place_id: 66 }),
],
},
});
const real = buildAssignment({ id: 500, day_id: 10, place: place55, place_id: 55 });
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: real });
const { assignments } = useTripStore.getState();
const ids = assignments['10'].map(a => a.id);
expect(assignments['10']).toHaveLength(2);
expect(ids).toContain(500); // temp 55 reconciled to real
expect(ids).toContain(-2); // sibling temp 66 untouched
expect(ids).not.toContain(-1);
});
it('FE-WSEVT-ASSIGN-003d: place-less assignments do not collapse onto each other (H11)', () => {
// Defensive: a malformed event lacking place data must not let the
// `place?.id === placeId` reconciliation match undefined === undefined.
const placeless = (id: number): Assignment =>
({ ...buildAssignment({ id, day_id: 10 }), place: undefined, place_id: undefined } as unknown as Assignment);
useTripStore.setState({
days: [buildDay({ id: 10 })],
assignments: { '10': [placeless(-1)] },
});
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: placeless(700) });
const { assignments } = useTripStore.getState();
// No placeId → no reconcile; both survive as distinct rows (no collapse).
expect(assignments['10']).toHaveLength(2);
});
it('FE-WSEVT-ASSIGN-004: assignment:updated merges updated data into correct day', () => {
seedData();
const updated = buildAssignment({ id: 100, day_id: 10, notes: 'Updated notes' });
+14
View File
@@ -64,6 +64,20 @@ describe('placeRepo.list', () => {
const result = await placeRepo.list(99);
expect(result.places).toHaveLength(0);
});
it('online but request fails — falls back to Dexie cache (captive portal)', async () => {
// navigator.onLine lies "true" on a captive portal; the request throws.
const place = buildPlace({ trip_id: 1 });
await offlineDb.places.put(place);
server.use(
http.get('/api/trips/1/places', () => HttpResponse.error()),
);
const result = await placeRepo.list(1);
expect(result.places).toHaveLength(1);
expect(result.places[0].id).toBe(place.id);
});
});
describe('placeRepo.create', () => {
@@ -0,0 +1,76 @@
/**
* onlineThenCache — the read-through fallback shared by every repo (H2).
*
* Branches:
* - navigator offline → cache only (skip the request)
* - online but the request fails at the network level → fall back to cache
* - online but the server returns an HTTP error → rethrow (don't mask)
* - online and the request succeeds → return it, skip cache
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { onlineThenCache } from '../../../src/repo/withOfflineFallback';
beforeEach(() => {
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('onlineThenCache', () => {
it('returns the online result when online', async () => {
const online = vi.fn().mockResolvedValue('online');
const cache = vi.fn().mockResolvedValue('cache');
expect(await onlineThenCache(online, cache)).toBe('online');
expect(online).toHaveBeenCalledOnce();
expect(cache).not.toHaveBeenCalled();
});
it('reads the cache without calling online when navigator is offline', async () => {
Object.defineProperty(navigator, 'onLine', { value: false });
const online = vi.fn().mockResolvedValue('online');
const cache = vi.fn().mockResolvedValue('cache');
expect(await onlineThenCache(online, cache)).toBe('cache');
expect(online).not.toHaveBeenCalled();
});
it('falls back to the cache on a network-level failure (no HTTP response)', async () => {
// Axios network error: the request never reached the server (captive portal).
const netErr = Object.assign(new Error('Network Error'), { isAxiosError: true, response: undefined });
const online = vi.fn().mockRejectedValue(netErr);
const cache = vi.fn().mockResolvedValue('cache');
expect(await onlineThenCache(online, cache)).toBe('cache');
expect(online).toHaveBeenCalledOnce();
expect(cache).toHaveBeenCalledOnce();
});
it('rethrows a genuine HTTP error (server responded) instead of masking it', async () => {
// 404/403/500 mean the server replied — callers must see it, not a stale cache.
const httpErr = Object.assign(new Error('Not Found'), { isAxiosError: true, response: { status: 404 } });
const online = vi.fn().mockRejectedValue(httpErr);
const cache = vi.fn().mockResolvedValue('cache');
await expect(onlineThenCache(online, cache)).rejects.toThrow('Not Found');
expect(cache).not.toHaveBeenCalled();
});
it('rethrows a non-Axios error rather than swallowing it', async () => {
const online = vi.fn().mockRejectedValue(new Error('bug'));
const cache = vi.fn().mockResolvedValue('cache');
await expect(onlineThenCache(online, cache)).rejects.toThrow('bug');
expect(cache).not.toHaveBeenCalled();
});
it('propagates a cache error (e.g. nothing cached) when online also failed', async () => {
Object.defineProperty(navigator, 'onLine', { value: false });
const online = vi.fn().mockResolvedValue('online');
const cache = vi.fn().mockRejectedValue(new Error('No cached data'));
await expect(onlineThenCache(online, cache)).rejects.toThrow('No cached data');
});
});
+4 -4
View File
@@ -105,10 +105,10 @@ describe('authStore', () => {
});
describe('FE-AUTH-006: logout', () => {
it('calls disconnect() and clears user state', () => {
it('calls disconnect() and clears user state', async () => {
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
useAuthStore.getState().logout();
await useAuthStore.getState().logout();
const state = useAuthStore.getState();
expect(disconnect).toHaveBeenCalledOnce();
@@ -441,10 +441,10 @@ describe('authStore', () => {
});
describe('FE-STORE-AUTH-PERSIST-001: logout resets persisted snapshot', () => {
it('snapshot has isAuthenticated:false after logout (PWA offline will redirect to login)', () => {
it('snapshot has isAuthenticated:false after logout (PWA offline will redirect to login)', async () => {
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
useAuthStore.getState().logout();
await useAuthStore.getState().logout();
const snapshot = JSON.parse(localStorage.getItem('trek_auth_snapshot') ?? '{}');
expect(snapshot?.state?.isAuthenticated).toBe(false);
+198 -1
View File
@@ -8,18 +8,22 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import 'fake-indexeddb/auto';
import { server } from '../../helpers/msw/server';
import { http, HttpResponse } from 'msw';
import { mutationQueue, generateUUID } from '../../../src/sync/mutationQueue';
import { setAuthed } from '../../../src/sync/authGate';
import { mutationQueue, generateUUID, nextTempId } from '../../../src/sync/mutationQueue';
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
import { placeRepo } from '../../../src/repo/placeRepo';
import { buildPlace, buildPackingItem } from '../../helpers/factories';
beforeEach(async () => {
await clearAll();
mutationQueue._resetFlushing();
setAuthed(true);
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
});
afterEach(() => {
vi.restoreAllMocks();
setAuthed(false);
});
// ── helpers ──────────────────────────────────────────────────────────────────
@@ -214,6 +218,25 @@ describe('mutationQueue.flush — offline guard', () => {
const m = await offlineDb.mutationQueue.get(id);
expect(m!.status).toBe('pending');
});
it('does nothing when logged out (auth gate closed)', async () => {
setAuthed(false);
const id = generateUUID();
await mutationQueue.enqueue(makeMutation({ id }));
let called = false;
server.use(
http.post('/api/trips/1/places', () => {
called = true;
return HttpResponse.json({ place: buildPlace({ trip_id: 1 }) });
}),
);
await mutationQueue.flush();
expect(called).toBe(false);
const m = await offlineDb.mutationQueue.get(id);
expect(m!.status).toBe('pending');
});
});
// ── pending / pendingCount ────────────────────────────────────────────────────
@@ -265,3 +288,177 @@ describe('mutationQueue.pendingCount', () => {
expect(await mutationQueue.pendingCount()).toBe(2);
});
});
describe('mutationQueue.failedCount', () => {
it('counts only failed mutations (not pending/syncing)', async () => {
const id1 = generateUUID();
const id2 = generateUUID();
await mutationQueue.enqueue(makeMutation({ id: id1 }));
await mutationQueue.enqueue(makeMutation({ id: id2 }));
await offlineDb.mutationQueue.update(id2, { status: 'failed' });
expect(await mutationQueue.failedCount()).toBe(1);
expect(await mutationQueue.pendingCount()).toBe(1);
});
});
// ── B2: collision-free temp ids ────────────────────────────────────────────────
describe('nextTempId (B2)', () => {
it('returns distinct negative ids even within the same millisecond', () => {
mutationQueue._resetFlushing();
const a = nextTempId();
const b = nextTempId();
const c = nextTempId();
expect(a).toBeLessThan(0);
expect(new Set([a, b, c]).size).toBe(3);
});
it('two tight offline creates produce two distinct Dexie rows', async () => {
Object.defineProperty(navigator, 'onLine', { value: false });
await placeRepo.create(1, { name: 'First' });
await placeRepo.create(1, { name: 'Second' });
const rows = await offlineDb.places.where('trip_id').equals(1).toArray();
expect(rows).toHaveLength(2);
expect(rows.map(r => r.name).sort()).toEqual(['First', 'Second']);
});
});
// ── B1: temp-id → real-id remapping ─────────────────────────────────────────────
describe('mutationQueue.flush — temp-id remapping (B1)', () => {
it('rewrites a dependent PUT/DELETE to the real id within one flush', async () => {
const tempId = -1;
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
const createId = generateUUID();
const putId = generateUUID();
const deleteId = generateUUID();
await mutationQueue.enqueue({
id: createId, tripId: 1, method: 'POST', url: '/trips/1/places',
body: { name: 'Temp' }, resource: 'places', tempId,
});
await mutationQueue.enqueue({
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
body: { name: 'Edited' }, resource: 'places', entityId: tempId, tempEntityId: tempId,
});
await mutationQueue.enqueue({
id: deleteId, tripId: 1, method: 'DELETE', url: '/trips/1/places/{id}',
body: undefined, resource: 'places', entityId: tempId, tempEntityId: tempId,
});
const putUrls: string[] = [];
const deleteUrls: string[] = [];
server.use(
http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) })),
http.put('/api/trips/1/places/:id', ({ params }) => { putUrls.push(String(params.id)); return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42, name: 'Edited' }) }); }),
http.delete('/api/trips/1/places/:id', ({ params }) => { deleteUrls.push(String(params.id)); return HttpResponse.json({ success: true }); }),
);
await mutationQueue.flush();
expect(putUrls).toEqual(['42']);
expect(deleteUrls).toEqual(['42']);
expect(await mutationQueue.pendingCount()).toBe(0);
expect(await mutationQueue.failedCount()).toBe(0);
});
it('durably rewrites a still-queued dependent after the CREATE flushes alone', async () => {
const tempId = -7;
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
const createId = generateUUID();
const putId = generateUUID();
await mutationQueue.enqueue({
id: createId, tripId: 1, method: 'POST', url: '/trips/1/places',
body: { name: 'Temp' }, resource: 'places', tempId,
});
await mutationQueue.enqueue({
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
body: { name: 'Edited' }, resource: 'places', entityId: tempId, tempEntityId: tempId,
});
// Only the CREATE succeeds this round; the PUT errors out (network) and stays queued.
let putAttempts = 0;
server.use(
http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 88 }) })),
http.put('/api/trips/1/places/:id', () => { putAttempts++; return HttpResponse.error(); }),
);
await mutationQueue.flush();
const queuedPut = await offlineDb.mutationQueue.get(putId);
expect(queuedPut).toBeDefined();
expect(queuedPut!.url).toBe('/trips/1/places/88');
expect(queuedPut!.entityId).toBe(88);
expect(queuedPut!.tempEntityId).toBeUndefined();
expect(putAttempts).toBeGreaterThanOrEqual(1);
});
it('marks an orphaned dependent (placeholder never resolved) as failed', async () => {
const putId = generateUUID();
await mutationQueue.enqueue({
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
body: { name: 'Edited' }, resource: 'places', entityId: -999, tempEntityId: -999,
});
await mutationQueue.flush();
const m = await offlineDb.mutationQueue.get(putId);
expect(m!.status).toBe('failed');
});
});
// ── B3: terminal rollback + retryable classification ────────────────────────────
describe('mutationQueue.flush — failure handling (B3)', () => {
it('rolls back the phantom optimistic row on a terminal 400 CREATE', async () => {
const tempId = -3;
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
const id = generateUUID();
await mutationQueue.enqueue(makeMutation({ id, tempId }));
server.use(
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'Bad' }, { status: 400 })),
);
await mutationQueue.flush();
expect(await offlineDb.places.get(tempId)).toBeUndefined();
const m = await offlineDb.mutationQueue.get(id);
expect(m!.status).toBe('failed');
});
it('treats 429 as retryable: resets to pending and stops the flush', async () => {
const id = generateUUID();
await mutationQueue.enqueue(makeMutation({ id }));
server.use(
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'slow down' }, { status: 429 })),
);
await mutationQueue.flush();
const m = await offlineDb.mutationQueue.get(id);
expect(m!.status).toBe('pending');
expect(m!.attempts).toBe(1);
expect(await mutationQueue.failedCount()).toBe(0);
});
it('treats 401 as retryable rather than dropping the change', async () => {
const id = generateUUID();
await mutationQueue.enqueue(makeMutation({ id }));
server.use(
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'AUTH_REQUIRED' }, { status: 401 })),
);
await mutationQueue.flush();
const m = await offlineDb.mutationQueue.get(id);
expect(m!.status).toBe('pending');
});
});
@@ -0,0 +1,47 @@
/**
* requestPersistentStorage (H8 / M6) best-effort persistent storage request
* so prefetched tiles / file blobs / IndexedDB aren't evicted under pressure.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { requestPersistentStorage } from '../../../src/sync/persistentStorage';
const original = (navigator as Navigator & { storage?: StorageManager }).storage;
afterEach(() => {
Object.defineProperty(navigator, 'storage', { value: original, configurable: true });
vi.restoreAllMocks();
});
function stubStorage(storage: unknown) {
Object.defineProperty(navigator, 'storage', { value: storage, configurable: true });
}
describe('requestPersistentStorage', () => {
it('requests persistence when not already granted', async () => {
const persist = vi.fn().mockResolvedValue(true);
const persisted = vi.fn().mockResolvedValue(false);
stubStorage({ persist, persisted });
expect(await requestPersistentStorage()).toBe(true);
expect(persist).toHaveBeenCalledOnce();
});
it('skips the prompt when already persisted', async () => {
const persist = vi.fn().mockResolvedValue(true);
const persisted = vi.fn().mockResolvedValue(true);
stubStorage({ persist, persisted });
expect(await requestPersistentStorage()).toBe(true);
expect(persist).not.toHaveBeenCalled();
});
it('returns false (no throw) when the API is unavailable', async () => {
stubStorage(undefined);
expect(await requestPersistentStorage()).toBe(false);
});
it('returns false (no throw) when persist rejects', async () => {
stubStorage({ persist: vi.fn().mockRejectedValue(new Error('denied')) });
expect(await requestPersistentStorage()).toBe(false);
});
});
@@ -0,0 +1,76 @@
/**
* syncTriggers reconnect/online wiring (H1).
*
* Verifies the previously-dead refetch path is wired: on WS reconnect and on the
* `online` event the active trip's store is re-hydrated (after the queue flush).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const flush = vi.fn(() => Promise.resolve());
const syncAll = vi.fn(() => Promise.resolve());
const hydrate = vi.fn(() => Promise.resolve());
let refetchCb: ((tripId: string) => void) | null = null;
let preReconnect: (() => Promise<void>) | null = null;
vi.mock('../../../src/sync/mutationQueue', () => ({
mutationQueue: { flush: () => flush() },
}));
vi.mock('../../../src/sync/tripSyncManager', () => ({
tripSyncManager: { syncAll: () => syncAll() },
}));
vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: (fn: (() => Promise<void>) | null) => { preReconnect = fn; },
setRefetchCallback: (fn: ((tripId: string) => void) | null) => { refetchCb = fn; },
getActiveTrips: () => ['7'],
}));
vi.mock('../../../src/store/tripStore', () => ({
useTripStore: { getState: () => ({ hydrateActiveTrip: hydrate }) },
}));
import { registerSyncTriggers, unregisterSyncTriggers } from '../../../src/sync/syncTriggers';
const flushMicrotasks = async () => {
for (let i = 0; i < 5; i++) await Promise.resolve();
};
beforeEach(() => {
flush.mockClear(); syncAll.mockClear(); hydrate.mockClear();
refetchCb = null; preReconnect = null;
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
});
afterEach(() => {
unregisterSyncTriggers();
});
describe('syncTriggers', () => {
it('registers a refetch callback that hydrates the active trip', () => {
registerSyncTriggers();
expect(refetchCb).toBeTypeOf('function');
refetchCb!('7');
expect(hydrate).toHaveBeenCalledWith('7');
});
it('also registers the pre-reconnect flush hook', () => {
registerSyncTriggers();
expect(preReconnect).toBeTypeOf('function');
});
it('clears both reconnect hooks on unregister', () => {
registerSyncTriggers();
unregisterSyncTriggers();
expect(refetchCb).toBeNull();
expect(preReconnect).toBeNull();
});
it('online event flushes, then re-seeds Dexie and re-hydrates active trips', async () => {
registerSyncTriggers();
window.dispatchEvent(new Event('online'));
await flushMicrotasks();
expect(flush).toHaveBeenCalled();
expect(syncAll).toHaveBeenCalled();
expect(hydrate).toHaveBeenCalledWith('7');
});
});
+31 -6
View File
@@ -207,17 +207,42 @@ describe('prefetchTilesForTrip', () => {
expect(meta!.tilesBbox).toHaveLength(4);
});
it('skips prefetch when estimated tiles exceed MAX_TILES', async () => {
it('zoom-clamps instead of skipping when the bbox exceeds MAX_TILES', async () => {
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
// Places far apart → huge bbox → estimate > MAX_TILES
// ~4° road-trip span: low zooms fit the budget, high zooms (z14+) blow past
// it. The old guard skipped the whole trip; now we keep what fits.
const places = [
buildPlace({ trip_id: 1, lat: -60, lng: -170 }),
buildPlace({ trip_id: 1, lat: 60, lng: 170 }),
buildPlace({ trip_id: 1, lat: 45.0, lng: 0.0 }),
buildPlace({ trip_id: 1, lat: 49.0, lng: 4.0 }),
];
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
// No fetches should have been made
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
// Previously this skipped entirely; now it prefetches a clamped subset.
const calls = vi.mocked(fetch).mock.calls.length;
expect(calls).toBeGreaterThan(0);
expect(calls).toBeLessThanOrEqual(MAX_TILES);
});
it('prefetches a region-sized (0.5°) trip that the old all-or-nothing guard would have skipped', async () => {
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
const places = [
buildPlace({ trip_id: 1, lat: 48.6, lng: 2.1 }),
buildPlace({ trip_id: 1, lat: 49.1, lng: 2.6 }),
];
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
const calls = vi.mocked(fetch).mock.calls.length;
expect(calls).toBeGreaterThan(0);
expect(calls).toBeLessThanOrEqual(MAX_TILES);
});
});
// ── cap coherence ───────────────────────────────────────────────────────────────
describe('MAX_TILES budget', () => {
it('matches the Workbox map-tiles maxEntries in vite.config.js (drift guard)', () => {
expect(MAX_TILES).toBe(12288);
});
});
@@ -9,6 +9,7 @@ import 'fake-indexeddb/auto';
import { server } from '../../helpers/msw/server';
import { http, HttpResponse } from 'msw';
import { tripSyncManager } from '../../../src/sync/tripSyncManager';
import { setAuthed } from '../../../src/sync/authGate';
import { offlineDb, clearAll, upsertTrip } from '../../../src/db/offlineDb';
import {
buildTrip,
@@ -45,6 +46,7 @@ function makeBundle(tripId: number) {
beforeEach(async () => {
await clearAll();
tripSyncManager._resetSyncing();
setAuthed(true);
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
// Stub fetch for blob caching (used by cacheFilesForTrip)
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
@@ -56,6 +58,19 @@ beforeEach(async () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
setAuthed(false);
});
describe('tripSyncManager.syncAll — auth gate (B4)', () => {
it('no-ops when logged out (gate closed)', async () => {
setAuthed(false);
let called = false;
server.use(
http.get('/api/trips', () => { called = true; return HttpResponse.json({ trips: [] }); }),
);
await tripSyncManager.syncAll();
expect(called).toBe(false);
});
});
// ── offline guard ─────────────────────────────────────────────────────────────
+114 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { useTripStore } from '../../src/store/tripStore';
import { resetAllStores } from '../helpers/store';
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories';
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote, buildBudgetItem, buildReservation, buildTripFile } from '../helpers/factories';
import { server } from '../helpers/msw/server';
vi.mock('../../src/api/websocket', () => ({
@@ -21,6 +21,28 @@ beforeEach(() => {
resetAllStores();
});
/** Full set of MSW handlers for one trip's loadTrip fan-out. */
function tripHandlers(
id: number,
data: {
budget?: unknown[]; reservations?: unknown[]; files?: unknown[];
tags?: unknown[]; categories?: unknown[];
},
) {
return [
http.get(`/api/trips/${id}`, () => HttpResponse.json({ trip: buildTrip({ id }) })),
http.get(`/api/trips/${id}/days`, () => HttpResponse.json({ days: [] })),
http.get(`/api/trips/${id}/places`, () => HttpResponse.json({ places: [] })),
http.get(`/api/trips/${id}/packing`, () => HttpResponse.json({ items: [] })),
http.get(`/api/trips/${id}/todo`, () => HttpResponse.json({ items: [] })),
http.get(`/api/trips/${id}/budget`, () => HttpResponse.json({ items: data.budget ?? [] })),
http.get(`/api/trips/${id}/reservations`, () => HttpResponse.json({ reservations: data.reservations ?? [] })),
http.get(`/api/trips/${id}/files`, () => HttpResponse.json({ files: data.files ?? [] })),
http.get('/api/tags', () => HttpResponse.json({ tags: data.tags ?? [] })),
http.get('/api/categories', () => HttpResponse.json({ categories: data.categories ?? [] })),
];
}
describe('tripStore', () => {
describe('loadTrip', () => {
it('FE-TRIP-001: fires parallel API calls for trips, days, places, packing, todo, tags, categories', async () => {
@@ -178,6 +200,97 @@ describe('tripStore', () => {
expect(state.isLoading).toBe(false);
expect(state.error).not.toBeNull();
});
it('FE-TRIP-H5: loadTrip uniformly hydrates budget, reservations and files', async () => {
const budgetItem = buildBudgetItem({ trip_id: 1 });
const reservation = buildReservation({ trip_id: 1 });
const file = buildTripFile({ trip_id: 1 });
server.use(...tripHandlers(1, { budget: [budgetItem], reservations: [reservation], files: [file] }));
await useTripStore.getState().loadTrip(1);
const state = useTripStore.getState();
expect(state.budgetItems).toEqual([budgetItem]);
expect(state.reservations).toEqual([reservation]);
expect(state.files).toEqual([file]);
});
it('FE-TRIP-H4: switching trips does not leak budget/reservations/files from the previous trip', async () => {
// Trip 1 has budget/reservations/files; trip 2 has none.
server.use(...tripHandlers(1, {
budget: [buildBudgetItem({ trip_id: 1 })],
reservations: [buildReservation({ trip_id: 1 })],
files: [buildTripFile({ trip_id: 1 })],
}));
await useTripStore.getState().loadTrip(1);
expect(useTripStore.getState().budgetItems).toHaveLength(1);
server.use(...tripHandlers(2, {}));
await useTripStore.getState().loadTrip(2);
const state = useTripStore.getState();
expect(state.trip!.id).toBe(2);
expect(state.budgetItems).toEqual([]);
expect(state.reservations).toEqual([]);
expect(state.files).toEqual([]);
});
it('FE-TRIP-H4b: resetTrip clears every trip-scoped slice but keeps tags/categories', async () => {
server.use(...tripHandlers(1, {
budget: [buildBudgetItem({ trip_id: 1 })],
reservations: [buildReservation({ trip_id: 1 })],
files: [buildTripFile({ trip_id: 1 })],
tags: [buildTag()],
}));
await useTripStore.getState().loadTrip(1);
expect(useTripStore.getState().budgetItems).toHaveLength(1);
useTripStore.getState().resetTrip();
const state = useTripStore.getState();
expect(state.trip).toBeNull();
expect(state.places).toEqual([]);
expect(state.budgetItems).toEqual([]);
expect(state.reservations).toEqual([]);
expect(state.files).toEqual([]);
expect(state.selectedDayId).toBeNull();
// Global lookups survive a trip reset.
expect(state.tags).toHaveLength(1);
});
});
describe('hydrateActiveTrip', () => {
const loadHandlers = (places: unknown[] = [], budget: unknown[] = []) => [
http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
http.get('/api/trips/1/places', () => HttpResponse.json({ places })),
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: budget })),
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
];
it('FE-TRIP-H1: silently refreshes resources without resetting or splashing', async () => {
server.use(...loadHandlers());
await useTripStore.getState().loadTrip(1);
expect(useTripStore.getState().trip!.id).toBe(1);
// New collaborative state arrives (as if edited by someone while we were offline).
const place = buildPlace({ trip_id: 1 });
const budgetItem = buildBudgetItem({ trip_id: 1 });
server.use(...loadHandlers([place], [budgetItem]));
await useTripStore.getState().hydrateActiveTrip(1);
const state = useTripStore.getState();
expect(state.places).toEqual([place]);
expect(state.budgetItems).toEqual([budgetItem]);
expect(state.trip!.id).toBe(1); // trip not reset
expect(state.isLoading).toBe(false); // no splash toggled
});
});
describe('refreshDays', () => {
+28 -9
View File
@@ -15,21 +15,25 @@ export default defineConfig({
runtimeCaching: [
{
// Carto map tiles (default provider)
// maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts
// (both are 12288) so prefetched tiles aren't evicted on arrival.
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// OpenStreetMap tiles (fallback / alternative)
// Shares the 'map-tiles' cache; keep maxEntries equal to the Carto
// rule above and MAX_TILES in src/sync/tilePrefetcher.ts (12288).
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
@@ -44,17 +48,32 @@ export default defineConfig({
},
},
{
// API calls — prefer network, fall back to cache
// Exclude sensitive endpoints (auth, admin, backup, settings)
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
handler: 'NetworkFirst',
// Mapbox GL style, glyphs, sprites and vector tiles. Best-effort
// offline only: opportunistically caches what the user has already
// viewed online. Full pre-download offline maps require the Leaflet
// renderer (raster prefetch in tilePrefetcher.ts) — the GL vector
// pipeline is not prefetched. StaleWhileRevalidate keeps the basemap
// fresh online while still serving from cache when offline. Mapbox
// sends CORS, so responses are non-opaque (real 200s, no quota pad).
urlPattern: /^https:\/\/(api\.mapbox\.com|[a-d]\.tiles\.mapbox\.com)\/.*/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-data',
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
networkTimeoutSeconds: 5,
cacheName: 'mapbox-tiles',
expiration: { maxEntries: 3000, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [200] },
},
},
{
// API calls — network only. We deliberately do NOT cache API
// responses in the Service Worker: Workbox keys entries by URL and
// cannot vary on the httpOnly session cookie, so a shared device
// could serve one user's cached data to the next (cross-user leak).
// Offline reads are served from the per-user IndexedDB cache via the
// repo layer instead. The urlPattern is kept so these requests still
// bypass the SPA navigation fallback.
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
handler: 'NetworkOnly',
},
{
// Uploaded files (photos, covers — public assets only)
urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i,
+32 -7
View File
@@ -291,20 +291,45 @@ function startVersionCheck(): void {
}, { timezone: tz });
}
// Idempotency key cleanup: nightly at 3 AM — delete keys older than 24 hours
// Idempotency key cleanup: nightly at 3 AM — delete keys past their TTL.
// The TTL must exceed any realistic offline window: the TREK client replays
// queued mutations with their X-Idempotency-Key when it reconnects, so a key
// GC'd before the device comes back online would let the replay create a
// duplicate. 24h was far too short for a multi-day offline trip; default 30d,
// overridable via IDEMPOTENCY_TTL_SECONDS.
const DEFAULT_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
let idempotencyCleanupTask: ScheduledTask | null = null;
function idempotencyTtlSeconds(): number {
const n = Number(process.env.IDEMPOTENCY_TTL_SECONDS);
return Number.isFinite(n) && n > 0 ? n : DEFAULT_IDEMPOTENCY_TTL_SECONDS;
}
interface PurgeDb {
prepare(sql: string): { run(...args: unknown[]): { changes: number } };
}
/** Delete idempotency keys older than the configured TTL. Returns rows removed.
* The db is injectable for testing; the cron job uses the default. */
function purgeExpiredIdempotencyKeys(
now: number = Date.now(),
ttlSeconds: number = idempotencyTtlSeconds(),
database: PurgeDb = require('./db/database').db,
): number {
const cutoff = Math.floor(now / 1000) - ttlSeconds;
const result = database.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
return result.changes;
}
function startIdempotencyCleanup(): void {
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
const tz = process.env.TZ || 'UTC';
idempotencyCleanupTask = cron.schedule('0 3 * * *', () => {
try {
const { db } = require('./db/database');
const cutoff = Math.floor(Date.now() / 1000) - 86400;
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
if (result.changes > 0) {
logInfo(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
const removed = purgeExpiredIdempotencyKeys();
if (removed > 0) {
logInfo(`Idempotency cleanup: removed ${removed} expired key(s)`);
}
} catch (err: unknown) {
logError(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
@@ -394,4 +419,4 @@ function stop(): void {
if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; }
}
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, startPlacePhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS };
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, purgeExpiredIdempotencyKeys, startTrekPhotoCacheCleanup, startPlacePhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS };
@@ -0,0 +1,58 @@
/**
* Idempotency key TTL cleanup (H6).
*
* The TREK client replays queued mutations with their X-Idempotency-Key on
* reconnect, so the server must keep keys long enough to cover a realistic
* offline window otherwise a key GC'd before the device returns lets the
* replay create a duplicate. The TTL was raised from 24h to 30d (overridable).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { db } from '../../src/db/database';
import { purgeExpiredIdempotencyKeys } from '../../src/scheduler';
const DAY = 24 * 60 * 60;
const NOW = 2_000_000_000_000; // fixed ms so the test is deterministic
const NOW_SEC = Math.floor(NOW / 1000);
function insertKey(key: string, ageSeconds: number): void {
db.prepare(
`INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
VALUES (?, 1, 'POST', '/x', 200, '{}', ?)`,
).run(key, NOW_SEC - ageSeconds);
}
beforeEach(() => {
db.pragma('foreign_keys = OFF'); // fixtures reference a user we don't seed here
db.prepare('DELETE FROM idempotency_keys').run();
});
afterEach(() => {
db.prepare('DELETE FROM idempotency_keys').run();
db.pragma('foreign_keys = ON');
delete process.env.IDEMPOTENCY_TTL_SECONDS;
});
describe('purgeExpiredIdempotencyKeys', () => {
it('removes keys older than the 30-day default, keeps recent ones', () => {
insertKey('old', 31 * DAY);
insertKey('fresh', 5 * DAY);
const removed = purgeExpiredIdempotencyKeys(NOW, undefined, db);
expect(removed).toBe(1);
const keys = db.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key);
expect(keys).toEqual(['fresh']);
});
it('keeps a 25-day-old key that the old 24h TTL would have dropped', () => {
insertKey('offline-trip', 25 * DAY);
expect(purgeExpiredIdempotencyKeys(NOW, undefined, db)).toBe(0);
expect(db.prepare('SELECT COUNT(*) c FROM idempotency_keys').get()).toMatchObject({ c: 1 });
});
it('respects the IDEMPOTENCY_TTL_SECONDS override', () => {
process.env.IDEMPOTENCY_TTL_SECONDS = String(DAY);
insertKey('twoDays', 2 * DAY);
expect(purgeExpiredIdempotencyKeys(NOW, undefined, db)).toBe(1);
});
});