mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-18 19:36:02 +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 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 }}>
|
||||||
|
|||||||
@@ -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 })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user