mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
55f14c6a70
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
132 lines
5.1 KiB
TypeScript
132 lines
5.1 KiB
TypeScript
/**
|
|
* Offline preferences — device-local choices about WHAT gets stored offline and
|
|
* HOW sync conflicts are resolved (discussion #1135, asks 2 and 3).
|
|
*
|
|
* These live in localStorage rather than the server user-settings because they
|
|
* are inherently per-device: how much storage a phone should spend on map tiles,
|
|
* or which trips to keep on this particular device, has nothing to do with the
|
|
* account and everything to do with the hardware in the user's hand.
|
|
*
|
|
* cacheTiles — global on/off for pre-downloading map tiles. Off keeps
|
|
* the cache to trip data + documents only ("not the whole
|
|
* world map"). See tripSyncManager / clearTileCache.
|
|
* disabledTripIds — trips the user explicitly excluded from offline storage.
|
|
* Everything else that is date-eligible is cached.
|
|
* pinnedTripIds : trips the user explicitly asked for that the date rule
|
|
* would otherwise skip or evict (a finished trip). Without
|
|
* this there was no way to say yes: the list was opt-out
|
|
* only, so ticking a past trip changed nothing and the
|
|
* download quietly stored none of them (#2228).
|
|
* conflictStrategy — what to do when an offline edit collides with a newer
|
|
* server change: 'ask' surfaces a per-conflict picker,
|
|
* 'mine'/'server' resolve automatically.
|
|
*/
|
|
|
|
export type ConflictStrategy = 'ask' | 'mine' | 'server'
|
|
|
|
export interface OfflinePrefs {
|
|
cacheTiles: boolean
|
|
disabledTripIds: number[]
|
|
pinnedTripIds: number[]
|
|
conflictStrategy: ConflictStrategy
|
|
}
|
|
|
|
const STORAGE_KEY = 'trek_offline_prefs'
|
|
|
|
const DEFAULTS: OfflinePrefs = {
|
|
cacheTiles: true,
|
|
disabledTripIds: [],
|
|
pinnedTripIds: [],
|
|
conflictStrategy: 'ask',
|
|
}
|
|
|
|
let _prefs: OfflinePrefs = read()
|
|
const listeners = new Set<() => void>()
|
|
|
|
function read(): OfflinePrefs {
|
|
try {
|
|
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null
|
|
if (!raw) return { ...DEFAULTS }
|
|
const parsed = JSON.parse(raw) as Partial<OfflinePrefs>
|
|
return {
|
|
cacheTiles: typeof parsed.cacheTiles === 'boolean' ? parsed.cacheTiles : DEFAULTS.cacheTiles,
|
|
disabledTripIds: Array.isArray(parsed.disabledTripIds) ? parsed.disabledTripIds.filter(n => typeof n === 'number') : [],
|
|
pinnedTripIds: Array.isArray(parsed.pinnedTripIds) ? parsed.pinnedTripIds.filter(n => typeof n === 'number') : [],
|
|
conflictStrategy: parsed.conflictStrategy === 'mine' || parsed.conflictStrategy === 'server' ? parsed.conflictStrategy : 'ask',
|
|
}
|
|
} catch {
|
|
return { ...DEFAULTS }
|
|
}
|
|
}
|
|
|
|
function write(next: OfflinePrefs): void {
|
|
_prefs = next
|
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) } catch { /* best-effort */ }
|
|
listeners.forEach(fn => { try { fn() } catch { /* isolate listeners */ } })
|
|
}
|
|
|
|
/** Current snapshot (a copy — callers must not mutate it in place). */
|
|
export function getOfflinePrefs(): OfflinePrefs {
|
|
return { ..._prefs, disabledTripIds: [..._prefs.disabledTripIds], pinnedTripIds: [..._prefs.pinnedTripIds] }
|
|
}
|
|
|
|
export function setCacheTiles(on: boolean): void {
|
|
if (_prefs.cacheTiles === on) return
|
|
write({ ..._prefs, cacheTiles: on })
|
|
}
|
|
|
|
export function setConflictStrategy(strategy: ConflictStrategy): void {
|
|
if (_prefs.conflictStrategy === strategy) return
|
|
write({ ..._prefs, conflictStrategy: strategy })
|
|
}
|
|
|
|
/** True when this trip should be cached offline (i.e. not explicitly disabled). */
|
|
export function isTripOfflineEnabled(tripId: number): boolean {
|
|
return !_prefs.disabledTripIds.includes(tripId)
|
|
}
|
|
|
|
/**
|
|
* True when the user explicitly asked to keep this trip, overriding the date
|
|
* rule that otherwise skips and evicts finished trips (tripSyncManager).
|
|
*/
|
|
export function isTripPinned(tripId: number): boolean {
|
|
return _prefs.pinnedTripIds.includes(tripId)
|
|
}
|
|
|
|
/** Turn offline storage for a single trip on or off. */
|
|
export function setTripOfflineEnabled(tripId: number, on: boolean): void {
|
|
const has = _prefs.disabledTripIds.includes(tripId)
|
|
if (on && !has) return
|
|
if (!on && has) return
|
|
const disabledTripIds = on
|
|
? _prefs.disabledTripIds.filter(id => id !== tripId)
|
|
: [..._prefs.disabledTripIds, tripId]
|
|
write({ ..._prefs, disabledTripIds })
|
|
}
|
|
|
|
/**
|
|
* Pin a trip the date rule would not keep on its own, or release it again.
|
|
* Turning a trip off releases the pin too, so "off then on" cannot leave a
|
|
* finished trip silently pinned.
|
|
*/
|
|
export function setTripPinned(tripId: number, on: boolean): void {
|
|
const has = _prefs.pinnedTripIds.includes(tripId)
|
|
if (on === has) return
|
|
const pinnedTripIds = on
|
|
? [..._prefs.pinnedTripIds, tripId]
|
|
: _prefs.pinnedTripIds.filter(id => id !== tripId)
|
|
write({ ..._prefs, pinnedTripIds })
|
|
}
|
|
|
|
/** Subscribe to preference changes. Returns an unsubscribe function. */
|
|
export function onOfflinePrefsChange(fn: () => void): () => void {
|
|
listeners.add(fn)
|
|
return () => { listeners.delete(fn) }
|
|
}
|
|
|
|
/** Reset to defaults — test helper only. */
|
|
export function _resetOfflinePrefs(): void {
|
|
_prefs = { ...DEFAULTS }
|
|
listeners.clear()
|
|
}
|