From 57697ec2aabc7ea4f776129041b07d355341877d Mon Sep 17 00:00:00 2001 From: Andreas Schneider Date: Fri, 1 May 2026 16:35:02 +0200 Subject: [PATCH] feat: offline-first client All reads now come directly from Dexie; all mutations write to Dexie + outbox immediately without waiting for the server. The background sync loop (every 30s) pushes the outbox and pulls server changes. Day/week close and reopen remain server-only (require server-side computation). triggerSync() is called after them to update Dexie promptly. The optimistic closedDaysMap update in the week page is kept separate from the Dexie reload to avoid a race that was causing the reopen button and day actions to disappear until a page reload. - client.ts: remove online-first fetch paths; all reads from Dexie - sync.ts: add triggerSync() and waitForSync() exports - DayDetail: pass ClosedDay | null to oninvalidate after close/reopen - week/+page.svelte: update closedDaysMap optimistically on close/reopen; only reload from Dexie on entry mutations - settings/+page.svelte: read history() directly (never throws 503); derive current locally - layout: remove offline banner and online.ts (behaviour is now identical online and offline) --- PLAN.md | 24 +- web/src/lib/api/client.ts | 345 +++++++++--------------- web/src/lib/components/DayDetail.svelte | 15 +- web/src/lib/stores/online.ts | 8 - web/src/lib/stores/sync.ts | 16 +- web/src/routes/+layout.svelte | 14 - web/src/routes/settings/+page.svelte | 13 +- web/src/routes/week/+page.svelte | 36 ++- 8 files changed, 205 insertions(+), 266 deletions(-) delete mode 100644 web/src/lib/stores/online.ts diff --git a/PLAN.md b/PLAN.md index a256316..39dd3e3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -215,11 +215,13 @@ Three regions: ### Offline Strategy +Fully offline-first: the UI always reads from and writes to Dexie (IndexedDB) without touching the network. A background sync loop (every 30 s) reconciles with the server. + 1. App shell precached by Workbox. -2. All reads come from Dexie (IndexedDB); a sync worker reconciles with the server in the background. -3. All mutations create a local Dexie record with a client-generated UUIDv7 and an entry in a local `outbox` table. -4. When online, the outbox is flushed via `/api/sync/push`; responses update the local cache. -5. `/api/sync/pull` fetches any server-side changes since `last_pulled_version`. +2. **All reads** come directly from Dexie — no network latency in the hot path. +3. **All mutations** write to Dexie + outbox immediately and return. The background sync loop pushes the outbox via `POST /api/sync/push` and pulls server changes via `GET /api/sync/pull`. +4. **Exceptions**: day/week close and reopen require server-side computation and remain server-only. `triggerSync()` is called after them to update Dexie promptly without waiting 30 s. +5. On `410 Gone` (server pruned data the client hasn't seen), `coldStart()` wipes Dexie and re-pulls everything from version 0. ## 4. Sync Protocol @@ -285,7 +287,17 @@ Full offline support with online-first, offline-fallback mutation strategy. - `db.ts`: Dexie v3 — `settings_history` key path changed to `id` (string); upgrade handler clears the table for repopulation via pull. - Settings page: `editingId` and ID params updated from `number` to `string`. -### M10 — Future +### M10 — Offline-first client ✅ + +Switched from online-first/offline-fallback to fully offline-first. + +- `client.ts`: all reads now come directly from Dexie (no server fetch). All mutations write to Dexie + outbox immediately and return without waiting for the server. +- `sync.ts`: added `triggerSync()` for imperative sync after server-only mutations. Updated comment header. +- `DayDetail.svelte`, `week/+page.svelte`: call `triggerSync()` after day/week close and reopen so Dexie reflects server-computed state promptly. +- `+layout.svelte`: removed offline banner (no longer meaningful; behaviour is identical online or offline). +- `online.ts`: deleted (unused). + +### M11 — Future CSV/JSON export, monthly summary view. ## 7. Decisions & Rationale @@ -308,4 +320,4 @@ CSV/JSON export, monthly summary view. | Settings history PK | TEXT UUID (migration 003) | Consistent with other entities; enables offline create; `updated_at` enables last-write-wins sync | | Sync prune strategy | Prune marker row at boundary version | No extra table; client detects stale state from the log itself; 410 triggers full re-sync | | Sync conflict resolution | Last `updated_at` wins | Server is authoritative; simple to implement and reason about for single-user | -| Offline mutation flow | Online-first, offline-fallback | Server is primary; client writes to Dexie+outbox only on network failure; simpler than full local-first | +| Offline mutation flow | Offline-first | All reads/writes go through Dexie; background sync loop reconciles with server. Day/week close remains server-only (requires server computation); `triggerSync()` called after. | diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 215aa41..b27e8aa 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -1,9 +1,12 @@ // API client for Wotra backend. // Base URL: /api (relative, works both in dev proxy and production) // -// Offline fallback: if a mutation throws a network error (TypeError: Failed to fetch), -// the call enqueues the operation in the Dexie outbox and resolves with the local object. -// The background sync loop will push the outbox to the server when connectivity returns. +// Offline-first: all reads come from Dexie immediately; all mutations write to +// Dexie + outbox and return without waiting for the server. The background sync +// loop (sync.ts) pushes the outbox and pulls server changes every 30 seconds. +// +// Exceptions: day/week close and reopen require server-side computation and +// remain server-only. Call triggerSync() after them to update Dexie promptly. import { db } from '$lib/stores/db'; @@ -21,10 +24,6 @@ export function hasToken(): boolean { return !!localStorage.getItem('auth_token'); } -function isNetworkError(e: unknown): boolean { - return e instanceof TypeError && e.message.toLowerCase().includes('fetch'); -} - async function request(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${API_BASE}${path}`, { method, @@ -42,7 +41,7 @@ async function request(method: string, path: string, body?: unknown): Promise return res.json(); } -/** Enqueue an outbox item for offline push. */ +/** Enqueue an outbox item for background push to the server. */ async function enqueue(entity: string, entity_id: string, op: 'upsert' | 'delete', payload: unknown) { await db.outbox.add({ entity, @@ -124,118 +123,79 @@ export interface BalanceSummary { export const entries = { start: async (note = ''): Promise => { - try { - return await request('POST', '/entries/start', { note }); - } catch (e) { - if (!isNetworkError(e)) throw e; - const entry: Entry = { - id: crypto.randomUUID(), - start_time: Date.now(), - end_time: null, - auto_stopped: false, - note, - day_key: new Date().toISOString().slice(0, 10), - updated_at: Date.now() - }; - await db.entries.put(entry); - await enqueue('entries', entry.id, 'upsert', entry); - return entry; - } + const entry: Entry = { + id: crypto.randomUUID(), + start_time: Date.now(), + end_time: null, + auto_stopped: false, + note, + day_key: new Date().toISOString().slice(0, 10), + updated_at: Date.now() + }; + await db.entries.put(entry); + await enqueue('entries', entry.id, 'upsert', entry); + return entry; }, createInterval: async (startTime: number, endTime: number, note = ''): Promise => { - try { - return await request('POST', '/entries', { start_time: startTime, end_time: endTime, note }); - } catch (e) { - if (!isNetworkError(e)) throw e; - const entry: Entry = { - id: crypto.randomUUID(), - start_time: startTime, - end_time: endTime, - auto_stopped: false, - note, - day_key: new Date(startTime).toISOString().slice(0, 10), - updated_at: Date.now() - }; - await db.entries.put(entry); - await enqueue('entries', entry.id, 'upsert', entry); - return entry; - } + const entry: Entry = { + id: crypto.randomUUID(), + start_time: startTime, + end_time: endTime, + auto_stopped: false, + note, + day_key: new Date(startTime).toISOString().slice(0, 10), + updated_at: Date.now() + }; + await db.entries.put(entry); + await enqueue('entries', entry.id, 'upsert', entry); + return entry; }, stop: async (id: string): Promise => { - try { - return await request('POST', `/entries/${id}/stop`); - } catch (e) { - if (!isNetworkError(e)) throw e; - const existing = await db.entries.get(id); - if (!existing) throw e; - const updated: Entry = { ...existing, end_time: Date.now(), updated_at: Date.now() }; - await db.entries.put(updated); - await enqueue('entries', id, 'upsert', updated); - return updated; - } + const existing = await db.entries.get(id); + if (!existing) throw new ApiError(404, 'Entry not found'); + const updated: Entry = { ...existing, end_time: Date.now(), updated_at: Date.now() }; + await db.entries.put(updated); + await enqueue('entries', id, 'upsert', updated); + return updated; }, list: async (from?: string, to?: string): Promise => { - const params = new URLSearchParams(); - if (from) params.set('from', from); - if (to) params.set('to', to); - try { - return await request('GET', `/entries?${params}`); - } catch (e) { - if (!isNetworkError(e)) throw e; - let q = db.entries.toCollection(); - if (from && to) q = db.entries.where('day_key').between(from, to, true, true); - else if (from) q = db.entries.where('day_key').aboveOrEqual(from); - else if (to) q = db.entries.where('day_key').belowOrEqual(to); - const rows = await q.toArray(); - return rows.sort((a, b) => a.start_time - b.start_time); - } + let q = db.entries.toCollection(); + if (from && to) q = db.entries.where('day_key').between(from, to, true, true); + else if (from) q = db.entries.where('day_key').aboveOrEqual(from); + else if (to) q = db.entries.where('day_key').belowOrEqual(to); + const rows = await q.toArray(); + return rows.sort((a, b) => a.start_time - b.start_time); }, update: async (id: string, body: { start_time?: number; end_time?: number; note?: string }): Promise => { - try { - return await request('PUT', `/entries/${id}`, body); - } catch (e) { - if (!isNetworkError(e)) throw e; - const existing = await db.entries.get(id); - if (!existing) throw e; - const updated: Entry = { ...existing, ...body, updated_at: Date.now() }; - await db.entries.put(updated); - await enqueue('entries', id, 'upsert', updated); - return updated; - } + const existing = await db.entries.get(id); + if (!existing) throw new ApiError(404, 'Entry not found'); + const updated: Entry = { ...existing, ...body, updated_at: Date.now() }; + await db.entries.put(updated); + await enqueue('entries', id, 'upsert', updated); + return updated; }, delete: async (id: string): Promise => { - try { - return await request('DELETE', `/entries/${id}`); - } catch (e) { - if (!isNetworkError(e)) throw e; - await db.entries.delete(id); - await enqueue('entries', id, 'delete', { id, updated_at: Date.now() }); - } + await db.entries.delete(id); + await enqueue('entries', id, 'delete', { id, updated_at: Date.now() }); } }; // ─── Days ──────────────────────────────────────────────────────────────────── +// close/mark/reopen remain server-only (require server-side computation). +// Call triggerSync() after them to update Dexie promptly. export const days = { list: async (from?: string, to?: string): Promise => { - const params = new URLSearchParams(); - if (from) params.set('from', from); - if (to) params.set('to', to); - try { - return await request('GET', `/days?${params}`); - } catch (e) { - if (!isNetworkError(e)) throw e; - let q = db.closed_days.toCollection(); - if (from && to) q = db.closed_days.where('day_key').between(from, to, true, true); - else if (from) q = db.closed_days.where('day_key').aboveOrEqual(from); - else if (to) q = db.closed_days.where('day_key').belowOrEqual(to); - return q.toArray(); - } + let q = db.closed_days.toCollection(); + if (from && to) q = db.closed_days.where('day_key').between(from, to, true, true); + else if (from) q = db.closed_days.where('day_key').aboveOrEqual(from); + else if (to) q = db.closed_days.where('day_key').belowOrEqual(to); + return q.toArray(); }, close: (dayKey: string) => request('POST', `/days/${dayKey}/close`), mark: (dayKey: string, kind: 'holiday' | 'vacation' | 'sick') => @@ -244,44 +204,33 @@ export const days = { }; // ─── Weeks ─────────────────────────────────────────────────────────────────── +// close/reopen remain server-only (require server-side computation). +// Call triggerSync() after them to update Dexie promptly. export const weeks = { list: async (from?: string, to?: string): Promise => { - const params = new URLSearchParams(); - if (from) params.set('from', from); - if (to) params.set('to', to); - try { - return await request('GET', `/weeks?${params}`); - } catch (e) { - if (!isNetworkError(e)) throw e; - let q = db.closed_weeks.toCollection(); - if (from && to) q = db.closed_weeks.where('week_key').between(from, to, true, true); - else if (from) q = db.closed_weeks.where('week_key').aboveOrEqual(from); - else if (to) q = db.closed_weeks.where('week_key').belowOrEqual(to); - return q.toArray(); - } + let q = db.closed_weeks.toCollection(); + if (from && to) q = db.closed_weeks.where('week_key').between(from, to, true, true); + else if (from) q = db.closed_weeks.where('week_key').aboveOrEqual(from); + else if (to) q = db.closed_weeks.where('week_key').belowOrEqual(to); + return q.toArray(); }, close: (weekKey: string) => request('POST', `/weeks/${weekKey}/close`), reopen: (weekKey: string) => request('DELETE', `/weeks/${weekKey}/close`), balance: async (): Promise => { - try { - return await request('GET', '/weeks/balance'); - } catch (e) { - if (!isNetworkError(e)) throw e; - const [ws, adjs] = await Promise.all([ - db.closed_weeks.toArray(), - db.balance_adjustments.toArray() - ]); - const weeks_delta_ms = ws.reduce((s, w) => s + w.delta_ms, 0); - const adjustments_delta_ms = adjs.reduce((s, a) => s + a.delta_ms, 0); - return { - total_delta_ms: weeks_delta_ms + adjustments_delta_ms, - weeks_delta_ms, - adjustments_delta_ms, - closed_week_count: ws.length, - adjustment_count: adjs.length - }; - } + const [ws, adjs] = await Promise.all([ + db.closed_weeks.toArray(), + db.balance_adjustments.toArray() + ]); + const weeks_delta_ms = ws.reduce((s, w) => s + w.delta_ms, 0); + const adjustments_delta_ms = adjs.reduce((s, a) => s + a.delta_ms, 0); + return { + total_delta_ms: weeks_delta_ms + adjustments_delta_ms, + weeks_delta_ms, + adjustments_delta_ms, + closed_week_count: ws.length, + adjustment_count: adjs.length + }; } }; @@ -289,57 +238,37 @@ export const weeks = { export const balance = { list: async (): Promise => { - try { - return await request('GET', '/balance/adjustments'); - } catch (e) { - if (!isNetworkError(e)) throw e; - return db.balance_adjustments.orderBy('effective_at').reverse().toArray(); - } + return db.balance_adjustments.orderBy('effective_at').reverse().toArray(); }, create: async (body: { delta_ms: number; note?: string; effective_at?: number }): Promise => { - try { - return await request('POST', '/balance/adjustments', body); - } catch (e) { - if (!isNetworkError(e)) throw e; - const now = Date.now(); - const adj: BalanceAdjustment = { - id: crypto.randomUUID(), - delta_ms: body.delta_ms, - note: body.note ?? '', - effective_at: body.effective_at ?? now, - created_at: now, - updated_at: now - }; - await db.balance_adjustments.put(adj); - await enqueue('balance_adjustments', adj.id, 'upsert', adj); - return adj; - } + const now = Date.now(); + const adj: BalanceAdjustment = { + id: crypto.randomUUID(), + delta_ms: body.delta_ms, + note: body.note ?? '', + effective_at: body.effective_at ?? now, + created_at: now, + updated_at: now + }; + await db.balance_adjustments.put(adj); + await enqueue('balance_adjustments', adj.id, 'upsert', adj); + return adj; }, update: async (id: string, body: { delta_ms: number; note?: string; effective_at?: number }): Promise => { - try { - return await request('PUT', `/balance/adjustments/${id}`, body); - } catch (e) { - if (!isNetworkError(e)) throw e; - const existing = await db.balance_adjustments.get(id); - if (!existing) throw e; - const updated: BalanceAdjustment = { ...existing, ...body, updated_at: Date.now() }; - await db.balance_adjustments.put(updated); - await enqueue('balance_adjustments', id, 'upsert', updated); - return updated; - } + const existing = await db.balance_adjustments.get(id); + if (!existing) throw new ApiError(404, 'Adjustment not found'); + const updated: BalanceAdjustment = { ...existing, ...body, updated_at: Date.now() }; + await db.balance_adjustments.put(updated); + await enqueue('balance_adjustments', id, 'upsert', updated); + return updated; }, delete: async (id: string): Promise => { - try { - return await request('DELETE', `/balance/adjustments/${id}`); - } catch (e) { - if (!isNetworkError(e)) throw e; - const existing = await db.balance_adjustments.get(id); - await db.balance_adjustments.delete(id); - await enqueue('balance_adjustments', id, 'delete', { id, updated_at: existing?.updated_at ?? Date.now() }); - } + const existing = await db.balance_adjustments.get(id); + await db.balance_adjustments.delete(id); + await enqueue('balance_adjustments', id, 'delete', { id, updated_at: existing?.updated_at ?? Date.now() }); } }; @@ -347,24 +276,15 @@ export const balance = { export const settings = { current: async (): Promise => { - try { - return await request('GET', '/settings'); - } catch (e) { - if (!isNetworkError(e)) throw e; - const today = new Date().toISOString().slice(0, 10); - const all = await db.settings_history.orderBy('effective_from').reverse().toArray(); - const match = all.find((s) => s.effective_from <= today) ?? all[0]; - if (!match) throw new ApiError(503, 'No cached settings available'); - return match; - } + const today = new Date().toISOString().slice(0, 10); + const all = await db.settings_history.orderBy('effective_from').reverse().toArray(); + const match = all.find((s) => s.effective_from <= today) ?? all[0]; + if (!match) throw new ApiError(503, 'No settings available — sync pending'); + return match; }, + history: async (): Promise => { - try { - return await request('GET', '/settings/history'); - } catch (e) { - if (!isNetworkError(e)) throw e; - return db.settings_history.orderBy('effective_from').reverse().toArray(); - } + return db.settings_history.orderBy('effective_from').reverse().toArray(); }, upsert: async (body: { @@ -373,21 +293,16 @@ export const settings = { workdays_mask: number; timezone: string; }): Promise => { - try { - return await request('PUT', '/settings', body); - } catch (e) { - if (!isNetworkError(e)) throw e; - const now = Date.now(); - const s: Settings = { - id: crypto.randomUUID(), - ...body, - created_at: now, - updated_at: now - }; - await db.settings_history.put(s); - await enqueue('settings_history', s.id, 'upsert', s); - return s; - } + const now = Date.now(); + const s: Settings = { + id: crypto.randomUUID(), + ...body, + created_at: now, + updated_at: now + }; + await db.settings_history.put(s); + await enqueue('settings_history', s.id, 'upsert', s); + return s; }, update: async (id: string, body: { @@ -396,27 +311,17 @@ export const settings = { workdays_mask: number; timezone: string; }): Promise => { - try { - return await request('PUT', `/settings/history/${id}`, body); - } catch (e) { - if (!isNetworkError(e)) throw e; - const existing = await db.settings_history.get(id); - if (!existing) throw e; - const updated: Settings = { ...existing, ...body, updated_at: Date.now() }; - await db.settings_history.put(updated); - await enqueue('settings_history', id, 'upsert', updated); - return updated; - } + const existing = await db.settings_history.get(id); + if (!existing) throw new ApiError(404, 'Settings row not found'); + const updated: Settings = { ...existing, ...body, updated_at: Date.now() }; + await db.settings_history.put(updated); + await enqueue('settings_history', id, 'upsert', updated); + return updated; }, delete: async (id: string): Promise => { - try { - return await request('DELETE', `/settings/history/${id}`); - } catch (e) { - if (!isNetworkError(e)) throw e; - await db.settings_history.delete(id); - await enqueue('settings_history', id, 'delete', { id, updated_at: Date.now() }); - } + await db.settings_history.delete(id); + await enqueue('settings_history', id, 'delete', { id, updated_at: Date.now() }); } }; diff --git a/web/src/lib/components/DayDetail.svelte b/web/src/lib/components/DayDetail.svelte index 5459b42..02a8181 100644 --- a/web/src/lib/components/DayDetail.svelte +++ b/web/src/lib/components/DayDetail.svelte @@ -1,6 +1,7 @@