/** * Offline settings tab — shows cached trips, storage info, and controls * to re-sync or clear the offline cache. Also exposes runtime SW cache config. */ import React, { useState, useEffect, useCallback, useRef } from 'react' import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle } from 'lucide-react' import Section from './Section' import { offlineDb, clearAll } from '../../db/offlineDb' import { tripSyncManager } from '../../sync/tripSyncManager' import { mutationQueue } from '../../sync/mutationQueue' import { DEFAULT_SW_CONFIG, loadSwConfig, saveSwConfig, validateSwConfig, SW_CONFIG_BOUNDS, type SwCacheConfig, } from '../../sync/swConfig' 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([]) const [pendingCount, setPendingCount] = useState(0) const [syncing, setSyncing] = useState(false) const [clearing, setClearing] = useState(false) const [loading, setLoading] = useState(true) // Cache config state const [cacheConfig, setCacheConfig] = useState({ ...DEFAULT_SW_CONFIG }) const [configSaving, setConfigSaving] = useState(false) const [configApplied, setConfigApplied] = useState(null) const appliedTimerRef = useRef | null>(null) 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]) // Load persisted cache config on mount useEffect(() => { loadSwConfig().then(setCacheConfig).catch(() => {}) }, []) // Listen for SW acknowledgement useEffect(() => { const handler = (event: MessageEvent) => { if (event.data?.type === 'CACHE_CONFIG_APPLIED') { setConfigApplied(new Date()) setConfigSaving(false) if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current) appliedTimerRef.current = setTimeout(() => setConfigApplied(null), 5000) } } navigator.serviceWorker?.addEventListener('message', handler) return () => { navigator.serviceWorker?.removeEventListener('message', handler) if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current) } }, []) async function handleSaveConfig() { const validated = validateSwConfig(cacheConfig) setCacheConfig(validated) setConfigSaving(true) try { await saveSwConfig(validated) const controller = navigator.serviceWorker?.controller if (controller) { controller.postMessage({ type: 'UPDATE_CACHE_CONFIG', config: validated }) // configSaving cleared by the SW message handler } else { // No active SW yet (e.g. first install) — config saved to IDB, applied on next SW activation setConfigApplied(new Date()) setConfigSaving(false) } } catch { setConfigSaving(false) } } function handleResetConfig() { setCacheConfig({ ...DEFAULT_SW_CONFIG }) } function updateField(field: keyof SwCacheConfig) { return (e: React.ChangeEvent) => { const v = parseInt(e.target.value, 10) if (!isNaN(v)) setCacheConfig(prev => ({ ...prev, [field]: v })) } } 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 (
{/* Stats row */}
{/* Actions */}
{/* Cache configuration */}
Cache configuration

Changes apply immediately to the service worker and persist across reloads. Existing cached entries follow their original TTL; new entries use the updated settings.

{configApplied && ( Applied at {configApplied.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })} )}
{/* Cached trip list */} {loading ? (

Loading…

) : rows.length === 0 ? (

No trips cached yet. Connect to internet to sync.

) : (
{rows.map(({ trip, meta, placeCount, fileCount }) => (
{trip.title || 'Unnamed trip'} {trip.description ? ( {trip.description.length > 72 ? trip.description.slice(0, 72) + '…' : trip.description} ) : null} {trip.start_date ? `${formatDate(trip.start_date)} – ${formatDate(trip.end_date)}` : 'No dates set'} {' · '} {placeCount} place{placeCount !== 1 ? 's' : ''} {fileCount > 0 ? ` · ${fileCount} file${fileCount !== 1 ? 's' : ''}` : null}
{meta.lastSyncedAt ? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) : '—'}
))}
)}
) } function Stat({ label, value }: { label: string; value: number }) { return (
{value}
{label}
) } function CacheField({ label, value, min, max, onChange, }: { label: string value: number min: number max: number onChange: (e: React.ChangeEvent) => void }) { return ( ) }