mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
feat: complete offline write support with mutation queue + runtime SW cache config
- Add offline CRUD to todoRepo, budgetRepo, reservationRepo, accommodationRepo, dayRepo, tripRepo, fileRepo with optimistic Dexie writes and mutation queue - Wire all store slices (todo, budget, reservations, files, dayNotes, assignments, tripStore) through repos for offline-aware writes - Cover archive/unarchive, file toggleStar/update/delete, assignment create/delete, day title/notes update offline paths - Migrate service worker from generateSW to injectManifest (custom sw.ts) with runtime-configurable api-data (7d/500) and map-tiles (30d/1000) cache policies - Add Settings → Offline cache configuration UI with save/reset and live SW postMessage - Extend mutationQueue flush to cover all writable Dexie tables
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* Offline settings tab — shows cached trips, storage info, and controls
|
||||
* to re-sync or clear the offline cache.
|
||||
* to re-sync or clear the offline cache. Also exposes runtime SW cache config.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
|
||||
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'
|
||||
|
||||
@@ -25,6 +33,12 @@ export default function OfflineTab(): React.ReactElement {
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Cache config state
|
||||
const [cacheConfig, setCacheConfig] = useState<SwCacheConfig>({ ...DEFAULT_SW_CONFIG })
|
||||
const [configSaving, setConfigSaving] = useState(false)
|
||||
const [configApplied, setConfigApplied] = useState<Date | null>(null)
|
||||
const appliedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -53,6 +67,59 @@ export default function OfflineTab(): React.ReactElement {
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (!isNaN(v)) setCacheConfig(prev => ({ ...prev, [field]: v }))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
@@ -120,6 +187,86 @@ export default function OfflineTab(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cache configuration */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Settings2 size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Cache configuration</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
|
||||
Changes apply immediately to the service worker and persist across reloads.
|
||||
Existing cached entries follow their original TTL; new entries use the updated settings.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<CacheField
|
||||
label="API cache TTL (days)"
|
||||
value={cacheConfig.apiTtlDays}
|
||||
min={SW_CONFIG_BOUNDS.ttlMin}
|
||||
max={SW_CONFIG_BOUNDS.ttlMax}
|
||||
onChange={updateField('apiTtlDays')}
|
||||
/>
|
||||
<CacheField
|
||||
label="API max entries"
|
||||
value={cacheConfig.apiMaxEntries}
|
||||
min={SW_CONFIG_BOUNDS.entriesMin}
|
||||
max={SW_CONFIG_BOUNDS.entriesMax}
|
||||
onChange={updateField('apiMaxEntries')}
|
||||
/>
|
||||
<CacheField
|
||||
label="Map tiles TTL (days)"
|
||||
value={cacheConfig.tilesTtlDays}
|
||||
min={SW_CONFIG_BOUNDS.ttlMin}
|
||||
max={SW_CONFIG_BOUNDS.ttlMax}
|
||||
onChange={updateField('tilesTtlDays')}
|
||||
/>
|
||||
<CacheField
|
||||
label="Map tiles max entries"
|
||||
value={cacheConfig.tilesMaxEntries}
|
||||
min={SW_CONFIG_BOUNDS.entriesMin}
|
||||
max={SW_CONFIG_BOUNDS.entriesMax}
|
||||
onChange={updateField('tilesMaxEntries')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={configSaving}
|
||||
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: configSaving ? 'not-allowed' : 'pointer',
|
||||
fontSize: 13, fontWeight: 500, opacity: configSaving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} style={configSaving ? { animation: 'spin 1s linear infinite' } : {}} />
|
||||
{configSaving ? 'Applying…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetConfig}
|
||||
disabled={configSaving}
|
||||
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-muted)',
|
||||
cursor: configSaving ? 'not-allowed' : 'pointer',
|
||||
fontSize: 13, fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Reset to defaults
|
||||
</button>
|
||||
{configApplied && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: '#22c55e' }}>
|
||||
<CheckCircle size={12} />
|
||||
Applied at {configApplied.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cached trip list */}
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading…</p>
|
||||
@@ -139,24 +286,32 @@ export default function OfflineTab(): React.ReactElement {
|
||||
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)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: trip.title ? 'var(--text-primary)' : 'var(--text-muted)', fontStyle: trip.title ? 'normal' : 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.title || 'Unnamed trip'}
|
||||
</span>
|
||||
{trip.description ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.description.length > 72 ? trip.description.slice(0, 72) + '…' : trip.description}
|
||||
</span>
|
||||
) : null}
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{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}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
<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>
|
||||
@@ -178,3 +333,32 @@ function Stat({ label, value }: { label: string; value: number }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CacheField({
|
||||
label, value, min, max, onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
min: number
|
||||
max: number
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 500 }}>{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={onChange}
|
||||
style={{
|
||||
padding: '6px 10px', borderRadius: 6,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
|
||||
fontSize: 13, width: '100%', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user