mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
feat: add sync progress and result feedback to offline settings tab
This commit is contained in:
@@ -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 }}>
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user