import { api } from '/js/api.js'; import { renderStats } from '/js/dashboard.js'; // ---------- tiny DOM + date helpers ---------- export const h = (tag, attrs = {}, ...kids) => { const e = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'class') e.className = v; else if (k === 'html') e.innerHTML = v; else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v); else if (v === true) e.setAttribute(k, ''); else if (v !== false && v != null) e.setAttribute(k, v); } for (const kid of kids.flat()) { if (kid == null || kid === false) continue; e.append(kid.nodeType ? kid : document.createTextNode(kid)); } return e; }; const pad = (n) => String(n).padStart(2, '0'); export const isoOf = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; export const todayISO = () => isoOf(new Date()); export const parseISO = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); }; export const addDays = (iso, n) => { const d = parseISO(iso); d.setDate(d.getDate() + n); return isoOf(d); }; const WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; export const prettyDay = (iso) => { const d = parseISO(iso); const t = todayISO(); if (iso === t) return `Today · ${WEEK[d.getDay()]} ${MON[d.getMonth()]} ${d.getDate()}`; if (iso === addDays(t, -1)) return `Yesterday · ${MON[d.getMonth()]} ${d.getDate()}`; if (iso === addDays(t, 1)) return `Tomorrow · ${MON[d.getMonth()]} ${d.getDate()}`; return `${WEEK[d.getDay()]} · ${MON[d.getMonth()]} ${d.getDate()}`; }; export function toast(msg) { document.querySelector('.toast')?.remove(); const t = h('div', { class: 'toast' }, msg); document.body.append(t); setTimeout(() => t.remove(), 1900); } const modalRoot = document.getElementById('modal-root'); export function openModal(title, contentEl, { onClose } = {}) { closeModal(); const close = () => { modalRoot.innerHTML = ''; onClose?.(); }; const backdrop = h('div', { class: 'modal-backdrop', onclick: (e) => { if (e.target === backdrop) close(); } }, h('div', { class: 'modal' }, h('h2', {}, title, h('button', { class: 'close', onclick: close }, '✕')), contentEl)); modalRoot.append(backdrop); return close; } export const closeModal = () => { modalRoot.innerHTML = ''; }; const textColorFor = (hex) => { const c = hex.replace('#', ''); const r = parseInt(c.substr(0, 2), 16), g = parseInt(c.substr(2, 2), 16), b = parseInt(c.substr(4, 2), 16); return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? '#15181f' : '#fff'; }; // ---------- state ---------- const state = { categories: [], day: todayISO(), tab: 'today' }; const catById = (id) => state.categories.find((c) => c.id === id); async function loadCategories() { state.categories = await api.categories(); } // ---------- TODAY (logging) ---------- async function renderToday(view) { const data = await api.day(state.day); const loggedCatIds = new Set(data.entries.map((e) => e.category_id)); const plannedIds = new Set(data.plans.map((p) => p.category_id)); view.innerHTML = ''; view.append( h('div', { class: 'datenav' }, h('button', { onclick: () => { state.day = addDays(state.day, -1); renderToday(view); } }, '‹'), h('div', { class: 'day-label' }, prettyDay(state.day)), h('button', { onclick: () => { state.day = addDays(state.day, 1); renderToday(view); } }, '›'))); if (plannedIds.size) { view.append(h('div', { class: 'card' }, h('h2', {}, '🗓️ Today\'s plan'), h('div', { class: 'pill-grid' }, [...plannedIds].map((id) => { const c = catById(id); if (!c) return null; return h('span', { class: 'pill' + (loggedCatIds.has(id) ? ' done' : '') }, h('span', { class: 'emoji' }, c.emoji), c.name, loggedCatIds.has(id) ? ' ✅' : ''); })))); } view.append(h('div', { class: 'card' }, h('h2', {}, '⚽ What did you train?'), h('p', { class: 'muted small' }, 'Tap a category to log it.'), h('div', { class: 'pill-grid' }, state.categories.map((c) => { const done = loggedCatIds.has(c.id); const style = `background:${c.color};border-color:${c.color};color:${textColorFor(c.color)}`; return h('button', { class: 'pill' + (done ? ' done' : ''), style: done ? style : '', onclick: () => { // If this category is already logged today, edit the existing entry // (pre-filled) instead of starting a blank one. const existing = data.entries.filter((e) => e.category_id === c.id); openLogModal(c, view, existing.length ? existing[existing.length - 1] : null); }, }, h('span', { class: 'emoji' }, c.emoji), c.name, done ? ' ✅' : ''); })))); // Logged entries for the day const log = h('div', { class: 'card' }, h('h2', {}, '✅ Logged')); if (!data.entries.length) { log.append(h('p', { class: 'muted' }, 'Nothing yet — tap a category above to start!')); } else { log.append(h('p', { class: 'muted small' }, 'Tap an entry to edit it.')); for (const e of data.entries) { const c = catById(e.category_id) || { emoji: '⚽', name: 'Category', metrics: [] }; const valStr = e.values.map((v) => { const m = c.metrics?.find((mm) => mm.id === v.metric_id); return m ? `${v.value} ${m.unit || m.name}` : v.value; }).join(' · '); log.append(h('div', { class: 'entry' }, h('span', { class: 'emoji' }, c.emoji), h('div', { style: 'flex:1; cursor:pointer', onclick: () => openLogModal(c, view, e) }, h('div', {}, c.name, h('span', { class: 'muted small' }, ' ✎ edit')), valStr && h('div', { class: 'vals' }, valStr), e.note && h('div', { class: 'vals note' }, '📝 ' + e.note)), h('button', { class: 'btn-danger', onclick: async () => { await api.deleteEntry(e.id); await loadCategories(); renderToday(view); refreshStatsIfActive(); } }, '🗑'))); } } view.append(log); // Daily notes const ta = h('textarea', { rows: 3, placeholder: 'Notes, thoughts, things to remember…' }, data.notes || ''); view.append(h('div', { class: 'card' }, h('h2', {}, '📝 Notes for the day'), ta, h('div', { class: 'btn-row' }, h('button', { class: 'btn-primary', onclick: async () => { await api.saveNotes(state.day, ta.value); toast('Notes saved'); } }, 'Save notes')))); } function openLogModal(cat, view, existingEntry = null) { const metrics = cat.metrics || []; const values = {}; // When editing, pre-fill with the entry's saved values. const existingVals = {}; if (existingEntry) for (const v of existingEntry.values) existingVals[v.metric_id] = v.value; const body = h('div', {}); body.append(h('p', { class: 'muted' }, `${cat.emoji} ${cat.name}`)); if (!metrics.length) body.append(h('p', { class: 'muted small' }, 'No metrics — this just logs a session.')); for (const m of metrics) { const isScore = m.kind === 'score'; // 0–10 rating const isDecimal = m.kind === 'decimal'; // free decimal value (e.g. speed in mph) const maxV = isScore ? 10 : null; // Pre-fill from the existing entry if present; otherwise sensible defaults. const start = existingVals[m.id] != null ? existingVals[m.id] : (isScore ? 5 : 0); values[m.id] = start; // Decimals keep up to 2 places; everything else is a whole number. const round = (n) => (isDecimal ? Math.round(n * 100) / 100 : Math.round(n)); const clamp = (n) => { let v = Math.max(0, round(n)); if (maxV != null) v = Math.min(maxV, v); return v; }; // Every value is a tap-to-type number input, with +/- buttons for quick nudges. const valEl = h('input', { class: 'val num-input', type: 'number', min: '0', ...(maxV != null ? { max: String(maxV) } : {}), step: isDecimal ? 'any' : '1', inputmode: isDecimal ? 'decimal' : 'numeric', value: String(start), }); valEl.addEventListener('focus', () => valEl.select()); valEl.addEventListener('input', () => { values[m.id] = clamp(Number(valEl.value) || 0); }); valEl.addEventListener('blur', () => { valEl.value = String(values[m.id]); }); const set = (n) => { values[m.id] = clamp(n); valEl.value = String(values[m.id]); }; const metricEl = h('div', { class: 'metric' }, h('label', {}, m.name)); if (m.track_record) { metricEl.append(h('div', { class: 'record-line' }, m.record != null ? `🏆 Record: ${m.record}${m.unit ? ' ' + m.unit : ''}` : '🏆 No record yet — set one in this session!')); } metricEl.append(h('div', { class: 'stepper' }, h('button', { type: 'button', onclick: () => set(values[m.id] - (m.step || 1)) }, '−'), valEl, h('button', { type: 'button', onclick: () => set(values[m.id] + (m.step || 1)) }, '+'), h('span', { class: 'unit' }, m.unit || ''))); body.append(metricEl); } // Optional per-entry note (handy for coaching sessions like 1-on-1 with Elijah). const noteEl = h('textarea', { rows: 2, placeholder: 'Notes for this session (optional)…' }, existingEntry?.note || ''); body.append(h('div', { class: 'metric' }, h('label', {}, '📝 Session note'), noteEl)); body.append(h('div', { class: 'btn-row' }, h('button', { class: 'btn-primary big', onclick: async () => { const payload = { note: noteEl.value, values: metrics.map((m) => ({ metric_id: m.id, value: values[m.id] })), }; const res = existingEntry ? await api.updateEntry(existingEntry.id, payload) : await api.logEntry({ day: state.day, category_id: cat.id, ...payload }); closeModal(); const recs = res?.newRecords || []; if (recs.length) { const r = recs[0]; toast(`🏆 NEW RECORD! ${r.value}${r.unit ? ' ' + r.unit : ''} — ${cat.name}!`); } else { toast(`${cat.emoji} ${cat.name} ${existingEntry ? 'updated' : 'logged'}!`); } await loadCategories(); // pick up any updated records renderToday(view); refreshStatsIfActive(); } }, existingEntry ? 'Save changes ✅' : 'Log it! 🎉'))); openModal(`${existingEntry ? 'Edit' : 'Log'} ${cat.name}`, body); } // ---------- PLAN ---------- async function renderPlan(view) { const from = todayISO(); const to = addDays(from, 13); const plans = await api.plans(from, to); const byDay = {}; for (const p of plans) (byDay[p.day] ||= []).push(p); view.innerHTML = ''; view.append(h('div', { class: 'card' }, h('h2', {}, '🗓️ Plan your week'), h('p', { class: 'muted small' }, 'Pick what to work on each day. You can always change your mind!'))); for (let i = 0; i < 14; i++) { const day = addDays(from, i); const dayPlans = byDay[day] || []; const plannedIds = new Set(dayPlans.map((p) => p.category_id)); view.append(h('div', { class: 'card' }, h('div', { class: 'row spread' }, h('strong', {}, prettyDay(day)), h('span', { class: 'badge' }, `${plannedIds.size} planned`)), h('div', { class: 'pill-grid', style: 'margin-top:10px' }, state.categories.map((c) => { const on = plannedIds.has(c.id); const style = on ? `background:${c.color};border-color:${c.color};color:${textColorFor(c.color)}` : ''; return h('button', { class: 'pill' + (on ? ' selected' : ''), style, onclick: async () => { if (on) { const p = dayPlans.find((x) => x.category_id === c.id); await api.deletePlan(p.id); } else { await api.addPlan({ day, category_id: c.id }); } renderPlan(view); } }, h('span', { class: 'emoji' }, c.emoji), c.name); })))); } } // ---------- GOALS ---------- async function renderGoals(view) { const stats = await api.stats(); view.innerHTML = ''; view.append(h('div', { class: 'row spread' }, h('h2', {}, '🏆 Goals'), h('button', { class: 'btn-primary', onclick: () => openGoalModal(view) }, '+ New goal'))); if (!stats.goals.length) { view.append(h('div', { class: 'empty' }, h('span', { class: 'big-emoji' }, '🎯'), 'No goals yet. Add one to chase!')); return; } for (const g of stats.goals) { view.append(goalCard(g, view)); } } function goalCard(g, view) { const done = g.pct >= 100; const catName = g.category_id ? (catById(g.category_id)?.name || '') : 'Overall'; return h('div', { class: 'card goal' }, h('div', { class: 'top' }, h('span', { class: 'label' }, (g.is_main ? '⭐ ' : '') + (g.label || catName)), h('span', { class: 'muted small' }, `${Math.round(g.current)} / ${g.target}`)), h('div', { class: 'muted small' }, `${catName} · ${kindLabel(g.kind)}${g.reward ? ' · 🎁 ' + g.reward : ''}`), h('div', { class: 'bar' + (done ? ' done' : '') }, h('span', { style: `width:${g.pct}%` })), h('div', { class: 'btn-row' }, h('button', { class: 'btn-danger', onclick: async () => { if (confirm('Delete this goal?')) { await api.deleteGoal(g.id); renderGoals(view); } } }, 'Delete'))); } const kindLabel = (k) => ({ session_count: 'Times trained', metric_best: 'Personal best', metric_total: 'Total amount' }[k] || k); function openGoalModal(view) { const body = h('div', {}); const labelIn = h('input', { placeholder: 'Goal name (e.g. Juggle 100 in a row)' }); const scopeSel = h('select', {}, h('option', { value: 'category' }, 'A category'), h('option', { value: 'overall' }, 'Overall (all training)')); const catSel = h('select', {}, state.categories.map((c) => h('option', { value: c.id }, `${c.emoji} ${c.name}`))); const kindSel = h('select', {}, h('option', { value: 'session_count' }, 'Number of times trained'), h('option', { value: 'metric_best' }, 'Personal best (one session)'), h('option', { value: 'metric_total' }, 'Total added up')); const metricSel = h('select', {}); const targetIn = h('input', { type: 'number', min: '1', placeholder: 'Target number' }); const rewardIn = h('input', { placeholder: 'Reward (optional)' }); const mainChk = h('input', { type: 'checkbox' }); const catField = h('div', { class: 'field' }, h('label', {}, 'Category'), catSel); const metricField = h('div', { class: 'field' }, h('label', {}, 'Which metric?'), metricSel); const refreshMetrics = () => { const c = catById(Number(catSel.value)); metricSel.innerHTML = ''; (c?.metrics || []).forEach((m) => metricSel.append(h('option', { value: m.id }, `${m.name} (${m.unit || ''})`))); }; const refreshVis = () => { catField.style.display = scopeSel.value === 'category' ? '' : 'none'; const needMetric = kindSel.value !== 'session_count' && scopeSel.value === 'category'; metricField.style.display = needMetric ? '' : 'none'; if (scopeSel.value === 'overall') kindSel.value = 'session_count'; }; scopeSel.addEventListener('change', refreshVis); kindSel.addEventListener('change', refreshVis); catSel.addEventListener('change', refreshMetrics); refreshMetrics(); refreshVis(); body.append( h('div', { class: 'field' }, h('label', {}, 'Goal name'), labelIn), h('div', { class: 'field' }, h('label', {}, 'Track…'), scopeSel), catField, h('div', { class: 'field' }, h('label', {}, 'Goal type'), kindSel), metricField, h('div', { class: 'field' }, h('label', {}, 'Target'), targetIn), h('div', { class: 'field' }, h('label', {}, 'Reward'), rewardIn), h('label', { class: 'row', style: 'gap:10px' }, mainChk, ' Make this the ⭐ main goal (thermometer)'), h('div', { class: 'btn-row' }, h('button', { class: 'btn-primary big', onclick: async () => { const payload = { label: labelIn.value, scope: scopeSel.value, kind: kindSel.value, target: Number(targetIn.value), reward: rewardIn.value, is_main: mainChk.checked, }; if (scopeSel.value === 'category') payload.category_id = Number(catSel.value); if (kindSel.value !== 'session_count') payload.metric_id = Number(metricSel.value); try { await api.addGoal(payload); closeModal(); renderGoals(view); toast('Goal added 🏆'); } catch (e) { alert(e.message); } } }, 'Save goal'))); openModal('New goal', body); } // ---------- SETTINGS ---------- function openSettings() { const body = h('div', {}); body.append(h('h3', {}, 'Categories & metrics')); body.append(h('p', { class: 'muted small' }, 'Rename categories and metrics, change units & type, add or remove metrics, set 🏆 record tracking, and archive or delete categories.')); const kindOptions = () => [ h('option', { value: 'duration' }, 'Time'), h('option', { value: 'count' }, 'Count'), h('option', { value: 'score' }, 'Score (0–10)'), h('option', { value: 'decimal' }, 'Number (decimal)'), ]; const list = h('div', {}); async function renderList() { const cats = await api.categories(true); // include archived so they can be restored/deleted list.innerHTML = ''; for (const c of cats) { const card = h('div', { class: 'card cat-edit' + (c.archived ? ' archived' : ''), style: 'margin:10px 0' }); // ---- category header: emoji, name, color ---- const emojiIn = h('input', { value: c.emoji, maxlength: '4', style: 'max-width:54px;text-align:center' }); const nameIn = h('input', { value: c.name, style: 'flex:1;min-width:120px' }); const colorIn = h('input', { type: 'color', value: c.color, style: 'max-width:54px;height:44px;padding:4px' }); const catSave = h('button', { class: 'btn-ghost', onclick: async () => { if (!nameIn.value.trim()) return alert('Name can’t be empty'); await api.updateCategory(c.id, { name: nameIn.value.trim(), emoji: emojiIn.value || '⚽', color: colorIn.value }); await loadCategories(); renderList(); refreshActive(); toast('Category saved'); } }, 'Save'); card.append(h('div', { class: 'cat-head' }, emojiIn, nameIn, colorIn, catSave, c.archived ? h('span', { class: 'badge' }, 'Archived') : null)); // ---- metrics ---- card.append(h('div', { class: 'muted small', style: 'margin-top:6px' }, 'Metrics')); for (const m of (c.metrics || [])) { const mName = h('input', { value: m.name, placeholder: 'Name', style: 'flex:1 1 100%;min-width:120px' }); const mUnit = h('input', { value: m.unit || '', placeholder: 'unit', style: 'max-width:80px' }); const mKind = h('select', {}, kindOptions()); mKind.value = m.kind; const stepIn = h('input', { type: 'number', min: '1', value: String(m.step || 1), style: 'max-width:64px' }); const trackChk = h('input', { type: 'checkbox', ...(m.track_record ? { checked: true } : {}) }); const recordIn = h('input', { type: 'number', placeholder: 'record', value: m.record != null ? String(m.record) : '', style: 'max-width:90px', ...(m.track_record ? {} : { disabled: true }) }); trackChk.addEventListener('change', () => { recordIn.disabled = !trackChk.checked; }); const mSave = h('button', { class: 'btn-ghost', onclick: async () => { if (!mName.value.trim()) return alert('Metric name can’t be empty'); await api.updateMetric(m.id, { name: mName.value.trim(), unit: mUnit.value.trim(), kind: mKind.value, step: Number(stepIn.value) || 1, track_record: trackChk.checked, record: trackChk.checked ? (recordIn.value === '' ? null : Number(recordIn.value)) : null, }); await loadCategories(); renderList(); refreshActive(); toast('Metric saved'); } }, 'Save'); const mDel = h('button', { class: 'btn-danger', onclick: async () => { if (!confirm(`Remove the "${m.name}" metric? Its logged values will be deleted.`)) return; await api.deleteMetric(m.id); await loadCategories(); renderList(); refreshActive(); toast('Metric removed'); } }, '🗑'); card.append(h('div', { class: 'metric-edit' }, mName, mUnit, mKind, h('label', { class: 'mini' }, 'Step', stepIn), h('label', { class: 'mini' }, '🏆', trackChk), h('label', { class: 'mini' }, 'Record', recordIn), mSave, mDel)); } const addMetricBtn = h('button', { class: 'btn-ghost', style: 'margin-top:8px', onclick: async () => { await api.addMetric(c.id, { name: 'New metric', unit: '', kind: 'count', step: 1 }); await loadCategories(); renderList(); refreshActive(); } }, '➕ Add metric'); // ---- category actions ---- const archiveBtn = h('button', { class: 'btn-ghost', onclick: async () => { await api.updateCategory(c.id, { archived: c.archived ? 0 : 1 }); await loadCategories(); renderList(); refreshActive(); } }, c.archived ? 'Unarchive' : 'Archive'); const deleteBtn = h('button', { class: 'btn-danger', onclick: async () => { if (!confirm(`Delete "${c.name}" and ALL of its logged data permanently? This cannot be undone.`)) return; await api.deleteCategory(c.id); await loadCategories(); renderList(); refreshActive(); toast('Category deleted'); } }, 'Delete category'); card.append(addMetricBtn, h('div', { class: 'row', style: 'gap:10px;margin-top:10px' }, archiveBtn, deleteBtn)); list.append(card); } } renderList(); body.append(list); // Add category form const nameIn = h('input', { placeholder: 'New category name' }); const emojiIn = h('input', { placeholder: 'Emoji', value: '⚽', maxlength: '4', style: 'max-width:90px' }); const colorIn = h('input', { type: 'color', value: '#EF0107', style: 'max-width:60px;height:48px;padding:4px' }); const metricName = h('input', { placeholder: 'Metric (e.g. Minutes)', value: 'Minutes' }); const metricKind = h('select', {}, h('option', { value: 'duration' }, 'Time'), h('option', { value: 'count' }, 'Count'), h('option', { value: 'score' }, 'Score (0–10)'), h('option', { value: 'decimal' }, 'Number (decimal)')); body.append(h('div', { class: 'card', style: 'margin-top:16px' }, h('h3', {}, '➕ Add a category'), h('div', { class: 'field' }, h('label', {}, 'Name'), nameIn), h('div', { class: 'row', style: 'gap:10px' }, emojiIn, colorIn), h('div', { class: 'field', style: 'margin-top:10px' }, h('label', {}, 'First metric'), h('div', { class: 'row', style: 'gap:10px' }, metricName, metricKind)), h('button', { class: 'btn-primary', onclick: async () => { if (!nameIn.value.trim()) return alert('Enter a name'); await api.addCategory({ name: nameIn.value.trim(), emoji: emojiIn.value || '⚽', color: colorIn.value, metrics: [{ name: metricName.value || 'Value', unit: metricKind.value === 'duration' ? 'min' : '', kind: metricKind.value, step: metricKind.value === 'duration' ? 5 : 1 }] }); await loadCategories(); renderList(); nameIn.value = ''; refreshActive(); toast('Category added'); } }, 'Add category'))); // Password change const cur = h('input', { type: 'password', placeholder: 'Current password' }); const nxt = h('input', { type: 'password', placeholder: 'New password' }); body.append(h('div', { class: 'card' }, h('h3', {}, '🔒 Change password'), h('div', { class: 'field' }, cur), h('div', { class: 'field' }, nxt), h('button', { class: 'btn-ghost', onclick: async () => { try { await api.setPassword(cur.value, nxt.value); toast('Password changed'); cur.value = nxt.value = ''; } catch (e) { alert(e.message); } } }, 'Update password'))); body.append(h('button', { class: 'btn-danger', style: 'margin-top:8px', onclick: async () => { await api.logout(); location.href = '/login.html'; } }, 'Log out')); openModal('⚙️ Settings', body, { onClose: refreshActive }); } // ---------- router ---------- function refreshActive() { switchTab(state.tab); } function refreshStatsIfActive() { if (state.tab === 'stats') switchTab('stats'); } const view = document.getElementById('view'); function switchTab(tab) { state.tab = tab; document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab)); view.innerHTML = '

Loading…

'; if (tab === 'today') renderToday(view); else if (tab === 'plan') renderPlan(view); else if (tab === 'stats') renderStats(view, { catById }); else if (tab === 'goals') renderGoals(view); } document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab))); document.getElementById('settings-btn').addEventListener('click', openSettings); // ---------- service worker + update prompt ---------- function showUpdateBanner(worker) { if (document.querySelector('.update-banner')) return; const banner = h('div', { class: 'update-banner' }, h('span', {}, '⚽ A new version is ready!'), h('button', { class: 'btn-refresh', onclick: () => { banner.querySelector('button').textContent = 'Updating…'; worker.postMessage({ type: 'SKIP_WAITING' }); } }, 'Refresh')); document.body.append(banner); } function setupServiceWorker() { if (!('serviceWorker' in navigator)) return; let refreshing = false; // When the freshly-activated worker takes control, reload once to pick up new assets. navigator.serviceWorker.addEventListener('controllerchange', () => { if (refreshing) return; refreshing = true; location.reload(); }); navigator.serviceWorker.register('/sw.js').then((reg) => { // An update may already be waiting from a previous visit. if (reg.waiting && navigator.serviceWorker.controller) showUpdateBanner(reg.waiting); reg.addEventListener('updatefound', () => { const nw = reg.installing; if (!nw) return; nw.addEventListener('statechange', () => { // "installed" + an existing controller means this is an update, not first install. if (nw.state === 'installed' && navigator.serviceWorker.controller) showUpdateBanner(nw); }); }); // Proactively check for updates on launch and when the app regains focus. reg.update().catch(() => {}); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') reg.update().catch(() => {}); }); }).catch(() => {}); } // ---------- boot ---------- (async () => { try { await api.me(); } catch { return; } await loadCategories(); switchTab('today'); setupServiceWorker(); })();