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:
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
+10
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user