import { db, hydrateFromServer, startSync, setCompletion, onChange, } from './db.js'; import { initTheme, cycleTheme, currentTheme } from './theme.js'; import { renderCalendar } from './calendar.js'; import { todayISO, daysRemaining, completedSet, currentStreak, longestStreak, totalCompleted, } from './stats.js'; const VALID_USERS = new Set(['ray', 'cer']); const app = document.getElementById('app'); const state = { route: '/', user: '', goals: [], completions: [], view: { year: new Date().getFullYear(), month: new Date().getMonth() }, }; /* ---------- Router ---------- */ function parseRoute(pathname) { if (pathname === '/' || pathname === '') return { route: '/', user: '' }; if (pathname === '/ray') return { route: '/ray', user: 'ray' }; if (pathname === '/cer') return { route: '/cer', user: 'cer' }; return { route: '/', user: '' }; } function navigate(path, { replace = false } = {}) { if (replace) history.replaceState({}, '', path); else history.pushState({}, '', path); handleRoute(); } function handleRoute() { const { route, user } = parseRoute(location.pathname); state.route = route; state.user = user; document.body.dataset.route = route; document.body.dataset.user = user || ''; render(); } window.addEventListener('popstate', handleRoute); document.addEventListener('click', (e) => { const link = e.target.closest('a[data-link]'); if (!link) return; const href = link.getAttribute('href'); if (!href || href.startsWith('http') || link.target === '_blank') return; e.preventDefault(); navigate(href); }); /* ---------- Data load ---------- */ async function loadFromDexie() { const [goals, completions] = await Promise.all([ db.goals.orderBy('start_date').toArray(), db.completions.toArray(), ]); state.goals = goals; state.completions = completions; } /* ---------- Templates ---------- */ function tpl(id) { const t = document.getElementById(id); return t.content.cloneNode(true); } /* ---------- Render: landing ---------- */ function renderLanding() { app.innerHTML = ''; app.appendChild(tpl('tpl-landing')); } /* ---------- Render: route page ---------- */ async function renderRoute() { app.innerHTML = ''; app.appendChild(tpl('tpl-route')); // Topbar const themeBtn = app.querySelector('[data-theme-toggle]'); const themeIcon = app.querySelector('[data-theme-icon]'); themeIcon.textContent = await currentTheme(); themeBtn.addEventListener('click', async () => { themeIcon.textContent = await cycleTheme(); }); // Countdown - use earliest end_date among active goals const cdEl = app.querySelector('[data-countdown]'); const today = todayISO(); const activeGoals = state.goals.filter((g) => g.end_date >= today); if (activeGoals.length) { const minEnd = activeGoals .map((g) => g.end_date) .sort()[0]; const remain = daysRemaining(minEnd); cdEl.textContent = remain === 0 ? 'last day' : `${remain}d left`; } else { cdEl.textContent = 'window closed'; } renderStats(); renderActionbar(); renderCalendarRoot(); } /* ---------- Render: stats row ---------- */ function renderStats() { const root = app.querySelector('[data-stats]'); if (!root) return; root.innerHTML = ''; const today = todayISO(); const orderedGoals = [...state.goals].sort((a, b) => a.start_date === b.start_date ? a.id.localeCompare(b.id) : a.start_date.localeCompare(b.start_date) ); for (const g of orderedGoals) { const card = document.createElement('div'); card.className = 'stat'; const label = document.createElement('div'); label.className = 'stat__label'; label.textContent = g.name; card.appendChild(label); for (const user of ['ray', 'cer']) { const set = completedSet(state.completions, user, g.id); const cur = currentStreak(set, g, today); const long = longestStreak(set, g, today); const total = totalCompleted(set, g, today); const row = document.createElement('div'); row.className = 'stat__row'; row.innerHTML = ` ${user} ${cur} streak / ${long} best / ${total} total `; card.appendChild(row); } root.appendChild(card); } } /* ---------- Render: action bar ---------- */ function renderActionbar() { const root = app.querySelector('[data-actionbar]'); if (!root) return; root.innerHTML = ''; const today = todayISO(); const orderedGoals = [...state.goals].sort((a, b) => a.start_date === b.start_date ? a.id.localeCompare(b.id) : a.start_date.localeCompare(b.start_date) ); for (const g of orderedGoals) { const inWindow = today >= g.start_date && today <= g.end_date; const existing = state.completions.find( (c) => c.user === state.user && c.goal_id === g.id && c.date === today ); const done = !!existing?.completed; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'action' + (done ? ' action--done' : ''); btn.disabled = !inWindow; btn.innerHTML = ` ${state.user} • today ${g.name} ${done ? 'completed — tap to undo' : inWindow ? 'tap to mark done' : 'outside goal window'} `; btn.addEventListener('click', async () => { await setCompletion(state.user, g.id, today, !done); // optimistic UI update via subscription, but also re-render now await loadFromDexie(); renderStats(); renderActionbar(); renderCalendarRoot(); }); root.appendChild(btn); } } /* ---------- Render: calendar ---------- */ function renderCalendarRoot() { const root = app.querySelector('[data-calendar]'); if (!root) return; renderCalendar(root, { year: state.view.year, month: state.view.month, goals: state.goals, completions: state.completions, activeUser: state.user, onPrev: () => { const m = state.view.month - 1; if (m < 0) { state.view.month = 11; state.view.year -= 1; } else { state.view.month = m; } renderCalendarRoot(); }, onNext: () => { const m = state.view.month + 1; if (m > 11) { state.view.month = 0; state.view.year += 1; } else { state.view.month = m; } renderCalendarRoot(); }, onJumpToday: (y, m) => { state.view.year = y; state.view.month = m; renderCalendarRoot(); }, onDayClick: ({ iso }) => openDayPopover(iso), }); } /* ---------- Popover ---------- */ function openDayPopover(iso) { const root = app.querySelector('[data-popover]'); if (!root) return; root.hidden = false; root.innerHTML = ''; const card = document.createElement('div'); card.className = 'popover'; const head = document.createElement('div'); head.className = 'popover__head'; head.innerHTML = `
${iso} • ${state.user}
`; card.appendChild(head); const list = document.createElement('div'); list.className = 'popover__list'; const orderedGoals = [...state.goals].sort((a, b) => a.start_date === b.start_date ? a.id.localeCompare(b.id) : a.start_date.localeCompare(b.start_date) ); for (const g of orderedGoals) { const inWindow = iso >= g.start_date && iso <= g.end_date; if (!inWindow) continue; const existing = state.completions.find( (c) => c.user === state.user && c.goal_id === g.id && c.date === iso ); const done = !!existing?.completed; const row = document.createElement('button'); row.type = 'button'; row.className = 'popover__row' + (done ? ' popover__row--on' : ''); row.innerHTML = ` ${g.name} ${done ? 'done • tap to undo' : 'tap to mark done'} `; row.addEventListener('click', async () => { await setCompletion(state.user, g.id, iso, !done); await loadFromDexie(); renderStats(); renderActionbar(); renderCalendarRoot(); openDayPopover(iso); // refresh popover state }); list.appendChild(row); } if (!list.children.length) { const empty = document.createElement('div'); empty.className = 'popover__hint'; empty.textContent = 'no goals active on this date'; list.appendChild(empty); } card.appendChild(list); const hint = document.createElement('div'); hint.className = 'popover__hint'; hint.textContent = 'changes only affect the active user'; card.appendChild(hint); root.appendChild(card); const close = () => { root.hidden = true; root.innerHTML = ''; }; head.querySelector('[data-close]').addEventListener('click', close); root.addEventListener('click', (e) => { if (e.target === root) close(); }); document.addEventListener( 'keydown', function onKey(e) { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } }, { once: true } ); } /* ---------- Top-level render ---------- */ function render() { if (state.route === '/' || !VALID_USERS.has(state.user)) { renderLanding(); } else { renderRoute(); } } /* ---------- Boot ---------- */ async function boot() { await initTheme(); await loadFromDexie(); handleRoute(); // Hydrate from server (non-blocking) and start sync hydrateFromServer().then(async () => { await loadFromDexie(); if (state.route !== '/') render(); else if (state.goals.length) { // landing has no data dependency, but we still re-render to no-op safely } }); startSync(); onChange(async () => { await loadFromDexie(); if (state.route !== '/') { renderStats(); renderActionbar(); renderCalendarRoot(); } }); // Register service worker if ('serviceWorker' in navigator) { try { await navigator.serviceWorker.register('/sw.js'); } catch (e) { console.warn('[sw] register failed:', e); } } } boot().catch((e) => { console.error('[boot] failed:', e); app.innerHTML = `
boot failed: ${e?.message || e}
`; });