feat: add sync progress and result feedback to offline settings tab

This commit is contained in:
jubnl
2026-05-05 21:02:25 +02:00
parent 544d5641d0
commit 37d9a321ab
2 changed files with 65 additions and 6 deletions
+41 -2
View File
@@ -7,6 +7,7 @@ import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle }
import Section from './Section' import Section from './Section'
import { offlineDb, clearAll } from '../../db/offlineDb' import { offlineDb, clearAll } from '../../db/offlineDb'
import { tripSyncManager } from '../../sync/tripSyncManager' import { tripSyncManager } from '../../sync/tripSyncManager'
import type { SyncProgress } from '../../sync/tripSyncManager'
import { mutationQueue } from '../../sync/mutationQueue' import { mutationQueue } from '../../sync/mutationQueue'
import { import {
DEFAULT_SW_CONFIG, DEFAULT_SW_CONFIG,
@@ -30,6 +31,9 @@ export default function OfflineTab(): React.ReactElement {
const [rows, setRows] = useState<CachedTripRow[]>([]) const [rows, setRows] = useState<CachedTripRow[]>([])
const [pendingCount, setPendingCount] = useState(0) const [pendingCount, setPendingCount] = useState(0)
const [syncing, setSyncing] = useState(false) const [syncing, setSyncing] = useState(false)
const [syncProgress, setSyncProgress] = useState<{ current: number; total: number } | null>(null)
const [syncResult, setSyncResult] = useState<{ ok: number; failed: number } | null>(null)
const syncResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [clearing, setClearing] = useState(false) const [clearing, setClearing] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -89,6 +93,10 @@ export default function OfflineTab(): React.ReactElement {
} }
}, []) }, [])
useEffect(() => {
return () => { if (syncResultTimerRef.current) clearTimeout(syncResultTimerRef.current) }
}, [])
async function handleSaveConfig() { async function handleSaveConfig() {
const validated = validateSwConfig(cacheConfig) const validated = validateSwConfig(cacheConfig)
setCacheConfig(validated) setCacheConfig(validated)
@@ -122,9 +130,26 @@ export default function OfflineTab(): React.ReactElement {
async function handleResync() { async function handleResync() {
setSyncing(true) setSyncing(true)
setSyncProgress(null)
setSyncResult(null)
if (syncResultTimerRef.current) clearTimeout(syncResultTimerRef.current)
function handleProgress(p: SyncProgress) {
if (p.phase === 'trip') {
setSyncProgress({ current: p.index + 1, total: p.total })
} else if (p.phase === 'done') {
setSyncProgress(null)
setSyncResult({ ok: p.ok, failed: p.failed })
syncResultTimerRef.current = setTimeout(() => setSyncResult(null), 5000)
}
}
try { try {
const timeout = new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 120_000)) const timeout = new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 120_000))
const result = await Promise.race([tripSyncManager.syncAll().then(() => 'done' as const), timeout]) const result = await Promise.race([
tripSyncManager.syncAll({ onProgress: handleProgress }).then(() => 'done' as const),
timeout,
])
if (result === 'timeout') { if (result === 'timeout') {
tripSyncManager.interrupt() tripSyncManager.interrupt()
console.warn('[OfflineTab] sync timed out after 120 s') console.warn('[OfflineTab] sync timed out after 120 s')
@@ -173,7 +198,11 @@ export default function OfflineTab(): React.ReactElement {
}} }}
> >
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} /> <RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
{syncing ? 'Syncing…' : 'Re-sync now'} {syncing
? syncProgress
? `Syncing ${syncProgress.current}/${syncProgress.total}`
: 'Syncing…'
: 'Re-sync now'}
</button> </button>
<button <button
@@ -192,6 +221,16 @@ export default function OfflineTab(): React.ReactElement {
</button> </button>
</div> </div>
{/* Sync result */}
{syncResult && (
<span style={{ fontSize: 12, color: syncResult.failed > 0 ? '#ef4444' : '#22c55e', display: 'flex', alignItems: 'center', gap: 4 }}>
<CheckCircle size={12} />
{syncResult.failed > 0
? `Synced ${syncResult.ok} trip${syncResult.ok !== 1 ? 's' : ''} · ${syncResult.failed} failed`
: `Synced ${syncResult.ok} trip${syncResult.ok !== 1 ? 's' : ''}`}
</span>
)}
{/* Cache configuration */} {/* Cache configuration */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+24 -4
View File
@@ -34,6 +34,11 @@ import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation,
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
export type SyncProgress =
| { phase: 'start'; total: number }
| { phase: 'trip'; tripId: number; index: number; total: number }
| { phase: 'done'; ok: number; failed: number }
interface TripBundle { interface TripBundle {
trip: Trip trip: Trip
days: Day[] days: Day[]
@@ -146,7 +151,7 @@ export const tripSyncManager = {
* Evicts stale trips. Caches file blobs in the background. * Evicts stale trips. Caches file blobs in the background.
* No-ops when offline or already syncing (unless stale flag). * No-ops when offline or already syncing (unless stale flag).
*/ */
async syncAll(): Promise<void> { async syncAll(opts?: { onProgress?: (p: SyncProgress) => void }): Promise<void> {
// Treat a _syncing flag that's been set for >2 minutes as stale (e.g. page unload mid-sync) // Treat a _syncing flag that's been set for >2 minutes as stale (e.g. page unload mid-sync)
if (_syncing && Date.now() - _syncStartedAt < SYNC_STALE_MS) return if (_syncing && Date.now() - _syncStartedAt < SYNC_STALE_MS) return
if (!navigator.onLine) return if (!navigator.onLine) return
@@ -159,7 +164,7 @@ export const tripSyncManager = {
) )
try { try {
await Promise.race([this._doSync(), timeout]) await Promise.race([this._doSync(opts?.onProgress), timeout])
} catch (err) { } catch (err) {
if (err instanceof Error && err.message === 'syncAll timeout') { if (err instanceof Error && err.message === 'syncAll timeout') {
console.warn('[tripSync] syncAll timed out after 90 s — interrupting') console.warn('[tripSync] syncAll timed out after 90 s — interrupting')
@@ -170,7 +175,7 @@ export const tripSyncManager = {
} }
}, },
async _doSync(): Promise<void> { async _doSync(onProgress?: (p: SyncProgress) => void): Promise<void> {
const { trips } = await tripsApi.list() as { trips: Trip[] } const { trips } = await tripsApi.list() as { trips: Trip[] }
// Evict stale trips first // Evict stale trips first
@@ -179,25 +184,37 @@ export const tripSyncManager = {
// Sync eligible trips — stop early if interrupted (e.g. user navigated to a trip page) // Sync eligible trips — stop early if interrupted (e.g. user navigated to a trip page)
const toSync = trips.filter(shouldCache) const toSync = trips.filter(shouldCache)
for (const trip of toSync) { onProgress?.({ phase: 'start', total: toSync.length })
let ok = 0
let failed = 0
for (let i = 0; i < toSync.length; i++) {
const trip = toSync[i]
if (_interrupted) return if (_interrupted) return
onProgress?.({ phase: 'trip', tripId: trip.id, index: i, total: toSync.length })
let tripOk = false
try { try {
await syncTrip(trip.id) await syncTrip(trip.id)
tripOk = true
} catch (err) { } catch (err) {
if (isQuotaError(err)) { if (isQuotaError(err)) {
console.warn(`[tripSync] quota exceeded for trip ${trip.id}, clearing trip data and retrying`) console.warn(`[tripSync] quota exceeded for trip ${trip.id}, clearing trip data and retrying`)
try { try {
await clearTripData(trip.id) await clearTripData(trip.id)
await syncTrip(trip.id) await syncTrip(trip.id)
tripOk = true
} catch (retryErr) { } catch (retryErr) {
if (isQuotaError(retryErr)) { if (isQuotaError(retryErr)) {
console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying') console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying')
await clearBlobCache() await clearBlobCache()
try { try {
await syncTrip(trip.id) await syncTrip(trip.id)
tripOk = true
} catch { } catch {
console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data') console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data')
await clearAll() await clearAll()
onProgress?.({ phase: 'done', ok, failed: failed + 1 })
return return
} }
} else { } else {
@@ -208,6 +225,7 @@ export const tripSyncManager = {
console.error(`[tripSync] failed for trip ${trip.id}:`, err) console.error(`[tripSync] failed for trip ${trip.id}:`, err)
} }
} }
if (tripOk) ok++; else failed++
} }
if (_interrupted) return if (_interrupted) return
@@ -229,6 +247,8 @@ export const tripSyncManager = {
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error) prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
}) })
await Promise.allSettled(prefetchWork) await Promise.allSettled(prefetchWork)
onProgress?.({ phase: 'done', ok, failed })
}, },
/** /**