Files
premier-gunner/public/js/app.js
T
Keysat 284c5ff079 Add full category & metric management in Settings
- New Settings editor: rename categories (emoji/color too), rename
  metrics, change unit and type, add/remove metrics, set step + record
  tracking, archive/unarchive, and permanently delete categories.
- Settings now lists archived categories so they can be restored/deleted.
- Backend: add DELETE /api/categories/:id (cascades metrics, entries,
  entry_values, plans, goals).
- Bump StartOS package to 0.1.6:0; service worker cache to v6.
2026-06-04 08:43:11 -05:00

540 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'; // 010 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 (010)'),
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 cant 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 cant 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 (010)'),
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 = '<p class="muted center">Loading…</p>';
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();
})();