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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user