Add EPA speeds, entry editing, and tap-to-type numeric inputs

- EPA Agility & Speed: add Max Speed and Max Weighted Speed (mph),
  decimal record-tracked metrics, per session.
- Logged sessions are editable: tapping a category or a logged entry
  opens it pre-filled; saving updates that entry (PUT /api/entries/:id)
  instead of creating a duplicate. Record auto-update runs on edit too.
- Every value field is now a tap-to-type number input (decimal keypad
  for speeds) while keeping the +/- stepper buttons; native spinners
  removed. New decimal metric kind; seed + idempotent migration add the
  EPA speed metrics.
- Bump StartOS package to 0.1.5:0; service worker cache to v5.
This commit is contained in:
Keysat
2026-06-04 08:05:30 -05:00
parent 5868852686
commit cf64a2dc50
8 changed files with 138 additions and 62 deletions
+3 -1
View File
@@ -136,7 +136,9 @@ 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 input.val.num-input { width: 90px; min-width: 0; border: 2px solid var(--line); border-radius: 12px; height: 48px; background: var(--card); color: inherit; -moz-appearance: textfield; }
.stepper input.val.num-input::-webkit-outer-spin-button,
.stepper input.val.num-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.stepper .unit { color: var(--muted); font-size: .9rem; }
.record-line { color: var(--arsenal-red); font-weight: 700; font-size: .9rem; margin-bottom: 8px; }
+1
View File
@@ -31,6 +31,7 @@ export const api = {
day: (day) => req('GET', `/api/day/${day}`),
logEntry: (e) => req('POST', '/api/entries', e),
updateEntry: (id, e) => req('PUT', `/api/entries/${id}`, e),
deleteEntry: (id) => req('DELETE', `/api/entries/${id}`),
saveNotes: (day, notes) => req('PUT', `/api/day/${day}/notes`, { notes }),
+49 -30
View File
@@ -99,7 +99,12 @@ async function renderToday(view) {
return h('button', {
class: 'pill' + (done ? ' done' : ''),
style: done ? style : '',
onclick: () => openLogModal(c, view),
onclick: () => {
// If this category is already logged today, edit the existing entry
// (pre-filled) instead of starting a blank one.
const existing = data.entries.filter((e) => e.category_id === c.id);
openLogModal(c, view, existing.length ? existing[existing.length - 1] : null);
},
}, h('span', { class: 'emoji' }, c.emoji), c.name, done ? ' ✅' : '');
}))));
@@ -108,6 +113,7 @@ async function renderToday(view) {
if (!data.entries.length) {
log.append(h('p', { class: 'muted' }, 'Nothing yet — tap a category above to start!'));
} else {
log.append(h('p', { class: 'muted small' }, 'Tap an entry to edit it.'));
for (const e of data.entries) {
const c = catById(e.category_id) || { emoji: '⚽', name: 'Category', metrics: [] };
const valStr = e.values.map((v) => {
@@ -116,8 +122,8 @@ 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),
h('div', { style: 'flex:1; cursor:pointer', onclick: () => openLogModal(c, view, e) },
h('div', {}, c.name, h('span', { class: 'muted small' }, ' ✎ edit')),
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(); } }, '🗑')));
@@ -134,35 +140,40 @@ async function renderToday(view) {
h('button', { class: 'btn-primary', onclick: async () => { await api.saveNotes(state.day, ta.value); toast('Notes saved'); } }, 'Save notes'))));
}
function openLogModal(cat, view) {
function openLogModal(cat, view, existingEntry = null) {
const metrics = cat.metrics || [];
const values = {};
// When editing, pre-fill with the entry's saved values.
const existingVals = {};
if (existingEntry) for (const v of existingEntry.values) existingVals[v.metric_id] = v.value;
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) {
const isScore = m.kind === 'score';
const isScore = m.kind === 'score'; // 010 rating
const isDecimal = m.kind === 'decimal'; // free decimal value (e.g. speed in mph)
const maxV = isScore ? 10 : null;
const start = isScore ? 5 : 0;
// Pre-fill from the existing entry if present; otherwise sensible defaults.
const start = existingVals[m.id] != null ? existingVals[m.id] : (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; };
// Decimals keep up to 2 places; everything else is a whole number.
const round = (n) => (isDecimal ? Math.round(n * 100) / 100 : Math.round(n));
const clamp = (n) => { let v = Math.max(0, round(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]);
};
// Every value is a tap-to-type number input, with +/- buttons for quick nudges.
const valEl = h('input', {
class: 'val num-input', type: 'number', min: '0',
...(maxV != null ? { max: String(maxV) } : {}),
step: isDecimal ? 'any' : '1',
inputmode: isDecimal ? 'decimal' : 'numeric',
value: String(start),
});
valEl.addEventListener('focus', () => valEl.select());
valEl.addEventListener('input', () => { values[m.id] = clamp(Number(valEl.value) || 0); });
valEl.addEventListener('blur', () => { valEl.value = String(values[m.id]); });
const set = (n) => { values[m.id] = clamp(n); valEl.value = String(values[m.id]); };
const metricEl = h('div', { class: 'metric' }, h('label', {}, m.name));
if (m.track_record) {
@@ -180,30 +191,34 @@ function openLogModal(cat, view) {
}
// 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)…' });
const noteEl = h('textarea', { rows: 2, placeholder: 'Notes for this session (optional)…' },
existingEntry?.note || '');
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 () => {
const res = await api.logEntry({
day: state.day, category_id: cat.id, note: noteEl.value,
const payload = {
note: noteEl.value,
values: metrics.map((m) => ({ metric_id: m.id, value: values[m.id] })),
});
};
const res = existingEntry
? await api.updateEntry(existingEntry.id, payload)
: await api.logEntry({ day: state.day, category_id: cat.id, ...payload });
closeModal();
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!`);
toast(`${cat.emoji} ${cat.name} ${existingEntry ? 'updated' : 'logged'}!`);
}
await loadCategories(); // pick up any updated records
renderToday(view);
refreshStatsIfActive();
} }, 'Log it! 🎉')));
openModal(`Log ${cat.name}`, body);
} }, existingEntry ? 'Save changes ✅' : 'Log it! 🎉')));
openModal(`${existingEntry ? 'Edit' : 'Log'} ${cat.name}`, body);
}
// ---------- PLAN ----------
@@ -379,7 +394,11 @@ function openSettings() {
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'));
const metricKind = h('select', {},
h('option', { value: 'duration' }, 'Time'),
h('option', { value: 'count' }, 'Count'),
h('option', { value: 'score' }, 'Score (010)'),
h('option', { value: 'decimal' }, 'Number (decimal)'));
body.append(h('div', { class: 'card', style: 'margin-top:16px' },
h('h3', {}, ' Add a category'),
h('div', { class: 'field' }, h('label', {}, 'Name'), nameIn),
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'premier-gunner-v3';
const CACHE = 'premier-gunner-v5';
const SHELL = [
'/', '/index.html', '/login.html',
'/css/styles.css',