Files
TREK/client/src/sync/networkMode.ts
T
Maurice 6707dac4a9 feat(offline): force-offline mode, selective sync and a conflict queue
A force-offline override routes every read to the cache and every write to the queue; preparing for offline downloads trip data, documents and map tiles up front and waits for them to finish. Map tiles and individual trips can be left out of the cache. Queued edits carry the version they were based on so the queue can surface server conflicts for a keep-mine / keep-theirs decision; chained offline edits to one entity no longer conflict with each other, and evicting a trip preserves its unsynced writes.
2026-06-30 10:04:15 +02:00

100 lines
3.1 KiB
TypeScript

/**
* Network mode — the single source of truth for whether the app should behave
* as if it were offline right now.
*
* Two inputs combine here:
* - the real browser state (`navigator.onLine`)
* - a user-controlled "force offline" override (the Settings → Offline toggle)
*
* The repo layer, the mutation queue and the sync triggers all gate on
* `isEffectivelyOffline()` instead of reading `navigator.onLine` directly, so a
* forced-offline session routes every read to the Dexie cache and every write to
* the mutation queue exactly as a genuine disconnection would. The override is
* persisted so it survives a reload (a user who forced offline before boarding a
* plane stays offline after the PWA is relaunched).
*
* Forcing offline does NOT pretend the network is gone for everything: it is the
* caller's job (Settings → Offline) to pre-download first and only then flip the
* switch. See tripSyncManager.prepareForOffline().
*/
const STORAGE_KEY = 'trek_forced_offline'
let _forced = readPersisted()
const listeners = new Set<() => void>()
function readPersisted(): boolean {
try {
return typeof localStorage !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1'
} catch {
return false
}
}
function persist(v: boolean): void {
try {
if (v) localStorage.setItem(STORAGE_KEY, '1')
else localStorage.removeItem(STORAGE_KEY)
} catch {
/* private mode / quota — the in-memory flag still governs this session */
}
}
function notify(): void {
listeners.forEach(fn => {
try { fn() } catch { /* a listener throwing must not break the others */ }
})
}
/** True when the user has manually forced the app into offline mode. */
export function isForcedOffline(): boolean {
return _forced
}
/** Flip the manual force-offline override and notify subscribers. */
export function setForcedOffline(v: boolean): void {
if (_forced === v) return
_forced = v
persist(v)
notify()
}
/**
* True when the app should treat itself as offline: either the browser is
* genuinely offline OR the user forced offline mode. This is the flag the
* offline read/write paths must gate on.
*/
export function isEffectivelyOffline(): boolean {
return _forced || !navigator.onLine
}
/** Convenience inverse of {@link isEffectivelyOffline}. */
export function isEffectivelyOnline(): boolean {
return !isEffectivelyOffline()
}
/**
* Subscribe to network-mode changes (force-offline toggled, or the browser's own
* online/offline events). Returns an unsubscribe function. Registers the global
* browser listeners lazily on first subscription.
*/
export function onNetworkModeChange(fn: () => void): () => void {
ensureBrowserListeners()
listeners.add(fn)
return () => listeners.delete(fn)
}
let _browserListenersBound = false
function ensureBrowserListeners(): void {
if (_browserListenersBound || typeof window === 'undefined') return
_browserListenersBound = true
window.addEventListener('online', notify)
window.addEventListener('offline', notify)
}
/** Reset state — test helper only. */
export function _resetNetworkMode(): void {
_forced = false
listeners.clear()
}