diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 5b52dce..215aa41 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -177,11 +177,21 @@ export const entries = { } }, - list: (from?: string, to?: string) => { + list: async (from?: string, to?: string): Promise => { const params = new URLSearchParams(); if (from) params.set('from', from); if (to) params.set('to', to); - return request('GET', `/entries?${params}`); + 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); + } }, update: async (id: string, body: { start_time?: number; end_time?: number; note?: string }): Promise => { @@ -212,11 +222,20 @@ export const entries = { // ─── Days ──────────────────────────────────────────────────────────────────── export const days = { - list: (from?: string, to?: string) => { + list: async (from?: string, to?: string): Promise => { const params = new URLSearchParams(); if (from) params.set('from', from); if (to) params.set('to', to); - return request('GET', `/days?${params}`); + 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(); + } }, close: (dayKey: string) => request('POST', `/days/${dayKey}/close`), mark: (dayKey: string, kind: 'holiday' | 'vacation' | 'sick') => @@ -227,21 +246,56 @@ export const days = { // ─── Weeks ─────────────────────────────────────────────────────────────────── export const weeks = { - list: (from?: string, to?: string) => { + list: async (from?: string, to?: string): Promise => { const params = new URLSearchParams(); if (from) params.set('from', from); if (to) params.set('to', to); - return request('GET', `/weeks?${params}`); + 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(); + } }, close: (weekKey: string) => request('POST', `/weeks/${weekKey}/close`), reopen: (weekKey: string) => request('DELETE', `/weeks/${weekKey}/close`), - balance: () => request('GET', '/weeks/balance') + 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 + }; + } + } }; // ─── Balance adjustments ───────────────────────────────────────────────────── export const balance = { - list: () => request('GET', '/balance/adjustments'), + 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(); + } + }, create: async (body: { delta_ms: number; note?: string; effective_at?: number }): Promise => { try { @@ -292,8 +346,26 @@ export const balance = { // ─── Settings ──────────────────────────────────────────────────────────────── export const settings = { - current: () => request('GET', '/settings'), - history: () => request('GET', '/settings/history'), + 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; + } + }, + 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(); + } + }, upsert: async (body: { effective_from: string; diff --git a/web/src/lib/stores/online.ts b/web/src/lib/stores/online.ts new file mode 100644 index 0000000..96f2ed4 --- /dev/null +++ b/web/src/lib/stores/online.ts @@ -0,0 +1,8 @@ +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)); +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 1da3837..4033570 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { onMount, onDestroy } from 'svelte'; import { goto } from '$app/navigation'; import { startSync, stopSync } from '$lib/stores/sync'; + import { isOnline } from '$lib/stores/online'; import { todayKey, currentWeekKey } from '$lib/utils'; let { children } = $props(); @@ -39,6 +40,10 @@ Settings +{#if !$isOnline} +
Offline — showing cached data
+{/if} +
{@render children()}
@@ -83,6 +88,15 @@ background: #0f3460; 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 { max-width: 800px; margin: 0 auto;