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:
@@ -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
@@ -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. 1–10), 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();
|
||||
})();
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user