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:
@@ -177,11 +177,21 @@ export const entries = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
list: (from?: string, to?: string) => {
|
list: async (from?: string, to?: string): Promise<Entry[]> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (from) params.set('from', from);
|
if (from) params.set('from', from);
|
||||||
if (to) params.set('to', to);
|
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> => {
|
update: async (id: string, body: { start_time?: number; end_time?: number; note?: string }): Promise<Entry> => {
|
||||||
@@ -212,11 +222,20 @@ export const entries = {
|
|||||||
// ─── Days ────────────────────────────────────────────────────────────────────
|
// ─── Days ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const days = {
|
export const days = {
|
||||||
list: (from?: string, to?: string) => {
|
list: async (from?: string, to?: string): Promise<ClosedDay[]> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (from) params.set('from', from);
|
if (from) params.set('from', from);
|
||||||
if (to) params.set('to', to);
|
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`),
|
close: (dayKey: string) => request<ClosedDay>('POST', `/days/${dayKey}/close`),
|
||||||
mark: (dayKey: string, kind: 'holiday' | 'vacation' | 'sick') =>
|
mark: (dayKey: string, kind: 'holiday' | 'vacation' | 'sick') =>
|
||||||
@@ -227,21 +246,56 @@ export const days = {
|
|||||||
// ─── Weeks ───────────────────────────────────────────────────────────────────
|
// ─── Weeks ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const weeks = {
|
export const weeks = {
|
||||||
list: (from?: string, to?: string) => {
|
list: async (from?: string, to?: string): Promise<ClosedWeek[]> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (from) params.set('from', from);
|
if (from) params.set('from', from);
|
||||||
if (to) params.set('to', to);
|
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`),
|
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: () => 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 ─────────────────────────────────────────────────────
|
// ─── Balance adjustments ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const balance = {
|
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> => {
|
create: async (body: { delta_ms: number; note?: string; effective_at?: number }): Promise<BalanceAdjustment> => {
|
||||||
try {
|
try {
|
||||||
@@ -292,8 +346,26 @@ export const balance = {
|
|||||||
// ─── Settings ────────────────────────────────────────────────────────────────
|
// ─── Settings ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const settings = {
|
export const settings = {
|
||||||
current: () => request<Settings>('GET', '/settings'),
|
current: async (): Promise<Settings> => {
|
||||||
history: () => request<Settings[]>('GET', '/settings/history'),
|
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: {
|
upsert: async (body: {
|
||||||
effective_from: string;
|
effective_from: string;
|
||||||
|
|||||||
8
web/src/lib/stores/online.ts
Normal file
8
web/src/lib/stores/online.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
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();
|
||||||
@@ -39,6 +40,10 @@
|
|||||||
<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>
|
||||||
@@ -83,6 +88,15 @@
|
|||||||
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user