Files
premier-gunner/public/js/app.js
T
Keysat 5868852686 Add records, Elijah scores, per-session notes, and PWA update prompt
App features:
- Personal-best records per metric: manually settable in Settings and
  auto-updated when a session beats them; shown in the log modal and a
  new dashboard "Personal records" card.
- Juggling now counts by 1 instead of 5.
- 1-on-1 with Elijah gains Technical Skill and Effort scores (out of 10)
  as manual inputs, plus an optional per-session note.
- Service worker now uses a controlled update flow: an in-app
  "new version ready" banner activates the update on tap and reloads.

Data model:
- category_metrics gains track_record + record; entries gains note.
- Idempotent migrations bring existing databases up to date (juggling
  step/record, Elijah score metrics) alongside the updated seed.

StartOS package:
- Bump to 0.1.2:0 with release notes.
- Build x86_64 only (drop aarch64) per deployment target.
2026-06-03 08:46:27 -05:00

475 lines
22 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: () => openLogModal(c, view),
}, 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 {
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' },
h('div', {}, c.name),
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) {
const metrics = cat.metrics || [];
const values = {};
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';
const maxV = isScore ? 10 : null;
const start = isScore ? 5 : 0;
values[m.id] = start;
const clamp = (n) => { let v = Math.max(0, n); if (maxV != null) v = Math.min(maxV, v); return v; };
let valEl;
if (isScore) {
// Manual numeric entry for scores (e.g. 110), with stepper buttons too.
valEl = h('input', { class: 'val score-input', type: 'number', min: '0', max: '10',
inputmode: 'numeric', value: String(start) });
valEl.addEventListener('input', () => { values[m.id] = clamp(Number(valEl.value) || 0); });
valEl.addEventListener('blur', () => { valEl.value = String(values[m.id]); });
} else {
valEl = h('span', { class: 'val' }, '0');
}
const set = (n) => {
values[m.id] = clamp(n);
if (isScore) valEl.value = String(values[m.id]);
else valEl.textContent = 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)…' });
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 res = await api.logEntry({
day: state.day, category_id: cat.id, note: noteEl.value,
values: metrics.map((m) => ({ metric_id: m.id, value: values[m.id] })),
});
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} logged!`);
}
await loadCategories(); // pick up any updated records
renderToday(view);
refreshStatsIfActive();
} }, 'Log it! 🎉')));
openModal(`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 & records'));
body.append(h('p', { class: 'muted small' }, 'Set how each metric counts, turn on 🏆 record tracking, and set the current record by hand. Records auto-update when a session beats them.'));
const list = h('div', {});
const renderList = () => {
list.innerHTML = '';
for (const c of state.categories) {
const card = h('div', { class: 'card', style: 'margin:10px 0' });
card.append(h('div', { class: 'row spread' },
h('strong', {}, `${c.emoji} ${c.name}`),
h('button', { class: 'btn-danger', onclick: async () => { await api.updateCategory(c.id, { archived: 1 }); await loadCategories(); renderList(); refreshActive(); } }, 'Archive')));
for (const m of (c.metrics || [])) {
const stepIn = h('input', { type: 'number', min: '1', value: String(m.step || 1), style: 'max-width:80px' });
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:100px', ...(m.track_record ? {} : { disabled: true }) });
trackChk.addEventListener('change', () => { recordIn.disabled = !trackChk.checked; });
const save = h('button', { class: 'btn-ghost', onclick: async () => {
await api.updateMetric(m.id, {
step: Number(stepIn.value) || 1,
track_record: trackChk.checked,
record: trackChk.checked ? (recordIn.value === '' ? null : Number(recordIn.value)) : null,
});
await loadCategories(); renderList(); refreshActive(); toast('Saved');
} }, 'Save');
card.append(h('div', { class: 'metric-edit' },
h('div', { class: 'metric-edit-name' }, `${m.name}${m.unit ? ' (' + m.unit + ')' : ''}`),
h('label', { class: 'mini' }, 'Step', stepIn),
h('label', { class: 'mini' }, '🏆 Track', trackChk),
h('label', { class: 'mini' }, 'Record', recordIn),
save));
}
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'));
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();
})();