diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index ff9c2fe90..ad87b9451 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -522,6 +522,83 @@ describe('Version cache-busting', () => { renderApp('/') await waitFor(() => expect(reload).toHaveBeenCalled()) }) + + it('FE-COMP-APP-026: a version bump never deletes the caches or unregisters the worker (#2228)', async () => { + // The old handler wiped every Cache Storage bucket and every registration + // here, which left the device with no app shell until a fresh ~22 MB + // precache finished: anyone who closed the app or lost signal inside that + // window could no longer start the PWA offline at all, and it also threw + // away the map tiles and documents the user had downloaded on purpose. + localStorage.setItem('trek_app_version', '2.9.9') + const cachesDelete = vi.fn().mockResolvedValue(true) + const unregister = vi.fn().mockResolvedValue(true) + const update = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete }) + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { + getRegistration: vi.fn().mockResolvedValue({ update, unregister }), + getRegistrations: vi.fn().mockResolvedValue([{ unregister }]), + addEventListener: vi.fn(), + }, + }) + + server.use( + http.get('/api/auth/app-config', () => + HttpResponse.json({ version: '2.9.10' }) + ) + ) + seedAuth() + renderApp('/') + + // It asks the worker to fetch the new build; Workbox swaps the precache + // atomically once that install completes. + await waitFor(() => expect(update).toHaveBeenCalled()) + expect(cachesDelete).not.toHaveBeenCalled() + expect(unregister).not.toHaveBeenCalled() + }) + + it('FE-COMP-APP-028: a version that does not look like a release tag is ignored', async () => { + // The value arrives over the wire and is written to this device's storage, + // so it is validated rather than trusted. An unusable one must also not be + // compared against the stored marker, or every launch would see a mismatch + // and try to update. + localStorage.setItem('trek_app_version', '2.9.9') + const reload = vi.fn() + Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } }) + + server.use( + http.get('/api/auth/app-config', () => + HttpResponse.json({ version: '', managed: false }) + ) + ) + seedAuth() + renderApp('/') + + await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument()) + expect(localStorage.getItem('trek_app_version')).toBe('2.9.9') + expect(reload).not.toHaveBeenCalled() + }) + + it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => { + // The marker used to be written after the purge and outside its try, so a + // throwing step left it unwritten and the whole destructive path repeated on + // every launch, for good. + localStorage.setItem('trek_app_version', '2.9.9') + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() }, + }) + + server.use( + http.get('/api/auth/app-config', () => + HttpResponse.json({ version: '2.9.10' }) + ) + ) + seedAuth() + renderApp('/') + await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10')) + }) }) // Regression: a device that has never mirrored the preference must not treat diff --git a/client/src/App.tsx b/client/src/App.tsx index fd5b4caae..1eeeaefe8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -316,24 +316,46 @@ export default function App() { if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) - if (config?.version) { + // A version is a short release tag and nothing else. It arrives over the + // wire and is written to this device's storage, so it is rebuilt from the + // characters a tag may contain and kept only when nothing had to be + // stripped. Anything else is ignored, which also keeps a malformed value + // from being compared against the stored marker and starting an update on + // every launch. + const reportedVersion = typeof config?.version === 'string' ? config.version : '' + const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64) + if (version && version === reportedVersion) { const storedVersion = localStorage.getItem('trek_app_version') - if (storedVersion && storedVersion !== config.version) { + // Record the version BEFORE acting on it. The old code wrote the marker + // after the purge and outside its try, so a throwing setItem (private + // mode, blocked site data, quota) left the caches deleted, the marker + // unwritten and the reload unreached, and the purge then repeated on + // every single launch, permanently (#2228). + try { localStorage.setItem('trek_app_version', version) } catch { /* site data blocked */ } + if (storedVersion && storedVersion !== version) { + // A newer build is deployed. Ask the service worker to fetch it and + // hand over; Workbox ('autoUpdate' + skipWaiting + clientsClaim) + // installs the new precache and only then drops the outdated one, so + // there is never a moment without an app shell. + // + // This used to delete EVERY Cache Storage bucket and unregister EVERY + // worker instead. That left the device with no shell and no worker + // until a fresh ~22 MB precache finished (minutes on mobile data), + // and anyone who closed the app or lost signal in that window was + // left with a PWA that could no longer start offline at all. It also + // threw away the map tiles and file blobs the user had deliberately + // downloaded for offline use (#2228). try { - if ('caches' in window) { - const names = await caches.keys() - await Promise.all(names.map(n => caches.delete(n))) + const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined + if (reg) { + navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true }) + await reg.update() + return } - if ('serviceWorker' in navigator) { - const regs = await navigator.serviceWorker.getRegistrations() - await Promise.all(regs.map(r => r.unregister())) - } - } catch {} - localStorage.setItem('trek_app_version', config.version) + } catch { /* fall through to a plain reload */ } window.location.reload() return } - localStorage.setItem('trek_app_version', config.version) } }).catch(() => {}) }, []) diff --git a/client/src/api/client.behavior.test.ts b/client/src/api/client.behavior.test.ts index e33de84f8..b65ba7dbd 100644 --- a/client/src/api/client.behavior.test.ts +++ b/client/src/api/client.behavior.test.ts @@ -230,7 +230,8 @@ describe('client > proxy auth challenges', () => { return getRegistration } - it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => { + it('FE-APIWIRE-011: an HTML 401 behind a confirmed proxy wall unregisters the service worker and reloads', async () => { + probeNow.mockResolvedValue('proxy-wall') const unregister = vi.fn(async () => true) installServiceWorker(unregister) server.use(http.get('/api/auth/me', () => @@ -243,6 +244,25 @@ describe('client > proxy auth challenges', () => { expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1') }) + it('FE-APIWIRE-016: an HTML 401 from TREK itself keeps the service worker (#2228)', async () => { + // text/html is not proof of a proxy: several of TREK's own routes answer + // res.status(401).send('Authentication required'), which Express labels + // text/html. Tearing the worker down for one of those costs the user + // offline mode for a wall that is not there, so confirm reachability first. + probeNow.mockResolvedValue('online') + const unregister = vi.fn(async () => true) + installServiceWorker(unregister) + server.use(http.get('/api/auth/me', () => + new HttpResponse('Authentication required', { status: 401, headers: { 'Content-Type': 'text/html' } }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(probeNow).toHaveBeenCalled() + expect(unregister).not.toHaveBeenCalled() + expect(reload).not.toHaveBeenCalled() + expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull() + }) + it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => { installServiceWorker(vi.fn(async () => true)) sessionStorage.setItem('proxy_reauth_attempted', '1') diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 652003728..871db664f 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -214,16 +214,24 @@ apiClient.interceptors.response.use( } } // Pangolin header-auth extended compatibility mode: returns 401 with an - // HTML body (a JS redirect page) instead of a 302. TREK's own 401s are - // always application/json, so checking for text/html is unambiguous. + // HTML body (a JS redirect page) instead of a 302. + // + // A text/html 401 is NOT unambiguous on its own: several of TREK's own + // routes answer with res.status(401).send('Authentication required'), + // which Express labels text/html. Tearing the service worker down for one + // of those would cost the user offline mode for a proxy wall that isn't + // there, so confirm a reachable proxy first, exactly like the no-response + // branch above (#2228). if (error.response?.status === 401) { const ct = (error.response.headers?.['content-type'] as string | undefined) ?? '' if (ct.includes('text/html')) { const { pathname } = window.location if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) { - sessionStorage.setItem('proxy_reauth_attempted', '1') - await unregisterSWAndReload() - return Promise.reject(error) + if (await probeNow() === 'proxy-wall') { + sessionStorage.setItem('proxy_reauth_attempted', '1') + await unregisterSWAndReload() + return Promise.reject(error) + } } } } diff --git a/client/src/components/Settings/OfflineTab.test.tsx b/client/src/components/Settings/OfflineTab.test.tsx index ee37d2693..e386a7486 100644 --- a/client/src/components/Settings/OfflineTab.test.tsx +++ b/client/src/components/Settings/OfflineTab.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-OFFLINETAB-001 to FE-COMP-OFFLINETAB-024 +// FE-COMP-OFFLINETAB-001 to FE-COMP-OFFLINETAB-028 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -21,16 +21,17 @@ const h = vi.hoisted(() => { const syncMetaToArray = vi.fn(); const tripsGet = vi.fn(); const tripsToArray = vi.fn(); + const tripsCount = vi.fn(); const placeCount = vi.fn(); const fileCount = vi.fn(); const counted = (fn: (id: number) => Promise) => ({ where: () => ({ equals: (id: number) => ({ count: () => fn(id) }) }), }); return { - syncMetaToArray, tripsGet, tripsToArray, placeCount, fileCount, + syncMetaToArray, tripsGet, tripsToArray, tripsCount, placeCount, fileCount, fakeDb: { syncMeta: { toArray: syncMetaToArray }, - trips: { get: tripsGet, toArray: tripsToArray }, + trips: { get: tripsGet, toArray: tripsToArray, count: tripsCount }, places: counted(placeCount), tripFiles: counted(fileCount), }, @@ -104,6 +105,7 @@ const conflict = (over: Partial = {}): QueuedMutation => ({ /** Seed the Dexie cache with the given (trip, meta) pairs. */ function cache(rows: { trip: Trip; meta?: Partial; places?: number; files?: number }[]): void { h.syncMetaToArray.mockResolvedValue(rows.map(r => meta(r.trip.id, r.meta))); + h.tripsCount.mockResolvedValue(rows.length); h.tripsGet.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.trip); h.placeCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.places ?? 0); h.fileCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.files ?? 0); @@ -130,6 +132,7 @@ beforeEach(() => { h.syncMetaToArray.mockResolvedValue([]); h.tripsGet.mockResolvedValue(undefined); h.tripsToArray.mockResolvedValue([]); + h.tripsCount.mockResolvedValue(0); h.placeCount.mockResolvedValue(0); h.fileCount.mockResolvedValue(0); h.pendingCount.mockResolvedValue(0); @@ -138,8 +141,8 @@ beforeEach(() => { h.clearAll.mockResolvedValue(undefined); h.clearTripData.mockResolvedValue(undefined); h.clearTileCache.mockResolvedValue(undefined); - h.prepareForOffline.mockResolvedValue(undefined); - h.syncAll.mockResolvedValue(undefined); + h.prepareForOffline.mockResolvedValue({ status: 'done', trips: 1 }); + h.syncAll.mockResolvedValue({ status: 'done', trips: 1 }); h.resolveKeepMine.mockResolvedValue(undefined); h.resolveKeepServer.mockResolvedValue(undefined); @@ -284,6 +287,7 @@ describe('OfflineTab', () => { cb({ phase: 'files', current: 1, total: 4, label: 'Paris' }); await new Promise(resolve => { release = resolve; }); cb({ phase: 'done', current: 4, total: 4 }); + return { status: 'done', trips: 4 }; }); render(); @@ -294,7 +298,7 @@ describe('OfflineTab', () => { expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled(); await act(async () => { release(); }); - expect(await screen.findByText('Ready for offline use')).toBeInTheDocument(); + expect(await screen.findByText('Stored 4 trip(s) on this device')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled(); }); @@ -304,6 +308,7 @@ describe('OfflineTab', () => { h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => { cb({ phase: 'tiles', current: 0, total: 0 }); await new Promise(resolve => { release = resolve; }); + return { status: 'done', trips: 1 }; }); render(); @@ -322,6 +327,7 @@ describe('OfflineTab', () => { h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => { cb({ phase: 'trips', current: 1, total: 4 }); await new Promise(resolve => { release = resolve; }); + return { status: 'done', trips: 4 }; }); render(); @@ -380,7 +386,7 @@ describe('OfflineTab', () => { it('FE-COMP-OFFLINETAB-017: Re-sync shows the syncing label while it runs', async () => { const user = userEvent.setup(); let release!: () => void; - h.syncAll.mockImplementation(() => new Promise(resolve => { release = resolve; })); + h.syncAll.mockImplementation(() => new Promise(resolve => { release = () => resolve({ status: 'done', trips: 1 }); })); render(); await screen.findByText('No trips cached yet. Connect to the internet to sync.'); @@ -408,21 +414,69 @@ describe('OfflineTab', () => { expect(h.clearTileCache).toHaveBeenCalledTimes(1); }); - it('FE-COMP-OFFLINETAB-019: switching a trip off evicts it, switching it back on re-syncs', async () => { + it('FE-COMP-OFFLINETAB-019: switching a finished trip on pins and re-syncs it, switching it off evicts it', async () => { const user = userEvent.setup(); render(); + // Both fixture trips ended in 2025, so the date rule would skip them: the + // switch has to read "off" rather than promising storage that never happens. const toggle = await screen.findByRole('button', { name: 'Tokyo' }); - expect(toggle).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getAllByText('Stored offline')).toHaveLength(2); - - await user.click(toggle); - expect(h.clearTripData).toHaveBeenCalledWith(2); - await waitFor(() => expect(screen.getByText('Not stored')).toBeInTheDocument()); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getAllByText(/Not stored · Finished/)).toHaveLength(2); await user.click(toggle); await waitFor(() => expect(h.syncAll).toHaveBeenCalledTimes(1)); - expect(h.clearTripData).toHaveBeenCalledTimes(1); + expect(getOfflinePrefs().pinnedTripIds).toEqual([2]); + await waitFor(() => expect(screen.getByRole('button', { name: 'Tokyo' })).toHaveAttribute('aria-pressed', 'true')); + + await user.click(screen.getByRole('button', { name: 'Tokyo' })); + expect(h.clearTripData).toHaveBeenCalledWith(2); + // Turning it off releases the pin, so "off then on" cannot leave it pinned. + expect(getOfflinePrefs().pinnedTripIds).toEqual([]); + }); + + it('FE-COMP-OFFLINETAB-025: an ongoing trip is stored without needing a pin', async () => { + const soon = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10); + const ongoing = buildTrip({ id: 7, title: 'Lisbon', start_date: '2025-01-01', end_date: soon }); + server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [ongoing] }))); + render(); + + const toggle = await screen.findByRole('button', { name: 'Lisbon' }); + expect(toggle).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('Stored offline')).toBeInTheDocument(); + expect(getOfflinePrefs().pinnedTripIds).toEqual([]); + }); + + it('FE-COMP-OFFLINETAB-026: a sync that never ran says so instead of reporting success', async () => { + const user = userEvent.setup(); + h.prepareForOffline.mockResolvedValue({ status: 'skipped', reason: 'busy' }); + render(); + + await screen.findByText('No trips cached yet. Connect to the internet to sync.'); + await user.click(screen.getByRole('button', { name: 'Download for offline use' })); + + expect(await screen.findByText('A sync is already running. Try again in a moment.')).toBeInTheDocument(); + expect(screen.queryByText(/Stored .* trip/)).not.toBeInTheDocument(); + }); + + it('FE-COMP-OFFLINETAB-028: a download that throws is reported instead of leaving the last frame up', async () => { + const user = userEvent.setup(); + h.prepareForOffline.mockRejectedValue(new Error('network went away')); + render(); + + await screen.findByText('No trips cached yet. Connect to the internet to sync.'); + await user.click(screen.getByRole('button', { name: 'Download for offline use' })); + + expect(await screen.findByText('The download could not finish. Check your connection and try again.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled(); + }); + + it('FE-COMP-OFFLINETAB-027: an unreadable offline database is reported, not shown as empty', async () => { + h.syncMetaToArray.mockRejectedValue(new Error('DatabaseClosedError')); + render(); + + expect(await screen.findByText(/Could not read this device’s offline storage/)).toBeInTheDocument(); + expect(screen.queryByText('No trips cached yet. Connect to the internet to sync.')).not.toBeInTheDocument(); }); it('FE-COMP-OFFLINETAB-020: the per-trip section disappears when there are no trips at all', async () => { @@ -478,6 +532,7 @@ describe('OfflineTab', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Clear cache' })).toBeEnabled()); h.syncMetaToArray.mockResolvedValue([]); + h.tripsCount.mockResolvedValue(0); await user.click(screen.getByRole('button', { name: 'Clear cache' })); expect(h.clearAll).toHaveBeenCalledTimes(1); diff --git a/client/src/components/Settings/OfflineTab.tsx b/client/src/components/Settings/OfflineTab.tsx index 5c950b234..fb123effb 100644 --- a/client/src/components/Settings/OfflineTab.tsx +++ b/client/src/components/Settings/OfflineTab.tsx @@ -6,33 +6,18 @@ * - What to store: a map-tiles toggle plus a per-trip on/off. * - Sync conflicts: a keep-mine / keep-theirs resolver and a default strategy. * - Cache stats + clear. + * + * All of the logic lives in `useOfflineSettings`, shared with the phone twin + * `MSettingsOffline`; this file is the desktop markup over it. */ -import React, { useState, useEffect, useCallback } from 'react' -import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon } from 'lucide-react' +import React from 'react' +import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react' import Section from './Section' import ToggleSwitch from './ToggleSwitch' -import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb' -import { tripsApi } from '../../api/client' -import { tripSyncManager, type PrepareProgress } from '../../sync/tripSyncManager' -import { mutationQueue } from '../../sync/mutationQueue' -import { clearTileCache } from '../../sync/tilePrefetcher' -import { isEffectivelyOffline } from '../../sync/networkMode' -import { - getOfflinePrefs, setCacheTiles, setConflictStrategy, - isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange, - type ConflictStrategy, -} from '../../sync/offlinePrefs' -import { useNetworkMode } from '../../hooks/useNetworkMode' +import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning } from './useOfflineSettings' import { useTranslation } from '../../i18n' -import type { SyncMeta, QueuedMutation } from '../../db/offlineDb' -import type { Trip } from '../../types' - -interface CachedTripRow { - trip: Trip - meta: SyncMeta - placeCount: number - fileCount: number -} +import type { ConflictStrategy } from '../../sync/offlinePrefs' +import type { QueuedMutation } from '../../db/offlineDb' function conflictName(m: QueuedMutation): string { const body = (m.body ?? {}) as { name?: unknown } @@ -44,132 +29,14 @@ function conflictName(m: QueuedMutation): string { export default function OfflineTab(): React.ReactElement { const { t } = useTranslation() - const { offline, forced, setForced } = useNetworkMode() - const [rows, setRows] = useState([]) - const [allTrips, setAllTrips] = useState([]) - const [pendingCount, setPendingCount] = useState(0) - const [failedCount, setFailedCount] = useState(0) - const [conflicts, setConflicts] = useState([]) - const [syncing, setSyncing] = useState(false) - const [clearing, setClearing] = useState(false) - const [loading, setLoading] = useState(true) - const [preparing, setPreparing] = useState(false) - const [progress, setProgress] = useState(null) - const [prefs, setPrefs] = useState(getOfflinePrefs()) - - useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), []) - - const load = useCallback(async () => { - setLoading(true) - try { - const [metas, pending, failed, conflictList] = await Promise.all([ - offlineDb.syncMeta.toArray(), - mutationQueue.pendingCount(), - mutationQueue.failedCount(), - mutationQueue.conflicts(), - ]) - setPendingCount(pending) - setFailedCount(failed) - setConflicts(conflictList) - - const result: CachedTripRow[] = [] - for (const meta of metas) { - const trip = await offlineDb.trips.get(meta.tripId) - if (!trip) continue - const [placeCount, fileCount] = await Promise.all([ - offlineDb.places.where('trip_id').equals(meta.tripId).count(), - offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(), - ]) - result.push({ trip, meta, placeCount, fileCount }) - } - result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? '')) - setRows(result) - - // The per-trip storage toggles are driven by the FULL trip list, not just - // the cached ones, so a trip turned off stays visible and re-enableable. - try { - const trips = isEffectivelyOffline() - ? await offlineDb.trips.toArray() - : await tripsApi.list().then(r => (r as { trips: Trip[] }).trips).catch(() => offlineDb.trips.toArray()) - trips.sort((a, b) => (a.start_date ?? '').localeCompare(b.start_date ?? '')) - setAllTrips(trips) - } catch { - setAllTrips([]) - } - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { load() }, [load]) - - const runPrepare = useCallback(async () => { - setPreparing(true) - setProgress(null) - try { - await tripSyncManager.prepareForOffline(p => setProgress(p)) - await load() - } finally { - setPreparing(false) - } - }, [load]) - - async function handleToggleForce() { - if (!forced) { - // Turning offline mode on: download everything first (while still online), - // then engage so the app has all it needs before the network drops. - if (navigator.onLine) await runPrepare() - setForced(true) - } else { - // Back online: lifting the switch flushes the queue + re-syncs (syncTriggers). - setForced(false) - } - } - - async function handleResync() { - setSyncing(true) - try { - await tripSyncManager.syncAll() - await load() - } finally { - setSyncing(false) - } - } - - async function handleClear() { - if (!window.confirm(t('settings.offline.clearConfirm'))) return - setClearing(true) - try { - await clearAll() - await load() - } finally { - setClearing(false) - } - } - - async function handleToggleTiles() { - const next = !prefs.cacheTiles - setCacheTiles(next) - // Turning tiles off reclaims the bulk tile storage straight away. - if (!next) await clearTileCache() - } - - async function handleToggleTrip(tripId: number) { - const next = !isTripOfflineEnabled(tripId) - setTripOfflineEnabled(tripId, next) - if (!next) { - await clearTripData(tripId) - await load() - } else if (navigator.onLine) { - tripSyncManager.syncAll().then(load).catch(() => {}) - } - } - - async function resolveConflict(id: string, keepMine: boolean) { - if (keepMine) await mutationQueue.resolveKeepMine(id) - else await mutationQueue.resolveKeepServer(id) - await load() - } + const { + offline, forced, + rows, allTrips, pendingCount, failedCount, conflicts, + syncing, clearing, loading, preparing, progress, notice, prefs, canClear, + runPrepare, handleToggleForce, handleResync, handleClear, + handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict, + handleConflictStrategy, + } = useOfflineSettings() const formatDate = (d: string | null | undefined) => d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—' @@ -237,9 +104,10 @@ export default function OfflineTab(): React.ReactElement { )} - {!preparing && progress?.phase === 'done' && ( -
- {t('settings.offline.prepare.done')} + {!preparing && notice && notice.kind !== 'load-failed' && ( +
+ {isOfflineNoticeWarning(notice) ? : } + {t(offlineNoticeKey(notice), notice.kind === 'stored' ? { count: notice.trips } : undefined)}
)}
@@ -273,7 +141,7 @@ export default function OfflineTab(): React.ReactElement { control={