163 lines
4.7 KiB
JavaScript
163 lines
4.7 KiB
JavaScript
|
|
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(() => {}));
|
||
|
|
}
|
||
|
|
}
|