Initial commit: Premier Gunner tracker + StartOS 0.4.0 s9pk package

This commit is contained in:
Keysat
2026-05-31 21:04:48 -05:00
commit 0265699504
67 changed files with 4578 additions and 0 deletions
+366
View File
@@ -0,0 +1,366 @@
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)),
h('button', { class: 'btn-danger', onclick: async () => { await api.deleteEntry(e.id); 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) {
values[m.id] = 0;
const valEl = h('span', { class: 'val' }, '0');
const set = (n) => { values[m.id] = Math.max(0, n); valEl.textContent = values[m.id]; };
body.append(h('div', { class: 'metric' },
h('label', {}, m.name),
h('div', { class: 'stepper' },
h('button', { onclick: () => set(values[m.id] - (m.step || 1)) }, ''),
valEl,
h('button', { onclick: () => set(values[m.id] + (m.step || 1)) }, '+'),
h('span', { class: 'unit' }, m.unit || ''))));
}
body.append(h('div', { class: 'btn-row' },
h('button', { class: 'btn-primary big', onclick: async () => {
await api.logEntry({
day: state.day, category_id: cat.id,
values: metrics.map((m) => ({ metric_id: m.id, value: values[m.id] })),
});
closeModal();
toast(`${cat.emoji} ${cat.name} logged!`);
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'));
const list = h('div', {});
const renderList = () => {
list.innerHTML = '';
for (const c of state.categories) {
list.append(h('div', { class: 'entry' },
h('span', { class: 'emoji' }, c.emoji),
h('div', { style: 'flex:1' }, c.name,
h('div', { class: 'vals' }, (c.metrics || []).map((m) => m.name).join(', '))),
h('button', { class: 'btn-danger', onclick: async () => { await api.updateCategory(c.id, { archived: 1 }); await loadCategories(); renderList(); refreshActive(); } }, 'Archive')));
}
};
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);
// ---------- boot ----------
(async () => {
try { await api.me(); } catch { return; }
await loadCategories();
switchTab('today');
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
})();