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.
This commit is contained in:
Keysat
2026-06-03 08:46:27 -05:00
parent 0265699504
commit 5868852686
17 changed files with 441 additions and 121 deletions
+1
View File
@@ -26,6 +26,7 @@ export const api = {
addCategory: (c) => req('POST', '/api/categories', c),
updateCategory: (id, c) => req('PUT', `/api/categories/${id}`, c),
addMetric: (catId, m) => req('POST', `/api/categories/${catId}/metrics`, m),
updateMetric: (id, m) => req('PUT', `/api/metrics/${id}`, m),
deleteMetric: (id) => req('DELETE', `/api/metrics/${id}`),
day: (day) => req('GET', `/api/day/${day}`),
+129 -21
View File
@@ -116,8 +116,11 @@ async function renderToday(view) {
}).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(); } }, '🗑')));
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);
@@ -138,25 +141,65 @@ function openLogModal(cat, view) {
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 || ''))));
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 () => {
await api.logEntry({
day: state.day, category_id: cat.id,
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();
toast(`${cat.emoji} ${cat.name} logged!`);
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! 🎉')));
@@ -291,16 +334,41 @@ function openGoalModal(view) {
// ---------- SETTINGS ----------
function openSettings() {
const body = h('div', {});
body.append(h('h3', {}, 'Categories'));
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) {
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(', '))),
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();
@@ -357,10 +425,50 @@ function switchTab(tab) {
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');
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
setupServiceWorker();
})();
+14
View File
@@ -23,6 +23,20 @@ export async function renderStats(view, { catById }) {
const main = stats.goals.find((g) => g.is_main);
if (main) view.append(thermometer(main));
// ---- personal records ----
const records = (stats.records || []).length ? stats.records : null;
if (records) {
const card = h('div', { class: 'card' }, h('h2', {}, '🏆 Personal records'));
for (const r of records) {
card.append(h('div', { class: 'record-row' },
h('span', { class: 'emoji' }, r.emoji),
h('div', { style: 'flex:1' },
h('div', {}, `${r.category}${r.name}`)),
h('span', { class: 'record-val' }, r.record != null ? `${r.record}${r.unit ? ' ' + r.unit : ''}` : '—')));
}
view.append(card);
}
// ---- heatmap ----
view.append(h('div', { class: 'card' },
h('h2', {}, '🔥 Training calendar'),