// 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. import { db } from '$lib/stores/db'; const API_BASE = '/api'; function getToken(): string { return localStorage.getItem('auth_token') ?? ''; } export function setToken(token: string) { localStorage.setItem('auth_token', token); } 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(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${API_BASE}${path}`, { method, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }, body: body !== undefined ? JSON.stringify(body) : undefined }); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new ApiError(res.status, err.error ?? res.statusText); } if (res.status === 204) return undefined as T; return res.json(); } /** Enqueue an outbox item for offline push. */ async function enqueue(entity: string, entity_id: string, op: 'upsert' | 'delete', payload: unknown) { await db.outbox.add({ entity, entity_id, op, payload: JSON.stringify(payload), created_at: Date.now() }); } export class ApiError extends Error { constructor( public status: number, message: string ) { super(message); } } // ─── Types ────────────────────────────────────────────────────────────────── export interface Entry { id: string; start_time: number; // unix ms UTC end_time: number | null; auto_stopped: boolean; note: string; day_key: string; updated_at: number; } export interface ClosedDay { day_key: string; start_time: number | null; end_time: number | null; worked_ms: number; kind: 'work' | 'holiday' | 'vacation' | 'sick'; closed_at: number; updated_at: number; } export interface ClosedWeek { week_key: string; expected_ms: number; worked_ms: number; delta_ms: number; closed_at: number; updated_at: number; } export interface Settings { id: string; // UUID effective_from: string; hours_per_week: number; workdays_mask: number; timezone: string; created_at: number; updated_at: number; } export interface BalanceAdjustment { id: string; delta_ms: number; note: string; effective_at: number; // unix ms created_at: number; updated_at: number; } export interface BalanceSummary { total_delta_ms: number; weeks_delta_ms: number; adjustments_delta_ms: number; closed_week_count: number; adjustment_count: number; } // ─── Entries ───────────────────────────────────────────────────────────────── export const entries = { start: async (note = ''): Promise => { try { return await request('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; } }, createInterval: async (startTime: number, endTime: number, note = ''): Promise => { try { return await request('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; } }, stop: async (id: string): Promise => { try { return await request('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; } }, list: (from?: string, to?: string) => { const params = new URLSearchParams(); if (from) params.set('from', from); if (to) params.set('to', to); return request('GET', `/entries?${params}`); }, update: async (id: string, body: { start_time?: number; end_time?: number; note?: string }): Promise => { try { return await request('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; } }, delete: async (id: string): Promise => { try { return await request('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() }); } } }; // ─── Days ──────────────────────────────────────────────────────────────────── export const days = { list: (from?: string, to?: string) => { const params = new URLSearchParams(); if (from) params.set('from', from); if (to) params.set('to', to); return request('GET', `/days?${params}`); }, close: (dayKey: string) => request('POST', `/days/${dayKey}/close`), mark: (dayKey: string, kind: 'holiday' | 'vacation' | 'sick') => request('POST', `/days/${dayKey}/mark`, { kind }), reopen: (dayKey: string) => request('DELETE', `/days/${dayKey}/close`) }; // ─── Weeks ─────────────────────────────────────────────────────────────────── export const weeks = { list: (from?: string, to?: string) => { const params = new URLSearchParams(); if (from) params.set('from', from); if (to) params.set('to', to); return request('GET', `/weeks?${params}`); }, close: (weekKey: string) => request('POST', `/weeks/${weekKey}/close`), reopen: (weekKey: string) => request('DELETE', `/weeks/${weekKey}/close`), balance: () => request('GET', '/weeks/balance') }; // ─── Balance adjustments ───────────────────────────────────────────────────── export const balance = { list: () => request('GET', '/balance/adjustments'), create: async (body: { delta_ms: number; note?: string; effective_at?: number }): Promise => { try { return await request('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; } }, update: async (id: string, body: { delta_ms: number; note?: string; effective_at?: number }): Promise => { try { return await request('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; } }, delete: async (id: string): Promise => { try { return await request('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() }); } } }; // ─── Settings ──────────────────────────────────────────────────────────────── export const settings = { current: () => request('GET', '/settings'), history: () => request('GET', '/settings/history'), upsert: async (body: { effective_from: string; hours_per_week: number; workdays_mask: number; timezone: string; }): Promise => { try { return await request('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; } }, update: async (id: string, body: { effective_from: string; hours_per_week: number; workdays_mask: number; timezone: string; }): Promise => { try { return await request('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; } }, delete: async (id: string): Promise => { try { return await request('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() }); } } }; // ─── Health ────────────────────────────────────────────────────────────────── export const healthz = () => fetch('/healthz').then((r) => r.ok);