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]'); // Show immediately if we already have the prompt or are on iOS if (deferredInstallPrompt || isIOS) wrap.hidden = false; // Always attach handler — check state at click time (handles async beforeinstallprompt) btn.addEventListener('click', async () => { if (deferredInstallPrompt) { deferredInstallPrompt.prompt(); const { outcome } = await deferredInstallPrompt.userChoice; if (outcome === 'accepted') { deferredInstallPrompt = null; wrap.hidden = true; } } else if (isIOS) { 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 = `
boot failed: ${e?.message || e}`;
});