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
+31
View File
@@ -136,13 +136,28 @@ input:focus, select:focus, textarea:focus { outline: none; border-color: var(--a
.stepper { display: flex; align-items: center; gap: 12px; }
.stepper button { width: 48px; height: 48px; border-radius: 50%; border: none; background: var(--arsenal-red); color: #fff; font-size: 1.6rem; font-weight: 700; }
.stepper .val { font-size: 1.6rem; font-weight: 800; min-width: 70px; text-align: center; }
.stepper input.val.score-input { width: 80px; min-width: 0; border: 2px solid var(--line); border-radius: 12px; height: 48px; background: var(--card); color: inherit; }
.stepper .unit { color: var(--muted); font-size: .9rem; }
.record-line { color: var(--arsenal-red); font-weight: 700; font-size: .9rem; margin-bottom: 8px; }
/* ---------- Metric / record editor (settings) ---------- */
.metric-edit { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 0; border-top: 1px solid var(--line); }
.metric-edit-name { flex: 1 1 100%; font-weight: 600; }
.metric-edit .mini { display: flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--muted); font-weight: 600; }
.metric-edit .mini input[type="number"] { height: 38px; }
/* ---------- Records (dashboard) ---------- */
.record-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--line); }
.record-row:last-child { border-bottom: none; }
.record-row .emoji { font-size: 1.4rem; }
.record-row .record-val { font-size: 1.3rem; font-weight: 800; color: var(--arsenal-red); }
/* ---------- Logged entries ---------- */
.entry { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--line); }
.entry:last-child { border-bottom: none; }
.entry .emoji { font-size: 1.4rem; }
.entry .vals { color: var(--muted); font-size: .9rem; }
.entry .vals.note { font-style: italic; }
.badge { display: inline-block; background: var(--bg); border-radius: 999px; padding: 2px 10px; font-size: .8rem; font-weight: 600; }
/* ---------- Stats ---------- */
@@ -190,6 +205,22 @@ canvas { max-width: 100%; }
.empty .big-emoji { font-size: 3rem; display: block; margin-bottom: 8px; }
.toast { position: fixed; left: 50%; bottom: 84px; transform: translateX(-50%); background: var(--ink); color: #fff; padding: 12px 20px; border-radius: 999px; font-weight: 600; z-index: 60; box-shadow: var(--shadow); }
/* ---------- Update banner ---------- */
.update-banner {
position: fixed; left: 12px; right: 12px;
bottom: calc(72px + var(--safe-bottom)); z-index: 50;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
background: var(--arsenal-red); color: #fff;
padding: 12px 16px; border-radius: 14px; font-weight: 700;
box-shadow: var(--shadow);
animation: slide-up .25s ease;
}
.update-banner .btn-refresh {
flex: none; background: #fff; color: var(--arsenal-red);
border: none; border-radius: 999px; padding: 8px 18px; font-weight: 800;
}
@keyframes slide-up { from { transform: translateY(140%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (min-width: 620px) {
.stat-grid { grid-template-columns: repeat(4, 1fr); }
}
+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'),
+10 -2
View File
@@ -1,4 +1,4 @@
const CACHE = 'premier-gunner-v1';
const CACHE = 'premier-gunner-v3';
const SHELL = [
'/', '/index.html', '/login.html',
'/css/styles.css',
@@ -10,7 +10,10 @@ const SHELL = [
];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
// Pre-cache the new shell, but do NOT skipWaiting automatically — we wait
// until the page tells us to (via the "Refresh" banner) so the update is
// controlled and the user is never interrupted mid-action.
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
});
self.addEventListener('activate', (e) => {
@@ -20,6 +23,11 @@ self.addEventListener('activate', (e) => {
);
});
// The page posts this when the user taps "Refresh" on the update banner.
self.addEventListener('message', (e) => {
if (e.data && e.data.type === 'SKIP_WAITING') self.skipWaiting();
});
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET') return;