Files
premier-gunner/public/js/app.js
T
Keysat cf64a2dc50 Add EPA speeds, entry editing, and tap-to-type numeric inputs
- EPA Agility & Speed: add Max Speed and Max Weighted Speed (mph),
  decimal record-tracked metrics, per session.
- Logged sessions are editable: tapping a category or a logged entry
  opens it pre-filled; saving updates that entry (PUT /api/entries/:id)
  instead of creating a duplicate. Record auto-update runs on edit too.
- Every value field is now a tap-to-type number input (decimal keypad
  for speeds) while keeping the +/- stepper buttons; native spinners
  removed. New decimal metric kind; seed + idempotent migration add the
  EPA speed metrics.
- Bump StartOS package to 0.1.5:0; service worker cache to v5.
2026-06-04 08:05:30 -05:00

494 lines
23 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 & 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 (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();
})();