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
|
### 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.
|
1. App shell precached by Workbox.
|
||||||
2. All reads come from Dexie (IndexedDB); a sync worker reconciles with the server in the background.
|
2. **All reads** come directly from Dexie — no network latency in the hot path.
|
||||||
3. All mutations create a local Dexie record with a client-generated UUIDv7 and an entry in a local `outbox` table.
|
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. When online, the outbox is flushed via `/api/sync/push`; responses update the local cache.
|
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. `/api/sync/pull` fetches any server-side changes since `last_pulled_version`.
|
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
|
## 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.
|
- `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`.
|
- 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.
|
CSV/JSON export, monthly summary view.
|
||||||
|
|
||||||
## 7. Decisions & Rationale
|
## 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 |
|
| 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 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 |
|
| 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.
|
// API client for Wotra backend.
|
||||||
// Base URL: /api (relative, works both in dev proxy and production)
|
// Base URL: /api (relative, works both in dev proxy and production)
|
||||||
//
|
//
|
||||||
// Offline fallback: if a mutation throws a network error (TypeError: Failed to fetch),
|
// Offline-first: all reads come from Dexie immediately; all mutations write to
|
||||||
// the call enqueues the operation in the Dexie outbox and resolves with the local object.
|
// Dexie + outbox and return without waiting for the server. The background sync
|
||||||
// The background sync loop will push the outbox to the server when connectivity returns.
|
// 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';
|
import { db } from '$lib/stores/db';
|
||||||
|
|
||||||
@@ -21,10 +24,6 @@ export function hasToken(): boolean {
|
|||||||
return !!localStorage.getItem('auth_token');
|
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> {
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
method,
|
method,
|
||||||
@@ -42,7 +41,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
return res.json();
|
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) {
|
async function enqueue(entity: string, entity_id: string, op: 'upsert' | 'delete', payload: unknown) {
|
||||||
await db.outbox.add({
|
await db.outbox.add({
|
||||||
entity,
|
entity,
|
||||||
@@ -124,118 +123,79 @@ export interface BalanceSummary {
|
|||||||
|
|
||||||
export const entries = {
|
export const entries = {
|
||||||
start: async (note = ''): Promise<Entry> => {
|
start: async (note = ''): Promise<Entry> => {
|
||||||
try {
|
const entry: Entry = {
|
||||||
return await request<Entry>('POST', '/entries/start', { note });
|
id: crypto.randomUUID(),
|
||||||
} catch (e) {
|
start_time: Date.now(),
|
||||||
if (!isNetworkError(e)) throw e;
|
end_time: null,
|
||||||
const entry: Entry = {
|
auto_stopped: false,
|
||||||
id: crypto.randomUUID(),
|
note,
|
||||||
start_time: Date.now(),
|
day_key: new Date().toISOString().slice(0, 10),
|
||||||
end_time: null,
|
updated_at: Date.now()
|
||||||
auto_stopped: false,
|
};
|
||||||
note,
|
await db.entries.put(entry);
|
||||||
day_key: new Date().toISOString().slice(0, 10),
|
await enqueue('entries', entry.id, 'upsert', entry);
|
||||||
updated_at: Date.now()
|
return entry;
|
||||||
};
|
|
||||||
await db.entries.put(entry);
|
|
||||||
await enqueue('entries', entry.id, 'upsert', entry);
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
createInterval: async (startTime: number, endTime: number, note = ''): Promise<Entry> => {
|
createInterval: async (startTime: number, endTime: number, note = ''): Promise<Entry> => {
|
||||||
try {
|
const entry: Entry = {
|
||||||
return await request<Entry>('POST', '/entries', { start_time: startTime, end_time: endTime, note });
|
id: crypto.randomUUID(),
|
||||||
} catch (e) {
|
start_time: startTime,
|
||||||
if (!isNetworkError(e)) throw e;
|
end_time: endTime,
|
||||||
const entry: Entry = {
|
auto_stopped: false,
|
||||||
id: crypto.randomUUID(),
|
note,
|
||||||
start_time: startTime,
|
day_key: new Date(startTime).toISOString().slice(0, 10),
|
||||||
end_time: endTime,
|
updated_at: Date.now()
|
||||||
auto_stopped: false,
|
};
|
||||||
note,
|
await db.entries.put(entry);
|
||||||
day_key: new Date(startTime).toISOString().slice(0, 10),
|
await enqueue('entries', entry.id, 'upsert', entry);
|
||||||
updated_at: Date.now()
|
return entry;
|
||||||
};
|
|
||||||
await db.entries.put(entry);
|
|
||||||
await enqueue('entries', entry.id, 'upsert', entry);
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
stop: async (id: string): Promise<Entry> => {
|
stop: async (id: string): Promise<Entry> => {
|
||||||
try {
|
const existing = await db.entries.get(id);
|
||||||
return await request<Entry>('POST', `/entries/${id}/stop`);
|
if (!existing) throw new ApiError(404, 'Entry not found');
|
||||||
} catch (e) {
|
const updated: Entry = { ...existing, end_time: Date.now(), updated_at: Date.now() };
|
||||||
if (!isNetworkError(e)) throw e;
|
await db.entries.put(updated);
|
||||||
const existing = await db.entries.get(id);
|
await enqueue('entries', id, 'upsert', updated);
|
||||||
if (!existing) throw e;
|
return updated;
|
||||||
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[]> => {
|
list: async (from?: string, to?: string): Promise<Entry[]> => {
|
||||||
const params = new URLSearchParams();
|
let q = db.entries.toCollection();
|
||||||
if (from) params.set('from', from);
|
if (from && to) q = db.entries.where('day_key').between(from, to, true, true);
|
||||||
if (to) params.set('to', to);
|
else if (from) q = db.entries.where('day_key').aboveOrEqual(from);
|
||||||
try {
|
else if (to) q = db.entries.where('day_key').belowOrEqual(to);
|
||||||
return await request<Entry[]>('GET', `/entries?${params}`);
|
const rows = await q.toArray();
|
||||||
} catch (e) {
|
return rows.sort((a, b) => a.start_time - b.start_time);
|
||||||
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> => {
|
||||||
try {
|
const existing = await db.entries.get(id);
|
||||||
return await request<Entry>('PUT', `/entries/${id}`, body);
|
if (!existing) throw new ApiError(404, 'Entry not found');
|
||||||
} catch (e) {
|
const updated: Entry = { ...existing, ...body, updated_at: Date.now() };
|
||||||
if (!isNetworkError(e)) throw e;
|
await db.entries.put(updated);
|
||||||
const existing = await db.entries.get(id);
|
await enqueue('entries', id, 'upsert', updated);
|
||||||
if (!existing) throw e;
|
return updated;
|
||||||
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> => {
|
delete: async (id: string): Promise<void> => {
|
||||||
try {
|
await db.entries.delete(id);
|
||||||
return await request<void>('DELETE', `/entries/${id}`);
|
await enqueue('entries', id, 'delete', { id, updated_at: Date.now() });
|
||||||
} catch (e) {
|
|
||||||
if (!isNetworkError(e)) throw e;
|
|
||||||
await db.entries.delete(id);
|
|
||||||
await enqueue('entries', id, 'delete', { id, updated_at: Date.now() });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Days ────────────────────────────────────────────────────────────────────
|
// ─── Days ────────────────────────────────────────────────────────────────────
|
||||||
|
// close/mark/reopen remain server-only (require server-side computation).
|
||||||
|
// Call triggerSync() after them to update Dexie promptly.
|
||||||
|
|
||||||
export const days = {
|
export const days = {
|
||||||
list: async (from?: string, to?: string): Promise<ClosedDay[]> => {
|
list: async (from?: string, to?: string): Promise<ClosedDay[]> => {
|
||||||
const params = new URLSearchParams();
|
let q = db.closed_days.toCollection();
|
||||||
if (from) params.set('from', from);
|
if (from && to) q = db.closed_days.where('day_key').between(from, to, true, true);
|
||||||
if (to) params.set('to', to);
|
else if (from) q = db.closed_days.where('day_key').aboveOrEqual(from);
|
||||||
try {
|
else if (to) q = db.closed_days.where('day_key').belowOrEqual(to);
|
||||||
return await request<ClosedDay[]>('GET', `/days?${params}`);
|
return q.toArray();
|
||||||
} 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') =>
|
||||||
@@ -244,44 +204,33 @@ export const days = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── Weeks ───────────────────────────────────────────────────────────────────
|
// ─── Weeks ───────────────────────────────────────────────────────────────────
|
||||||
|
// close/reopen remain server-only (require server-side computation).
|
||||||
|
// Call triggerSync() after them to update Dexie promptly.
|
||||||
|
|
||||||
export const weeks = {
|
export const weeks = {
|
||||||
list: async (from?: string, to?: string): Promise<ClosedWeek[]> => {
|
list: async (from?: string, to?: string): Promise<ClosedWeek[]> => {
|
||||||
const params = new URLSearchParams();
|
let q = db.closed_weeks.toCollection();
|
||||||
if (from) params.set('from', from);
|
if (from && to) q = db.closed_weeks.where('week_key').between(from, to, true, true);
|
||||||
if (to) params.set('to', to);
|
else if (from) q = db.closed_weeks.where('week_key').aboveOrEqual(from);
|
||||||
try {
|
else if (to) q = db.closed_weeks.where('week_key').belowOrEqual(to);
|
||||||
return await request<ClosedWeek[]>('GET', `/weeks?${params}`);
|
return q.toArray();
|
||||||
} 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: async (): Promise<BalanceSummary> => {
|
balance: async (): Promise<BalanceSummary> => {
|
||||||
try {
|
const [ws, adjs] = await Promise.all([
|
||||||
return await request<BalanceSummary>('GET', '/weeks/balance');
|
db.closed_weeks.toArray(),
|
||||||
} catch (e) {
|
db.balance_adjustments.toArray()
|
||||||
if (!isNetworkError(e)) throw e;
|
]);
|
||||||
const [ws, adjs] = await Promise.all([
|
const weeks_delta_ms = ws.reduce((s, w) => s + w.delta_ms, 0);
|
||||||
db.closed_weeks.toArray(),
|
const adjustments_delta_ms = adjs.reduce((s, a) => s + a.delta_ms, 0);
|
||||||
db.balance_adjustments.toArray()
|
return {
|
||||||
]);
|
total_delta_ms: weeks_delta_ms + adjustments_delta_ms,
|
||||||
const weeks_delta_ms = ws.reduce((s, w) => s + w.delta_ms, 0);
|
weeks_delta_ms,
|
||||||
const adjustments_delta_ms = adjs.reduce((s, a) => s + a.delta_ms, 0);
|
adjustments_delta_ms,
|
||||||
return {
|
closed_week_count: ws.length,
|
||||||
total_delta_ms: weeks_delta_ms + adjustments_delta_ms,
|
adjustment_count: adjs.length
|
||||||
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 = {
|
export const balance = {
|
||||||
list: async (): Promise<BalanceAdjustment[]> => {
|
list: async (): Promise<BalanceAdjustment[]> => {
|
||||||
try {
|
return db.balance_adjustments.orderBy('effective_at').reverse().toArray();
|
||||||
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 {
|
const now = Date.now();
|
||||||
return await request<BalanceAdjustment>('POST', '/balance/adjustments', body);
|
const adj: BalanceAdjustment = {
|
||||||
} catch (e) {
|
id: crypto.randomUUID(),
|
||||||
if (!isNetworkError(e)) throw e;
|
delta_ms: body.delta_ms,
|
||||||
const now = Date.now();
|
note: body.note ?? '',
|
||||||
const adj: BalanceAdjustment = {
|
effective_at: body.effective_at ?? now,
|
||||||
id: crypto.randomUUID(),
|
created_at: now,
|
||||||
delta_ms: body.delta_ms,
|
updated_at: now
|
||||||
note: body.note ?? '',
|
};
|
||||||
effective_at: body.effective_at ?? now,
|
await db.balance_adjustments.put(adj);
|
||||||
created_at: now,
|
await enqueue('balance_adjustments', adj.id, 'upsert', adj);
|
||||||
updated_at: now
|
return adj;
|
||||||
};
|
|
||||||
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> => {
|
update: async (id: string, body: { delta_ms: number; note?: string; effective_at?: number }): Promise<BalanceAdjustment> => {
|
||||||
try {
|
const existing = await db.balance_adjustments.get(id);
|
||||||
return await request<BalanceAdjustment>('PUT', `/balance/adjustments/${id}`, body);
|
if (!existing) throw new ApiError(404, 'Adjustment not found');
|
||||||
} catch (e) {
|
const updated: BalanceAdjustment = { ...existing, ...body, updated_at: Date.now() };
|
||||||
if (!isNetworkError(e)) throw e;
|
await db.balance_adjustments.put(updated);
|
||||||
const existing = await db.balance_adjustments.get(id);
|
await enqueue('balance_adjustments', id, 'upsert', updated);
|
||||||
if (!existing) throw e;
|
return updated;
|
||||||
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> => {
|
delete: async (id: string): Promise<void> => {
|
||||||
try {
|
const existing = await db.balance_adjustments.get(id);
|
||||||
return await request<void>('DELETE', `/balance/adjustments/${id}`);
|
await db.balance_adjustments.delete(id);
|
||||||
} catch (e) {
|
await enqueue('balance_adjustments', id, 'delete', { id, updated_at: existing?.updated_at ?? Date.now() });
|
||||||
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() });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -347,24 +276,15 @@ export const balance = {
|
|||||||
|
|
||||||
export const settings = {
|
export const settings = {
|
||||||
current: async (): Promise<Settings> => {
|
current: async (): Promise<Settings> => {
|
||||||
try {
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
return await request<Settings>('GET', '/settings');
|
const all = await db.settings_history.orderBy('effective_from').reverse().toArray();
|
||||||
} catch (e) {
|
const match = all.find((s) => s.effective_from <= today) ?? all[0];
|
||||||
if (!isNetworkError(e)) throw e;
|
if (!match) throw new ApiError(503, 'No settings available — sync pending');
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
return match;
|
||||||
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[]> => {
|
history: async (): Promise<Settings[]> => {
|
||||||
try {
|
return db.settings_history.orderBy('effective_from').reverse().toArray();
|
||||||
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: {
|
||||||
@@ -373,21 +293,16 @@ export const settings = {
|
|||||||
workdays_mask: number;
|
workdays_mask: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
}): Promise<Settings> => {
|
}): Promise<Settings> => {
|
||||||
try {
|
const now = Date.now();
|
||||||
return await request<Settings>('PUT', '/settings', body);
|
const s: Settings = {
|
||||||
} catch (e) {
|
id: crypto.randomUUID(),
|
||||||
if (!isNetworkError(e)) throw e;
|
...body,
|
||||||
const now = Date.now();
|
created_at: now,
|
||||||
const s: Settings = {
|
updated_at: now
|
||||||
id: crypto.randomUUID(),
|
};
|
||||||
...body,
|
await db.settings_history.put(s);
|
||||||
created_at: now,
|
await enqueue('settings_history', s.id, 'upsert', s);
|
||||||
updated_at: now
|
return s;
|
||||||
};
|
|
||||||
await db.settings_history.put(s);
|
|
||||||
await enqueue('settings_history', s.id, 'upsert', s);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (id: string, body: {
|
update: async (id: string, body: {
|
||||||
@@ -396,27 +311,17 @@ export const settings = {
|
|||||||
workdays_mask: number;
|
workdays_mask: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
}): Promise<Settings> => {
|
}): Promise<Settings> => {
|
||||||
try {
|
const existing = await db.settings_history.get(id);
|
||||||
return await request<Settings>('PUT', `/settings/history/${id}`, body);
|
if (!existing) throw new ApiError(404, 'Settings row not found');
|
||||||
} catch (e) {
|
const updated: Settings = { ...existing, ...body, updated_at: Date.now() };
|
||||||
if (!isNetworkError(e)) throw e;
|
await db.settings_history.put(updated);
|
||||||
const existing = await db.settings_history.get(id);
|
await enqueue('settings_history', id, 'upsert', updated);
|
||||||
if (!existing) throw e;
|
return updated;
|
||||||
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> => {
|
delete: async (id: string): Promise<void> => {
|
||||||
try {
|
await db.settings_history.delete(id);
|
||||||
return await request<void>('DELETE', `/settings/history/${id}`);
|
await enqueue('settings_history', id, 'delete', { id, updated_at: Date.now() });
|
||||||
} catch (e) {
|
|
||||||
if (!isNetworkError(e)) throw e;
|
|
||||||
await db.settings_history.delete(id);
|
|
||||||
await enqueue('settings_history', id, 'delete', { id, updated_at: Date.now() });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { entries, days, type Entry, type ClosedDay, ApiError } from '$lib/api/client';
|
import { entries, days, type Entry, type ClosedDay, ApiError } from '$lib/api/client';
|
||||||
|
import { triggerSync } from '$lib/stores/sync';
|
||||||
import {
|
import {
|
||||||
todayKey, formatTime, formatDuration, formatDurationShort,
|
todayKey, formatTime, formatDuration, formatDurationShort,
|
||||||
parseTimeInput, toTimeInput, type DayCapabilities
|
parseTimeInput, toTimeInput, type DayCapabilities
|
||||||
@@ -9,8 +10,9 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
dayKey: string;
|
dayKey: string;
|
||||||
capabilities: DayCapabilities;
|
capabilities: DayCapabilities;
|
||||||
/** Called after any mutation that changes day or entry state. */
|
/** Called after any mutation that changes day or entry state.
|
||||||
oninvalidate?: () => void;
|
* closedDay: the new closed-day value (null = reopened, ClosedDay = just closed). */
|
||||||
|
oninvalidate?: (closedDay?: ClosedDay | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { dayKey, capabilities, oninvalidate }: Props = $props();
|
let { dayKey, capabilities, oninvalidate }: Props = $props();
|
||||||
@@ -115,7 +117,8 @@
|
|||||||
try {
|
try {
|
||||||
const cd = await days.close(dayKey);
|
const cd = await days.close(dayKey);
|
||||||
closedDay = cd;
|
closedDay = cd;
|
||||||
oninvalidate?.();
|
oninvalidate?.(cd);
|
||||||
|
triggerSync();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
}
|
}
|
||||||
@@ -126,7 +129,8 @@
|
|||||||
try {
|
try {
|
||||||
const cd = await days.mark(dayKey, kind);
|
const cd = await days.mark(dayKey, kind);
|
||||||
closedDay = cd;
|
closedDay = cd;
|
||||||
oninvalidate?.();
|
oninvalidate?.(cd);
|
||||||
|
triggerSync();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
}
|
}
|
||||||
@@ -137,7 +141,8 @@
|
|||||||
try {
|
try {
|
||||||
await days.reopen(dayKey);
|
await days.reopen(dayKey);
|
||||||
closedDay = null;
|
closedDay = null;
|
||||||
oninvalidate?.();
|
oninvalidate?.(null);
|
||||||
|
triggerSync();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : String(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.
|
* Sync layer: push local outbox to server, pull server changes.
|
||||||
*
|
*
|
||||||
* Online-first, offline-fallback:
|
* Offline-first:
|
||||||
* - Mutations go directly to the server via REST; on network error they are
|
* - All reads and writes go through Dexie immediately (see client.ts).
|
||||||
* written to Dexie + outbox by the API client.
|
|
||||||
* - This loop pushes any queued outbox items, then pulls new server changes.
|
* - 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.
|
* - On 410 Gone the client is stale: wipe all tables and re-pull from 0.
|
||||||
*/
|
*/
|
||||||
import { db, getLastVersion, setLastVersion } from './db';
|
import { db, getLastVersion, setLastVersion } from './db';
|
||||||
@@ -144,6 +144,16 @@ export function startSync() {
|
|||||||
syncInterval = setInterval(sync, 30_000);
|
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() {
|
export function stopSync() {
|
||||||
if (syncInterval) {
|
if (syncInterval) {
|
||||||
clearInterval(syncInterval);
|
clearInterval(syncInterval);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
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();
|
||||||
@@ -40,10 +39,6 @@
|
|||||||
<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>
|
||||||
@@ -88,15 +83,6 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -53,13 +53,14 @@
|
|||||||
if (!hasToken()) return;
|
if (!hasToken()) return;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const [c, h] = await Promise.all([settings.current(), settings.history()]);
|
const h = await settings.history();
|
||||||
current = c;
|
|
||||||
history = h ?? [];
|
history = h ?? [];
|
||||||
if (c) {
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
formHoursPerWeek = c.hours_per_week;
|
current = history.find((s) => s.effective_from <= today) ?? history[0] ?? null;
|
||||||
formWorkdaysMask = c.workdays_mask;
|
if (current) {
|
||||||
formTimezone = c.timezone;
|
formHoursPerWeek = current.hours_per_week;
|
||||||
|
formWorkdaysMask = current.workdays_mask;
|
||||||
|
formTimezone = current.timezone;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { entries, days, weeks, settings, type Entry, type ClosedDay, type ClosedWeek, type Settings, ApiError } from '$lib/api/client';
|
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 {
|
import {
|
||||||
currentWeekKey, weekDayKeys, formatDurationShort, formatDelta,
|
currentWeekKey, weekDayKeys, formatDurationShort, formatDelta,
|
||||||
todayKey, isWorkday, dayCapabilities
|
todayKey, isWorkday, dayCapabilities
|
||||||
@@ -80,19 +81,25 @@
|
|||||||
const from = dayKeys[0];
|
const from = dayKeys[0];
|
||||||
const to = dayKeys[6];
|
const to = dayKeys[6];
|
||||||
try {
|
try {
|
||||||
const [ds, ws, s, es] = await Promise.all([
|
const [ds, ws, es] = await Promise.all([
|
||||||
days.list(from, to),
|
days.list(from, to),
|
||||||
weeks.list(weekKey, weekKey),
|
weeks.list(weekKey, weekKey),
|
||||||
settings.current(),
|
|
||||||
entries.list(from, to)
|
entries.list(from, to)
|
||||||
]);
|
]);
|
||||||
closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d]));
|
closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d]));
|
||||||
closedWeek = (ws ?? []).find((w) => w.week_key === weekKey) ?? null;
|
closedWeek = (ws ?? []).find((w) => w.week_key === weekKey) ?? null;
|
||||||
currentSettings = s;
|
|
||||||
weekEntries = es ?? [];
|
weekEntries = es ?? [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : String(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(); });
|
$effect(() => { weekKey; load(); });
|
||||||
@@ -117,6 +124,7 @@
|
|||||||
try {
|
try {
|
||||||
const cw = await weeks.close(weekKey);
|
const cw = await weeks.close(weekKey);
|
||||||
closedWeek = cw;
|
closedWeek = cw;
|
||||||
|
triggerSync();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
}
|
}
|
||||||
@@ -127,6 +135,7 @@
|
|||||||
try {
|
try {
|
||||||
await weeks.reopen(weekKey);
|
await weeks.reopen(weekKey);
|
||||||
closedWeek = null;
|
closedWeek = null;
|
||||||
|
triggerSync();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
}
|
}
|
||||||
@@ -246,7 +255,26 @@
|
|||||||
<!-- Day detail panel -->
|
<!-- Day detail panel -->
|
||||||
<div class="day-detail-panel" role="tabpanel">
|
<div class="day-detail-panel" role="tabpanel">
|
||||||
<h2 class="detail-heading">{selectedDay}</h2>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user