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)
This commit is contained in:
2026-05-01 16:35:02 +02:00
parent f4836a6fa2
commit 57697ec2aa
8 changed files with 205 additions and 266 deletions

24
PLAN.md
View File

@@ -215,11 +215,13 @@ Three regions:
### Offline Strategy ### 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. 1. App shell precached by Workbox.
2. All reads come from Dexie (IndexedDB); a sync worker reconciles with the server in the background. 2. **All reads** come directly from Dexie — no network latency in the hot path.
3. All mutations create a local Dexie record with a client-generated UUIDv7 and an entry in a local `outbox` table. 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. When online, the outbox is flushed via `/api/sync/push`; responses update the local cache. 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. `/api/sync/pull` fetches any server-side changes since `last_pulled_version`. 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 ## 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. - `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`. - 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. CSV/JSON export, monthly summary view.
## 7. Decisions & Rationale ## 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 | | 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 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 | | 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. |

View File

@@ -1,9 +1,12 @@
// API client for Wotra backend. // API client for Wotra backend.
// Base URL: /api (relative, works both in dev proxy and production) // Base URL: /api (relative, works both in dev proxy and production)
// //
// Offline fallback: if a mutation throws a network error (TypeError: Failed to fetch), // Offline-first: all reads come from Dexie immediately; all mutations write to
// the call enqueues the operation in the Dexie outbox and resolves with the local object. // Dexie + outbox and return without waiting for the server. The background sync
// The background sync loop will push the outbox to the server when connectivity returns. // 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'; import { db } from '$lib/stores/db';
@@ -21,10 +24,6 @@ export function hasToken(): boolean {
return !!localStorage.getItem('auth_token'); return !!localStorage.getItem('auth_token');
} }
function isNetworkError(e: unknown): boolean {
return e instanceof TypeError && e.message.toLowerCase().includes('fetch');
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> { async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${API_BASE}${path}`, {
method, method,
@@ -42,7 +41,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
return res.json(); 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) { async function enqueue(entity: string, entity_id: string, op: 'upsert' | 'delete', payload: unknown) {
await db.outbox.add({ await db.outbox.add({
entity, entity,
@@ -124,118 +123,79 @@ export interface BalanceSummary {
export const entries = { export const entries = {
start: async (note = ''): Promise<Entry> => { start: async (note = ''): Promise<Entry> => {
try { const entry: Entry = {
return await request<Entry>('POST', '/entries/start', { note }); id: crypto.randomUUID(),
} catch (e) { start_time: Date.now(),
if (!isNetworkError(e)) throw e; end_time: null,
const entry: Entry = { auto_stopped: false,
id: crypto.randomUUID(), note,
start_time: Date.now(), day_key: new Date().toISOString().slice(0, 10),
end_time: null, updated_at: Date.now()
auto_stopped: false, };
note, await db.entries.put(entry);
day_key: new Date().toISOString().slice(0, 10), await enqueue('entries', entry.id, 'upsert', entry);
updated_at: Date.now() return entry;
};
await db.entries.put(entry);
await enqueue('entries', entry.id, 'upsert', entry);
return entry;
}
}, },
createInterval: async (startTime: number, endTime: number, note = ''): Promise<Entry> => { createInterval: async (startTime: number, endTime: number, note = ''): Promise<Entry> => {
try { const entry: Entry = {
return await request<Entry>('POST', '/entries', { start_time: startTime, end_time: endTime, note }); id: crypto.randomUUID(),
} catch (e) { start_time: startTime,
if (!isNetworkError(e)) throw e; end_time: endTime,
const entry: Entry = { auto_stopped: false,
id: crypto.randomUUID(), note,
start_time: startTime, day_key: new Date(startTime).toISOString().slice(0, 10),
end_time: endTime, updated_at: Date.now()
auto_stopped: false, };
note, await db.entries.put(entry);
day_key: new Date(startTime).toISOString().slice(0, 10), await enqueue('entries', entry.id, 'upsert', entry);
updated_at: Date.now() return entry;
};
await db.entries.put(entry);
await enqueue('entries', entry.id, 'upsert', entry);
return entry;
}
}, },
stop: async (id: string): Promise<Entry> => { stop: async (id: string): Promise<Entry> => {
try { const existing = await db.entries.get(id);
return await request<Entry>('POST', `/entries/${id}/stop`); if (!existing) throw new ApiError(404, 'Entry not found');
} catch (e) { const updated: Entry = { ...existing, end_time: Date.now(), updated_at: Date.now() };
if (!isNetworkError(e)) throw e; await db.entries.put(updated);
const existing = await db.entries.get(id); await enqueue('entries', id, 'upsert', updated);
if (!existing) throw e; return updated;
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<Entry[]> => { list: async (from?: string, to?: string): Promise<Entry[]> => {
const params = new URLSearchParams(); let q = db.entries.toCollection();
if (from) params.set('from', from); if (from && to) q = db.entries.where('day_key').between(from, to, true, true);
if (to) params.set('to', to); else if (from) q = db.entries.where('day_key').aboveOrEqual(from);
try { else if (to) q = db.entries.where('day_key').belowOrEqual(to);
return await request<Entry[]>('GET', `/entries?${params}`); const rows = await q.toArray();
} catch (e) { return rows.sort((a, b) => a.start_time - b.start_time);
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);
}
}, },
update: async (id: string, body: { start_time?: number; end_time?: number; note?: string }): Promise<Entry> => { update: async (id: string, body: { start_time?: number; end_time?: number; note?: string }): Promise<Entry> => {
try { const existing = await db.entries.get(id);
return await request<Entry>('PUT', `/entries/${id}`, body); if (!existing) throw new ApiError(404, 'Entry not found');
} catch (e) { const updated: Entry = { ...existing, ...body, updated_at: Date.now() };
if (!isNetworkError(e)) throw e; await db.entries.put(updated);
const existing = await db.entries.get(id); await enqueue('entries', id, 'upsert', updated);
if (!existing) throw e; return updated;
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<void> => { delete: async (id: string): Promise<void> => {
try { await db.entries.delete(id);
return await request<void>('DELETE', `/entries/${id}`); await enqueue('entries', id, 'delete', { id, updated_at: Date.now() });
} catch (e) {
if (!isNetworkError(e)) throw e;
await db.entries.delete(id);
await enqueue('entries', id, 'delete', { id, updated_at: Date.now() });
}
} }
}; };
// ─── Days ──────────────────────────────────────────────────────────────────── // ─── Days ────────────────────────────────────────────────────────────────────
// close/mark/reopen remain server-only (require server-side computation).
// Call triggerSync() after them to update Dexie promptly.
export const days = { export const days = {
list: async (from?: string, to?: string): Promise<ClosedDay[]> => { list: async (from?: string, to?: string): Promise<ClosedDay[]> => {
const params = new URLSearchParams(); let q = db.closed_days.toCollection();
if (from) params.set('from', from); if (from && to) q = db.closed_days.where('day_key').between(from, to, true, true);
if (to) params.set('to', to); else if (from) q = db.closed_days.where('day_key').aboveOrEqual(from);
try { else if (to) q = db.closed_days.where('day_key').belowOrEqual(to);
return await request<ClosedDay[]>('GET', `/days?${params}`); return q.toArray();
} 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();
}
}, },
close: (dayKey: string) => request<ClosedDay>('POST', `/days/${dayKey}/close`), close: (dayKey: string) => request<ClosedDay>('POST', `/days/${dayKey}/close`),
mark: (dayKey: string, kind: 'holiday' | 'vacation' | 'sick') => mark: (dayKey: string, kind: 'holiday' | 'vacation' | 'sick') =>
@@ -244,44 +204,33 @@ export const days = {
}; };
// ─── Weeks ─────────────────────────────────────────────────────────────────── // ─── Weeks ───────────────────────────────────────────────────────────────────
// close/reopen remain server-only (require server-side computation).
// Call triggerSync() after them to update Dexie promptly.
export const weeks = { export const weeks = {
list: async (from?: string, to?: string): Promise<ClosedWeek[]> => { list: async (from?: string, to?: string): Promise<ClosedWeek[]> => {
const params = new URLSearchParams(); let q = db.closed_weeks.toCollection();
if (from) params.set('from', from); if (from && to) q = db.closed_weeks.where('week_key').between(from, to, true, true);
if (to) params.set('to', to); else if (from) q = db.closed_weeks.where('week_key').aboveOrEqual(from);
try { else if (to) q = db.closed_weeks.where('week_key').belowOrEqual(to);
return await request<ClosedWeek[]>('GET', `/weeks?${params}`); return q.toArray();
} 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();
}
}, },
close: (weekKey: string) => request<ClosedWeek>('POST', `/weeks/${weekKey}/close`), close: (weekKey: string) => request<ClosedWeek>('POST', `/weeks/${weekKey}/close`),
reopen: (weekKey: string) => request<void>('DELETE', `/weeks/${weekKey}/close`), reopen: (weekKey: string) => request<void>('DELETE', `/weeks/${weekKey}/close`),
balance: async (): Promise<BalanceSummary> => { balance: async (): Promise<BalanceSummary> => {
try { const [ws, adjs] = await Promise.all([
return await request<BalanceSummary>('GET', '/weeks/balance'); db.closed_weeks.toArray(),
} catch (e) { db.balance_adjustments.toArray()
if (!isNetworkError(e)) throw e; ]);
const [ws, adjs] = await Promise.all([ const weeks_delta_ms = ws.reduce((s, w) => s + w.delta_ms, 0);
db.closed_weeks.toArray(), const adjustments_delta_ms = adjs.reduce((s, a) => s + a.delta_ms, 0);
db.balance_adjustments.toArray() return {
]); total_delta_ms: weeks_delta_ms + adjustments_delta_ms,
const weeks_delta_ms = ws.reduce((s, w) => s + w.delta_ms, 0); weeks_delta_ms,
const adjustments_delta_ms = adjs.reduce((s, a) => s + a.delta_ms, 0); adjustments_delta_ms,
return { closed_week_count: ws.length,
total_delta_ms: weeks_delta_ms + adjustments_delta_ms, adjustment_count: adjs.length
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 = { export const balance = {
list: async (): Promise<BalanceAdjustment[]> => { list: async (): Promise<BalanceAdjustment[]> => {
try { return db.balance_adjustments.orderBy('effective_at').reverse().toArray();
return await request<BalanceAdjustment[]>('GET', '/balance/adjustments');
} catch (e) {
if (!isNetworkError(e)) throw e;
return db.balance_adjustments.orderBy('effective_at').reverse().toArray();
}
}, },
create: async (body: { delta_ms: number; note?: string; effective_at?: number }): Promise<BalanceAdjustment> => { create: async (body: { delta_ms: number; note?: string; effective_at?: number }): Promise<BalanceAdjustment> => {
try { const now = Date.now();
return await request<BalanceAdjustment>('POST', '/balance/adjustments', body); const adj: BalanceAdjustment = {
} catch (e) { id: crypto.randomUUID(),
if (!isNetworkError(e)) throw e; delta_ms: body.delta_ms,
const now = Date.now(); note: body.note ?? '',
const adj: BalanceAdjustment = { effective_at: body.effective_at ?? now,
id: crypto.randomUUID(), created_at: now,
delta_ms: body.delta_ms, updated_at: now
note: body.note ?? '', };
effective_at: body.effective_at ?? now, await db.balance_adjustments.put(adj);
created_at: now, await enqueue('balance_adjustments', adj.id, 'upsert', adj);
updated_at: now return adj;
};
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<BalanceAdjustment> => { update: async (id: string, body: { delta_ms: number; note?: string; effective_at?: number }): Promise<BalanceAdjustment> => {
try { const existing = await db.balance_adjustments.get(id);
return await request<BalanceAdjustment>('PUT', `/balance/adjustments/${id}`, body); if (!existing) throw new ApiError(404, 'Adjustment not found');
} catch (e) { const updated: BalanceAdjustment = { ...existing, ...body, updated_at: Date.now() };
if (!isNetworkError(e)) throw e; await db.balance_adjustments.put(updated);
const existing = await db.balance_adjustments.get(id); await enqueue('balance_adjustments', id, 'upsert', updated);
if (!existing) throw e; return updated;
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<void> => { delete: async (id: string): Promise<void> => {
try { const existing = await db.balance_adjustments.get(id);
return await request<void>('DELETE', `/balance/adjustments/${id}`); await db.balance_adjustments.delete(id);
} catch (e) { await enqueue('balance_adjustments', id, 'delete', { id, updated_at: existing?.updated_at ?? Date.now() });
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() });
}
} }
}; };
@@ -347,24 +276,15 @@ export const balance = {
export const settings = { export const settings = {
current: async (): Promise<Settings> => { current: async (): Promise<Settings> => {
try { const today = new Date().toISOString().slice(0, 10);
return await request<Settings>('GET', '/settings'); const all = await db.settings_history.orderBy('effective_from').reverse().toArray();
} catch (e) { const match = all.find((s) => s.effective_from <= today) ?? all[0];
if (!isNetworkError(e)) throw e; if (!match) throw new ApiError(503, 'No settings available — sync pending');
const today = new Date().toISOString().slice(0, 10); return match;
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;
}
}, },
history: async (): Promise<Settings[]> => { history: async (): Promise<Settings[]> => {
try { return db.settings_history.orderBy('effective_from').reverse().toArray();
return await request<Settings[]>('GET', '/settings/history');
} catch (e) {
if (!isNetworkError(e)) throw e;
return db.settings_history.orderBy('effective_from').reverse().toArray();
}
}, },
upsert: async (body: { upsert: async (body: {
@@ -373,21 +293,16 @@ export const settings = {
workdays_mask: number; workdays_mask: number;
timezone: string; timezone: string;
}): Promise<Settings> => { }): Promise<Settings> => {
try { const now = Date.now();
return await request<Settings>('PUT', '/settings', body); const s: Settings = {
} catch (e) { id: crypto.randomUUID(),
if (!isNetworkError(e)) throw e; ...body,
const now = Date.now(); created_at: now,
const s: Settings = { updated_at: now
id: crypto.randomUUID(), };
...body, await db.settings_history.put(s);
created_at: now, await enqueue('settings_history', s.id, 'upsert', s);
updated_at: now return s;
};
await db.settings_history.put(s);
await enqueue('settings_history', s.id, 'upsert', s);
return s;
}
}, },
update: async (id: string, body: { update: async (id: string, body: {
@@ -396,27 +311,17 @@ export const settings = {
workdays_mask: number; workdays_mask: number;
timezone: string; timezone: string;
}): Promise<Settings> => { }): Promise<Settings> => {
try { const existing = await db.settings_history.get(id);
return await request<Settings>('PUT', `/settings/history/${id}`, body); if (!existing) throw new ApiError(404, 'Settings row not found');
} catch (e) { const updated: Settings = { ...existing, ...body, updated_at: Date.now() };
if (!isNetworkError(e)) throw e; await db.settings_history.put(updated);
const existing = await db.settings_history.get(id); await enqueue('settings_history', id, 'upsert', updated);
if (!existing) throw e; return updated;
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<void> => { delete: async (id: string): Promise<void> => {
try { await db.settings_history.delete(id);
return await request<void>('DELETE', `/settings/history/${id}`); await enqueue('settings_history', id, 'delete', { id, updated_at: Date.now() });
} catch (e) {
if (!isNetworkError(e)) throw e;
await db.settings_history.delete(id);
await enqueue('settings_history', id, 'delete', { id, updated_at: Date.now() });
}
} }
}; };

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { entries, days, type Entry, type ClosedDay, ApiError } from '$lib/api/client'; import { entries, days, type Entry, type ClosedDay, ApiError } from '$lib/api/client';
import { triggerSync } from '$lib/stores/sync';
import { import {
todayKey, formatTime, formatDuration, formatDurationShort, todayKey, formatTime, formatDuration, formatDurationShort,
parseTimeInput, toTimeInput, type DayCapabilities parseTimeInput, toTimeInput, type DayCapabilities
@@ -9,8 +10,9 @@
interface Props { interface Props {
dayKey: string; dayKey: string;
capabilities: DayCapabilities; capabilities: DayCapabilities;
/** Called after any mutation that changes day or entry state. */ /** Called after any mutation that changes day or entry state.
oninvalidate?: () => void; * closedDay: the new closed-day value (null = reopened, ClosedDay = just closed). */
oninvalidate?: (closedDay?: ClosedDay | null) => void;
} }
let { dayKey, capabilities, oninvalidate }: Props = $props(); let { dayKey, capabilities, oninvalidate }: Props = $props();
@@ -115,7 +117,8 @@
try { try {
const cd = await days.close(dayKey); const cd = await days.close(dayKey);
closedDay = cd; closedDay = cd;
oninvalidate?.(); oninvalidate?.(cd);
triggerSync();
} catch (e) { } catch (e) {
error = e instanceof ApiError ? e.message : String(e); error = e instanceof ApiError ? e.message : String(e);
} }
@@ -126,7 +129,8 @@
try { try {
const cd = await days.mark(dayKey, kind); const cd = await days.mark(dayKey, kind);
closedDay = cd; closedDay = cd;
oninvalidate?.(); oninvalidate?.(cd);
triggerSync();
} catch (e) { } catch (e) {
error = e instanceof ApiError ? e.message : String(e); error = e instanceof ApiError ? e.message : String(e);
} }
@@ -137,7 +141,8 @@
try { try {
await days.reopen(dayKey); await days.reopen(dayKey);
closedDay = null; closedDay = null;
oninvalidate?.(); oninvalidate?.(null);
triggerSync();
} catch (e) { } catch (e) {
error = e instanceof ApiError ? e.message : String(e); error = e instanceof ApiError ? e.message : String(e);
} }

View File

@@ -1,8 +0,0 @@
import { writable } from 'svelte/store';
export const isOnline = writable(typeof navigator !== 'undefined' ? navigator.onLine : true);
if (typeof window !== 'undefined') {
window.addEventListener('online', () => isOnline.set(true));
window.addEventListener('offline', () => isOnline.set(false));
}

View File

@@ -1,10 +1,10 @@
/** /**
* Sync layer: push local outbox to server, pull server changes. * Sync layer: push local outbox to server, pull server changes.
* *
* Online-first, offline-fallback: * Offline-first:
* - Mutations go directly to the server via REST; on network error they are * - All reads and writes go through Dexie immediately (see client.ts).
* written to Dexie + outbox by the API client.
* - This loop pushes any queued outbox items, then pulls new server changes. * - This loop pushes any queued outbox items, then pulls new server changes.
* - triggerSync() can be called imperatively (e.g. after day/week close).
* - On 410 Gone the client is stale: wipe all tables and re-pull from 0. * - On 410 Gone the client is stale: wipe all tables and re-pull from 0.
*/ */
import { db, getLastVersion, setLastVersion } from './db'; import { db, getLastVersion, setLastVersion } from './db';
@@ -144,6 +144,16 @@ export function startSync() {
syncInterval = setInterval(sync, 30_000); syncInterval = setInterval(sync, 30_000);
} }
/** Trigger an immediate sync cycle — call after server-only mutations (day/week close, reopen). */
export function triggerSync() {
sync();
}
/** Trigger an immediate sync and return a promise that resolves when it completes. */
export function waitForSync(): Promise<void> {
return sync();
}
export function stopSync() { export function stopSync() {
if (syncInterval) { if (syncInterval) {
clearInterval(syncInterval); clearInterval(syncInterval);

View File

@@ -4,7 +4,6 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { startSync, stopSync } from '$lib/stores/sync'; import { startSync, stopSync } from '$lib/stores/sync';
import { isOnline } from '$lib/stores/online';
import { todayKey, currentWeekKey } from '$lib/utils'; import { todayKey, currentWeekKey } from '$lib/utils';
let { children } = $props(); let { children } = $props();
@@ -40,10 +39,6 @@
<a href="/settings" class:active={page.url.pathname === '/settings'}>Settings</a> <a href="/settings" class:active={page.url.pathname === '/settings'}>Settings</a>
</nav> </nav>
{#if !$isOnline}
<div class="offline-banner" role="status">Offline — showing cached data</div>
{/if}
<main> <main>
{@render children()} {@render children()}
</main> </main>
@@ -88,15 +83,6 @@
background: #0f3460; background: #0f3460;
color: #e9ecef; color: #e9ecef;
} }
.offline-banner {
background: #856404;
color: #fff3cd;
text-align: center;
padding: 0.35rem 1rem;
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.02em;
}
main { main {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;

View File

@@ -53,13 +53,14 @@
if (!hasToken()) return; if (!hasToken()) return;
error = ''; error = '';
try { try {
const [c, h] = await Promise.all([settings.current(), settings.history()]); const h = await settings.history();
current = c;
history = h ?? []; history = h ?? [];
if (c) { const today = new Date().toISOString().slice(0, 10);
formHoursPerWeek = c.hours_per_week; current = history.find((s) => s.effective_from <= today) ?? history[0] ?? null;
formWorkdaysMask = c.workdays_mask; if (current) {
formTimezone = c.timezone; formHoursPerWeek = current.hours_per_week;
formWorkdaysMask = current.workdays_mask;
formTimezone = current.timezone;
} }
} catch (e) { } catch (e) {
error = e instanceof ApiError ? e.message : String(e); error = e instanceof ApiError ? e.message : String(e);

View File

@@ -2,6 +2,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { entries, days, weeks, settings, type Entry, type ClosedDay, type ClosedWeek, type Settings, ApiError } from '$lib/api/client'; import { entries, days, weeks, settings, type Entry, type ClosedDay, type ClosedWeek, type Settings, ApiError } from '$lib/api/client';
import { triggerSync } from '$lib/stores/sync';
import { import {
currentWeekKey, weekDayKeys, formatDurationShort, formatDelta, currentWeekKey, weekDayKeys, formatDurationShort, formatDelta,
todayKey, isWorkday, dayCapabilities todayKey, isWorkday, dayCapabilities
@@ -80,19 +81,25 @@
const from = dayKeys[0]; const from = dayKeys[0];
const to = dayKeys[6]; const to = dayKeys[6];
try { try {
const [ds, ws, s, es] = await Promise.all([ const [ds, ws, es] = await Promise.all([
days.list(from, to), days.list(from, to),
weeks.list(weekKey, weekKey), weeks.list(weekKey, weekKey),
settings.current(),
entries.list(from, to) entries.list(from, to)
]); ]);
closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d])); closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d]));
closedWeek = (ws ?? []).find((w) => w.week_key === weekKey) ?? null; closedWeek = (ws ?? []).find((w) => w.week_key === weekKey) ?? null;
currentSettings = s;
weekEntries = es ?? []; weekEntries = es ?? [];
} catch (e) { } catch (e) {
error = e instanceof ApiError ? e.message : String(e); error = e instanceof ApiError ? e.message : String(e);
} }
// Load settings independently so a missing settings row doesn't break the rest.
try {
currentSettings = await settings.current();
} catch (e) {
if (!(e instanceof ApiError && e.status === 503)) {
error = e instanceof ApiError ? e.message : String(e);
}
}
} }
$effect(() => { weekKey; load(); }); $effect(() => { weekKey; load(); });
@@ -117,6 +124,7 @@
try { try {
const cw = await weeks.close(weekKey); const cw = await weeks.close(weekKey);
closedWeek = cw; closedWeek = cw;
triggerSync();
} catch (e) { } catch (e) {
error = e instanceof ApiError ? e.message : String(e); error = e instanceof ApiError ? e.message : String(e);
} }
@@ -127,6 +135,7 @@
try { try {
await weeks.reopen(weekKey); await weeks.reopen(weekKey);
closedWeek = null; closedWeek = null;
triggerSync();
} catch (e) { } catch (e) {
error = e instanceof ApiError ? e.message : String(e); error = e instanceof ApiError ? e.message : String(e);
} }
@@ -246,7 +255,26 @@
<!-- Day detail panel --> <!-- Day detail panel -->
<div class="day-detail-panel" role="tabpanel"> <div class="day-detail-panel" role="tabpanel">
<h2 class="detail-heading">{selectedDay}</h2> <h2 class="detail-heading">{selectedDay}</h2>
<DayDetail dayKey={selectedDay} capabilities={detailCaps} oninvalidate={load} /> <DayDetail
dayKey={selectedDay}
capabilities={detailCaps}
oninvalidate={(cd) => {
if (cd !== undefined) {
// Day was closed or reopened — update map immediately so detailCaps reacts.
// Do NOT call load() here; Dexie doesn't have the server result yet and
// would overwrite the optimistic update.
if (cd === null) {
const { [selectedDay]: _, ...rest } = closedDaysMap;
closedDaysMap = rest;
} else {
closedDaysMap = { ...closedDaysMap, [selectedDay]: cd };
}
} else {
// Entry mutation — reload entries/days from Dexie.
load();
}
}}
/>
</div> </div>
</div> </div>