feat: offline read fallback + online status indicator

All GET calls in client.ts now fall back to Dexie when a network error
(TypeError) is caught, so pages render from cached data when the server
is unreachable:

- entries.list()       → db.entries filtered by day_key range
- days.list()          → db.closed_days filtered by day_key range
- weeks.list()         → db.closed_weeks filtered by week_key range
- weeks.balance()      → computed locally from closed_weeks + balance_adjustments
- balance.list()       → db.balance_adjustments ordered by effective_at DESC
- settings.current()   → db.settings_history, latest row with effective_from <= today
- settings.history()   → db.settings_history ordered by effective_from DESC

Day/week close and reopen remain online-only (they require server-side
computation).

Add isOnline store (navigator.onLine + window online/offline events) and
an amber 'Offline — showing cached data' banner in +layout.svelte shown
whenever the store is false.
This commit is contained in:
2026-05-01 09:47:14 +02:00
parent 31535e944d
commit 725df56cc8
3 changed files with 104 additions and 10 deletions

View File

@@ -177,11 +177,21 @@ export const entries = {
}
},
list: (from?: string, to?: string) => {
list: async (from?: string, to?: string): Promise<Entry[]> => {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
return request<Entry[]>('GET', `/entries?${params}`);
try {
return await request<Entry[]>('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<Entry> => {
@@ -212,11 +222,20 @@ export const entries = {
// ─── Days ────────────────────────────────────────────────────────────────────
export const days = {
list: (from?: string, to?: string) => {
list: async (from?: string, to?: string): Promise<ClosedDay[]> => {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
return request<ClosedDay[]>('GET', `/days?${params}`);
try {
return await request<ClosedDay[]>('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<ClosedDay>('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<ClosedWeek[]> => {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
return request<ClosedWeek[]>('GET', `/weeks?${params}`);
try {
return await request<ClosedWeek[]>('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<ClosedWeek>('POST', `/weeks/${weekKey}/close`),
reopen: (weekKey: string) => request<void>('DELETE', `/weeks/${weekKey}/close`),
balance: () => request<BalanceSummary>('GET', '/weeks/balance')
balance: async (): Promise<BalanceSummary> => {
try {
return await request<BalanceSummary>('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<BalanceAdjustment[]>('GET', '/balance/adjustments'),
list: async (): Promise<BalanceAdjustment[]> => {
try {
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> => {
try {
@@ -292,8 +346,26 @@ export const balance = {
// ─── Settings ────────────────────────────────────────────────────────────────
export const settings = {
current: () => request<Settings>('GET', '/settings'),
history: () => request<Settings[]>('GET', '/settings/history'),
current: async (): Promise<Settings> => {
try {
return await request<Settings>('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<Settings[]> => {
try {
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: {
effective_from: string;

View File

@@ -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));
}

View File

@@ -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 @@
<a href="/settings" class:active={page.url.pathname === '/settings'}>Settings</a>
</nav>
{#if !$isOnline}
<div class="offline-banner" role="status">Offline — showing cached data</div>
{/if}
<main>
{@render children()}
</main>
@@ -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;