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:
24
PLAN.md
24
PLAN.md
@@ -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. |
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user