Files
TREK/client/src/components/Settings/OfflineTab.tsx
T
jubnl b194e8317d 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
2026-04-14 23:04:25 +02:00

181 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
)
}