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 { 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 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 .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; } .stepper .unit { color: var(--muted); font-size: .9rem; }
.record-line { color: var(--arsenal-red); font-weight: 700; font-size: .9rem; margin-bottom: 8px; } .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}`), day: (day) => req('GET', `/api/day/${day}`),
logEntry: (e) => req('POST', '/api/entries', e), logEntry: (e) => req('POST', '/api/entries', e),
updateEntry: (id, e) => req('PUT', `/api/entries/${id}`, e),
deleteEntry: (id) => req('DELETE', `/api/entries/${id}`), deleteEntry: (id) => req('DELETE', `/api/entries/${id}`),
saveNotes: (day, notes) => req('PUT', `/api/day/${day}/notes`, { notes }), 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', { return h('button', {
class: 'pill' + (done ? ' done' : ''), class: 'pill' + (done ? ' done' : ''),
style: done ? style : '', 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 ? ' ✅' : ''); }, h('span', { class: 'emoji' }, c.emoji), c.name, done ? ' ✅' : '');
})))); }))));
@@ -108,6 +113,7 @@ async function renderToday(view) {
if (!data.entries.length) { if (!data.entries.length) {
log.append(h('p', { class: 'muted' }, 'Nothing yet — tap a category above to start!')); log.append(h('p', { class: 'muted' }, 'Nothing yet — tap a category above to start!'));
} else { } else {
log.append(h('p', { class: 'muted small' }, 'Tap an entry to edit it.'));
for (const e of data.entries) { for (const e of data.entries) {
const c = catById(e.category_id) || { emoji: '⚽', name: 'Category', metrics: [] }; const c = catById(e.category_id) || { emoji: '⚽', name: 'Category', metrics: [] };
const valStr = e.values.map((v) => { const valStr = e.values.map((v) => {
@@ -116,8 +122,8 @@ async function renderToday(view) {
}).join(' · '); }).join(' · ');
log.append(h('div', { class: 'entry' }, log.append(h('div', { class: 'entry' },
h('span', { class: 'emoji' }, c.emoji), h('span', { class: 'emoji' }, c.emoji),
h('div', { style: 'flex:1' }, h('div', { style: 'flex:1; cursor:pointer', onclick: () => openLogModal(c, view, e) },
h('div', {}, c.name), h('div', {}, c.name, h('span', { class: 'muted small' }, ' ✎ edit')),
valStr && h('div', { class: 'vals' }, valStr), valStr && h('div', { class: 'vals' }, valStr),
e.note && h('div', { class: 'vals note' }, '📝 ' + e.note)), 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(); } }, '🗑'))); 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')))); 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 metrics = cat.metrics || [];
const values = {}; 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', {}); const body = h('div', {});
body.append(h('p', { class: 'muted' }, `${cat.emoji} ${cat.name}`)); 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.')); if (!metrics.length) body.append(h('p', { class: 'muted small' }, 'No metrics — this just logs a session.'));
for (const m of metrics) { 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 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; 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; // Every value is a tap-to-type number input, with +/- buttons for quick nudges.
if (isScore) { const valEl = h('input', {
// Manual numeric entry for scores (e.g. 110), with stepper buttons too. class: 'val num-input', type: 'number', min: '0',
valEl = h('input', { class: 'val score-input', type: 'number', min: '0', max: '10', ...(maxV != null ? { max: String(maxV) } : {}),
inputmode: 'numeric', value: String(start) }); step: isDecimal ? 'any' : '1',
valEl.addEventListener('input', () => { values[m.id] = clamp(Number(valEl.value) || 0); }); inputmode: isDecimal ? 'decimal' : 'numeric',
valEl.addEventListener('blur', () => { valEl.value = String(values[m.id]); }); value: String(start),
} else { });
valEl = h('span', { class: 'val' }, '0'); valEl.addEventListener('focus', () => valEl.select());
} valEl.addEventListener('input', () => { values[m.id] = clamp(Number(valEl.value) || 0); });
const set = (n) => { valEl.addEventListener('blur', () => { valEl.value = String(values[m.id]); });
values[m.id] = clamp(n); const set = (n) => { values[m.id] = clamp(n); valEl.value = String(values[m.id]); };
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)); const metricEl = h('div', { class: 'metric' }, h('label', {}, m.name));
if (m.track_record) { 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). // 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' }, body.append(h('div', { class: 'metric' },
h('label', {}, '📝 Session note'), h('label', {}, '📝 Session note'),
noteEl)); noteEl));
body.append(h('div', { class: 'btn-row' }, body.append(h('div', { class: 'btn-row' },
h('button', { class: 'btn-primary big', onclick: async () => { h('button', { class: 'btn-primary big', onclick: async () => {
const res = await api.logEntry({ const payload = {
day: state.day, category_id: cat.id, note: noteEl.value, note: noteEl.value,
values: metrics.map((m) => ({ metric_id: m.id, value: values[m.id] })), 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(); closeModal();
const recs = res?.newRecords || []; const recs = res?.newRecords || [];
if (recs.length) { if (recs.length) {
const r = recs[0]; const r = recs[0];
toast(`🏆 NEW RECORD! ${r.value}${r.unit ? ' ' + r.unit : ''}${cat.name}!`); toast(`🏆 NEW RECORD! ${r.value}${r.unit ? ' ' + r.unit : ''}${cat.name}!`);
} else { } else {
toast(`${cat.emoji} ${cat.name} logged!`); toast(`${cat.emoji} ${cat.name} ${existingEntry ? 'updated' : 'logged'}!`);
} }
await loadCategories(); // pick up any updated records await loadCategories(); // pick up any updated records
renderToday(view); renderToday(view);
refreshStatsIfActive(); refreshStatsIfActive();
} }, 'Log it! 🎉'))); } }, existingEntry ? 'Save changes ✅' : 'Log it! 🎉')));
openModal(`Log ${cat.name}`, body); openModal(`${existingEntry ? 'Edit' : 'Log'} ${cat.name}`, body);
} }
// ---------- PLAN ---------- // ---------- PLAN ----------
@@ -379,7 +394,11 @@ function openSettings() {
const emojiIn = h('input', { placeholder: 'Emoji', value: '⚽', maxlength: '4', style: 'max-width:90px' }); 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 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 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' }, body.append(h('div', { class: 'card', style: 'margin-top:16px' },
h('h3', {}, ' Add a category'), h('h3', {}, ' Add a category'),
h('div', { class: 'field' }, h('label', {}, 'Name'), nameIn), 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 = [ const SHELL = [
'/', '/index.html', '/login.html', '/', '/index.html', '/login.html',
'/css/styles.css', '/css/styles.css',
+6 -6
View File
@@ -2,13 +2,13 @@ import { IMPOSSIBLE, utils, VersionInfo } from '@start9labs/start-sdk'
import { store } from '../fileModels/store' import { store } from '../fileModels/store'
export const current = VersionInfo.of({ export const current = VersionInfo.of({
version: '0.1.2:0', version: '0.1.5:0',
releaseNotes: { releaseNotes: {
en_US: 'Personal-best records (auto-update + manual set), juggling counts by 1, technical-skill/effort scores plus per-session notes for 1-on-1 with Elijah, and an in-app "new version ready" refresh prompt.', en_US: 'Every value field is now tap-to-type: tap the number to enter an exact value (decimals supported for speeds) or use the +/- buttons as before.',
es_ES: 'Récords personales (actualización automática + ajuste manual), los toques cuentan de 1 en 1, puntuaciones de técnica/esfuerzo con notas por sesión para el 1 contra 1 con Elijah, y un aviso de actualización dentro de la app.', es_ES: 'Cada campo de valor ahora permite escribir: toca el número para introducir un valor exacto (con decimales para las velocidades) o usa los botones +/- como antes.',
de_DE: 'Persönliche Bestwerte (automatisch + manuell setzbar), Jonglieren zählt in 1er-Schritten, Technik-/Einsatz-Bewertungen mit Notizen pro Einheit für 1-gegen-1 mit Elijah und ein In-App-Hinweis „Neue Version verfügbar".', de_DE: 'Jedes Wertefeld kann jetzt direkt eingetippt werden: Tippe auf die Zahl für einen exakten Wert (Dezimalstellen für Geschwindigkeiten) oder nutze wie bisher die +/- Tasten.',
pl_PL: 'Rekordy życiowe (automatyczna aktualizacja + ręczne ustawianie), żonglerka liczona co 1, oceny techniki/zaangażowania z notatkami dla sesji 1 na 1 z Elijah oraz powiadomienie o aktualizacji w aplikacji.', pl_PL: 'Każde pole wartości można teraz wpisać dotykiem: dotknij liczby, aby wprowadzić dokładną wartość (z dziesiętnymi dla prędkości) lub użyj przycisków +/- jak wcześniej.',
fr_FR: "Records personnels (mise à jour automatique + réglage manuel), jonglages comptés par 1, notes de technique/d'effort avec commentaires par séance pour le 1-contre-1 avec Elijah, et une invite de mise à jour dans l'application.", fr_FR: "Chaque champ de valeur est désormais modifiable au clavier : touchez le nombre pour saisir une valeur exacte (décimales pour les vitesses) ou utilisez les boutons +/- comme avant.",
}, },
migrations: { migrations: {
up: async ({ effects }) => { up: async ({ effects }) => {
+28
View File
@@ -83,3 +83,31 @@ if (getSetting('migr_records_scores') !== 'done') {
tx(); tx();
setSetting('migr_records_scores', 'done'); setSetting('migr_records_scores', 'done');
} }
// One-off data migration: add Max Speed + Max Weighted Speed (record-tracked
// decimal metrics) to the EPA Agility & Speed category.
if (getSetting('migr_epa_speed') !== 'done') {
const tx = db.transaction(() => {
const epa = db.prepare("SELECT id FROM categories WHERE name = 'EPA Agility & Speed'").get();
if (epa) {
const addSpeed = (name) => {
const exists = db.prepare(
'SELECT 1 FROM category_metrics WHERE category_id = ? AND name = ?'
).get(epa.id, name);
if (!exists) {
const maxOrder = db.prepare(
'SELECT COALESCE(MAX(sort_order), -1) AS m FROM category_metrics WHERE category_id = ?'
).get(epa.id).m;
db.prepare(
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, track_record, sort_order) ' +
"VALUES (?, ?, 'mph', 'decimal', 1, 1, 1, ?)"
).run(epa.id, name, maxOrder + 1);
}
};
addSpeed('Max Speed');
addSpeed('Max Weighted Speed');
}
});
tx();
setSetting('migr_epa_speed', 'done');
}
+45 -23
View File
@@ -6,6 +6,31 @@ function ensureDay(day) {
db.prepare('INSERT OR IGNORE INTO training_days (day) VALUES (?)').run(day); db.prepare('INSERT OR IGNORE INTO training_days (day) VALUES (?)').run(day);
} }
// Write an entry's metric values and bump any personal-best records they beat.
// Pushes beaten records onto `newRecords`. Used by both create and update.
function writeValues(entryId, values, newRecords) {
if (!Array.isArray(values)) return;
const ins = db.prepare('INSERT INTO entry_values (entry_id, metric_id, value) VALUES (?, ?, ?)');
for (const v of values) {
if (!v || v.metric_id == null) continue;
const metricId = Number(v.metric_id);
const value = Number(v.value) || 0;
ins.run(entryId, metricId, value);
const m = db.prepare(
'SELECT id, name, record, higher_is_better, unit FROM category_metrics WHERE id = ? AND track_record = 1'
).get(metricId);
if (m) {
const beats = m.record == null
|| (m.higher_is_better ? value > m.record : value < m.record);
if (beats) {
db.prepare('UPDATE category_metrics SET record = ? WHERE id = ?').run(value, metricId);
newRecords.push({ metric_id: m.id, name: m.name, unit: m.unit, value, previous: m.record });
}
}
}
}
function dayPayload(day) { function dayPayload(day) {
const td = db.prepare('SELECT day, notes FROM training_days WHERE day = ?').get(day) const td = db.prepare('SELECT day, notes FROM training_days WHERE day = ?').get(day)
|| { day, notes: '' }; || { day, notes: '' };
@@ -53,34 +78,31 @@ export default async function entryRoutes(app) {
const { lastInsertRowid: entryId } = db.prepare( const { lastInsertRowid: entryId } = db.prepare(
'INSERT INTO entries (day, category_id, note) VALUES (?, ?, ?)' 'INSERT INTO entries (day, category_id, note) VALUES (?, ?, ?)'
).run(day, cat.id, String(note || '')); ).run(day, cat.id, String(note || ''));
if (Array.isArray(values)) { writeValues(entryId, values, newRecords);
const ins = db.prepare('INSERT INTO entry_values (entry_id, metric_id, value) VALUES (?, ?, ?)');
for (const v of values) {
if (!v || v.metric_id == null) continue;
const metricId = Number(v.metric_id);
const value = Number(v.value) || 0;
ins.run(entryId, metricId, value);
// Auto-update personal-best records.
const m = db.prepare(
'SELECT id, name, record, higher_is_better, unit FROM category_metrics WHERE id = ? AND track_record = 1'
).get(metricId);
if (m) {
const beats = m.record == null
|| (m.higher_is_better ? value > m.record : value < m.record);
if (beats) {
db.prepare('UPDATE category_metrics SET record = ? WHERE id = ?').run(value, metricId);
newRecords.push({ metric_id: m.id, name: m.name, unit: m.unit, value, previous: m.record });
}
}
}
}
return entryId;
}); });
tx(); tx();
return { ...dayPayload(day), newRecords }; return { ...dayPayload(day), newRecords };
}); });
// Edit an existing entry: replace its values and note (and bump records).
app.put('/api/entries/:id', async (req, reply) => {
const id = Number(req.params.id);
const entry = db.prepare('SELECT id, day FROM entries WHERE id = ?').get(id);
if (!entry) return reply.code(404).send({ error: 'Not found' });
const { values, note } = req.body || {};
const newRecords = [];
const tx = db.transaction(() => {
if (note !== undefined) {
db.prepare('UPDATE entries SET note = ? WHERE id = ?').run(String(note || ''), id);
}
db.prepare('DELETE FROM entry_values WHERE entry_id = ?').run(id);
writeValues(id, values, newRecords);
});
tx();
return { ...dayPayload(entry.day), newRecords };
});
app.delete('/api/entries/:id', async (req, reply) => { app.delete('/api/entries/:id', async (req, reply) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const row = db.prepare('SELECT day FROM entries WHERE id = ?').get(id); const row = db.prepare('SELECT day FROM entries WHERE id = ?').get(id);
+5 -1
View File
@@ -26,7 +26,11 @@ const DEFAULTS = [
{ name: 'Effort', unit: '/10', kind: 'score', step: 1 }, { name: 'Effort', unit: '/10', kind: 'score', step: 1 },
] }, ] },
{ name: 'EPA Agility & Speed', emoji: '⚡', color: '#ffc107', { name: 'EPA Agility & Speed', emoji: '⚡', color: '#ffc107',
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] }, metrics: [
{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 },
{ name: 'Max Speed', unit: 'mph', kind: 'decimal', step: 1, track_record: 1 },
{ name: 'Max Weighted Speed', unit: 'mph', kind: 'decimal', step: 1, track_record: 1 },
] },
]; ];
export function seedIfEmpty() { export function seedIfEmpty() {