Compare commits

...

12 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
jubnl 8077ffab34 fix(maps): bound place-photo cache growth (Wikimedia + Google) (#1174)
The place-photo cache (uploads/photos/google) grew unbounded: a Wikimedia
geosearch path cached full-res originals despite requesting a 400px thumb,
the writer applied no size guard, nothing reclaimed orphaned files, and
backups archived the whole re-derivable cache verbatim.

- Prefer the scaled `thumburl` over the full-res `info.url` in the Commons
  geosearch fallback.
- Downscale any cached image to <=800px JPEG via the existing jimp dep,
  with a safe fallback to the original bytes on decode failure.
- Add sweepOrphans() (orphaned meta rows + stray files) wired into the
  scheduler (startup + nightly), and removeIfUnreferenced() called on
  place delete for prompt reclamation.
- Exclude the re-derivable photo/trek caches from backups; restores
  self-heal as the cache dirs are recreated at startup.
2026-06-14 23:31:02 +02:00
Maurice 3e9626fce9 feat(places): enrich list-imported places via the Places API (#886) (#1161)
* feat(places): enrich list-imported places via the Places API (#886)

Google/Naver list imports only carry a name and coordinates, so the places open
as bare pins — the Maps tab jumps to coordinates, with no photo, address or
open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import
dialog, shown only when a Google Maps key is configured.

When enabled, after the (fast, unchanged) import the server runs a background
pass that re-resolves each place by name — biased to and validated against the
imported coordinates so a common-name search cannot overwrite the wrong place —
and fills the empty address/website/phone/photo columns plus the resolved
google_place_id, pushing each row over the live sync. Opening hours and the
proper Maps link then work on demand from the stored id.

Enrichment only fills empty fields, runs detached so a long list never blocks
the import, and no-ops when no key is configured.

* fix(places): use the ToggleSwitch component for the enrich toggle

Match the rest of the app — the import-enrichment opt-in used a raw checkbox;
swap it for the shared ToggleSwitch (text left, switch right) like the settings
toggles.
2026-06-14 00:54:11 +02:00
78 changed files with 2241 additions and 248 deletions
+4 -4
View File
@@ -366,10 +366,10 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
}
+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)
@@ -1,10 +1,12 @@
import ReactDOM from 'react-dom'
import ToggleSwitch from '../Settings/ToggleSwitch'
import type { SidebarState } from './usePlacesSidebar'
export function ListImportModal(S: SidebarState) {
const {
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
listImportEnrich, setListImportEnrich, canEnrichImport,
} = S
return ReactDOM.createPortal(
<div
@@ -55,6 +57,15 @@ export function ListImportModal(S: SidebarState) {
fontFamily: 'inherit', boxSizing: 'border-box',
}}
/>
{canEnrichImport && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginTop: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600 }}>{t('places.enrichOnImport')}</div>
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('places.enrichOnImportHint')}</div>
</div>
<ToggleSwitch on={listImportEnrich} onToggle={() => setListImportEnrich(!listImportEnrich)} />
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<button
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
@@ -7,6 +7,7 @@ import { useContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
export interface PlacesSidebarProps {
@@ -49,6 +50,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const loadTrip = useTripStore((s) => s.loadTrip)
const can = useCanDo()
const canEditPlaces = can('place_edit', trip)
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
const isNaverListImportEnabled = true
const [fileImportOpen, setFileImportOpen] = useState(false)
@@ -94,6 +97,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [listImportUrl, setListImportUrl] = useState('')
const [listImportLoading, setListImportLoading] = useState(false)
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
const [listImportEnrich, setListImportEnrich] = useState(false)
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
const hasMultipleListImportProviders = availableListImportProviders.length > 1
@@ -108,9 +112,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
setListImportLoading(true)
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
try {
const enrich = listImportEnrich && canEnrichImport
const result = provider === 'google'
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
: await placesApi.importNaverList(tripId, listImportUrl.trim())
? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich)
: await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich)
await loadTrip(tripId)
if (result.count === 0 && result.skipped > 0) {
toast.warning(t('places.importAllSkipped'))
@@ -223,6 +228,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
scrollContainerRef, onScrollTopChange,
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
listImportLoading, listImportProvider, setListImportProvider,
listImportEnrich, setListImportEnrich, canEnrichImport,
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
@@ -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,
+1
View File
@@ -79,6 +79,7 @@ const onListen = () => {
scheduler.startDemoReset();
scheduler.startIdempotencyCleanup();
scheduler.startTrekPhotoCacheCleanup();
scheduler.startPlacePhotoCacheCleanup();
scheduler.startAirTrailSync();
const { startTokenCleanup } = require('./services/ephemeralTokens');
startTokenCleanup();
+10 -7
View File
@@ -163,27 +163,30 @@ export class PlacesController {
}
@Post('import/google-list')
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
return this.importList('google', user, tripId, url, socketId);
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
return this.importList('google', user, tripId, url, enrich, socketId);
}
@Post('import/naver-list')
async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
return this.importList('naver', user, tripId, url, socketId);
async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
return this.importList('naver', user, tripId, url, enrich, socketId);
}
/** Shared google/naver list import — identical flow, different provider + error string. */
private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, socketId?: string) {
private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, enrich: unknown, socketId?: string) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!url || typeof url !== 'string') {
throw new HttpException({ error: 'URL is required' }, 400);
}
// Opt-in: re-resolve each imported place via the Places API to fill in
// photo / address / website / phone and persist a google_place_id (#886).
const opts = { enrich: parseBool(enrich, false), userId: user.id };
const label = provider === 'google' ? 'Google' : 'Naver';
try {
const result = provider === 'google'
? await this.places.importGoogleList(tripId, url)
: await this.places.importNaverList(tripId, url);
? await this.places.importGoogleList(tripId, url, opts)
: await this.places.importNaverList(tripId, url, opts);
if ('error' in result) {
throw new HttpException({ error: result.error }, result.status);
}
+4 -4
View File
@@ -64,12 +64,12 @@ export class PlacesService {
return svc.importMapFile(tripId, buffer, filename, opts);
}
importGoogleList(tripId: string, url: string) {
return svc.importGoogleList(tripId, url);
importGoogleList(tripId: string, url: string, opts?: Parameters<typeof svc.importGoogleList>[2]) {
return svc.importGoogleList(tripId, url, opts);
}
importNaverList(tripId: string, url: string) {
return svc.importNaverList(tripId, url);
importNaverList(tripId: string, url: string, opts?: Parameters<typeof svc.importNaverList>[2]) {
return svc.importNaverList(tripId, url, opts);
}
searchImage(tripId: string, id: string, userId: number) {
+57 -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}`);
@@ -334,6 +359,30 @@ function startTrekPhotoCacheCleanup(): void {
});
}
// Place-photo (Google/Wikimedia) cache cleanup: nightly — reclaim cached files and
// meta rows no place references anymore (deleted places/trips, overwritten image_url).
let placePhotoCacheTask: ScheduledTask | null = null;
function startPlacePhotoCacheCleanup(): void {
if (placePhotoCacheTask) { placePhotoCacheTask.stop(); placePhotoCacheTask = null; }
const sweep = () => {
try {
const { sweepOrphans } = require('./services/placePhotoCache');
const removed = sweepOrphans();
if (removed > 0) logInfo(`Place-photo cache cleanup: removed ${removed} orphaned file(s)/row(s)`);
} catch (err: unknown) {
logError(`Place-photo cache cleanup: ${err instanceof Error ? err.message : err}`);
}
};
// Run once on startup to reclaim orphans left over from before this sweeper existed.
sweep();
const tz = process.env.TZ || 'UTC';
placePhotoCacheTask = cron.schedule('30 3 * * *', sweep, { timezone: tz });
}
// AirTrail sync: poll connected instances on an interval and reconcile linked
// flights both ways (#214). The per-tick enable gate (addon + setting) lives in
// runAirtrailSync, so toggling the addon takes effect without a restart.
@@ -366,7 +415,8 @@ function stop(): void {
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
if (placePhotoCacheTask) { placePhotoCacheTask.stop(); placePhotoCacheTask = null; }
if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; }
}
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS };
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, purgeExpiredIdempotencyKeys, startTrekPhotoCacheCleanup, startPlacePhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS };
+8 -1
View File
@@ -156,7 +156,14 @@ export async function createBackup(): Promise<BackupInfo> {
}
if (fs.existsSync(uploadsDir)) {
archive.directory(uploadsDir, 'uploads');
// Exclude the place-photo and trek-memory caches: both are re-derivable
// (re-fetched on demand, keyed on stable ids) and would otherwise dominate
// backup size. Restores self-heal — the cache dirs are recreated at startup.
archive.glob(
'**/*',
{ cwd: uploadsDir, ignore: ['photos/google/**', 'photos/trek/**'], nodir: true, dot: true },
{ prefix: 'uploads' },
);
}
archive.finalize();
+4 -2
View File
@@ -33,7 +33,7 @@ interface OverpassElement {
}
interface WikiCommonsPage {
imageinfo?: { url?: string; extmetadata?: { Artist?: { value?: string } } }[];
imageinfo?: { url?: string; thumburl?: string; extmetadata?: { Artist?: { value?: string } } }[];
}
interface GooglePlaceResult {
@@ -537,7 +537,9 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
const mime = (info as { mime?: string })?.mime || '';
if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) {
const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null;
return { photoUrl: info.url, attribution };
// iiurlwidth=400 makes Commons also return a scaled thumburl. Prefer it —
// info.url is the full-resolution original (multi-megapixel camera exports).
return { photoUrl: info.thumburl ?? info.url, attribution };
}
}
return null;
+164
View File
@@ -0,0 +1,164 @@
import { db, getPlaceWithTags } from '../db/database';
import { broadcast } from '../websocket';
import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
/**
* Background enrichment for list-imported places (#886).
*
* Google/Naver list imports only carry name + coordinates, so the imported
* places open as bare pins (the Maps tab jumps to coordinates, no photo, no
* open/closed). When the importer opts in and a Google Maps key is configured,
* we re-resolve each place by name biased to and validated against the
* imported coordinates to a real Google place, then fill in the empty fields
* and persist the resolved `google_place_id` (which is what powers on-demand
* opening hours / the proper Maps link going forward).
*
* This runs detached from the import request (fire-and-forget) so a long list
* never blocks the response, and pushes each enriched row over the websocket so
* the sidebar fills in progressively. It only ever fills EMPTY columns, so it
* can never clobber data the import already captured (e.g. a Naver address).
*/
/** A place the import produced — only the fields enrichment reads/writes. */
export interface EnrichablePlace {
id: number;
name: string;
lat: number;
lng: number;
google_place_id?: string | null;
address?: string | null;
website?: string | null;
phone?: string | null;
image_url?: string | null;
}
/** How close a search hit must be to the imported coordinates to be trusted. */
const MATCH_RADIUS_METERS = 250;
/** Bias the text search to roughly the imported area. */
const SEARCH_BIAS_RADIUS_METERS = 2000;
/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
const ENRICH_CONCURRENCY = 3;
function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
/**
* Pick the search result that is the same place as the import: it must be a
* Google result (have a google_place_id) with coordinates within
* MATCH_RADIUS_METERS of the imported point. Returns the closest such hit, or
* null when nothing is close enough in which case the place is left as
* imported rather than risking a wrong-place overwrite (common-name / romanized
* lists). Exported for unit testing.
*/
export function pickEnrichmentMatch(
candidates: Record<string, unknown>[],
target: { lat: number; lng: number },
maxMeters: number = MATCH_RADIUS_METERS,
): Record<string, unknown> | null {
let best: { c: Record<string, unknown>; dist: number } | null = null;
for (const c of candidates || []) {
const gpid = c.google_place_id;
const lat = c.lat;
const lng = c.lng;
if (typeof gpid !== 'string' || !gpid) continue;
if (typeof lat !== 'number' || typeof lng !== 'number') continue;
const dist = haversineMeters(target, { lat, lng });
if (dist > maxMeters) continue;
if (!best || dist < best.dist) best = { c, dist };
}
return best?.c ?? null;
}
async function mapWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const item = items[cursor++];
await fn(item);
}
});
await Promise.all(workers);
}
const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
async function enrichOne(tripId: string, userId: number, place: EnrichablePlace, lang?: string): Promise<void> {
// Already linked (shouldn't happen for list imports) — nothing to resolve.
if (place.google_place_id) return;
if (typeof place.lat !== 'number' || typeof place.lng !== 'number') return;
const { places: results } = await searchPlaces(userId, place.name, lang, {
lat: place.lat,
lng: place.lng,
radius: SEARCH_BIAS_RADIUS_METERS,
});
const match = pickEnrichmentMatch(results, { lat: place.lat, lng: place.lng });
if (!match) return;
const gpid = str(match.google_place_id);
if (!gpid) return;
// COALESCE so enrichment only fills empty columns — never overwrites data the
// import already captured (e.g. Naver's address) or anything the user edited.
db.prepare(
`UPDATE places
SET google_place_id = COALESCE(google_place_id, ?),
address = COALESCE(address, ?),
website = COALESCE(website, ?),
phone = COALESCE(phone, ?),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND trip_id = ?`,
).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
// Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in
// that case — a missing photo must never abort the rest of the enrichment.
try {
const photo = await getPlacePhoto(userId, gpid, place.lat, place.lng, place.name);
if (photo?.photoUrl) {
db.prepare(
'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
).run(photo.photoUrl, place.id, tripId);
}
} catch {
/* no photo — leave image_url as-is */
}
// Push the enriched row to every connected client (no socket exclusion: the
// importer's own client should also receive the late update).
const updated = getPlaceWithTags(place.id);
if (updated) broadcast(tripId, 'place:updated', { place: updated }, undefined);
}
/**
* Enrich a batch of just-imported places in the background. Never throws
* any per-place failure is swallowed so one bad lookup can't take down the
* detached task or the process. No-ops when no Google Maps key is configured.
*/
export async function enrichImportedPlaces(
tripId: string,
userId: number,
places: EnrichablePlace[],
lang?: string,
): Promise<void> {
try {
if (!places.length) return;
if (!getMapsKey(userId)) return;
await mapWithConcurrency(places, ENRICH_CONCURRENCY, async (place) => {
try {
await enrichOne(tripId, userId, place, lang);
} catch (err) {
console.error(`[Places] enrichment failed for place ${place.id}:`, err instanceof Error ? err.message : err);
}
});
} catch (err) {
console.error('[Places] import enrichment pass failed:', err instanceof Error ? err.message : err);
}
}
+78 -2
View File
@@ -2,11 +2,20 @@ import path from 'node:path';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import crypto from 'node:crypto';
import { Jimp, JimpMime } from 'jimp';
import { db } from '../db/database';
const GOOGLE_PHOTO_DIR = path.join(__dirname, '../../uploads/photos/google');
// Overridable for tests (mirrors the TREK_DB_FILE seam) so the suite never touches
// the real uploads tree.
const GOOGLE_PHOTO_DIR = process.env.TREK_PLACE_PHOTO_DIR || path.join(__dirname, '../../uploads/photos/google');
const ERROR_TTL = 5 * 60 * 1000;
// Marker photos are displayed tiny — cap stored images so an oversized source
// (e.g. a Wikimedia Commons full-res original) can't bloat the cache. Matches
// THUMB_MAX/THUMB_QUALITY in memories/thumbnailService.ts.
const MAX_DIM = 800;
const JPEG_QUALITY = 80;
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
@@ -74,11 +83,27 @@ export function markError(placeId: string): void {
).run(placeId, Date.now(), Date.now());
}
// Downscale oversized images to MAX_DIM before caching, re-encoding to JPEG.
// Defense-in-depth: keeps the cache small regardless of what the fetch path hands
// us. Jimp auto-applies EXIF orientation on read. Falls back to the original bytes
// on any failure (corrupt/unsupported format) so behaviour is never worse than before.
async function downscale(bytes: Buffer): Promise<Buffer> {
try {
const img = await Jimp.read(bytes);
if (img.bitmap.width <= MAX_DIM && img.bitmap.height <= MAX_DIM) return bytes;
img.scaleToFit({ w: MAX_DIM, h: MAX_DIM });
return await img.getBuffer(JimpMime.jpeg, { quality: JPEG_QUALITY });
} catch {
return bytes;
}
}
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
const fp = filePath(placeId);
const tmp = fp + '.tmp';
await fsPromises.writeFile(tmp, bytes);
const resized = await downscale(bytes);
await fsPromises.writeFile(tmp, resized);
await fsPromises.rename(tmp, fp);
knownOnDisk.add(placeId);
@@ -108,3 +133,54 @@ export function serveFilePath(placeId: string): string | null {
knownOnDisk.add(placeId);
return fp;
}
// A cache entry is "referenced" while any place still points at it — either by the
// Google place_id (the dedup key) or by the stable proxy URL stored in image_url
// (covers coords: pseudo-ids, which never have a google_place_id).
function isReferenced(placeId: string): boolean {
const row = db.prepare(
'SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ? LIMIT 1'
).get(placeId, proxyUrl(placeId));
return !!row;
}
function deleteEntry(placeId: string): void {
try { fs.unlinkSync(filePath(placeId)); } catch { /* already gone */ }
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
knownOnDisk.delete(placeId);
}
// Drop a cache entry if no place references it anymore. Called after a place delete
// for prompt reclamation; the nightly sweep is the catch-all for every other path.
export function removeIfUnreferenced(placeId: string): void {
if (isReferenced(placeId)) return;
deleteEntry(placeId);
}
// Reclaim orphaned cache files + meta rows. Runs on startup and nightly (scheduler).
// Two passes: (1) meta rows no place references; (2) stray .jpg files with no meta row.
export function sweepOrphans(): number {
let removed = 0;
const rows = db.prepare('SELECT place_id FROM google_place_photo_meta').all() as { place_id: string }[];
const keepFiles = new Set<string>();
for (const { place_id } of rows) {
if (isReferenced(place_id)) {
keepFiles.add(`${crypto.createHash('sha1').update(place_id).digest('hex')}.jpg`);
} else {
deleteEntry(place_id);
removed++;
}
}
// Pass 2: files on disk that no surviving meta row maps to (e.g. left over from a
// crash between writeFile and the DB upsert, or a meta row deleted out-of-band).
let entries: string[] = [];
try { entries = fs.readdirSync(GOOGLE_PHOTO_DIR); } catch { entries = []; }
for (const entry of entries) {
if (!entry.endsWith('.jpg') || keepFiles.has(entry)) continue;
try { fs.unlinkSync(path.join(GOOGLE_PHOTO_DIR, entry)); removed++; } catch { /* race */ }
}
return removed;
}
+43 -4
View File
@@ -13,6 +13,28 @@ import {
resolveCategoryIdForFolder,
type KmlImportSummary,
} from './kmlImport';
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
import * as placePhotoCache from './placePhotoCache';
// Reclaim a deleted place's cached marker photo if nothing else references it.
// The cache key is the Google place_id, or — for coordinate-only places — the
// pseudo-id embedded in the stored proxy URL (/api/maps/place-photo/{id}/bytes).
function reclaimPhotoCache(googlePlaceId: string | null, imageUrl: string | null): void {
const candidates = new Set<string>();
if (googlePlaceId) candidates.add(googlePlaceId);
const m = imageUrl?.match(/^\/api\/maps\/place-photo\/(.+)\/bytes$/);
if (m) { try { candidates.add(decodeURIComponent(m[1])); } catch { /* malformed url */ } }
for (const id of candidates) {
try { placePhotoCache.removeIfUnreferenced(id); } catch { /* best-effort */ }
}
}
/** Opt-in Places-API enrichment for list imports (#886). */
export interface ListImportOptions {
enrich?: boolean;
userId?: number;
lang?: string;
}
interface PlaceWithCategory extends Place {
category_name: string | null;
@@ -234,25 +256,33 @@ export function updatePlace(
// ---------------------------------------------------------------------------
export function deletePlace(tripId: string, placeId: string): boolean {
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
const place = db.prepare(
'SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?'
).get(placeId, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
if (!place) return false;
db.prepare('DELETE FROM places WHERE id = ?').run(placeId);
reclaimPhotoCache(place.google_place_id, place.image_url);
return true;
}
export function deletePlacesMany(tripId: string, ids: number[]): number[] {
if (ids.length === 0) return [];
const selectStmt = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?');
const selectStmt = db.prepare('SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?');
const deleteStmt = db.prepare('DELETE FROM places WHERE id = ?');
const deleted: number[] = [];
const reclaimable: { google_place_id: string | null; image_url: string | null }[] = [];
const run = db.transaction((list: number[]) => {
for (const id of list) {
if (!selectStmt.get(id, tripId)) continue;
const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
if (!row) continue;
deleteStmt.run(id);
deleted.push(id);
reclaimable.push(row);
}
});
run(ids);
// Reclaim after the transaction commits so isReferenced() sees the final place set.
for (const row of reclaimable) reclaimPhotoCache(row.google_place_id, row.image_url);
return deleted;
}
@@ -595,7 +625,7 @@ export async function importMapFile(tripId: string, fileBuffer: Buffer, filename
// Import Google Maps list
// ---------------------------------------------------------------------------
export async function importGoogleList(tripId: string, url: string) {
export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) {
let listId: string | null = null;
let resolvedUrl = url;
@@ -697,6 +727,10 @@ export async function importGoogleList(tripId: string, url: string) {
});
insertAll();
if (opts?.enrich && opts.userId && created.length) {
void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
}
return { places: created, listName, skipped };
}
@@ -707,6 +741,7 @@ export async function importGoogleList(tripId: string, url: string) {
export async function importNaverList(
tripId: string,
url: string,
opts?: ListImportOptions,
): Promise<{ places: any[]; listName: string; skipped: number } | { error: string; status: number }> {
let resolvedUrl = url;
const limit = 20;
@@ -826,6 +861,10 @@ export async function importNaverList(
});
insertAll();
if (opts?.enrich && opts.userId && created.length) {
void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
}
return { places: created, listName, skipped };
}
@@ -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);
});
});
@@ -25,6 +25,7 @@ const archiverInstanceMock = vi.hoisted(() => ({
pipe: vi.fn(),
file: vi.fn(),
directory: vi.fn(),
glob: vi.fn(),
finalize: vi.fn(),
on: vi.fn(),
}));
@@ -441,7 +442,7 @@ describe('BACKUP-036 createBackup', () => {
);
});
it('BACKUP-036e — includes uploads directory when it exists', async () => {
it('BACKUP-036e — includes uploads but excludes the re-derivable photo caches', async () => {
fsMock.existsSync.mockImplementation((p: string) => {
if (String(p).endsWith('uploads')) return true;
return false;
@@ -467,10 +468,16 @@ describe('BACKUP-036 createBackup', () => {
await createBackup();
expect(archiverInstanceMock.directory).toHaveBeenCalledWith(
expect.stringContaining('uploads'),
'uploads'
expect(archiverInstanceMock.glob).toHaveBeenCalledWith(
'**/*',
expect.objectContaining({
cwd: expect.stringContaining('uploads'),
ignore: ['photos/google/**', 'photos/trek/**'],
}),
{ prefix: 'uploads' },
);
// The re-derivable caches must not be archived verbatim.
expect(archiverInstanceMock.directory).not.toHaveBeenCalled();
});
});
@@ -538,6 +538,31 @@ describe('fetchWikimediaPhoto (fetch stubbed)', () => {
expect(result!.attribution).toBe('Alice');
});
it('MAPS-036b: geosearch prefers the scaled thumburl over the full-res original', async () => {
const wikiResponse = { ok: true, json: async () => ({ query: { pages: { '-1': {} } } }) };
const commonsResponse = {
ok: true,
json: async () => ({
query: { pages: { '1': {
imageinfo: [{
url: 'https://commons.org/original-16mb.jpg',
thumburl: 'https://commons.org/thumb-400.jpg',
mime: 'image/jpeg',
extmetadata: { Artist: { value: 'Alice' } },
}],
} } },
}),
};
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(wikiResponse)
.mockResolvedValueOnce(commonsResponse));
const { fetchWikimediaPhoto } = await import('../../../src/services/mapsService');
const result = await fetchWikimediaPhoto(48.8, 2.3, 'Some Place');
expect(result).toBeDefined();
expect(result!.photoUrl).toBe('https://commons.org/thumb-400.jpg');
expect(result!.attribution).toBe('Alice');
});
it('MAPS-037: returns null when both strategies find nothing', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
@@ -0,0 +1,49 @@
/**
* Unit tests for the import-enrichment match selector (#886).
* Covers PENRICH-001 to PENRICH-004 the coordinate-validation guard that
* prevents a name search from overwriting an imported place with the wrong POI.
*/
import { describe, it, expect, vi } from 'vitest';
// placeEnrichment pulls in the DB, websocket and maps service at import time;
// stub them so the pure match selector can be tested in isolation.
vi.mock('../../../src/db/database', () => ({ db: {}, getPlaceWithTags: () => null }));
vi.mock('../../../src/websocket', () => ({ broadcast: () => {} }));
vi.mock('../../../src/services/mapsService', () => ({
getMapsKey: () => null,
searchPlaces: async () => ({ places: [], source: 'none' }),
getPlacePhoto: async () => ({ photoUrl: '', attribution: null }),
}));
import { pickEnrichmentMatch } from '../../../src/services/placeEnrichment';
const target = { lat: 48.85, lng: 2.35 };
describe('pickEnrichmentMatch', () => {
it('PENRICH-001: picks the closest Google candidate within the radius', () => {
const candidates = [
{ google_place_id: 'far', lat: 48.8512, lng: 2.3512 }, // ~170 m
{ google_place_id: 'near', lat: 48.85, lng: 2.35 }, // exact
];
const match = pickEnrichmentMatch(candidates, target);
expect(match?.google_place_id).toBe('near');
});
it('PENRICH-002: returns null when every candidate is beyond the radius', () => {
const candidates = [{ google_place_id: 'A', lat: 48.86, lng: 2.36 }]; // ~1.2 km
expect(pickEnrichmentMatch(candidates, target)).toBeNull();
});
it('PENRICH-003: ignores candidates without a google_place_id (e.g. OSM results)', () => {
const candidates = [
{ google_place_id: null, lat: 48.85, lng: 2.35 },
{ name: 'no id', lat: 48.85, lng: 2.35 },
];
expect(pickEnrichmentMatch(candidates, target)).toBeNull();
});
it('PENRICH-004: ignores candidates with non-numeric coordinates', () => {
const candidates = [{ google_place_id: 'A', lat: 'x', lng: 'y' }];
expect(pickEnrichmentMatch(candidates as never, target)).toBeNull();
});
});
@@ -0,0 +1,151 @@
/**
* Unit tests for placePhotoCache PPC-001 through PPC-010.
* Covers the downscale guard in put(), removeIfUnreferenced(), and sweepOrphans().
* Uses a real in-memory SQLite DB and a throwaway temp upload dir
* (TREK_PLACE_PHOTO_DIR) so the real uploads tree is never touched.
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import crypto from 'node:crypto';
import { Jimp, JimpMime } from 'jimp';
import Database from 'better-sqlite3';
// Throwaway upload dir — set before importing the module under test (it reads the
// env at load time and mkdirs the dir).
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'ppc-'));
process.env.TREK_PLACE_PHOTO_DIR = TMP_DIR;
// Minimal real DB with just the two tables placePhotoCache touches.
const testDb = new Database(':memory:');
testDb.exec(`
CREATE TABLE places (
id INTEGER PRIMARY KEY AUTOINCREMENT,
google_place_id TEXT,
image_url TEXT
);
CREATE TABLE google_place_photo_meta (
place_id TEXT PRIMARY KEY,
attribution TEXT,
fetched_at INTEGER NOT NULL,
error_at INTEGER
);
`);
vi.mock('../../../src/db/database', () => ({ db: testDb }));
function filePathFor(placeId: string): string {
const hash = crypto.createHash('sha1').update(placeId).digest('hex');
return path.join(TMP_DIR, `${hash}.jpg`);
}
async function makeJpeg(width: number, height: number): Promise<Buffer> {
const img = new Jimp({ width, height, color: 0xff0000ff });
return img.getBuffer(JimpMime.jpeg, { quality: 80 });
}
let cache: typeof import('../../../src/services/placePhotoCache');
beforeAll(async () => {
cache = await import('../../../src/services/placePhotoCache');
});
beforeEach(() => {
testDb.exec('DELETE FROM places; DELETE FROM google_place_photo_meta;');
for (const f of fs.readdirSync(TMP_DIR)) fs.rmSync(path.join(TMP_DIR, f), { force: true });
});
afterAll(() => {
testDb.close();
fs.rmSync(TMP_DIR, { recursive: true, force: true });
});
describe('placePhotoCache.put() downscale guard', () => {
it('PPC-001: downscales an oversized image to <= 800px', async () => {
const big = await makeJpeg(1600, 1200);
await cache.put('big-place', big, 'Alice');
const written = fs.readFileSync(filePathFor('big-place'));
const decoded = await Jimp.read(written);
expect(Math.max(decoded.bitmap.width, decoded.bitmap.height)).toBeLessThanOrEqual(800);
expect(written.length).toBeLessThan(big.length);
});
it('PPC-002: passes a small image through unchanged', async () => {
const small = await makeJpeg(100, 100);
await cache.put('small-place', small, null);
const written = fs.readFileSync(filePathFor('small-place'));
expect(written.equals(small)).toBe(true);
});
it('PPC-003: falls back to original bytes when the input is not a decodable image', async () => {
const garbage = Buffer.from('definitely not an image');
await cache.put('garbage-place', garbage, null);
const written = fs.readFileSync(filePathFor('garbage-place'));
expect(written.equals(garbage)).toBe(true);
});
});
describe('placePhotoCache.removeIfUnreferenced()', () => {
it('PPC-004: removes a cache entry that no place references', async () => {
await cache.put('orphan', await makeJpeg(50, 50), null);
expect(fs.existsSync(filePathFor('orphan'))).toBe(true);
cache.removeIfUnreferenced('orphan');
expect(fs.existsSync(filePathFor('orphan'))).toBe(false);
expect(testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('orphan')).toBeUndefined();
});
it('PPC-005: keeps an entry still referenced by google_place_id', async () => {
await cache.put('gid-1', await makeJpeg(50, 50), null);
testDb.prepare('INSERT INTO places (google_place_id) VALUES (?)').run('gid-1');
cache.removeIfUnreferenced('gid-1');
expect(fs.existsSync(filePathFor('gid-1'))).toBe(true);
});
it('PPC-006: keeps an entry referenced by a coords proxy URL in image_url', async () => {
const id = 'coords:48.8:2.3';
await cache.put(id, await makeJpeg(50, 50), null);
const proxy = `/api/maps/place-photo/${encodeURIComponent(id)}/bytes`;
testDb.prepare('INSERT INTO places (image_url) VALUES (?)').run(proxy);
cache.removeIfUnreferenced(id);
expect(fs.existsSync(filePathFor(id))).toBe(true);
});
});
describe('placePhotoCache.sweepOrphans()', () => {
it('PPC-007: removes orphaned meta rows + files, keeps referenced ones, deletes stray files', async () => {
await cache.put('keep-gid', await makeJpeg(50, 50), null);
await cache.put('drop-me', await makeJpeg(50, 50), null);
testDb.prepare('INSERT INTO places (google_place_id) VALUES (?)').run('keep-gid');
// A stray .jpg on disk with no meta row (e.g. a crash between write and upsert).
const strayPath = path.join(TMP_DIR, 'deadbeef'.padEnd(40, '0') + '.jpg');
fs.writeFileSync(strayPath, 'stray');
const removed = cache.sweepOrphans();
expect(fs.existsSync(filePathFor('keep-gid'))).toBe(true);
expect(fs.existsSync(filePathFor('drop-me'))).toBe(false);
expect(fs.existsSync(strayPath)).toBe(false);
expect(testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('drop-me')).toBeUndefined();
expect(testDb.prepare('SELECT 1 FROM google_place_photo_meta WHERE place_id = ?').get('keep-gid')).toBeDefined();
expect(removed).toBe(2); // drop-me (orphan meta+file) + stray file
});
it('PPC-008: returns 0 when every entry is referenced', async () => {
await cache.put('ref-a', await makeJpeg(50, 50), null);
testDb.prepare('INSERT INTO places (google_place_id) VALUES (?)').run('ref-a');
expect(cache.sweepOrphans()).toBe(0);
expect(fs.existsSync(filePathFor('ref-a'))).toBe(true);
});
});
@@ -41,6 +41,14 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
// Spy on the photo-cache reclaim hook so delete tests assert the wiring without
// touching disk. The removal logic itself is covered in placePhotoCache.test.ts.
const { removeIfUnreferencedSpy } = vi.hoisted(() => ({ removeIfUnreferencedSpy: vi.fn() }));
vi.mock('../../../src/services/placePhotoCache', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/services/placePhotoCache')>()),
removeIfUnreferenced: removeIfUnreferencedSpy,
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
@@ -252,6 +260,18 @@ describe('deletePlace', () => {
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(p1.id);
});
it('PLACE-SVC-019b — reclaims the photo cache for the deleted place', () => {
removeIfUnreferencedSpy.mockClear();
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'With Photo' }) as any;
testDb.prepare('UPDATE places SET google_place_id = ? WHERE id = ?').run('ChIJgid', place.id);
deletePlace(String(trip.id), String(place.id));
expect(removeIfUnreferencedSpy).toHaveBeenCalledWith('ChIJgid');
});
});
// ── importGpx ─────────────────────────────────────────────────────────────────
+3
View File
@@ -88,5 +88,8 @@ const places: TranslationStrings = {
'places.saveError': 'فشل الحفظ',
'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.",
'places.addAnyway': 'الإضافة على أي حال',
'places.enrichOnImport': 'إثراء الأماكن عبر Google',
'places.enrichOnImportHint':
'يبحث عن كل مكان مستورد لإضافة الصور والعنوان وبيانات الاتصال. يتطلب مفتاح خرائط Google.',
};
export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Falha ao salvar',
'places.duplicateExists': "'{name}' já está nesta viagem.",
'places.addAnyway': 'Adicionar mesmo assim',
'places.enrichOnImport': 'Enriquecer lugares via Google',
'places.enrichOnImportHint':
'Busca cada lugar importado para adicionar fotos, endereço e contato. Usa sua chave do Google Maps.',
};
export default places;
+3
View File
@@ -89,5 +89,8 @@ const places: TranslationStrings = {
'places.saveError': 'Uložení se nezdařilo',
'places.duplicateExists': "'{name}' už v tomto výletu existuje.",
'places.addAnyway': 'Přesto přidat',
'places.enrichOnImport': 'Obohatit místa přes Google',
'places.enrichOnImportHint':
'Vyhledá každé importované místo a doplní fotky, adresu a kontakty. Vyžaduje klíč Google Maps.',
};
export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Fehler beim Speichern',
'places.duplicateExists': "'{name}' ist bereits in dieser Reise.",
'places.addAnyway': 'Trotzdem hinzufügen',
'places.enrichOnImport': 'Orte über Google anreichern',
'places.enrichOnImportHint':
'Sucht jeden importierten Ort nach, um Fotos, Adresse und Kontaktdaten zu ergänzen. Nutzt deinen Google-Maps-Key.',
};
export default places;
+3
View File
@@ -89,5 +89,8 @@ const places: TranslationStrings = {
'places.saveError': 'Failed to save',
'places.duplicateExists': "'{name}' is already in this trip.",
'places.addAnyway': 'Add anyway',
'places.enrichOnImport': 'Enrich places via Google',
'places.enrichOnImportHint':
'Look up each imported place to fill in photos, address and contact details. Uses your Google Maps key.',
};
export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'No se pudo guardar',
'places.duplicateExists': "'{name}' ya está en este viaje.",
'places.addAnyway': 'Añadir de todos modos',
'places.enrichOnImport': 'Enriquecer lugares con Google',
'places.enrichOnImportHint':
'Busca cada lugar importado para añadir fotos, dirección y datos de contacto. Usa tu clave de Google Maps.',
};
export default places;
+3
View File
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': "Échec de l'enregistrement",
'places.duplicateExists': "'{name}' est déjà dans ce voyage.",
'places.addAnyway': 'Ajouter quand même',
'places.enrichOnImport': 'Enrichir les lieux via Google',
'places.enrichOnImportHint':
'Recherche chaque lieu importé pour ajouter photos, adresse et coordonnées. Utilise votre clé Google Maps.',
};
export default places;
+3
View File
@@ -92,5 +92,8 @@ const places: TranslationStrings = {
'places.saveError': 'Αποτυχία αποθήκευσης',
'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.",
'places.addAnyway': 'Προσθήκη ούτως ή άλλως',
'places.enrichOnImport': 'Εμπλουτισμός τόπων μέσω Google',
'places.enrichOnImportHint':
'Αναζητά κάθε εισαγόμενο μέρος για να προσθέσει φωτογραφίες, διεύθυνση και στοιχεία επικοινωνίας. Απαιτεί κλειδί Google Maps.',
};
export default places;
+3
View File
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': 'Nem sikerült menteni',
'places.duplicateExists': "A(z) '{name}' már szerepel ebben az utazásban.",
'places.addAnyway': 'Hozzáadás mindenképp',
'places.enrichOnImport': 'Helyek gazdagítása a Google-lel',
'places.enrichOnImportHint':
'Minden importált helyet megkeres, hogy fotókat, címet és elérhetőséget adjon hozzá. Google Maps-kulcs szükséges.',
};
export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Gagal menyimpan',
'places.duplicateExists': "'{name}' sudah ada di perjalanan ini.",
'places.addAnyway': 'Tetap tambahkan',
'places.enrichOnImport': 'Perkaya tempat via Google',
'places.enrichOnImportHint':
'Mencari setiap tempat yang diimpor untuk menambahkan foto, alamat, dan kontak. Memerlukan kunci Google Maps.',
};
export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Impossibile salvare',
'places.duplicateExists': "'{name}' è già in questo viaggio.",
'places.addAnyway': 'Aggiungi comunque',
'places.enrichOnImport': 'Arricchisci i luoghi con Google',
'places.enrichOnImportHint':
'Cerca ogni luogo importato per aggiungere foto, indirizzo e contatti. Usa la tua chiave Google Maps.',
};
export default places;
+3
View File
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': '保存に失敗しました',
'places.duplicateExists': '「{name}」はすでにこの旅程に含まれています。',
'places.addAnyway': 'それでも追加',
'places.enrichOnImport': 'Googleで場所を補完',
'places.enrichOnImportHint':
'インポートした各場所を検索して、写真・住所・連絡先を追加します。Google Maps キーが必要です。',
};
export default places;
+3
View File
@@ -88,5 +88,8 @@ const places: TranslationStrings = {
'places.saveError': '저장 실패',
'places.duplicateExists': "'{name}'은(는) 이미 이 여행에 있습니다.",
'places.addAnyway': '그래도 추가',
'places.enrichOnImport': 'Google로 장소 정보 보강',
'places.enrichOnImportHint':
'가져온 각 장소를 검색해 사진, 주소, 연락처를 추가합니다. Google Maps 키가 필요합니다.',
};
export default places;
+3
View File
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': 'Opslaan mislukt',
'places.duplicateExists': "'{name}' staat al in deze reis.",
'places.addAnyway': 'Toch toevoegen',
'places.enrichOnImport': 'Plaatsen verrijken via Google',
'places.enrichOnImportHint':
'Zoekt elke geïmporteerde plaats op om fotos, adres en contactgegevens toe te voegen. Gebruikt je Google Maps-sleutel.',
};
export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.naverListImported': 'Zaimportowano {count} miejsc z "{list}"',
'places.naverListError': 'Nie udało się zaimportować listy Naver Maps',
'places.viewDetails': 'Zobacz szczegóły',
'places.enrichOnImport': 'Wzbogać miejsca przez Google',
'places.enrichOnImportHint':
'Wyszukuje każde zaimportowane miejsce, aby dodać zdjęcia, adres i dane kontaktowe. Wymaga klucza Google Maps.',
};
export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Ошибка сохранения',
'places.duplicateExists': "'{name}' уже есть в этой поездке.",
'places.addAnyway': 'Всё равно добавить',
'places.enrichOnImport': 'Обогатить места через Google',
'places.enrichOnImportHint':
'Находит каждое импортированное место и добавляет фото, адрес и контакты. Требуется ключ Google Maps.',
};
export default places;
+3
View File
@@ -89,5 +89,8 @@ const places: TranslationStrings = {
'places.saveError': 'Kaydedilemedi',
'places.duplicateExists': "'{name}' zaten bu gezide var.",
'places.addAnyway': 'Yine de ekle',
'places.enrichOnImport': 'Yerleri Google ile zenginleştir',
'places.enrichOnImportHint':
'İçe aktarılan her yeri arayarak fotoğraf, adres ve iletişim bilgilerini ekler. Google Maps anahtarı gerekir.',
};
export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Помилка збереження',
'places.duplicateExists': "'{name}' вже є в цій подорожі.",
'places.addAnyway': 'Все одно додати',
'places.enrichOnImport': 'Збагатити місця через Google',
'places.enrichOnImportHint':
'Знаходить кожне імпортоване місце й додає фото, адресу та контакти. Потрібен ключ Google Maps.',
};
export default places;
+3
View File
@@ -85,5 +85,8 @@ const places: TranslationStrings = {
'places.saveError': '儲存失敗',
'places.duplicateExists': "'{name}' 已在此行程中。",
'places.addAnyway': '仍要新增',
'places.enrichOnImport': '透過 Google 豐富地點資訊',
'places.enrichOnImportHint':
'查詢每個匯入的地點以補上照片、地址與聯絡資訊。需要 Google Maps 金鑰。',
};
export default places;
+3
View File
@@ -85,5 +85,8 @@ const places: TranslationStrings = {
'places.saveError': '保存失败',
'places.duplicateExists': "'{name}' 已在此行程中。",
'places.addAnyway': '仍然添加',
'places.enrichOnImport': '通过 Google 丰富地点信息',
'places.enrichOnImportHint':
'查找每个导入的地点以补充照片、地址和联系方式。需要 Google Maps 密钥。',
};
export default places;
+3
View File
@@ -117,6 +117,9 @@ export type PlaceBulkDeleteRequest = z.infer<
export const placeImportListRequestSchema = z.object({
url: z.string().min(1),
// Opt-in: enrich imported places via the Places API (#886). Requires a Google
// Maps key; runs as a background pass after the import returns.
enrich: z.boolean().optional(),
});
export type PlaceImportListRequest = z.infer<
typeof placeImportListRequestSchema