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 { offlineDb, clearAll } from '../../db/offlineDb'
import { tripSyncManager } from '../../sync/tripSyncManager'
import type { SyncProgress } from '../../sync/tripSyncManager'
import { mutationQueue } from '../../sync/mutationQueue'
import {
DEFAULT_SW_CONFIG,
@@ -30,6 +31,9 @@ export default function OfflineTab(): React.ReactElement {
const [rows, setRows] = useState<CachedTripRow[]>([])
const [pendingCount, setPendingCount] = useState(0)
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 [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() {
const validated = validateSwConfig(cacheConfig)
setCacheConfig(validated)
@@ -122,9 +130,26 @@ export default function OfflineTab(): React.ReactElement {
async function handleResync() {
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 {
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') {
tripSyncManager.interrupt()
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' } : {}} />
{syncing ? 'Syncing…' : 'Re-sync now'}
{syncing
? syncProgress
? `Syncing ${syncProgress.current}/${syncProgress.total}`
: 'Syncing…'
: 'Re-sync now'}
</button>
<button
@@ -192,6 +221,16 @@ export default function OfflineTab(): React.ReactElement {
</button>
</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 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<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 ─────────────────────────────────────────────────────────────────────
export type SyncProgress =
| { phase: 'start'; total: number }
| { phase: 'trip'; tripId: number; index: number; total: number }
| { phase: 'done'; ok: number; failed: number }
interface TripBundle {
trip: Trip
days: Day[]
@@ -146,7 +151,7 @@ export const tripSyncManager = {
* Evicts stale trips. Caches file blobs in the background.
* 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)
if (_syncing && Date.now() - _syncStartedAt < SYNC_STALE_MS) return
if (!navigator.onLine) return
@@ -159,7 +164,7 @@ export const tripSyncManager = {
)
try {
await Promise.race([this._doSync(), timeout])
await Promise.race([this._doSync(opts?.onProgress), timeout])
} catch (err) {
if (err instanceof Error && err.message === 'syncAll timeout') {
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[] }
// 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)
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
onProgress?.({ phase: 'trip', tripId: trip.id, index: i, total: toSync.length })
let tripOk = false
try {
await syncTrip(trip.id)
tripOk = true
} catch (err) {
if (isQuotaError(err)) {
console.warn(`[tripSync] quota exceeded for trip ${trip.id}, clearing trip data and retrying`)
try {
await clearTripData(trip.id)
await syncTrip(trip.id)
tripOk = true
} catch (retryErr) {
if (isQuotaError(retryErr)) {
console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying')
await clearBlobCache()
try {
await syncTrip(trip.id)
tripOk = true
} catch {
console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data')
await clearAll()
onProgress?.({ phase: 'done', ok, failed: failed + 1 })
return
}
} else {
@@ -208,6 +225,7 @@ export const tripSyncManager = {
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
}
}
if (tripOk) ok++; else failed++
}
if (_interrupted) return
@@ -229,6 +247,8 @@ export const tripSyncManager = {
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
})
await Promise.allSettled(prefetchWork)
onProgress?.({ phase: 'done', ok, failed })
},
/**