mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
feat(pwa): implement real offline mode with IndexedDB sync
Add genuine offline read/write capability for trips: - Dexie IndexedDB schema (trips, places, packing, todo, budget, reservations, files, mutationQueue, syncMeta, blobCache) - Repo layer for all domains: offline reads from Dexie, writes optimistically to Dexie and enqueue mutations for later replay - Mutation queue with UUID idempotency keys (X-Idempotency-Key), FIFO flush, temp-ID reconciliation on 2xx, fail-and-continue on 4xx - Trip sync manager: caches all trips with end_date >= today or null, auto-evicts 7d after end_date, fetches bundle endpoint in one request - Map tile prefetcher: bbox from place coords, zooms 10-16, 50MB cap, warms SW cache via fetch - Sync triggers: network online → flush + syncAll; WS reconnect → flush only (rate-limiter safe); visibilitychange/30s → flush only - WS remoteEventHandler writes through to Dexie on every event - Server idempotency middleware + idempotency_keys table (migration 100, 24h TTL nightly cleanup) - GET /api/trips/:id/bundle endpoint for efficient single-request sync - OfflineBanner component: amber (offline) / blue (syncing) / hidden - OfflineTab in Settings: cached trip list, re-sync and clear actions - usePendingMutations hook for per-item pending indicators Closes #505 #541
This commit is contained in:
@@ -22,6 +22,8 @@ import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
@@ -146,6 +148,11 @@ export default function App() {
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
registerSyncTriggers()
|
||||
return () => unregisterSyncTriggers()
|
||||
}, [])
|
||||
|
||||
const location = useLocation()
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
|
||||
@@ -178,6 +185,7 @@ export default function App() {
|
||||
return (
|
||||
<TranslationProvider>
|
||||
<ToastContainer />
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
@@ -38,13 +38,25 @@ export const apiClient: AxiosInstance = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add socket ID
|
||||
const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
||||
|
||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
// Attach a per-request idempotency key to all write operations so the
|
||||
// server can deduplicate retried requests (e.g. network blips).
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
@@ -161,6 +173,7 @@ export const tripsApi = {
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
|
||||
@@ -13,6 +13,8 @@ let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
let connecting = false
|
||||
/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */
|
||||
let preReconnectHook: (() => Promise<void>) | null = null
|
||||
|
||||
export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
@@ -22,6 +24,16 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a hook that runs (and is awaited) before the refetch callback
|
||||
* fires on WS reconnect. Use this to flush the mutation queue so queued
|
||||
* local writes reach the server before the app reads back canonical state.
|
||||
* Pass null to clear.
|
||||
*/
|
||||
export function setPreReconnectHook(fn: (() => Promise<void>) | null): void {
|
||||
preReconnectHook = fn
|
||||
}
|
||||
|
||||
function getWsUrl(wsToken: string): string {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${location.host}/ws?token=${wsToken}`
|
||||
@@ -99,11 +111,20 @@ async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
}
|
||||
})
|
||||
if (refetchCallback) {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
const doRefetch = () => {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Flush queued mutations first so local writes land before server read-back.
|
||||
// If the hook fails, still refetch to keep the UI correct.
|
||||
if (preReconnectHook) {
|
||||
preReconnectHook().catch(console.error).then(doRefetch)
|
||||
} else {
|
||||
doRefetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* OfflineBanner — persistent top bar indicating connectivity + sync state.
|
||||
*
|
||||
* States:
|
||||
* offline + N queued → amber bar "Offline — N changes queued"
|
||||
* offline + 0 queued → amber bar "Offline"
|
||||
* online + N pending → blue bar "Syncing N changes…"
|
||||
* online + 0 pending → hidden
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { WifiOff, RefreshCw } from 'lucide-react'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
|
||||
const POLL_MS = 3_000
|
||||
|
||||
export default function OfflineBanner(): React.ReactElement | null {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const onOnline = () => setIsOnline(true)
|
||||
const onOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function poll() {
|
||||
const n = await mutationQueue.pendingCount()
|
||||
if (!cancelled) setPendingCount(n)
|
||||
}
|
||||
poll()
|
||||
const id = setInterval(poll, POLL_MS)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
const hidden = isOnline && pendingCount === 0
|
||||
if (hidden) return null
|
||||
|
||||
const offline = !isOnline
|
||||
const bg = offline ? '#92400e' : '#1e40af'
|
||||
const text = '#fff'
|
||||
|
||||
const label = offline
|
||||
? pendingCount > 0
|
||||
? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
|
||||
: 'Offline'
|
||||
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}…`
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
background: bg,
|
||||
color: text,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 16px',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{offline
|
||||
? <WifiOff size={14} />
|
||||
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Offline settings tab — shows cached trips, storage info, and controls
|
||||
* to re-sync or clear the offline cache.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
|
||||
import Section from './Section'
|
||||
import { offlineDb, clearAll } from '../../db/offlineDb'
|
||||
import { tripSyncManager } from '../../sync/tripSyncManager'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
import type { SyncMeta } from '../../db/offlineDb'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
interface CachedTripRow {
|
||||
trip: Trip
|
||||
meta: SyncMeta
|
||||
placeCount: number
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
export default function OfflineTab(): React.ReactElement {
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, pending] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
mutationQueue.pendingCount(),
|
||||
])
|
||||
setPendingCount(pending)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
const trip = await offlineDb.trips.get(meta.tripId)
|
||||
if (!trip) continue
|
||||
const [placeCount, fileCount] = await Promise.all([
|
||||
offlineDb.places.where('trip_id').equals(meta.tripId).count(),
|
||||
offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(),
|
||||
])
|
||||
result.push({ trip, meta, placeCount, fileCount })
|
||||
}
|
||||
result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
|
||||
setRows(result)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await tripSyncManager.syncAll()
|
||||
await load()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!window.confirm('Clear all offline trip data? You can re-sync anytime while online.')) return
|
||||
setClearing(true)
|
||||
try {
|
||||
await clearAll()
|
||||
await load()
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (d: string | null | undefined) =>
|
||||
d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
|
||||
|
||||
return (
|
||||
<Section title="Offline Cache" icon={Database}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Stat label="Cached trips" value={rows.length} />
|
||||
<Stat label="Pending changes" value={pendingCount} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={handleResync}
|
||||
disabled={syncing || !navigator.onLine}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
|
||||
cursor: syncing || !navigator.onLine ? 'not-allowed' : 'pointer',
|
||||
fontSize: 13, fontWeight: 500, opacity: !navigator.onLine ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||
{syncing ? 'Syncing…' : 'Re-sync now'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={clearing || rows.length === 0}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
|
||||
borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', color: '#ef4444',
|
||||
cursor: clearing || rows.length === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: 13, fontWeight: 500, opacity: rows.length === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
Clear cache
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cached trip list */}
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>
|
||||
No trips cached yet. Connect to internet to sync.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{rows.map(({ trip, meta, placeCount, fileCount }) => (
|
||||
<div
|
||||
key={trip.id}
|
||||
style={{
|
||||
padding: '10px 14px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)',
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
|
||||
{trip.name}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
|
||||
{meta.lastSyncedAt
|
||||
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{formatDate(trip.start_date)} – {formatDate(trip.end_date)}
|
||||
{' · '}
|
||||
{placeCount} place{placeCount !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
{fileCount} file{fileCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '8px 14px', borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', minWidth: 100,
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)' }}>{value}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Dexie, { type Table } from 'dexie';
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../types';
|
||||
|
||||
// ── Queue + sync types ────────────────────────────────────────────────────────
|
||||
|
||||
export type MutationStatus = 'pending' | 'syncing' | 'failed';
|
||||
|
||||
export interface QueuedMutation {
|
||||
/** UUID — also used as X-Idempotency-Key sent to the server */
|
||||
id: string;
|
||||
tripId: number;
|
||||
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
url: string;
|
||||
body: unknown;
|
||||
createdAt: number;
|
||||
status: MutationStatus;
|
||||
attempts: number;
|
||||
lastError: string | null;
|
||||
/** Dexie table name to write the server response into after flush (e.g. 'places') */
|
||||
resource?: string;
|
||||
/** For CREATE mutations enqueued offline: the temporary negative id written to Dexie */
|
||||
tempId?: number;
|
||||
/** For DELETE mutations: the entity id to remove from Dexie on flush */
|
||||
entityId?: number;
|
||||
}
|
||||
|
||||
export interface SyncMeta {
|
||||
tripId: number;
|
||||
lastSyncedAt: number | null;
|
||||
status: 'idle' | 'syncing' | 'error';
|
||||
/** Bounding box [minLng, minLat, maxLng, maxLat] of pre-downloaded map tiles */
|
||||
tilesBbox: [number, number, number, number] | null;
|
||||
filesCachedCount: number;
|
||||
}
|
||||
|
||||
export interface BlobCacheEntry {
|
||||
/** Relative URL, e.g. "/api/files/42/download" */
|
||||
url: string;
|
||||
blob: Blob;
|
||||
mime: string;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
// ── Dexie class ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TrekOfflineDb extends Dexie {
|
||||
trips!: Table<Trip, number>;
|
||||
days!: Table<Day, number>;
|
||||
places!: Table<Place, number>;
|
||||
packingItems!: Table<PackingItem, number>;
|
||||
todoItems!: Table<TodoItem, number>;
|
||||
budgetItems!: Table<BudgetItem, number>;
|
||||
reservations!: Table<Reservation, number>;
|
||||
tripFiles!: Table<TripFile, number>;
|
||||
mutationQueue!: Table<QueuedMutation, string>;
|
||||
syncMeta!: Table<SyncMeta, number>;
|
||||
blobCache!: Table<BlobCacheEntry, string>;
|
||||
|
||||
constructor() {
|
||||
super('trek-offline');
|
||||
|
||||
this.version(1).stores({
|
||||
trips: 'id',
|
||||
days: 'id, trip_id',
|
||||
places: 'id, trip_id',
|
||||
packingItems: 'id, trip_id',
|
||||
todoItems: 'id, trip_id',
|
||||
budgetItems: 'id, trip_id',
|
||||
reservations: 'id, trip_id',
|
||||
tripFiles: 'id, trip_id',
|
||||
mutationQueue:'id, tripId, status, createdAt',
|
||||
syncMeta: 'tripId',
|
||||
blobCache: 'url, cachedAt',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const offlineDb = new TrekOfflineDb();
|
||||
|
||||
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertTrip(trip: Trip): Promise<void> {
|
||||
await offlineDb.trips.put(trip);
|
||||
}
|
||||
|
||||
export async function upsertDays(days: Day[]): Promise<void> {
|
||||
await offlineDb.days.bulkPut(days);
|
||||
}
|
||||
|
||||
export async function upsertPlaces(places: Place[]): Promise<void> {
|
||||
await offlineDb.places.bulkPut(places);
|
||||
}
|
||||
|
||||
export async function upsertPackingItems(items: PackingItem[]): Promise<void> {
|
||||
await offlineDb.packingItems.bulkPut(items);
|
||||
}
|
||||
|
||||
export async function upsertTodoItems(items: TodoItem[]): Promise<void> {
|
||||
await offlineDb.todoItems.bulkPut(items);
|
||||
}
|
||||
|
||||
export async function upsertBudgetItems(items: BudgetItem[]): Promise<void> {
|
||||
await offlineDb.budgetItems.bulkPut(items);
|
||||
}
|
||||
|
||||
export async function upsertReservations(items: Reservation[]): Promise<void> {
|
||||
await offlineDb.reservations.bulkPut(items);
|
||||
}
|
||||
|
||||
export async function upsertTripFiles(files: TripFile[]): Promise<void> {
|
||||
await offlineDb.tripFiles.bulkPut(files);
|
||||
}
|
||||
|
||||
export async function upsertSyncMeta(meta: SyncMeta): Promise<void> {
|
||||
await offlineDb.syncMeta.put(meta);
|
||||
}
|
||||
|
||||
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||
export async function clearTripData(tripId: number): Promise<void> {
|
||||
await offlineDb.transaction(
|
||||
'rw',
|
||||
[
|
||||
offlineDb.days,
|
||||
offlineDb.places,
|
||||
offlineDb.packingItems,
|
||||
offlineDb.todoItems,
|
||||
offlineDb.budgetItems,
|
||||
offlineDb.reservations,
|
||||
offlineDb.tripFiles,
|
||||
offlineDb.mutationQueue,
|
||||
offlineDb.syncMeta,
|
||||
],
|
||||
async () => {
|
||||
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.places.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.packingItems.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.todoItems.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.budgetItems.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.reservations.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.tripFiles.where('trip_id').equals(tripId).delete();
|
||||
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
||||
},
|
||||
);
|
||||
// Remove the trip row itself outside the transaction since it's a separate table
|
||||
await offlineDb.trips.delete(tripId);
|
||||
}
|
||||
|
||||
/** Wipe the entire offline database (called on logout). */
|
||||
export async function clearAll(): Promise<void> {
|
||||
await offlineDb.delete();
|
||||
// Re-open so subsequent operations don't fail
|
||||
await offlineDb.open();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* usePendingMutations — returns the set of entity IDs that have a pending
|
||||
* or syncing mutation for a given trip.
|
||||
*
|
||||
* Components use this to render a clock/pending indicator on list rows.
|
||||
* Polls Dexie every 2 s so the indicator clears automatically once synced.
|
||||
*/
|
||||
import { useState, useEffect } from 'react'
|
||||
import { mutationQueue } from '../sync/mutationQueue'
|
||||
|
||||
const POLL_MS = 2_000
|
||||
|
||||
export function usePendingMutations(tripId: number): Set<number> {
|
||||
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function refresh() {
|
||||
const pending = await mutationQueue.pending(tripId)
|
||||
if (cancelled) return
|
||||
|
||||
const ids = new Set<number>()
|
||||
for (const m of pending) {
|
||||
// Extract entity id from the mutation URL (last numeric segment)
|
||||
const match = m.url.match(/\/(\d+)$/)
|
||||
if (match) ids.add(Number(match[1]))
|
||||
// Also include tempId for offline-created items
|
||||
if (m.tempId !== undefined) ids.add(m.tempId)
|
||||
}
|
||||
setPendingIds(ids)
|
||||
}
|
||||
|
||||
refresh()
|
||||
const timer = setInterval(refresh, POLL_MS)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
return pendingIds
|
||||
}
|
||||
@@ -17,6 +17,7 @@ vi.mock('../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -11,6 +11,7 @@ import NotificationsTab from '../components/Settings/NotificationsTab'
|
||||
import IntegrationsTab from '../components/Settings/IntegrationsTab'
|
||||
import AccountTab from '../components/Settings/AccountTab'
|
||||
import AboutTab from '../components/Settings/AboutTab'
|
||||
import OfflineTab from '../components/Settings/OfflineTab'
|
||||
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
@@ -41,6 +42,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
{ id: 'map', label: t('settings.tabs.map') },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications') },
|
||||
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations') }] : []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline', 'Offline') },
|
||||
{ id: 'account', label: t('settings.tabs.account') },
|
||||
...(appVersion ? [{ id: 'about', label: t('settings.tabs.about') }] : []),
|
||||
]
|
||||
@@ -84,6 +86,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
{activeTab === 'map' && <MapSettingsTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'integrations' && hasIntegrations && <IntegrationsTab />}
|
||||
{activeTab === 'offline' && <OfflineTab />}
|
||||
{activeTab === 'account' && <AccountTab />}
|
||||
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { budgetApi } from '../api/client'
|
||||
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { daysApi } from '../api/client'
|
||||
import { offlineDb, upsertDays } from '../db/offlineDb'
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { filesApi } from '../api/client'
|
||||
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { packingApi } from '../api/client'
|
||||
import { offlineDb, upsertPackingItems } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
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
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempItem: PackingItem = {
|
||||
...(data as Partial<PackingItem>),
|
||||
id: tempId,
|
||||
trip_id: Number(tripId),
|
||||
name: (data.name as string) ?? 'New item',
|
||||
checked: 0,
|
||||
} as PackingItem
|
||||
await offlineDb.packingItems.put(tempItem)
|
||||
const id = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id,
|
||||
tripId: Number(tripId),
|
||||
method: 'POST',
|
||||
url: `/trips/${tripId}/packing`,
|
||||
body: data,
|
||||
resource: 'packingItems',
|
||||
tempId,
|
||||
})
|
||||
return { item: tempItem }
|
||||
}
|
||||
const result = await packingApi.create(tripId, data)
|
||||
offlineDb.packingItems.put(result.item)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.packingItems.get(id)
|
||||
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
|
||||
await offlineDb.packingItems.put(optimistic)
|
||||
const mutId = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/packing/${id}`,
|
||||
body: data,
|
||||
resource: 'packingItems',
|
||||
})
|
||||
return { item: optimistic }
|
||||
}
|
||||
const result = await packingApi.update(tripId, id, data)
|
||||
offlineDb.packingItems.put(result.item)
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.packingItems.delete(id)
|
||||
const mutId = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/packing/${id}`,
|
||||
body: undefined,
|
||||
resource: 'packingItems',
|
||||
entityId: id,
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
const result = await packingApi.delete(tripId, id)
|
||||
offlineDb.packingItems.delete(id)
|
||||
return result
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { placesApi } from '../api/client'
|
||||
import { offlineDb, upsertPlaces } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
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
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempPlace: Place = {
|
||||
...(data as Partial<Place>),
|
||||
id: tempId,
|
||||
trip_id: Number(tripId),
|
||||
name: (data.name as string) ?? 'New place',
|
||||
} as Place
|
||||
await offlineDb.places.put(tempPlace)
|
||||
const id = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id,
|
||||
tripId: Number(tripId),
|
||||
method: 'POST',
|
||||
url: `/trips/${tripId}/places`,
|
||||
body: data,
|
||||
resource: 'places',
|
||||
tempId,
|
||||
})
|
||||
return { place: tempPlace }
|
||||
}
|
||||
const result = await placesApi.create(tripId, data)
|
||||
offlineDb.places.put(result.place)
|
||||
return result
|
||||
},
|
||||
|
||||
async update(tripId: number | string, id: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
|
||||
if (!navigator.onLine) {
|
||||
const existing = await offlineDb.places.get(Number(id))
|
||||
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
|
||||
await offlineDb.places.put(optimistic)
|
||||
const mutId = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
body: data,
|
||||
resource: 'places',
|
||||
})
|
||||
return { place: optimistic }
|
||||
}
|
||||
const result = await placesApi.update(tripId, id, data)
|
||||
offlineDb.places.put(result.place)
|
||||
return result
|
||||
},
|
||||
|
||||
async delete(tripId: number | string, id: number | string): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.places.delete(Number(id))
|
||||
const mutId = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
body: undefined,
|
||||
resource: 'places',
|
||||
entityId: Number(id),
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
const result = await placesApi.delete(tripId, id)
|
||||
offlineDb.places.delete(Number(id))
|
||||
return result
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { reservationsApi } from '../api/client'
|
||||
import { offlineDb, upsertReservations } from '../db/offlineDb'
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { todoApi } from '../api/client'
|
||||
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { tripsApi } from '../api/client'
|
||||
import { offlineDb, upsertTrip } from '../db/offlineDb'
|
||||
import type { Trip } from '../types'
|
||||
|
||||
export const tripRepo = {
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { authApi } from '../api/client'
|
||||
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'
|
||||
|
||||
interface AuthResponse {
|
||||
user: User
|
||||
@@ -88,6 +90,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
error: null,
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Login failed')
|
||||
@@ -108,6 +111,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
error: null,
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
return data as AuthResponse
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Verification failed')
|
||||
@@ -128,6 +132,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
error: null,
|
||||
})
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Registration failed')
|
||||
@@ -145,6 +150,8 @@ export const useAuthStore = create<AuthState>()(
|
||||
caches.delete('api-data').catch(() => {})
|
||||
caches.delete('user-uploads').catch(() => {})
|
||||
}
|
||||
// Purge all cached trip data from IndexedDB
|
||||
clearAll().catch(console.error)
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { packingApi } from '../../api/client'
|
||||
import { packingRepo } from '../../repo/packingRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { PackingItem } from '../../types'
|
||||
@@ -17,7 +17,7 @@ export interface PackingSlice {
|
||||
export const createPackingSlice = (set: SetState, get: GetState): PackingSlice => ({
|
||||
addPackingItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await packingApi.create(tripId, data)
|
||||
const result = await packingRepo.create(tripId, data as Record<string, unknown>)
|
||||
set(state => ({ packingItems: [...state.packingItems, result.item] }))
|
||||
return result.item
|
||||
} catch (err: unknown) {
|
||||
@@ -27,7 +27,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
|
||||
|
||||
updatePackingItem: async (tripId, id, data) => {
|
||||
try {
|
||||
const result = await packingApi.update(tripId, id, data)
|
||||
const result = await packingRepo.update(tripId, id, data as Record<string, unknown>)
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item => item.id === id ? result.item : item)
|
||||
}))
|
||||
@@ -41,7 +41,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
|
||||
const prev = get().packingItems
|
||||
set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) }))
|
||||
try {
|
||||
await packingApi.delete(tripId, id)
|
||||
await packingRepo.delete(tripId, id)
|
||||
} catch (err: unknown) {
|
||||
set({ packingItems: prev })
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting item'))
|
||||
@@ -55,7 +55,7 @@ export const createPackingSlice = (set: SetState, get: GetState): PackingSlice =
|
||||
)
|
||||
}))
|
||||
try {
|
||||
await packingApi.update(tripId, id, { checked })
|
||||
await packingRepo.update(tripId, id, { checked })
|
||||
} catch {
|
||||
set(state => ({
|
||||
packingItems: state.packingItems.map(item =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { placesApi } from '../../api/client'
|
||||
import { placeRepo } from '../../repo/placeRepo'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Place, Assignment } from '../../types'
|
||||
@@ -17,7 +17,7 @@ export interface PlacesSlice {
|
||||
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
|
||||
refreshPlaces: async (tripId) => {
|
||||
try {
|
||||
const data = await placesApi.list(tripId)
|
||||
const data = await placeRepo.list(tripId)
|
||||
set({ places: data.places })
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to refresh places:', err)
|
||||
@@ -26,7 +26,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
|
||||
addPlace: async (tripId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.create(tripId, placeData)
|
||||
const data = await placeRepo.create(tripId, placeData as Record<string, unknown>)
|
||||
set(state => ({ places: [data.place, ...state.places] }))
|
||||
return data.place
|
||||
} catch (err: unknown) {
|
||||
@@ -36,7 +36,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
|
||||
updatePlace: async (tripId, placeId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.update(tripId, placeId, placeData)
|
||||
const data = await placeRepo.update(tripId, placeId, placeData as Record<string, unknown>)
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
@@ -61,7 +61,7 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
|
||||
deletePlace: async (tripId, placeId) => {
|
||||
try {
|
||||
await placesApi.delete(tripId, placeId)
|
||||
await placeRepo.delete(tripId, placeId)
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
|
||||
@@ -1,14 +1,167 @@
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
|
||||
import { offlineDb } from '../../db/offlineDb'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
// ── Dexie write-through ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persist remote event to IndexedDB so the data is available offline.
|
||||
* Fire-and-forget: errors are swallowed to never block the Zustand update.
|
||||
* Called AFTER set() so `state` already reflects the update.
|
||||
*/
|
||||
function writeToDexie(
|
||||
type: string,
|
||||
payload: Record<string, unknown>,
|
||||
state: TripStoreState,
|
||||
): void {
|
||||
;(async () => {
|
||||
try {
|
||||
switch (type) {
|
||||
// ── Places ──────────────────────────────────────────────────────────
|
||||
case 'place:created':
|
||||
case 'place:updated':
|
||||
await offlineDb.places.put(payload.place as Place)
|
||||
break
|
||||
case 'place:deleted':
|
||||
await offlineDb.places.delete(payload.placeId as number)
|
||||
break
|
||||
|
||||
// ── Assignments (embedded in Day rows) ──────────────────────────────
|
||||
// Read the already-updated Day from the Zustand state and persist it.
|
||||
case 'assignment:created':
|
||||
case 'assignment:updated': {
|
||||
const assignment = payload.assignment as Assignment
|
||||
await _writeDayToDb(assignment.day_id, state)
|
||||
break
|
||||
}
|
||||
case 'assignment:deleted': {
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
}
|
||||
case 'assignment:moved': {
|
||||
const movedAssignment = payload.assignment as Assignment
|
||||
await Promise.all([
|
||||
_writeDayToDb(payload.oldDayId as number, state),
|
||||
_writeDayToDb(movedAssignment.day_id, state),
|
||||
])
|
||||
break
|
||||
}
|
||||
case 'assignment:reordered':
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
|
||||
// ── Days ─────────────────────────────────────────────────────────────
|
||||
case 'day:created':
|
||||
case 'day:updated': {
|
||||
const day = payload.day as Day
|
||||
await _writeDayToDb(day.id, state)
|
||||
break
|
||||
}
|
||||
case 'day:deleted':
|
||||
await offlineDb.days.delete(payload.dayId as number)
|
||||
break
|
||||
|
||||
// ── Day notes (embedded in Day rows) ─────────────────────────────────
|
||||
case 'dayNote:created':
|
||||
case 'dayNote:updated':
|
||||
case 'dayNote:deleted':
|
||||
await _writeDayToDb(payload.dayId as number, state)
|
||||
break
|
||||
|
||||
// ── Packing ──────────────────────────────────────────────────────────
|
||||
case 'packing:created':
|
||||
case 'packing:updated':
|
||||
await offlineDb.packingItems.put(payload.item as PackingItem)
|
||||
break
|
||||
case 'packing:deleted':
|
||||
await offlineDb.packingItems.delete(payload.itemId as number)
|
||||
break
|
||||
|
||||
// ── Todo ─────────────────────────────────────────────────────────────
|
||||
case 'todo:created':
|
||||
case 'todo:updated':
|
||||
await offlineDb.todoItems.put(payload.item as TodoItem)
|
||||
break
|
||||
case 'todo:deleted':
|
||||
await offlineDb.todoItems.delete(payload.itemId as number)
|
||||
break
|
||||
|
||||
// ── Budget ───────────────────────────────────────────────────────────
|
||||
case 'budget:created':
|
||||
case 'budget:updated':
|
||||
await offlineDb.budgetItems.put(payload.item as BudgetItem)
|
||||
break
|
||||
case 'budget:deleted':
|
||||
await offlineDb.budgetItems.delete(payload.itemId as number)
|
||||
break
|
||||
case 'budget:members-updated':
|
||||
case 'budget:member-paid-updated':
|
||||
case 'budget:reordered': {
|
||||
// Partial update — read canonical item(s) from updated Zustand state
|
||||
if (type === 'budget:reordered') {
|
||||
await offlineDb.budgetItems.bulkPut(state.budgetItems)
|
||||
} else {
|
||||
const item = state.budgetItems.find(i => i.id === (payload.itemId as number))
|
||||
if (item) await offlineDb.budgetItems.put(item)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// ── Reservations ─────────────────────────────────────────────────────
|
||||
case 'reservation:created':
|
||||
case 'reservation:updated':
|
||||
await offlineDb.reservations.put(payload.reservation as Reservation)
|
||||
break
|
||||
case 'reservation:deleted':
|
||||
await offlineDb.reservations.delete(payload.reservationId as number)
|
||||
break
|
||||
|
||||
// ── Trip ─────────────────────────────────────────────────────────────
|
||||
case 'trip:updated':
|
||||
await offlineDb.trips.put(payload.trip as Trip)
|
||||
break
|
||||
|
||||
// ── Files ─────────────────────────────────────────────────────────────
|
||||
case 'file:created':
|
||||
case 'file:updated':
|
||||
await offlineDb.tripFiles.put(payload.file as TripFile)
|
||||
break
|
||||
case 'file:deleted':
|
||||
await offlineDb.tripFiles.delete(payload.fileId as number)
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Dexie write failures are non-fatal — online state is source of truth
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/** Write a Day (with its current assignments + notes from Zustand) to Dexie. */
|
||||
async function _writeDayToDb(dayId: number, state: TripStoreState): Promise<void> {
|
||||
const day = state.days.find(d => d.id === dayId)
|
||||
if (!day) return
|
||||
await offlineDb.days.put({
|
||||
...day,
|
||||
assignments: state.assignments[String(dayId)] ?? [],
|
||||
notes_items: state.dayNotes[String(dayId)] ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
// ── Zustand event reducer ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Applies a remote WebSocket event to the local Zustand store, keeping state in sync across collaborators.
|
||||
* Each event type maps to an immutable state update (create/update/delete) for the relevant entity.
|
||||
* After the Zustand update, the change is also written through to IndexedDB for offline access.
|
||||
*/
|
||||
export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
|
||||
export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocketEvent): void {
|
||||
const { type, ...payload } = event
|
||||
|
||||
set(state => {
|
||||
@@ -285,4 +438,7 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
// Write the change through to IndexedDB using the post-update state
|
||||
writeToDexie(type, payload as Record<string, unknown>, get())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import { tripsApi, daysApi, placesApi, packingApi, todoApi, tagsApi, categoriesApi } from '../api/client'
|
||||
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
|
||||
import { tripRepo } from '../repo/tripRepo'
|
||||
import { dayRepo } from '../repo/dayRepo'
|
||||
import { placeRepo } from '../repo/placeRepo'
|
||||
import { packingRepo } from '../repo/packingRepo'
|
||||
import { todoRepo } from '../repo/todoRepo'
|
||||
import { createPlacesSlice } from './slices/placesSlice'
|
||||
import { createAssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import { createDayNotesSlice } from './slices/dayNotesSlice'
|
||||
@@ -78,19 +83,19 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
|
||||
setSelectedDay: (dayId: number | null) => set({ selectedDayId: dayId }),
|
||||
|
||||
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, event),
|
||||
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
|
||||
|
||||
loadTrip: async (tripId: number | string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
|
||||
tripsApi.get(tripId),
|
||||
daysApi.list(tripId),
|
||||
placesApi.list(tripId),
|
||||
packingApi.list(tripId),
|
||||
todoApi.list(tripId),
|
||||
tagsApi.list(),
|
||||
categoriesApi.list(),
|
||||
tripRepo.get(tripId),
|
||||
dayRepo.list(tripId),
|
||||
placeRepo.list(tripId),
|
||||
packingRepo.list(tripId),
|
||||
todoRepo.list(tripId),
|
||||
tagsApi.list().catch(() => ({ tags: [] })),
|
||||
categoriesApi.list().catch(() => ({ categories: [] })),
|
||||
])
|
||||
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
@@ -121,7 +126,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
|
||||
refreshDays: async (tripId: number | string) => {
|
||||
try {
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const daysData = await dayRepo.list(tripId)
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
const dayNotesMap: DayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
@@ -138,7 +143,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
try {
|
||||
const result = await tripsApi.update(tripId, data)
|
||||
set({ trip: result.trip })
|
||||
const daysData = await daysApi.list(tripId)
|
||||
const daysData = await dayRepo.list(tripId)
|
||||
const assignmentsMap: AssignmentsMap = {}
|
||||
const dayNotesMap: DayNotesMap = {}
|
||||
for (const day of daysData.days) {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Mutation queue — offline write queue backed by IndexedDB (Dexie).
|
||||
*
|
||||
* Flow:
|
||||
* offline create/update/delete → enqueue() → optimistic Dexie write (in repo)
|
||||
* online trigger → flush() → replay REST with X-Idempotency-Key header → update Dexie
|
||||
*/
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { apiClient } from '../api/client'
|
||||
import type { QueuedMutation } from '../db/offlineDb'
|
||||
import type { Table } from 'dexie'
|
||||
|
||||
// Map Dexie table names used in `resource` field → actual Dexie tables.
|
||||
function getTable(resource: string): Table | undefined {
|
||||
const map: Record<string, Table> = {
|
||||
places: offlineDb.places,
|
||||
packingItems: offlineDb.packingItems,
|
||||
todoItems: offlineDb.todoItems,
|
||||
budgetItems: offlineDb.budgetItems,
|
||||
reservations: offlineDb.reservations,
|
||||
tripFiles: offlineDb.tripFiles,
|
||||
}
|
||||
return map[resource]
|
||||
}
|
||||
|
||||
/** Generate a v4-style UUID using the platform crypto API. */
|
||||
export function generateUUID(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
// Fallback for environments without crypto.randomUUID (e.g. old Node)
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
let _flushing = false
|
||||
|
||||
export const mutationQueue = {
|
||||
/**
|
||||
* Add a mutation to the queue.
|
||||
* Returns the UUID (= idempotency key).
|
||||
*/
|
||||
async enqueue(
|
||||
mutation: Omit<QueuedMutation, 'status' | 'attempts' | 'createdAt' | 'lastError'>,
|
||||
): Promise<string> {
|
||||
const item: QueuedMutation = {
|
||||
...mutation,
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
createdAt: Date.now(),
|
||||
lastError: null,
|
||||
}
|
||||
await offlineDb.mutationQueue.put(item)
|
||||
return item.id
|
||||
},
|
||||
|
||||
/**
|
||||
* Drain the queue: replay each pending mutation against the server in FIFO order.
|
||||
* Stops on first network error (will retry on next trigger).
|
||||
* 4xx responses are marked failed and skipped.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (_flushing || !navigator.onLine) return
|
||||
_flushing = true
|
||||
try {
|
||||
const pending = await offlineDb.mutationQueue
|
||||
.where('status')
|
||||
.equals('pending')
|
||||
.sortBy('createdAt')
|
||||
|
||||
for (const mutation of pending) {
|
||||
// Mark as syncing so UI can show progress
|
||||
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
|
||||
|
||||
try {
|
||||
const response = await apiClient.request({
|
||||
method: mutation.method,
|
||||
url: mutation.url,
|
||||
data: mutation.body,
|
||||
headers: { 'X-Idempotency-Key': mutation.id },
|
||||
})
|
||||
|
||||
// Apply canonical server response to Dexie
|
||||
if (mutation.method !== 'DELETE' && mutation.resource) {
|
||||
const table = getTable(mutation.resource)
|
||||
if (table && response.data && typeof response.data === 'object') {
|
||||
// Server returns { place: {...} } or { item: {...} } — grab first value
|
||||
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) {
|
||||
await table.delete(mutation.tempId)
|
||||
}
|
||||
await table.put(entity)
|
||||
}
|
||||
}
|
||||
} else if (mutation.method === 'DELETE' && mutation.resource && mutation.entityId !== undefined) {
|
||||
// DELETE was already applied optimistically; ensure it's gone
|
||||
const table = getTable(mutation.resource)
|
||||
if (table) await table.delete(mutation.entityId)
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
await offlineDb.mutationQueue.update(mutation.id, {
|
||||
status: 'pending',
|
||||
attempts: mutation.attempts + 1,
|
||||
lastError: String(err),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_flushing = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Return all pending/syncing mutations, optionally filtered by tripId.
|
||||
* Used by the UI to show per-item pending indicators.
|
||||
*/
|
||||
async pending(tripId?: number): Promise<QueuedMutation[]> {
|
||||
if (tripId !== undefined) {
|
||||
return offlineDb.mutationQueue
|
||||
.where('tripId')
|
||||
.equals(tripId)
|
||||
.filter(m => m.status === 'pending' || m.status === 'syncing')
|
||||
.toArray()
|
||||
}
|
||||
return offlineDb.mutationQueue
|
||||
.where('status')
|
||||
.anyOf(['pending', 'syncing'])
|
||||
.toArray()
|
||||
},
|
||||
|
||||
/** Count pending mutations (for banner badge). */
|
||||
async pendingCount(): Promise<number> {
|
||||
return offlineDb.mutationQueue
|
||||
.where('status')
|
||||
.anyOf(['pending', 'syncing'])
|
||||
.count()
|
||||
},
|
||||
|
||||
/** Reset internal flushing flag — useful in tests. */
|
||||
_resetFlushing(): void {
|
||||
_flushing = false
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Sync triggers — register event listeners that flush the mutation queue
|
||||
* and/or run a full trip sync based on the connectivity trigger source.
|
||||
*
|
||||
* Trigger matrix:
|
||||
* window 'online' → flush mutations + full syncAll (network truly back)
|
||||
* visibilitychange visible → flush mutations only (avoid hammering server on tab switch)
|
||||
* periodic 30s → flush mutations only
|
||||
* WS reconnect → flush mutations only (no syncAll — avoids rate-limiter
|
||||
* on server restart / socket timeout while already online)
|
||||
*
|
||||
* Call `registerSyncTriggers()` once on app mount.
|
||||
* Call `unregisterSyncTriggers()` on unmount / logout.
|
||||
*/
|
||||
import { mutationQueue } from './mutationQueue'
|
||||
import { tripSyncManager } from './tripSyncManager'
|
||||
import { setPreReconnectHook } from '../api/websocket'
|
||||
|
||||
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. */
|
||||
function onOnline() {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
}
|
||||
|
||||
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
|
||||
function onVisibility() {
|
||||
if (!document.hidden && navigator.onLine) {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Periodic heartbeat — drain any lingering pending mutations. */
|
||||
function onPeriodic() {
|
||||
if (navigator.onLine) {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSyncTriggers(): void {
|
||||
if (_registered) return
|
||||
_registered = true
|
||||
|
||||
// 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())
|
||||
|
||||
window.addEventListener('online', onOnline)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
_intervalId = setInterval(onPeriodic, PERIODIC_MS)
|
||||
}
|
||||
|
||||
export function unregisterSyncTriggers(): void {
|
||||
if (!_registered) return
|
||||
_registered = false
|
||||
|
||||
setPreReconnectHook(null)
|
||||
window.removeEventListener('online', onOnline)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
if (_intervalId !== null) {
|
||||
clearInterval(_intervalId)
|
||||
_intervalId = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Map tile prefetcher — warms the Workbox 'map-tiles' cache for a trip's
|
||||
* bounding box so maps render offline.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Compute bbox from trip's place coordinates + padding.
|
||||
* 2. For zooms 10–16, enumerate tile XYZ coordinates within bbox.
|
||||
* 3. Stop when cumulative tile estimate exceeds MAX_TILES (~50 MB).
|
||||
* 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it.
|
||||
*
|
||||
* Tile URL template format: Leaflet-compatible {z}/{x}/{y} with optional
|
||||
* {s} (subdomain) and {r} (retina suffix).
|
||||
*/
|
||||
|
||||
import type { Place } from '../types'
|
||||
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Estimated average tile size in KB (road/transit 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
|
||||
|
||||
const DEFAULT_TILE_URL =
|
||||
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
|
||||
const SUBDOMAINS = ['a', 'b', 'c', 'd']
|
||||
let _subIdx = 0
|
||||
function nextSubdomain(): string {
|
||||
return SUBDOMAINS[_subIdx++ % SUBDOMAINS.length]
|
||||
}
|
||||
|
||||
// ── Tile math ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Longitude → tile X at given zoom. */
|
||||
export function lngToTileX(lng: number, zoom: number): number {
|
||||
return Math.floor(((lng + 180) / 360) * Math.pow(2, zoom))
|
||||
}
|
||||
|
||||
/** Latitude → tile Y at given zoom (Web Mercator, y increases southward). */
|
||||
export function latToTileY(lat: number, zoom: number): number {
|
||||
const n = Math.pow(2, zoom)
|
||||
const latRad = (lat * Math.PI) / 180
|
||||
return Math.floor(
|
||||
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n,
|
||||
)
|
||||
}
|
||||
|
||||
/** Expand a single-point bbox to min 0.1° span (~10 km) in each axis. */
|
||||
function ensureMinSpan(min: number, max: number, minSpan = 0.1): [number, number] {
|
||||
if (max - min < minSpan) {
|
||||
const mid = (min + max) / 2
|
||||
return [mid - minSpan / 2, mid + minSpan / 2]
|
||||
}
|
||||
return [min, max]
|
||||
}
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TileBbox {
|
||||
minLat: number
|
||||
maxLat: number
|
||||
minLng: number
|
||||
maxLng: number
|
||||
}
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute the bounding box for a list of places with optional padding.
|
||||
* Returns null if no places have coordinates.
|
||||
*/
|
||||
export function computeBbox(places: Place[], paddingFraction = 0.1): TileBbox | null {
|
||||
const valid = places.filter(p => p.lat !== null && p.lng !== null)
|
||||
if (valid.length === 0) return null
|
||||
|
||||
const lats = valid.map(p => p.lat as number)
|
||||
const lngs = valid.map(p => p.lng as number)
|
||||
|
||||
const [rawMinLat, rawMaxLat] = ensureMinSpan(Math.min(...lats), Math.max(...lats))
|
||||
const [rawMinLng, rawMaxLng] = ensureMinSpan(Math.min(...lngs), Math.max(...lngs))
|
||||
|
||||
const latPad = (rawMaxLat - rawMinLat) * paddingFraction
|
||||
const lngPad = (rawMaxLng - rawMinLng) * paddingFraction
|
||||
|
||||
return {
|
||||
minLat: Math.max(-85.0511, rawMinLat - latPad),
|
||||
maxLat: Math.min(85.0511, rawMaxLat + latPad),
|
||||
minLng: Math.max(-180, rawMinLng - lngPad),
|
||||
maxLng: Math.min(180, rawMaxLng + lngPad),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count tiles that would be fetched across the zoom range for a bbox.
|
||||
* Used to enforce the size guard without actually fetching.
|
||||
*/
|
||||
export function countTiles(bbox: TileBbox, minZoom: number, maxZoom: number): number {
|
||||
let total = 0
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
const minX = lngToTileX(bbox.minLng, z)
|
||||
const maxX = lngToTileX(bbox.maxLng, z)
|
||||
const minY = latToTileY(bbox.maxLat, z) // northern edge → smaller y
|
||||
const maxY = latToTileY(bbox.minLat, z) // southern edge → larger y
|
||||
total += (maxX - minX + 1) * (maxY - minY + 1)
|
||||
if (total > MAX_TILES) return total
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the concrete tile URL for given z/x/y from a Leaflet template.
|
||||
* Rotates through subdomains (a–d).
|
||||
*/
|
||||
export function buildTileUrl(template: string, z: number, x: number, y: number): string {
|
||||
return template
|
||||
.replace('{z}', String(z))
|
||||
.replace('{x}', String(x))
|
||||
.replace('{y}', String(y))
|
||||
.replace('{s}', nextSubdomain())
|
||||
.replace('{r}', '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch tiles for a bbox into the Service Worker cache.
|
||||
* Stops at the zoom level where the size cap would be exceeded.
|
||||
* No-ops when:
|
||||
* - offline
|
||||
* - no active Service Worker (tiles won't be cached anyway)
|
||||
* - total tile count exceeds MAX_TILES before even starting zoom 10
|
||||
*/
|
||||
export async function prefetchTiles(
|
||||
bbox: TileBbox,
|
||||
tileUrlTemplate: string,
|
||||
minZoom = 10,
|
||||
maxZoom = 16,
|
||||
): Promise<number> {
|
||||
if (!navigator.onLine) return 0
|
||||
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0
|
||||
|
||||
let fetched = 0
|
||||
|
||||
for (let z = minZoom; z <= maxZoom; z++) {
|
||||
const minX = lngToTileX(bbox.minLng, z)
|
||||
const maxX = lngToTileX(bbox.maxLng, z)
|
||||
const minY = latToTileY(bbox.maxLat, z)
|
||||
const maxY = latToTileY(bbox.minLat, z)
|
||||
const count = (maxX - minX + 1) * (maxY - minY + 1)
|
||||
|
||||
if (fetched + count > MAX_TILES) break
|
||||
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
const url = buildTileUrl(tileUrlTemplate, z, x, y)
|
||||
// Fire-and-forget: SW CacheFirst handler stores the response
|
||||
fetch(url, { mode: 'no-cors' }).catch(() => {})
|
||||
fetched++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fetched
|
||||
}
|
||||
|
||||
/**
|
||||
* Full pipeline: compute bbox → guard → prefetch → update syncMeta.
|
||||
* Designed to be called fire-and-forget from tripSyncManager.
|
||||
*/
|
||||
export async function prefetchTilesForTrip(
|
||||
tripId: number,
|
||||
places: Place[],
|
||||
tileUrlTemplate?: string,
|
||||
): Promise<void> {
|
||||
const template = tileUrlTemplate || DEFAULT_TILE_URL
|
||||
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
|
||||
}
|
||||
|
||||
const fetched = await prefetchTiles(bbox, template)
|
||||
|
||||
// Update syncMeta with bbox and tile count
|
||||
const meta = await offlineDb.syncMeta.get(tripId)
|
||||
if (meta) {
|
||||
await upsertSyncMeta({
|
||||
...meta,
|
||||
tilesBbox: [bbox.minLng, bbox.minLat, bbox.maxLng, bbox.maxLat],
|
||||
})
|
||||
}
|
||||
|
||||
if (fetched > 0) {
|
||||
console.info(`[tilePrefetch] trip ${tripId}: queued ${fetched} tiles for caching`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Trip sync manager — seeds Dexie with trip data for offline use.
|
||||
*
|
||||
* Cache scope: trips where end_date >= today OR end_date is null/empty.
|
||||
* Eviction: trips where end_date < today - 7 days.
|
||||
* File blobs: all non-photo files (MIME type != image/*) for cached trips.
|
||||
*
|
||||
* Call syncAll() on:
|
||||
* - login success
|
||||
* - trip list refresh (DashboardPage)
|
||||
* - WS reconnect (phase 7)
|
||||
*/
|
||||
import { tripsApi } from '../api/client'
|
||||
import {
|
||||
offlineDb,
|
||||
upsertTrip,
|
||||
upsertDays,
|
||||
upsertPlaces,
|
||||
upsertPackingItems,
|
||||
upsertTodoItems,
|
||||
upsertBudgetItems,
|
||||
upsertReservations,
|
||||
upsertTripFiles,
|
||||
upsertSyncMeta,
|
||||
clearTripData,
|
||||
} from '../db/offlineDb'
|
||||
import { prefetchTilesForTrip } from './tilePrefetcher'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../types'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TripBundle {
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
packingItems: PackingItem[]
|
||||
todoItems: TodoItem[]
|
||||
budgetItems: BudgetItem[]
|
||||
reservations: Reservation[]
|
||||
files: TripFile[]
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function todayStr(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function shouldCache(trip: Trip): boolean {
|
||||
if (!trip.end_date) return true // no end date → cache forever
|
||||
return trip.end_date >= todayStr() // ongoing or future
|
||||
}
|
||||
|
||||
function isStale(trip: Trip): boolean {
|
||||
if (!trip.end_date) return false
|
||||
const cutoff = new Date()
|
||||
cutoff.setDate(cutoff.getDate() - 7)
|
||||
return trip.end_date < cutoff.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function isPhoto(file: TripFile): boolean {
|
||||
return file.mime_type.startsWith('image/')
|
||||
}
|
||||
|
||||
// ── Core logic ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch bundle + write all entities for one trip into Dexie. */
|
||||
async function syncTrip(tripId: number): Promise<void> {
|
||||
const bundle = await tripsApi.bundle(tripId) as TripBundle
|
||||
|
||||
await upsertTrip(bundle.trip)
|
||||
await upsertDays(bundle.days)
|
||||
await upsertPlaces(bundle.places)
|
||||
await upsertPackingItems(bundle.packingItems)
|
||||
await upsertTodoItems(bundle.todoItems)
|
||||
await upsertBudgetItems(bundle.budgetItems)
|
||||
await upsertReservations(bundle.reservations)
|
||||
await upsertTripFiles(bundle.files)
|
||||
await upsertSyncMeta({
|
||||
tripId,
|
||||
lastSyncedAt: Date.now(),
|
||||
status: 'idle',
|
||||
tilesBbox: null,
|
||||
filesCachedCount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/** Cache non-photo file blobs for a trip. Fire-and-forget safe. */
|
||||
async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
|
||||
const nonPhotos = files.filter(f => f.url && !isPhoto(f))
|
||||
let cached = 0
|
||||
|
||||
for (const file of nonPhotos) {
|
||||
// Skip if already cached
|
||||
const existing = await offlineDb.blobCache.get(file.url!)
|
||||
if (existing) { cached++; continue }
|
||||
|
||||
try {
|
||||
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() })
|
||||
cached++
|
||||
} catch {
|
||||
// Network failure — skip this file, will retry next sync
|
||||
}
|
||||
}
|
||||
|
||||
// Update filesCachedCount in syncMeta
|
||||
const tripId = files[0]?.trip_id
|
||||
if (tripId) {
|
||||
const meta = await offlineDb.syncMeta.get(tripId)
|
||||
if (meta) await upsertSyncMeta({ ...meta, filesCachedCount: cached })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
let _syncing = false
|
||||
|
||||
export const tripSyncManager = {
|
||||
/**
|
||||
* Sync all cache-eligible trips.
|
||||
* Evicts stale trips. Caches file blobs in the background.
|
||||
* No-ops when offline.
|
||||
*/
|
||||
async syncAll(): Promise<void> {
|
||||
if (_syncing || !navigator.onLine) return
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
|
||||
// Evict stale trips first
|
||||
const stale = trips.filter(isStale)
|
||||
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
|
||||
|
||||
// Sync eligible trips
|
||||
const toSync = trips.filter(shouldCache)
|
||||
for (const trip of toSync) {
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
} catch (err) {
|
||||
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache file blobs + map tiles in background (don't block syncAll)
|
||||
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
|
||||
for (const trip of toSync) {
|
||||
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
||||
cacheFilesForTrip(files).catch(console.error)
|
||||
|
||||
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
|
||||
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
|
||||
}
|
||||
} finally {
|
||||
_syncing = false
|
||||
}
|
||||
},
|
||||
|
||||
/** Reset syncing flag — useful in tests. */
|
||||
_resetSyncing(): void {
|
||||
_syncing = false
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user