284c5ff079
- 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.
540 lines
26 KiB
JavaScript
540 lines
26 KiB
JavaScript
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 = '<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();
|
||
})();
|