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
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. |

View File

@@ -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<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method,
@@ -42,7 +41,7 @@ async function request<T>(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<Entry> => {
try {
return await request<Entry>('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<Entry> => {
try {
return await request<Entry>('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<Entry> => {
try {
return await request<Entry>('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<Entry[]> => {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
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);
}
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> => {
try {
return await request<Entry>('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<void> => {
try {
return await request<void>('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<ClosedDay[]> => {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
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();
}
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') =>
@@ -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<ClosedWeek[]> => {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
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();
}
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: 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
};
}
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<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();
}
return db.balance_adjustments.orderBy('effective_at').reverse().toArray();
},
create: async (body: { delta_ms: number; note?: string; effective_at?: number }): Promise<BalanceAdjustment> => {
try {
return await request<BalanceAdjustment>('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<BalanceAdjustment> => {
try {
return await request<BalanceAdjustment>('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<void> => {
try {
return await request<void>('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<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;
}
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<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();
}
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<Settings> => {
try {
return await request<Settings>('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<Settings> => {
try {
return await request<Settings>('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<void> => {
try {
return await request<void>('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() });
}
};

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { entries, days, type Entry, type ClosedDay, ApiError } from '$lib/api/client';
import { triggerSync } from '$lib/stores/sync';
import {
todayKey, formatTime, formatDuration, formatDurationShort,
parseTimeInput, toTimeInput, type DayCapabilities
@@ -9,8 +10,9 @@
interface Props {
dayKey: string;
capabilities: DayCapabilities;
/** Called after any mutation that changes day or entry state. */
oninvalidate?: () => void;
/** Called after any mutation that changes day or entry state.
* closedDay: the new closed-day value (null = reopened, ClosedDay = just closed). */
oninvalidate?: (closedDay?: ClosedDay | null) => void;
}
let { dayKey, capabilities, oninvalidate }: Props = $props();
@@ -115,7 +117,8 @@
try {
const cd = await days.close(dayKey);
closedDay = cd;
oninvalidate?.();
oninvalidate?.(cd);
triggerSync();
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
@@ -126,7 +129,8 @@
try {
const cd = await days.mark(dayKey, kind);
closedDay = cd;
oninvalidate?.();
oninvalidate?.(cd);
triggerSync();
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
@@ -137,7 +141,8 @@
try {
await days.reopen(dayKey);
closedDay = null;
oninvalidate?.();
oninvalidate?.(null);
triggerSync();
} catch (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.
*
* Online-first, offline-fallback:
* - Mutations go directly to the server via REST; on network error they are
* written to Dexie + outbox by the API client.
* Offline-first:
* - All reads and writes go through Dexie immediately (see client.ts).
* - 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.
*/
import { db, getLastVersion, setLastVersion } from './db';
@@ -144,6 +144,16 @@ export function startSync() {
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() {
if (syncInterval) {
clearInterval(syncInterval);

View File

@@ -4,7 +4,6 @@
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();
@@ -40,10 +39,6 @@
<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>
@@ -88,15 +83,6 @@
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;

View File

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

View File

@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
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 {
currentWeekKey, weekDayKeys, formatDurationShort, formatDelta,
todayKey, isWorkday, dayCapabilities
@@ -80,19 +81,25 @@
const from = dayKeys[0];
const to = dayKeys[6];
try {
const [ds, ws, s, es] = await Promise.all([
const [ds, ws, es] = await Promise.all([
days.list(from, to),
weeks.list(weekKey, weekKey),
settings.current(),
entries.list(from, to)
]);
closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d]));
closedWeek = (ws ?? []).find((w) => w.week_key === weekKey) ?? null;
currentSettings = s;
weekEntries = es ?? [];
} catch (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(); });
@@ -117,6 +124,7 @@
try {
const cw = await weeks.close(weekKey);
closedWeek = cw;
triggerSync();
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
@@ -127,6 +135,7 @@
try {
await weeks.reopen(weekKey);
closedWeek = null;
triggerSync();
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
@@ -246,7 +255,26 @@
<!-- Day detail panel -->
<div class="day-detail-panel" role="tabpanel">
<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>