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) + }) +})