raycer/frontend/public/js/db.js
Spencer Flagg ed3a0d3ea3 Initial commit: raycer accountability PWA
Vanilla HTML/JS/CSS PWA with Dexie offline-first sync, Hono+SQLite backend,
served via nginx reverse-proxy. Two seed goals (no-sugar, no-social-media)
for users ray and cer.

Local dev runs at https://raycer.test via the shared Traefik proxy.
Production deploys to https://raycer.altweb.me on cool2026/personal via
docker-compose.coolify.yaml — see deploy/COOLIFY.md.
2026-04-23 16:45:06 +02:00

162 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(() => {}));
}
}