import { db, hydrateFromServer, startSync, setCompletion, onChange, getMeta, setMeta, } 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'); /* ---------- PWA install prompt ---------- */ let deferredInstallPrompt = null; const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent); const isStandalone = window.matchMedia('(display-mode: standalone)').matches || ('standalone' in navigator && navigator.standalone === true); window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredInstallPrompt = e; const btn = document.querySelector('[data-install-wrap]'); if (btn) btn.hidden = false; }); window.addEventListener('appinstalled', () => { deferredInstallPrompt = null; const wrap = document.querySelector('[data-install-wrap]'); if (wrap) wrap.hidden = true; }); 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 || ''; if (user) setMeta('last_route', route).catch(() => {}); 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')); if (isStandalone) return; const wrap = app.querySelector('[data-install-wrap]'); const btn = app.querySelector('[data-install-btn]'); const hint = app.querySelector('[data-install-hint]'); if (deferredInstallPrompt) { wrap.hidden = false; btn.addEventListener('click', async () => { deferredInstallPrompt.prompt(); const { outcome } = await deferredInstallPrompt.userChoice; if (outcome === 'accepted') { deferredInstallPrompt = null; wrap.hidden = true; } }); } else if (isIOS) { wrap.hidden = false; btn.addEventListener('click', () => { hint.hidden = !hint.hidden; }); } } /* ---------- 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(); // Restore last visited user route if opening at root const curPath = location.pathname; if (curPath === '/' || curPath === '') { const saved = await getMeta('last_route', null); if (saved && VALID_USERS.has(saved.slice(1))) { history.replaceState({}, '', saved); } } 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}
`; });