- {t('settings.offline.empty')}
+ {t(notice?.kind === 'load-failed' ? 'settings.offline.notice.loadFailed' : 'settings.offline.empty')}
) : (
diff --git a/client/src/components/Settings/useOfflineSettings.ts b/client/src/components/Settings/useOfflineSettings.ts
new file mode 100644
index 000000000..cd0a0ae48
--- /dev/null
+++ b/client/src/components/Settings/useOfflineSettings.ts
@@ -0,0 +1,282 @@
+/**
+ * Shared state for the offline settings screen (#1135), used by both the desktop
+ * `OfflineTab` and its phone twin `MSettingsOffline`. The two shells render very
+ * different markup over identical logic, so the logic lives here once, the same
+ * arrangement as `useInstanceSettings` / `useRangeBypass`.
+ *
+ * It also carries the honesty fixes from #2228: a sync that never started is
+ * reported as such instead of silently painting a finished progress bar, a
+ * failed read of the offline database surfaces instead of rendering as "nothing
+ * cached", and a trip the user switches on is pinned so the date rule cannot
+ * quietly refuse to store it.
+ */
+import { useCallback, useEffect, useState } from 'react'
+import { offlineDb, clearAll, clearTripData } from '../../db/offlineDb'
+import { tripsApi } from '../../api/client'
+import { tripSyncManager, isDateEligible, type PrepareProgress, type SyncOutcome } from '../../sync/tripSyncManager'
+import { mutationQueue } from '../../sync/mutationQueue'
+import { clearTileCache } from '../../sync/tilePrefetcher'
+import { isEffectivelyOffline } from '../../sync/networkMode'
+import {
+ getOfflinePrefs, setCacheTiles, setConflictStrategy,
+ setTripOfflineEnabled, setTripPinned, onOfflinePrefsChange,
+ type ConflictStrategy,
+} from '../../sync/offlinePrefs'
+import { useNetworkMode } from '../../hooks/useNetworkMode'
+import type { SyncMeta, QueuedMutation } from '../../db/offlineDb'
+import type { Trip } from '../../types'
+
+export interface CachedTripRow {
+ trip: Trip
+ meta: SyncMeta
+ placeCount: number
+ fileCount: number
+}
+
+/** How a trip's storage switch should read, and why. */
+export interface TripStorageState {
+ /** The switch position: is this trip kept on the device? */
+ on: boolean
+ /** The user asked for it explicitly, overriding the date rule. */
+ pinned: boolean
+ /** The date rule would keep it on its own (ongoing, future, or open-ended). */
+ dateEligible: boolean
+}
+
+/**
+ * The last thing a download / re-sync run did. `null` while nothing has been
+ * run yet. The screen shows no banner in that state.
+ */
+export type OfflineNotice =
+ | { kind: 'stored'; trips: number }
+ | { kind: 'skipped'; reason: 'busy' | 'offline' | 'signed-out' }
+ | { kind: 'sync-failed' }
+ | { kind: 'load-failed' }
+
+/**
+ * The message a notice reads as. Shared so both shells say the same thing and
+ * only the chrome around it differs.
+ */
+export function offlineNoticeKey(notice: OfflineNotice): string {
+ if (notice.kind === 'load-failed') return 'settings.offline.notice.loadFailed'
+ if (notice.kind === 'sync-failed') return 'settings.offline.notice.failed'
+ if (notice.kind === 'skipped') {
+ if (notice.reason === 'busy') return 'settings.offline.notice.busy'
+ if (notice.reason === 'offline') return 'settings.offline.notice.offline'
+ return 'settings.offline.notice.signedOut'
+ }
+ return notice.trips > 0 ? 'settings.offline.notice.stored' : 'settings.offline.notice.nothing'
+}
+
+/** A notice that reports a problem rather than a result. */
+export function isOfflineNoticeWarning(notice: OfflineNotice): boolean {
+ return notice.kind !== 'stored' || notice.trips === 0
+}
+
+function noticeFor(outcome: SyncOutcome): OfflineNotice {
+ return outcome.status === 'done'
+ ? { kind: 'stored', trips: outcome.trips }
+ : { kind: 'skipped', reason: outcome.reason }
+}
+
+export function useOfflineSettings() {
+ const { offline, forced, setForced } = useNetworkMode()
+ const [rows, setRows] = useState
([])
+ const [allTrips, setAllTrips] = useState([])
+ const [storedTripCount, setStoredTripCount] = useState(0)
+ 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 [notice, setNotice] = useState(null)
+ const [prefs, setPrefs] = useState(getOfflinePrefs())
+
+ useEffect(() => onOfflinePrefsChange(() => setPrefs(getOfflinePrefs())), [])
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ try {
+ const [metas, storedTrips, pending, failed, conflictList] = await Promise.all([
+ offlineDb.syncMeta.toArray(),
+ offlineDb.trips.count(),
+ mutationQueue.pendingCount(),
+ mutationQueue.failedCount(),
+ mutationQueue.conflicts(),
+ ])
+ setStoredTripCount(storedTrips)
+ 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([])
+ }
+ } catch (err) {
+ // A Dexie read that rejects (a closed handle, a failed upgrade) used to
+ // leave every list at its initial empty value, so a broken offline
+ // database was indistinguishable from an empty one: the screen read
+ // "No trips cached yet" and gave the user nothing to act on (#2228).
+ console.error('[offline] could not read the offline database:', err)
+ setNotice({ kind: 'load-failed' })
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ const runPrepare = useCallback(async () => {
+ setPreparing(true)
+ setProgress(null)
+ setNotice(null)
+ try {
+ const outcome = await tripSyncManager.prepareForOffline(p => setProgress(p))
+ setNotice(noticeFor(outcome))
+ // A run that never started never reported progress either; clearing it
+ // keeps the bar from painting "finished" over a download that did not
+ // happen.
+ if (outcome.status !== 'done') setProgress(null)
+ } catch (err) {
+ // The trip list this starts from is a plain fetch, so a network blip
+ // rejects the whole run. Reporting that beats leaving the last progress
+ // frame on screen as if the download had completed.
+ console.error('[offline] the download could not finish:', err)
+ setProgress(null)
+ setNotice({ kind: 'sync-failed' })
+ } finally {
+ setPreparing(false)
+ }
+ await load()
+ }, [load])
+
+ const handleToggleForce = useCallback(async () => {
+ 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 (!isEffectivelyOffline()) await runPrepare()
+ setForced(true)
+ } else {
+ // Back online: lifting the switch flushes the queue + re-syncs (syncTriggers).
+ setForced(false)
+ }
+ }, [forced, runPrepare, setForced])
+
+ const handleResync = useCallback(async () => {
+ setSyncing(true)
+ setNotice(null)
+ try {
+ setNotice(noticeFor(await tripSyncManager.syncAll()))
+ } catch (err) {
+ console.error('[offline] the sync could not finish:', err)
+ setNotice({ kind: 'sync-failed' })
+ } finally {
+ setSyncing(false)
+ }
+ await load()
+ }, [load])
+
+ /** Wipes the offline database. Each shell asks for confirmation its own way. */
+ const handleClear = useCallback(async () => {
+ setClearing(true)
+ try {
+ await clearAll()
+ setNotice(null)
+ await load()
+ } finally {
+ setClearing(false)
+ }
+ }, [load])
+
+ const handleToggleTiles = useCallback(async () => {
+ const next = !prefs.cacheTiles
+ setCacheTiles(next)
+ // Turning tiles off reclaims the bulk tile storage straight away.
+ if (!next) await clearTileCache()
+ }, [prefs.cacheTiles])
+
+ /**
+ * How a trip's switch should read. A finished trip is only stored when the
+ * user pinned it, so reporting the raw opt-out flag would show "on" over a
+ * trip the sync deliberately skips, which is what made the whole feature
+ * look broken for anyone whose trips are all in the past (#2228).
+ */
+ const tripStorageState = useCallback((trip: Trip): TripStorageState => {
+ const dateEligible = isDateEligible(trip)
+ const pinned = prefs.pinnedTripIds.includes(trip.id)
+ const allowed = !prefs.disabledTripIds.includes(trip.id)
+ return { on: allowed && (pinned || dateEligible), pinned, dateEligible }
+ }, [prefs.pinnedTripIds, prefs.disabledTripIds])
+
+ const handleToggleTrip = useCallback(async (trip: Trip) => {
+ const next = !tripStorageState(trip).on
+ setTripOfflineEnabled(trip.id, next)
+ // Switching a finished trip on has to pin it, or the date rule would skip
+ // it on the next sync and evict it a week after it ended.
+ setTripPinned(trip.id, next && !isDateEligible(trip))
+ if (!next) {
+ await clearTripData(trip.id)
+ await load()
+ return
+ }
+ if (isEffectivelyOffline()) {
+ setNotice({ kind: 'skipped', reason: 'offline' })
+ return
+ }
+ try {
+ setNotice(noticeFor(await tripSyncManager.syncAll()))
+ } catch (err) {
+ console.error('[offline] the sync could not finish:', err)
+ setNotice({ kind: 'sync-failed' })
+ }
+ await load()
+ }, [load, tripStorageState])
+
+ const resolveConflict = useCallback(async (id: string, keepMine: boolean) => {
+ if (keepMine) await mutationQueue.resolveKeepMine(id)
+ else await mutationQueue.resolveKeepServer(id)
+ await load()
+ }, [load])
+
+ const handleConflictStrategy = useCallback((strategy: ConflictStrategy) => {
+ setConflictStrategy(strategy)
+ }, [])
+
+ return {
+ offline, forced,
+ rows, allTrips, pendingCount, failedCount, conflicts,
+ syncing, clearing, loading, preparing, progress, notice, prefs, storedTripCount,
+ // Clearable once the database actually holds something, not only once a
+ // sync wrote its bookkeeping row: a download that died between writing the
+ // trip and writing its syncMeta left `rows` empty over a non-empty Dexie,
+ // and the clear button was disabled on exactly the state that needed it.
+ canClear: storedTripCount > 0 || pendingCount > 0,
+ load, runPrepare, handleToggleForce, handleResync, handleClear,
+ handleToggleTiles, tripStorageState, handleToggleTrip, resolveConflict,
+ handleConflictStrategy,
+ }
+}
diff --git a/client/src/mobile/screens/settings/MSettingsOffline.tsx b/client/src/mobile/screens/settings/MSettingsOffline.tsx
index 7246b0abb..6e67d8ac1 100644
--- a/client/src/mobile/screens/settings/MSettingsOffline.tsx
+++ b/client/src/mobile/screens/settings/MSettingsOffline.tsx
@@ -3,35 +3,20 @@
* components/Settings/OfflineTab. Same logic (force-offline switch, prepare /
* resync, conflict resolver, per-trip storage, cache stats + clear), rebuilt on
* the MSet* card system with MToggle switches and an MConfirmSheet for clear.
+ *
+ * The logic itself lives in the shared `useOfflineSettings` hook; only the
+ * markup differs between the two shells.
*/
-import { useState, useEffect, useCallback } from 'react'
-import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon } from 'lucide-react'
-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 { useState } from 'react'
+import { RefreshCw, Trash2, Database, CloudOff, Download, Check, GitMerge, Map as MapIcon, AlertTriangle } from 'lucide-react'
+import { useOfflineSettings, offlineNoticeKey, isOfflineNoticeWarning } from '../../../components/Settings/useOfflineSettings'
import { useTranslation } from '../../../i18n'
-import type { SyncMeta, QueuedMutation } from '../../../db/offlineDb'
-import type { Trip } from '../../../types'
+import type { ConflictStrategy } from '../../../sync/offlinePrefs'
+import type { QueuedMutation } from '../../../db/offlineDb'
import { MSetCard, MSetEyebrow, MSetRow, MSetSegments, MSetButton } from './MSettingsUi'
import MToggle from '../../components/MToggle'
import MConfirmSheet from './MConfirmSheet'
-interface CachedTripRow {
- trip: Trip
- meta: SyncMeta
- placeCount: number
- fileCount: number
-}
-
function conflictName(m: QueuedMutation): string {
const body = (m.body ?? {}) as { name?: unknown }
const server = (m.conflictServer ?? {}) as { name?: unknown }
@@ -42,132 +27,19 @@ function conflictName(m: QueuedMutation): string {
export default function MSettingsOffline() {
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 [showClearConfirm, setShowClearConfirm] = 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)
-
- // 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.
- setForced(false)
- }
- }
-
- async function handleResync() {
- setSyncing(true)
- try {
- await tripSyncManager.syncAll()
- await load()
- } finally {
- setSyncing(false)
- }
- }
+ 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()
async function doClear() {
- setClearing(true)
- try {
- await clearAll()
- await load()
- setShowClearConfirm(false)
- } 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()
+ await handleClear()
+ setShowClearConfirm(false)
}
const formatDate = (d: string | null | undefined) =>
@@ -229,9 +101,10 @@ export default function MSettingsOffline() {