import Dexie from 'dexie'; import { api } from './api.js'; export const db = new Dexie('raycer'); db.version(1).stores({ goals: 'id, name, start_date, end_date', completions: '[user+goal_id+date], updated_at, goal_id, user, date', outbox: '++id, created_at', meta: 'key', }); const META_LAST_SYNC = 'last_sync_ts'; const META_THEME = 'theme'; const listeners = new Set(); export function onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); } function emit(kind) { for (const fn of listeners) { try { fn(kind); } catch (e) { console.error(e); } } } export async function getMeta(key, fallback = null) { const row = await db.meta.get(key); return row ? row.value : fallback; } export async function setMeta(key, value) { await db.meta.put({ key, value }); } export async function getTheme() { return getMeta(META_THEME, 'auto'); } export async function setTheme(value) { await setMeta(META_THEME, value); emit('theme'); } /* ---------- LWW upsert from server ---------- */ async function applyServerCompletions(rows) { if (!rows?.length) return; await db.transaction('rw', db.completions, async () => { for (const r of rows) { const key = [r.user, r.goal_id, r.date]; const local = await db.completions.get(key); if (!local || (r.updated_at ?? 0) > (local.updated_at ?? 0)) { await db.completions.put({ user: r.user, goal_id: r.goal_id, date: r.date, completed: !!r.completed, updated_at: r.updated_at, }); } } }); emit('completions'); } /* ---------- Local writes (optimistic + outbox) ---------- */ export async function setCompletion(user, goal_id, date, completed) { const updated_at = Date.now(); const payload = { user, goal_id, date, completed: !!completed, updated_at }; await db.transaction('rw', db.completions, db.outbox, async () => { await db.completions.put({ user, goal_id, date, completed: !!completed, updated_at }); await db.outbox.add({ op: 'PUT', payload, created_at: updated_at }); }); emit('completions'); flushOutbox().catch((e) => console.warn('[outbox] flush failed:', e)); return payload; } export async function getCompletion(user, goal_id, date) { return db.completions.get([user, goal_id, date]); } export async function listCompletions() { return db.completions.toArray(); } export async function listGoals() { return db.goals.orderBy('start_date').toArray(); } /* ---------- Outbox flusher ---------- */ let flushing = false; export async function flushOutbox() { if (flushing) return; if (typeof navigator !== 'undefined' && navigator.onLine === false) return; flushing = true; try { const items = await db.outbox.orderBy('id').toArray(); for (const item of items) { try { await api.putCompletion(item.payload); await db.outbox.delete(item.id); } catch (err) { console.warn('[outbox] item failed, will retry later:', err.message); break; } } } finally { flushing = false; } } /* ---------- Initial hydrate + polling ---------- */ let pollTimer = null; export async function hydrateFromServer() { try { const goalsRes = await api.listGoals(); if (goalsRes?.goals?.length) { await db.transaction('rw', db.goals, async () => { for (const g of goalsRes.goals) await db.goals.put(g); }); emit('goals'); } } catch (err) { console.warn('[hydrate] goals offline:', err.message); } try { const since = (await getMeta(META_LAST_SYNC, 0)) || 0; const compRes = await api.listCompletionsSince(since); if (compRes?.completions?.length) { await applyServerCompletions(compRes.completions); } if (compRes?.server_now) await setMeta(META_LAST_SYNC, compRes.server_now); } catch (err) { console.warn('[hydrate] completions offline:', err.message); } } export function startSync({ pollMs = 30_000, flushMs = 5_000 } = {}) { flushOutbox().catch(() => {}); if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(async () => { await flushOutbox().catch(() => {}); try { const since = (await getMeta(META_LAST_SYNC, 0)) || 0; const res = await api.listCompletionsSince(since); if (res?.completions?.length) await applyServerCompletions(res.completions); if (res?.server_now) await setMeta(META_LAST_SYNC, res.server_now); } catch { /* offline - will retry */ } }, pollMs); setInterval(() => flushOutbox().catch(() => {}), flushMs); if (typeof window !== 'undefined') { window.addEventListener('online', () => flushOutbox().catch(() => {})); window.addEventListener('focus', () => flushOutbox().catch(() => {})); } }