From 1b71b8633d1a5c2daad0522151b11e68491e4424 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 4 Sep 2026 12:16:00 +0200 Subject: [PATCH] fix(offline): the PWA starts offline again and the download does what it says Four separate defects sat behind "offline mode is broken". The app shell. On the first online launch after a version bump, App.tsx deleted EVERY Cache Storage bucket and unregistered EVERY service worker, then reloaded. That is exactly wrong for a Workbox autoUpdate build, which swaps its precache atomically on install and never leaves an empty one. Between the purge and the completion of a fresh 22 MB precache the origin had no shell at all; anyone who closed the app or lost signal inside that window could no longer start the PWA offline. It also threw away the map tiles and documents the user had downloaded on purpose. It now asks the worker to fetch the new build and reloads once it has taken over. The version marker also moves inside the guarded path, because it was written after the purge and outside its try: a throwing setItem left the caches gone, the marker unwritten and the reload unreached, repeating on every launch. The reported version is validated before it reaches this device's storage and before it is compared against the stored marker, so a value that is not a release tag can neither be persisted nor start an update on every launch. The session. loadUser's offline branch kept the persisted session but never called onAuthSuccess, the only caller of setAuthed(true), and the mutation queue's flush, syncAll and prepareForOffline are all gated on it. A session that launched without network could therefore never sync again for the rest of its life, even after the signal returned: the offline settings buttons spun and returned instantly having done nothing, and queued edits never uploaded. It also left reads pointed at the anonymous database instead of the user's own. The date rule. A trip is only cached when it has not ended, and one that ended over a week ago is actively evicted. The per-trip switch was an opt-out list, so ticking a finished trip changed nothing at all. For anyone whose trips are all in the past, "Download for offline use" stored none of them, reported success, and the cache stayed at zero. Switching a trip on now pins it, which overrides both the skip and the eviction, and the switch shows the honest position rather than promising storage that never happens. What the screen says. A sync that never started (already running, offline, signed out) was indistinguishable from one that stored everything: the progress bar painted full and a green "Ready for offline use" appeared over a run of nothing. Both syncs now report why they stopped and the screen says so. A rejected Dexie read no longer renders as "No trips cached yet", and the clear button stays reachable when the database holds data that no bookkeeping row points at. A run that rejects outright says so too: the trip list it starts from is a plain fetch, so a network blip took the whole thing down and left the last progress frame on screen looking like a finished download. The 401-with-HTML branch in the API client now confirms a reachable proxy before tearing the worker down, since several of TREK's own routes answer 401 with a text/html body. Both settings shells move onto one shared hook rather than growing the same logic twice, and the tile prefetch caps its depth when the browser refused persistent storage. Opaque tiles are billed at about 7 MB each, and an origin that is not exempt from eviction loses its whole bucket, precached shell included, which Workbox then never refills because sw.js has not changed. Closes #2228 --- client/src/App.test.tsx | 77 +++++ client/src/App.tsx | 46 ++- client/src/api/client.behavior.test.ts | 22 +- client/src/api/client.ts | 18 +- .../components/Settings/OfflineTab.test.tsx | 85 +++++- client/src/components/Settings/OfflineTab.tsx | 187 ++---------- .../components/Settings/useOfflineSettings.ts | 282 ++++++++++++++++++ .../screens/settings/MSettingsOffline.tsx | 186 ++---------- client/src/store/authStore.ts | 15 + client/src/sync/offlinePrefs.ts | 32 +- client/src/sync/persistentStorage.ts | 41 ++- client/src/sync/tilePrefetcher.ts | 19 +- .../src/sync/tripSyncManager.prepare.test.ts | 22 +- client/src/sync/tripSyncManager.ts | 57 +++- .../mobile/settings/MSettingsOffline.test.tsx | 46 ++- client/tests/unit/stores/authStore.test.ts | 53 +++- client/tests/unit/sync/offlinePrefs.test.ts | 33 +- client/tests/unit/sync/tilePrefetcher.test.ts | 26 ++ .../tests/unit/sync/tripSyncManager.test.ts | 60 ++++ shared/src/i18n/ar/settings.ts | 8 + shared/src/i18n/br/settings.ts | 8 + shared/src/i18n/ca/settings.ts | 8 + shared/src/i18n/cs/settings.ts | 8 + shared/src/i18n/de/settings.ts | 8 + shared/src/i18n/en/settings.ts | 8 + shared/src/i18n/es/settings.ts | 8 + shared/src/i18n/fr/settings.ts | 8 + shared/src/i18n/gr/settings.ts | 8 + shared/src/i18n/hu/settings.ts | 8 + shared/src/i18n/id/settings.ts | 8 + shared/src/i18n/it/settings.ts | 8 + shared/src/i18n/ja/settings.ts | 8 + shared/src/i18n/ko/settings.ts | 8 + shared/src/i18n/nl/settings.ts | 8 + shared/src/i18n/pl/settings.ts | 8 + shared/src/i18n/ru/settings.ts | 8 + shared/src/i18n/sv/settings.ts | 8 + shared/src/i18n/tr/settings.ts | 8 + shared/src/i18n/uk/settings.ts | 8 + shared/src/i18n/vi/settings.ts | 8 + shared/src/i18n/zh-TW/settings.ts | 8 + shared/src/i18n/zh/settings.ts | 8 + 42 files changed, 1101 insertions(+), 390 deletions(-) create mode 100644 client/src/components/Settings/useOfflineSettings.ts 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={