From 6707dac4a967c54b84eed671fe83df2ab476c08b Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 30 Jun 2026 09:52:55 +0200 Subject: [PATCH] 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. --- client/src/db/offlineDb.ts | 34 ++- client/src/hooks/useNetworkMode.ts | 21 ++ .../src/pages/tripPlanner/useTripPlanner.ts | 3 +- client/src/repo/packingRepo.ts | 8 +- client/src/repo/placeRepo.ts | 13 +- client/src/repo/withOfflineFallback.ts | 14 +- client/src/store/tripStore.ts | 5 +- client/src/sync/mutationQueue.ts | 137 ++++++++++++- client/src/sync/networkMode.ts | 99 +++++++++ client/src/sync/offlinePrefs.ts | 101 +++++++++ client/src/sync/syncTriggers.ts | 32 ++- client/src/sync/tilePrefetcher.ts | 28 ++- client/src/sync/tripSyncManager.ts | 104 +++++++++- client/src/utils/fileDownload.ts | 3 +- client/tests/unit/db/offlineDb.test.ts | 16 ++ .../unit/sync/mutationQueue.conflict.test.ts | 193 ++++++++++++++++++ client/tests/unit/sync/networkMode.test.ts | 56 +++++ client/tests/unit/sync/offlinePrefs.test.ts | 60 ++++++ 18 files changed, 889 insertions(+), 38 deletions(-) create mode 100644 client/src/hooks/useNetworkMode.ts create mode 100644 client/src/sync/networkMode.ts create mode 100644 client/src/sync/offlinePrefs.ts create mode 100644 client/tests/unit/sync/mutationQueue.conflict.test.ts create mode 100644 client/tests/unit/sync/networkMode.test.ts create mode 100644 client/tests/unit/sync/offlinePrefs.test.ts diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index 536e8c8a..31c903be 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -8,7 +8,10 @@ export interface CachedTripMember extends TripMember { // ── Queue + sync types ──────────────────────────────────────────────────────── -export type MutationStatus = 'pending' | 'syncing' | 'failed'; +// 'conflict' is terminal-until-resolved: the server rejected the replay because +// the entity changed underneath the offline edit (#1135 ask 3). It is surfaced +// to the user for a keep-mine / keep-theirs decision rather than dropped. +export type MutationStatus = 'pending' | 'syncing' | 'failed' | 'conflict'; export interface QueuedMutation { /** UUID — also used as X-Idempotency-Key sent to the server */ @@ -33,6 +36,21 @@ export interface QueuedMutation { * mutation queue rewrites to the real server id once the dependent CREATE flushes. */ tempEntityId?: number; + /** + * Optimistic-concurrency token: the entity's `updated_at` at the moment the + * offline edit was made. Sent as `X-Base-Updated-At` on replay so the server + * can reject the write (409) if someone else changed the entity in the + * meantime. Absent for creates and for resources without a token. + */ + baseUpdatedAt?: string | null; + /** + * Set when the replay came back 409: the server's current version of the + * entity, kept so the conflict resolver can show "theirs" beside "mine" + * (which is reconstructed from `body`). Only present while status==='conflict'. + */ + conflictServer?: unknown; + /** When the conflict was detected (for ordering / display). */ + conflictAt?: number; } export interface SyncMeta { @@ -348,7 +366,16 @@ export async function enforceBlobBudget( // ── Eviction / cleanup ──────────────────────────────────────────────────────── -/** Delete all cached data for one trip (eviction or explicit clear). */ +/** + * Delete one trip's cached READ data (eviction, per-trip opt-out). The offline + * write queue is deliberately preserved except for already-dropped 'failed' rows: + * a trip can be evicted for being stale, or turned off in the storage settings, + * while it still holds unsynced offline edits (pending/syncing) or unresolved + * conflicts — those must survive so the user's work is not silently lost (#1135). + * The replay only needs the queued REST request, not the cached entities, and a + * successful flush re-adds the canonical row. The full "Clear cache" wipe goes + * through clearAll(), which intentionally drops everything. + */ export async function clearTripData(tripId: number): Promise { await offlineDb.transaction( 'rw', @@ -376,7 +403,8 @@ export async function clearTripData(tripId: number): Promise { await offlineDb.tripFiles.where('trip_id').equals(tripId).delete(); await offlineDb.accommodations.where('trip_id').equals(tripId).delete(); await offlineDb.tripMembers.where('tripId').equals(tripId).delete(); - await offlineDb.mutationQueue.where('tripId').equals(tripId).delete(); + // Keep pending/syncing/conflict mutations — only purge dead 'failed' rows. + await offlineDb.mutationQueue.where('tripId').equals(tripId).and(m => m.status === 'failed').delete(); await offlineDb.syncMeta.where('tripId').equals(tripId).delete(); await offlineDb.blobCache.where('tripId').equals(tripId).delete(); }, diff --git a/client/src/hooks/useNetworkMode.ts b/client/src/hooks/useNetworkMode.ts new file mode 100644 index 00000000..0a265f6d --- /dev/null +++ b/client/src/hooks/useNetworkMode.ts @@ -0,0 +1,21 @@ +import { useSyncExternalStore } from 'react' +import { + isEffectivelyOffline, + isForcedOffline, + setForcedOffline, + onNetworkModeChange, +} from '../sync/networkMode' + +/** + * React binding for the global network mode. Re-renders when the browser goes + * online/offline or the user toggles force-offline. + * + * offline — the effective offline state (real disconnection OR forced) + * forced — whether the user has the force-offline switch on + * setForced — flip the force-offline switch + */ +export function useNetworkMode(): { offline: boolean; forced: boolean; setForced: (v: boolean) => void } { + const offline = useSyncExternalStore(onNetworkModeChange, isEffectivelyOffline, () => true) + const forced = useSyncExternalStore(onNetworkModeChange, isForcedOffline, () => false) + return { offline, forced, setForced: setForcedOffline } +} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index adfcb0ad..a120a97e 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -12,6 +12,7 @@ import { parsedItemToDraft, isTransportItem, type BookingReviewDraft } from '../ import type { BookingImportPreviewItem } from '@trek/shared' import { accommodationRepo } from '../../repo/accommodationRepo' import { offlineDb, getImportFiles, deleteImportFiles } from '../../db/offlineDb' +import { isEffectivelyOffline } from '../../sync/networkMode' import { useBackgroundTasksStore } from '../../store/backgroundTasksStore' import { useAuthStore } from '../../store/authStore' import { useResizablePanels } from '../../hooks/useResizablePanels' @@ -254,7 +255,7 @@ export function useTripPlanner() { if (tripId) { tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') }) loadAccommodations() - if (!navigator.onLine) { + if (isEffectivelyOffline()) { offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray() .then(rows => setTripMembers(rows)) .catch(() => {}) diff --git a/client/src/repo/packingRepo.ts b/client/src/repo/packingRepo.ts index 36adc2fd..38ba2da7 100644 --- a/client/src/repo/packingRepo.ts +++ b/client/src/repo/packingRepo.ts @@ -1,6 +1,7 @@ import { packingApi } from '../api/client' import { offlineDb, upsertPackingItems } from '../db/offlineDb' import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue' +import { isEffectivelyOffline } from '../sync/networkMode' import { onlineThenCache } from './withOfflineFallback' import type { PackingItem } from '../types' @@ -20,7 +21,7 @@ export const packingRepo = { }, async create(tripId: number | string, data: Record & { name: string }): Promise<{ item: PackingItem }> { - if (!navigator.onLine) { + if (isEffectivelyOffline()) { const tempId = nextTempId() const tempItem: PackingItem = { ...(data as Partial), @@ -48,7 +49,7 @@ export const packingRepo = { }, async update(tripId: number | string, id: number, data: Record): Promise<{ item: PackingItem }> { - if (!navigator.onLine) { + if (isEffectivelyOffline()) { const existing = await offlineDb.packingItems.get(id) const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial), id } await offlineDb.packingItems.put(optimistic) @@ -62,6 +63,7 @@ export const packingRepo = { body: data, resource: 'packingItems', entityId: id, + baseUpdatedAt: existing?.updated_at ?? null, ...(isTemp ? { tempEntityId: id } : {}), }) return { item: optimistic } @@ -72,7 +74,7 @@ export const packingRepo = { }, async delete(tripId: number | string, id: number): Promise { - if (!navigator.onLine) { + if (isEffectivelyOffline()) { await offlineDb.packingItems.delete(id) const mutId = generateUUID() const isTemp = id < 0 diff --git a/client/src/repo/placeRepo.ts b/client/src/repo/placeRepo.ts index 1f20e182..12a654d1 100644 --- a/client/src/repo/placeRepo.ts +++ b/client/src/repo/placeRepo.ts @@ -1,6 +1,7 @@ import { placesApi } from '../api/client' import { offlineDb, upsertPlaces } from '../db/offlineDb' import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue' +import { isEffectivelyOffline } from '../sync/networkMode' import { onlineThenCache } from './withOfflineFallback' import type { Place } from '../types' @@ -20,7 +21,7 @@ export const placeRepo = { }, async create(tripId: number | string, data: Record & { name: string }): Promise<{ place: Place }> { - if (!navigator.onLine) { + if (isEffectivelyOffline()) { const tempId = nextTempId() const tempPlace: Place = { ...(data as Partial), @@ -47,7 +48,7 @@ export const placeRepo = { }, async update(tripId: number | string, id: number | string, data: Record): Promise<{ place: Place }> { - if (!navigator.onLine) { + if (isEffectivelyOffline()) { const existing = await offlineDb.places.get(Number(id)) const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial), id: Number(id) } await offlineDb.places.put(optimistic) @@ -61,6 +62,7 @@ export const placeRepo = { body: data, resource: 'places', entityId: Number(id), + baseUpdatedAt: existing?.updated_at ?? null, ...(isTemp ? { tempEntityId: Number(id) } : {}), }) return { place: optimistic } @@ -71,7 +73,7 @@ export const placeRepo = { }, async delete(tripId: number | string, id: number | string): Promise { - if (!navigator.onLine) { + if (isEffectivelyOffline()) { await offlineDb.places.delete(Number(id)) const mutId = generateUUID() const isTemp = Number(id) < 0 @@ -93,7 +95,7 @@ export const placeRepo = { }, async deleteMany(tripId: number | string, ids: number[]): Promise { - if (!navigator.onLine) { + if (isEffectivelyOffline()) { await offlineDb.places.bulkDelete(ids) for (const id of ids) { const mutId = generateUUID() @@ -117,7 +119,7 @@ export const placeRepo = { }, async updateMany(tripId: number | string, ids: number[], data: Record): Promise<{ updated: number[]; count: number }> { - if (!navigator.onLine) { + if (isEffectivelyOffline()) { // Offline fans out one queued PUT per id (mirrors deleteMany's DELETE fan-out). for (const id of ids) { const existing = await offlineDb.places.get(id) @@ -132,6 +134,7 @@ export const placeRepo = { body: data, resource: 'places', entityId: id, + baseUpdatedAt: existing?.updated_at ?? null, ...(isTemp ? { tempEntityId: id } : {}), }) } diff --git a/client/src/repo/withOfflineFallback.ts b/client/src/repo/withOfflineFallback.ts index fba7a063..07ae4553 100644 --- a/client/src/repo/withOfflineFallback.ts +++ b/client/src/repo/withOfflineFallback.ts @@ -1,3 +1,5 @@ +import { isEffectivelyOffline } from '../sync/networkMode' + /** * True when an error means the request never reached the server — a network-level * failure (offline, captive portal, proxy auth wall, dropped connection, CORS). @@ -22,11 +24,11 @@ function isNetworkError(err: unknown): boolean { * connection (H2). Rather than surfacing that (which blanks the trip even * though a good cached copy exists), we fall back to the cache. * - * We intentionally gate only on `navigator.onLine`, NOT the connectivity probe: - * the probe is a coarse global flag, and a single failed health check would - * otherwise force every read to the (possibly empty) cache even when the request - * itself would succeed. The network-error catch below covers the captive-portal - * case the probe was meant to. + * We gate on the effective offline state (real `navigator.onLine` OR the user's + * force-offline override), NOT the connectivity probe: the probe is a coarse + * global flag, and a single failed health check would otherwise force every read + * to the (possibly empty) cache even when the request itself would succeed. The + * network-error catch below covers the captive-portal case the probe was meant to. * * A genuine HTTP error (404/403/500 — the server responded) is NOT swallowed: it * is rethrown so callers can set error state, navigate away, etc. @@ -38,7 +40,7 @@ export async function onlineThenCache( onlineFn: () => Promise, cacheFn: () => Promise, ): Promise { - if (!navigator.onLine) return cacheFn() + if (isEffectivelyOffline()) return cacheFn() try { return await onlineFn() } catch (err) { diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index b8826ae8..2597c099 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -10,6 +10,7 @@ import { todoRepo } from '../repo/todoRepo' import { budgetRepo } from '../repo/budgetRepo' import { reservationRepo } from '../repo/reservationRepo' import { fileRepo } from '../repo/fileRepo' +import { isEffectivelyOnline } from '../sync/networkMode' import { createPlacesSlice } from './slices/placesSlice' import { createAssignmentsSlice } from './slices/assignmentsSlice' import { createDaysSlice } from './slices/daysSlice' @@ -128,10 +129,10 @@ export const useTripStore = create((set, get) => ({ budgetRepo.list(tripId).catch(() => ({ items: [] as BudgetItem[] })), reservationRepo.list(tripId).catch(() => ({ reservations: [] as Reservation[] })), fileRepo.list(tripId).catch(() => ({ files: [] as TripFile[] })), - navigator.onLine + isEffectivelyOnline() ? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags }))) : offlineDb.tags.toArray().then(tags => ({ tags })), - navigator.onLine + isEffectivelyOnline() ? categoriesApi.list().catch(() => offlineDb.categories.toArray().then(categories => ({ categories }))) : offlineDb.categories.toArray().then(categories => ({ categories })), ]) diff --git a/client/src/sync/mutationQueue.ts b/client/src/sync/mutationQueue.ts index 717d6997..43112821 100644 --- a/client/src/sync/mutationQueue.ts +++ b/client/src/sync/mutationQueue.ts @@ -8,6 +8,8 @@ import { offlineDb } from '../db/offlineDb' import { apiClient } from '../api/client' import { isAuthed } from './authGate' +import { isEffectivelyOffline } from './networkMode' +import { getOfflinePrefs } from './offlinePrefs' import type { QueuedMutation } from '../db/offlineDb' import type { Table } from 'dexie' @@ -62,6 +64,22 @@ function isRetryableStatus(status: number | undefined): boolean { return status === 401 || status === 408 || status === 425 || status === 429 } +/** Pull the server's current entity out of a 409 response body ({ server: {...} }). */ +function extractConflictServer(err: unknown): unknown { + const data = (err as { response?: { data?: unknown } })?.response?.data + if (data && typeof data === 'object' && 'server' in data) { + return (data as { server: unknown }).server + } + return null +} + +/** Write a server entity into its Dexie table (used when "theirs" wins a conflict). */ +async function applyServerEntity(mutation: QueuedMutation, server: unknown): Promise { + if (!mutation.resource || !server || typeof server !== 'object' || !('id' in server)) return + const table = getTable(mutation.resource) + if (table) await table.put(server) +} + export const mutationQueue = { /** * Add a mutation to the queue. @@ -89,12 +107,19 @@ export const mutationQueue = { * 4xx responses are marked failed and skipped. */ async flush(): Promise { - if (_flushing || !navigator.onLine || !isAuthed()) return + if (_flushing || isEffectivelyOffline() || !isAuthed()) return _flushing = true // tempId → realId learned during this flush, so a dependent edit/delete // queued against an offline-created entity (still holding the negative id) // can be rewritten to the server id before it is replayed. const idMap = new Map() + // resource:entityId → freshest updated_at applied during this flush. A second + // queued edit of the same entity must send THIS token, not the stale one its + // snapshot was loaded with, or it would 409 against our own first edit (#1135). + const tokenMap = new Map() + // Set when a conflict auto-resolved as "mine wins": the mutation is re-queued + // without its base token, so one more pass overwrites the server cleanly. + let needsRetry = false try { const pending = await offlineDb.mutationQueue .where('status') @@ -128,11 +153,20 @@ export const mutationQueue = { } try { + // Send the optimistic-concurrency token when we have one so the server + // can reject a stale overwrite (409). Absent header => unconditional + // write (back-compat with servers / resources that don't check it). + // A newer token learned earlier in THIS flush (an earlier edit of the + // same entity) overrides the snapshot's stale base. + const headers: Record = { 'X-Idempotency-Key': mutation.id } + const tokenKey = mutation.resource !== undefined && reqEntityId !== undefined ? `${mutation.resource}:${reqEntityId}` : undefined + const baseToken = (tokenKey && tokenMap.get(tokenKey)) || mutation.baseUpdatedAt + if (baseToken) headers['X-Base-Updated-At'] = baseToken const response = await apiClient.request({ method: mutation.method, url: reqUrl, data: mutation.body, - headers: { 'X-Idempotency-Key': mutation.id }, + headers, }) // Apply canonical server response to Dexie @@ -161,6 +195,29 @@ export const mutationQueue = { }) } await table.put(entity) + // Advance the base-version token of any other queued edits to the + // same entity to the value we just wrote. Without this, a second + // offline edit of the same place/item still carries the pre-flush + // token and would 409 against our OWN just-applied first edit — + // self-conflicting and risking loss of the later edit (#1135). + const newToken = (entity as { updated_at?: unknown }).updated_at + if (typeof newToken === 'string') { + // In-memory: consulted when the sibling is replayed later in this + // same flush (its snapshot still holds the stale base). + if (mutation.resource) tokenMap.set(`${mutation.resource}:${realId}`, newToken) + // Durable: survives a flush boundary / reload if the sibling is + // not reached this pass. + await offlineDb.mutationQueue + .where('tripId') + .equals(mutation.tripId) + .filter(m => + m.id !== mutation.id && + m.resource === mutation.resource && + m.entityId === realId && + (m.status === 'pending' || m.status === 'syncing'), + ) + .modify(m => { m.baseUpdatedAt = newToken }) + } } } } else if (mutation.method === 'DELETE' && mutation.resource && reqEntityId !== undefined) { @@ -172,6 +229,37 @@ export const mutationQueue = { await offlineDb.mutationQueue.delete(mutation.id) } catch (err: unknown) { const httpStatus = (err as { response?: { status: number } })?.response?.status + + // 409 = the entity changed on the server since this offline edit was + // made. This is NOT a dropped change like other 4xx — resolve it per + // the user's strategy instead of failing it. Deliberately scoped to + // edits: an offline DELETE is "delete wins" by design (no CAS on the + // delete path), so it never reaches here. See the wiki Offline doc. + if (httpStatus === 409 && mutation.method !== 'DELETE') { + const server = extractConflictServer(err) + const strategy = getOfflinePrefs().conflictStrategy + if (strategy === 'server') { + // Theirs wins: adopt the server's version locally, drop our write. + await applyServerEntity(mutation, server) + await offlineDb.mutationQueue.delete(mutation.id) + } else if (strategy === 'mine') { + // Mine wins: re-queue without the base token so the next pass + // overwrites unconditionally. + await offlineDb.mutationQueue.update(mutation.id, { + status: 'pending', baseUpdatedAt: null, conflictServer: undefined, + attempts: mutation.attempts + 1, lastError: null, + }) + needsRetry = true + } else { + // Ask: park it as a conflict for the user to resolve. + await offlineDb.mutationQueue.update(mutation.id, { + status: 'conflict', conflictServer: server ?? null, conflictAt: Date.now(), + attempts: mutation.attempts + 1, lastError: 'conflict', + }) + } + continue + } + const isTerminal = httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500 && !isRetryableStatus(httpStatus) if (isTerminal) { @@ -200,6 +288,12 @@ export const mutationQueue = { } finally { _flushing = false } + // A "mine wins" auto-resolution dropped its base token; one more pass now + // overwrites the server unconditionally. Bounded: the retried write carries + // no token, so it cannot 409 for the same reason. + if (needsRetry && !isEffectivelyOffline()) { + await this.flush() + } }, /** @@ -237,6 +331,45 @@ export const mutationQueue = { .count() }, + /** Count unresolved sync conflicts (offline edits the server rejected as stale). */ + async conflictCount(): Promise { + return offlineDb.mutationQueue + .where('status') + .equals('conflict') + .count() + }, + + /** All unresolved conflicts, newest first, optionally scoped to one trip. */ + async conflicts(tripId?: number): Promise { + const all = await offlineDb.mutationQueue.where('status').equals('conflict').toArray() + const scoped = tripId === undefined ? all : all.filter(m => m.tripId === tripId) + return scoped.sort((a, b) => (b.conflictAt ?? 0) - (a.conflictAt ?? 0)) + }, + + /** + * Resolve a conflict by keeping the local (offline) edit: re-queue it without + * the base token so the next flush overwrites the server unconditionally. + */ + async resolveKeepMine(id: string): Promise { + const m = await offlineDb.mutationQueue.get(id) + if (!m || m.status !== 'conflict') return + await offlineDb.mutationQueue.update(id, { + status: 'pending', baseUpdatedAt: null, conflictServer: undefined, conflictAt: undefined, lastError: null, + }) + await this.flush() + }, + + /** + * Resolve a conflict by keeping the server's version: adopt it into the local + * cache and drop the queued write. + */ + async resolveKeepServer(id: string): Promise { + const m = await offlineDb.mutationQueue.get(id) + if (!m || m.status !== 'conflict') return + await applyServerEntity(m, m.conflictServer) + await offlineDb.mutationQueue.delete(id) + }, + /** Reset internal flushing flag and timestamp counters — useful in tests. */ _resetFlushing(): void { _flushing = false diff --git a/client/src/sync/networkMode.ts b/client/src/sync/networkMode.ts new file mode 100644 index 00000000..e13f6dd8 --- /dev/null +++ b/client/src/sync/networkMode.ts @@ -0,0 +1,99 @@ +/** + * 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() +} diff --git a/client/src/sync/offlinePrefs.ts b/client/src/sync/offlinePrefs.ts new file mode 100644 index 00000000..90bf18e1 --- /dev/null +++ b/client/src/sync/offlinePrefs.ts @@ -0,0 +1,101 @@ +/** + * 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. + * 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[] + conflictStrategy: ConflictStrategy +} + +const STORAGE_KEY = 'trek_offline_prefs' + +const DEFAULTS: OfflinePrefs = { + cacheTiles: true, + disabledTripIds: [], + 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 + return { + cacheTiles: typeof parsed.cacheTiles === 'boolean' ? parsed.cacheTiles : DEFAULTS.cacheTiles, + disabledTripIds: Array.isArray(parsed.disabledTripIds) ? parsed.disabledTripIds.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] } +} + +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) +} + +/** 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 }) +} + +/** 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() +} diff --git a/client/src/sync/syncTriggers.ts b/client/src/sync/syncTriggers.ts index 7b2ea248..a94aaf1f 100644 --- a/client/src/sync/syncTriggers.ts +++ b/client/src/sync/syncTriggers.ts @@ -14,12 +14,15 @@ */ import { mutationQueue } from './mutationQueue' import { tripSyncManager } from './tripSyncManager' +import { isEffectivelyOnline, onNetworkModeChange } from './networkMode' import { setPreReconnectHook, setRefetchCallback, getActiveTrips } from '../api/websocket' import { useTripStore } from '../store/tripStore' const PERIODIC_MS = 30_000 let _intervalId: ReturnType | null = null +let _unsubscribeNetworkMode: (() => void) | null = null +let _wasEffectivelyOnline = isEffectivelyOnline() let _registered = false /** Pull the latest server state for every open trip into the Zustand store. */ @@ -36,6 +39,11 @@ function rehydrateActiveTrips() { * edits made while we were offline appear without navigating away. */ function onOnline() { + // A real browser reconnect must NOT override a user-forced offline session: + // syncAll would re-seed Dexie from the server and wipe un-flushed optimistic + // edits from the cache/UI. Stay put until the user lifts the switch (which + // routes through onNetworkMode → here with the force flag already cleared). + if (!isEffectivelyOnline()) return mutationQueue.flush() .catch(console.error) .finally(() => { @@ -46,18 +54,30 @@ function onOnline() { /** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */ function onVisibility() { - if (!document.hidden && navigator.onLine) { + if (!document.hidden && isEffectivelyOnline()) { mutationQueue.flush().catch(console.error) } } /** Periodic heartbeat — drain any lingering pending mutations. */ function onPeriodic() { - if (navigator.onLine) { + if (isEffectivelyOnline()) { mutationQueue.flush().catch(console.error) } } +/** + * The force-offline toggle (or a browser online/offline event) changed the + * effective network mode. Coming back online — whether the network returned or + * the user lifted the force-offline switch — behaves like a real reconnection: + * flush queued writes, then re-seed and re-hydrate. + */ +function onNetworkMode() { + const nowOnline = isEffectivelyOnline() + if (nowOnline && !_wasEffectivelyOnline) onOnline() + _wasEffectivelyOnline = nowOnline +} + export function registerSyncTriggers(): void { if (_registered) return _registered = true @@ -73,6 +93,10 @@ export function registerSyncTriggers(): void { window.addEventListener('online', onOnline) document.addEventListener('visibilitychange', onVisibility) + // React to the force-offline toggle (and browser online/offline) so lifting + // the switch immediately flushes + re-seeds like a real reconnection. + _wasEffectivelyOnline = isEffectivelyOnline() + _unsubscribeNetworkMode = onNetworkModeChange(onNetworkMode) _intervalId = setInterval(onPeriodic, PERIODIC_MS) } @@ -84,6 +108,10 @@ export function unregisterSyncTriggers(): void { setRefetchCallback(null) window.removeEventListener('online', onOnline) document.removeEventListener('visibilitychange', onVisibility) + if (_unsubscribeNetworkMode) { + _unsubscribeNetworkMode() + _unsubscribeNetworkMode = null + } if (_intervalId !== null) { clearInterval(_intervalId) _intervalId = null diff --git a/client/src/sync/tilePrefetcher.ts b/client/src/sync/tilePrefetcher.ts index 016c9685..f4022392 100644 --- a/client/src/sync/tilePrefetcher.ts +++ b/client/src/sync/tilePrefetcher.ts @@ -143,11 +143,16 @@ export async function prefetchTiles( tileUrlTemplate: string, minZoom = 10, maxZoom = 16, + awaitAll = false, ): Promise { if (!navigator.onLine) return 0 if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0 let fetched = 0 + // When awaitAll is set (the "prepare for offline" path), we wait for every tile + // request to settle so the caller's progress bar only completes once the tiles + // are actually downloaded into the SW cache — not merely dispatched. + const inflight: Promise[] = [] for (let z = minZoom; z <= maxZoom; z++) { const minX = lngToTileX(bbox.minLng, z) @@ -161,16 +166,32 @@ export async function prefetchTiles( for (let x = minX; x <= maxX; x++) { for (let y = minY; y <= maxY; y++) { const url = buildTileUrl(tileUrlTemplate, z, x, y) - // Fire-and-forget: SW CacheFirst handler stores the response - fetch(url, { mode: 'no-cors' }).catch(() => {}) + // SW CacheFirst handler stores the response. Fire-and-forget unless the + // caller asked to await completion. + const p = fetch(url, { mode: 'no-cors' }).catch(() => {}) + if (awaitAll) inflight.push(p) fetched++ } } } + if (awaitAll && inflight.length) await Promise.allSettled(inflight) return fetched } +/** + * Drop the pre-downloaded map-tile cache. Called when the user turns off + * "store map tiles offline" (#1135 ask 2) so the bulk tile storage — the real + * "whole world map" concern — is reclaimed immediately. + */ +export async function clearTileCache(): Promise { + try { + if (typeof caches !== 'undefined') await caches.delete('map-tiles') + } catch { + /* Cache Storage unavailable (no SW / private mode) — nothing to clear */ + } +} + /** * Full pipeline: compute bbox → guard → prefetch → update syncMeta. * Designed to be called fire-and-forget from tripSyncManager. @@ -179,6 +200,7 @@ export async function prefetchTilesForTrip( tripId: number, places: Place[], tileUrlTemplate?: string, + awaitAll = false, ): Promise { const template = tileUrlTemplate || DEFAULT_TILE_URL const bbox = computeBbox(places) @@ -194,7 +216,7 @@ export async function prefetchTilesForTrip( // tile providers that don't send CORS headers. To stop the browser evicting // these tiles under the inflated quota, we request persistent storage at app // init instead (sync/persistentStorage.ts). - const fetched = await prefetchTiles(bbox, template) + const fetched = await prefetchTiles(bbox, template, 10, 16, awaitAll) // Update syncMeta with bbox and tile count const meta = await offlineDb.syncMeta.get(tripId) diff --git a/client/src/sync/tripSyncManager.ts b/client/src/sync/tripSyncManager.ts index 0a0e77e5..f14b4b1d 100644 --- a/client/src/sync/tripSyncManager.ts +++ b/client/src/sync/tripSyncManager.ts @@ -31,6 +31,7 @@ import { } from '../db/offlineDb' import { prefetchTilesForTrip } from './tilePrefetcher' import { isAuthed } from './authGate' +import { getOfflinePrefs, isTripOfflineEnabled } from './offlinePrefs' import { useSettingsStore } from '../store/settingsStore' import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types' @@ -130,26 +131,46 @@ async function cacheFilesForTrip(files: TripFile[]): Promise { // ── Public API ──────────────────────────────────────────────────────────────── +/** Progress callback payload for a {@link tripSyncManager.prepareForOffline} run. */ +export interface PrepareProgress { + /** Current stage. 'done' fires once at the end. */ + phase: 'trips' | 'files' | 'tiles' | 'done' + /** 1-based index of the trip currently processed in this phase. */ + current: number + /** Total trips to process in this phase. */ + total: number + /** Name of the trip currently processed (for the UI). */ + label?: string +} + let _syncing = false +/** + * Decide which trips to cache and which to drop, honouring both the date rule + * and the user's per-trip offline choices (#1135 ask 2). Returns the trips to + * sync; clears Dexie for stale or user-disabled trips as a side effect. + */ +async function reconcileTrips(trips: Trip[]): Promise { + const stale = trips.filter(isStale) + // Trips the user turned off explicitly are evicted regardless of date. + const disabled = trips.filter(t => !isTripOfflineEnabled(t.id)) + await Promise.all([...stale, ...disabled].map(t => clearTripData(t.id).catch(console.error))) + return trips.filter(t => shouldCache(t) && isTripOfflineEnabled(t.id)) +} + export const tripSyncManager = { /** * Sync all cache-eligible trips. - * Evicts stale trips. Caches file blobs in the background. - * No-ops when offline. + * Evicts stale and user-disabled trips. Caches file blobs + map tiles in the + * background. No-ops when offline. */ async syncAll(): Promise { if (_syncing || !navigator.onLine || !isAuthed()) return _syncing = true try { const { trips } = await tripsApi.list() as { trips: Trip[] } + const toSync = await reconcileTrips(trips) - // Evict stale trips first - const stale = trips.filter(isStale) - await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error))) - - // Sync eligible trips - const toSync = trips.filter(shouldCache) for (const trip of toSync) { try { await syncTrip(trip.id) @@ -163,19 +184,82 @@ export const tripSyncManager = { categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}) // Cache file blobs + map tiles in background (don't block syncAll) + const cacheTiles = getOfflinePrefs().cacheTiles const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined for (const trip of toSync) { const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray() cacheFilesForTrip(files).catch(console.error) - const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray() - prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error) + if (cacheTiles) { + const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray() + prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error) + } } } finally { _syncing = false } }, + /** + * "Prepare for offline" (#1135 ask 1): a fully-awaited sync the user runs while + * still online so everything they need is guaranteed on-device before they go + * offline. Unlike syncAll, this AWAITS file-blob and map-tile downloads and + * reports progress, so the UI can show a real completion state instead of + * resolving the moment the requests are merely dispatched. + * + * Returns the number of trips prepared. + */ + async prepareForOffline(onProgress?: (p: PrepareProgress) => void): Promise { + if (_syncing || !navigator.onLine || !isAuthed()) return 0 + _syncing = true + try { + const { trips } = await tripsApi.list() as { trips: Trip[] } + const toSync = await reconcileTrips(trips) + const total = toSync.length + + // 1) Trip bundles (structured data). + let i = 0 + for (const trip of toSync) { + onProgress?.({ phase: 'trips', current: ++i, total, label: trip.title }) + try { + await syncTrip(trip.id) + } catch (err) { + console.error(`[tripSync] prepare failed for trip ${trip.id}:`, err) + } + } + + // Global user data (tags + categories) — awaited here. + await Promise.all([ + tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {}), + categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {}), + ]) + + // 2) File blobs — awaited so "prepared" really means downloaded. + i = 0 + for (const trip of toSync) { + onProgress?.({ phase: 'files', current: ++i, total, label: trip.title }) + const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray() + await cacheFilesForTrip(files).catch(console.error) + } + + // 3) Map tiles — awaited, and only when the user opted to store them. + if (getOfflinePrefs().cacheTiles) { + const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined + i = 0 + for (const trip of toSync) { + onProgress?.({ phase: 'tiles', current: ++i, total, label: trip.title }) + const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray() + await prefetchTilesForTrip(trip.id, places, tileUrl, true).catch(console.error) + } + } + + onProgress?.({ phase: 'done', current: total, total }) + return total + } finally { + _syncing = false + } + }, + /** Reset syncing flag — useful in tests. */ _resetSyncing(): void { _syncing = false diff --git a/client/src/utils/fileDownload.ts b/client/src/utils/fileDownload.ts index be65616f..1a9fe300 100644 --- a/client/src/utils/fileDownload.ts +++ b/client/src/utils/fileDownload.ts @@ -1,4 +1,5 @@ import { getCachedBlob } from '../db/offlineDb' +import { isEffectivelyOffline } from '../sync/networkMode' // MIME types safe to open inline (will not execute script in any browser). // Everything else (text/html, image/svg+xml, text/javascript, …) is forced to @@ -51,7 +52,7 @@ function isIosStandalone(): boolean { */ async function getFileBlob(url: string): Promise { assertRelativeUrl(url) - if (typeof navigator !== 'undefined' && navigator.onLine === false) { + if (typeof navigator !== 'undefined' && isEffectivelyOffline()) { const cached = await getCachedBlob(url) if (cached) return cached throw new Error('File not available offline') diff --git a/client/tests/unit/db/offlineDb.test.ts b/client/tests/unit/db/offlineDb.test.ts index 1d130a72..904bc531 100644 --- a/client/tests/unit/db/offlineDb.test.ts +++ b/client/tests/unit/db/offlineDb.test.ts @@ -319,6 +319,22 @@ describe('offlineDb — clearTripData', () => { expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1); expect(await offlineDb.blobCache.get('/api/files/2/download')).toBeDefined(); }); + + it('preserves unsynced (pending/conflict) writes but drops dead failed ones (#1135)', async () => { + await upsertTrip(makeTrip(1)); + await offlineDb.mutationQueue.bulkPut([ + { id: 'p1', tripId: 1, method: 'PUT', url: '/trips/1/places/10', body: { name: 'X' }, createdAt: 1, status: 'pending', attempts: 0, lastError: null, resource: 'places', entityId: 10 }, + { id: 'c1', tripId: 1, method: 'PUT', url: '/trips/1/places/11', body: { name: 'Y' }, createdAt: 2, status: 'conflict', attempts: 1, lastError: 'conflict', resource: 'places', entityId: 11 }, + { id: 'f1', tripId: 1, method: 'PUT', url: '/trips/1/places/12', body: { name: 'Z' }, createdAt: 3, status: 'failed', attempts: 1, lastError: 'boom', resource: 'places', entityId: 12 }, + ]); + + await clearTripData(1); + + // The trip's cached read data is gone, but the unsynced work survives. + expect(await offlineDb.mutationQueue.get('p1')).toBeDefined(); + expect(await offlineDb.mutationQueue.get('c1')).toBeDefined(); + expect(await offlineDb.mutationQueue.get('f1')).toBeUndefined(); + }); }); describe('offlineDb — clearAll', () => { diff --git a/client/tests/unit/sync/mutationQueue.conflict.test.ts b/client/tests/unit/sync/mutationQueue.conflict.test.ts new file mode 100644 index 00000000..64c58117 --- /dev/null +++ b/client/tests/unit/sync/mutationQueue.conflict.test.ts @@ -0,0 +1,193 @@ +/** + * mutationQueue conflict tests (#1135) — 409 handling + resolution. + * + * Covers: X-Base-Updated-At header, 'ask' parks a conflict, keep-mine re-sends + * unconditionally, keep-theirs adopts the server entity, and the 'mine'/'server' + * auto-strategies. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import 'fake-indexeddb/auto' +import { server } from '../../helpers/msw/server' +import { http, HttpResponse } from 'msw' +import { setAuthed } from '../../../src/sync/authGate' +import { mutationQueue, generateUUID } from '../../../src/sync/mutationQueue' +import { offlineDb, clearAll } from '../../../src/db/offlineDb' +import { _resetNetworkMode } from '../../../src/sync/networkMode' +import { _resetOfflinePrefs, setConflictStrategy } from '../../../src/sync/offlinePrefs' +import { buildPlace } from '../../helpers/factories' + +beforeEach(async () => { + await clearAll() + mutationQueue._resetFlushing() + _resetNetworkMode() + _resetOfflinePrefs() + setAuthed(true) + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }) +}) + +afterEach(() => { + vi.restoreAllMocks() + setAuthed(false) +}) + +const BASE = '2026-01-01 00:00:00' + +function enqueueConflictingPut(id: string, baseUpdatedAt: string | null = BASE) { + return mutationQueue.enqueue({ + id, tripId: 1, method: 'PUT', url: '/trips/1/places/42', + body: { name: 'Mine' }, resource: 'places', entityId: 42, baseUpdatedAt, + }) +} + +/** Server that 409s when the base token is sent, and 200s once it isn't. */ +function conflictThenAcceptHandler(serverName = 'Theirs') { + server.use( + http.put('/api/trips/1/places/42', ({ request }) => { + if (request.headers.get('X-Base-Updated-At')) { + return HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: serverName }) }, { status: 409 }) + } + return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42, name: 'Mine' }) }) + }), + ) +} + +describe('mutationQueue — base-version header', () => { + it('sends X-Base-Updated-At when the mutation carries a base version', async () => { + let captured: string | null = null + server.use(http.put('/api/trips/1/places/42', ({ request }) => { + captured = request.headers.get('X-Base-Updated-At') + return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) }) + })) + await enqueueConflictingPut(generateUUID()) + await mutationQueue.flush() + expect(captured).toBe(BASE) + }) + + it('omits the header when there is no base version', async () => { + let hadHeader = true + server.use(http.put('/api/trips/1/places/42', ({ request }) => { + hadHeader = request.headers.has('X-Base-Updated-At') + return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) }) + })) + await enqueueConflictingPut(generateUUID(), null) + await mutationQueue.flush() + expect(hadHeader).toBe(false) + }) +}) + +describe('mutationQueue — 409 with strategy "ask"', () => { + it('parks the mutation as a conflict carrying the server version', async () => { + const id = generateUUID() + server.use(http.put('/api/trips/1/places/42', () => + HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 }))) + await enqueueConflictingPut(id) + + await mutationQueue.flush() + + const m = await offlineDb.mutationQueue.get(id) + expect(m!.status).toBe('conflict') + expect((m!.conflictServer as { name: string }).name).toBe('Theirs') + expect(await mutationQueue.conflictCount()).toBe(1) + expect(await mutationQueue.failedCount()).toBe(0) + }) + + it('does not count a conflict as pending and is skipped by later flushes', async () => { + const id = generateUUID() + server.use(http.put('/api/trips/1/places/42', () => + HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42 }) }, { status: 409 }))) + await enqueueConflictingPut(id) + await mutationQueue.flush() + expect(await mutationQueue.pendingCount()).toBe(0) + // A second flush must not touch the parked conflict. + await mutationQueue.flush() + expect((await offlineDb.mutationQueue.get(id))!.status).toBe('conflict') + }) +}) + +describe('mutationQueue — conflict resolution', () => { + it('keep-mine re-sends without the base token and clears the conflict', async () => { + const id = generateUUID() + conflictThenAcceptHandler() + await enqueueConflictingPut(id) + await mutationQueue.flush() + expect((await offlineDb.mutationQueue.get(id))!.status).toBe('conflict') + + await mutationQueue.resolveKeepMine(id) + + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined() + expect((await offlineDb.places.get(42))!.name).toBe('Mine') + expect(await mutationQueue.conflictCount()).toBe(0) + }) + + it('keep-theirs adopts the server entity and drops the queued write', async () => { + const id = generateUUID() + server.use(http.put('/api/trips/1/places/42', () => + HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 }))) + await enqueueConflictingPut(id) + await mutationQueue.flush() + + await mutationQueue.resolveKeepServer(id) + + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined() + expect((await offlineDb.places.get(42))!.name).toBe('Theirs') + }) +}) + +describe('mutationQueue — chained edits to the same entity', () => { + it('do NOT self-conflict: the new token is propagated to the next queued edit (#1135)', async () => { + // A server that does real compare-and-swap on X-Base-Updated-At and bumps + // the token on each accepted write. + let token = 'T0' + let serverPlace = { ...buildPlace({ trip_id: 1, id: 42, name: 'A' }), notes: 'orig', updated_at: token } as Record + server.use(http.put('/api/trips/1/places/42', async ({ request }) => { + const base = request.headers.get('X-Base-Updated-At') + if (base !== token) return HttpResponse.json({ error: 'conflict', server: serverPlace }, { status: 409 }) + const body = await request.json() as Record + token = token === 'T0' ? 'T1' : 'T2' + serverPlace = { ...serverPlace, ...body, updated_at: token } + return HttpResponse.json({ place: serverPlace }) + })) + + await offlineDb.places.put({ ...(serverPlace as object) } as never) + // Two offline edits to different fields of place 42, both based on T0. + await mutationQueue.enqueue({ id: 'm1', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { name: 'B' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' }) + await mutationQueue.enqueue({ id: 'm2', tripId: 1, method: 'PUT', url: '/trips/1/places/42', body: { notes: 'edited' }, resource: 'places', entityId: 42, baseUpdatedAt: 'T0' }) + + await mutationQueue.flush() + + expect(await mutationQueue.conflictCount()).toBe(0) + expect(await offlineDb.mutationQueue.count()).toBe(0) + const final = await offlineDb.places.get(42) as unknown as { name: string; notes: string } + expect(final.name).toBe('B') + expect(final.notes).toBe('edited') + }) +}) + +describe('mutationQueue — auto strategies', () => { + it('"server" adopts the server version automatically', async () => { + setConflictStrategy('server') + const id = generateUUID() + server.use(http.put('/api/trips/1/places/42', () => + HttpResponse.json({ error: 'conflict', server: buildPlace({ trip_id: 1, id: 42, name: 'Theirs' }) }, { status: 409 }))) + await enqueueConflictingPut(id) + + await mutationQueue.flush() + + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined() + expect((await offlineDb.places.get(42))!.name).toBe('Theirs') + expect(await mutationQueue.conflictCount()).toBe(0) + }) + + it('"mine" re-sends unconditionally and wins', async () => { + setConflictStrategy('mine') + const id = generateUUID() + conflictThenAcceptHandler() + await enqueueConflictingPut(id) + + await mutationQueue.flush() + + expect(await offlineDb.mutationQueue.get(id)).toBeUndefined() + expect((await offlineDb.places.get(42))!.name).toBe('Mine') + expect(await mutationQueue.conflictCount()).toBe(0) + }) +}) diff --git a/client/tests/unit/sync/networkMode.test.ts b/client/tests/unit/sync/networkMode.test.ts new file mode 100644 index 00000000..78a20e1f --- /dev/null +++ b/client/tests/unit/sync/networkMode.test.ts @@ -0,0 +1,56 @@ +/** + * networkMode unit tests — the force-offline override + effective offline state. + */ +import { describe, it, expect, beforeEach } from 'vitest' +import { + isEffectivelyOffline, isEffectivelyOnline, isForcedOffline, + setForcedOffline, onNetworkModeChange, _resetNetworkMode, +} from '../../../src/sync/networkMode' + +beforeEach(() => { + _resetNetworkMode() + try { localStorage.removeItem('trek_forced_offline') } catch { /* ignore */ } + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }) +}) + +describe('networkMode', () => { + it('is online by default', () => { + expect(isForcedOffline()).toBe(false) + expect(isEffectivelyOffline()).toBe(false) + expect(isEffectivelyOnline()).toBe(true) + }) + + it('forced offline overrides a real online connection', () => { + setForcedOffline(true) + expect(isForcedOffline()).toBe(true) + expect(isEffectivelyOffline()).toBe(true) + expect(isEffectivelyOnline()).toBe(false) + }) + + it('reports offline when the browser is offline even without the force flag', () => { + Object.defineProperty(navigator, 'onLine', { value: false, writable: true, configurable: true }) + expect(isForcedOffline()).toBe(false) + expect(isEffectivelyOffline()).toBe(true) + }) + + it('notifies subscribers on change, ignores no-op sets, and stops after unsubscribe', () => { + let count = 0 + const unsub = onNetworkModeChange(() => { count++ }) + setForcedOffline(true) + expect(count).toBe(1) + setForcedOffline(true) // same value → no notification + expect(count).toBe(1) + setForcedOffline(false) + expect(count).toBe(2) + unsub() + setForcedOffline(true) + expect(count).toBe(2) + }) + + it('persists the forced flag to localStorage', () => { + setForcedOffline(true) + expect(localStorage.getItem('trek_forced_offline')).toBe('1') + setForcedOffline(false) + expect(localStorage.getItem('trek_forced_offline')).toBeNull() + }) +}) diff --git a/client/tests/unit/sync/offlinePrefs.test.ts b/client/tests/unit/sync/offlinePrefs.test.ts new file mode 100644 index 00000000..66f83f80 --- /dev/null +++ b/client/tests/unit/sync/offlinePrefs.test.ts @@ -0,0 +1,60 @@ +/** + * offlinePrefs unit tests — device-local "what to store offline" + conflict strategy. + */ +import { describe, it, expect, beforeEach } from 'vitest' +import { + getOfflinePrefs, setCacheTiles, setConflictStrategy, + isTripOfflineEnabled, setTripOfflineEnabled, onOfflinePrefsChange, _resetOfflinePrefs, +} from '../../../src/sync/offlinePrefs' + +beforeEach(() => { + _resetOfflinePrefs() + try { localStorage.removeItem('trek_offline_prefs') } catch { /* ignore */ } +}) + +describe('offlinePrefs', () => { + it('defaults to tiles on, no disabled trips, ask strategy', () => { + const p = getOfflinePrefs() + expect(p.cacheTiles).toBe(true) + expect(p.disabledTripIds).toEqual([]) + expect(p.conflictStrategy).toBe('ask') + expect(isTripOfflineEnabled(5)).toBe(true) + }) + + it('toggles tile caching and persists it', () => { + setCacheTiles(false) + expect(getOfflinePrefs().cacheTiles).toBe(false) + expect(JSON.parse(localStorage.getItem('trek_offline_prefs')!).cacheTiles).toBe(false) + }) + + it('disables and re-enables a single trip', () => { + setTripOfflineEnabled(7, false) + expect(isTripOfflineEnabled(7)).toBe(false) + expect(getOfflinePrefs().disabledTripIds).toContain(7) + + setTripOfflineEnabled(7, true) + expect(isTripOfflineEnabled(7)).toBe(true) + expect(getOfflinePrefs().disabledTripIds).not.toContain(7) + }) + + it('does not duplicate a trip id when disabled twice', () => { + setTripOfflineEnabled(3, false) + setTripOfflineEnabled(3, false) + expect(getOfflinePrefs().disabledTripIds.filter(id => id === 3)).toHaveLength(1) + }) + + it('sets the conflict strategy', () => { + setConflictStrategy('mine') + expect(getOfflinePrefs().conflictStrategy).toBe('mine') + }) + + it('notifies subscribers and stops after unsubscribe', () => { + let n = 0 + const unsub = onOfflinePrefsChange(() => { n++ }) + setCacheTiles(false) + expect(n).toBe(1) + unsub() + setCacheTiles(true) + expect(n).toBe(1) + }) +})