diff --git a/public/css/styles.css b/public/css/styles.css index de83ade..9927f65 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -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; } diff --git a/public/js/api.js b/public/js/api.js index 75aaf89..cbc7f27 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -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 }), diff --git a/public/js/app.js b/public/js/app.js index a58d667..383aed7 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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'; // 0–10 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. 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]); - }; + // 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 (0–10)'), + 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), diff --git a/public/sw.js b/public/sw.js index dc250b7..4960d33 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'premier-gunner-v3'; +const CACHE = 'premier-gunner-v5'; const SHELL = [ '/', '/index.html', '/login.html', '/css/styles.css', diff --git a/s9pk/startos/versions/current.ts b/s9pk/startos/versions/current.ts index 5e6745e..8a4b0e9 100644 --- a/s9pk/startos/versions/current.ts +++ b/s9pk/startos/versions/current.ts @@ -2,13 +2,13 @@ import { IMPOSSIBLE, utils, VersionInfo } from '@start9labs/start-sdk' import { store } from '../fileModels/store' export const current = VersionInfo.of({ - version: '0.1.2:0', + version: '0.1.5:0', 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.', - 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.', - 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".', - 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.', - 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.", + 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: '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: '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: '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: "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: { up: async ({ effects }) => { diff --git a/src/db.js b/src/db.js index bdf0c3c..2b2d501 100644 --- a/src/db.js +++ b/src/db.js @@ -83,3 +83,31 @@ if (getSetting('migr_records_scores') !== 'done') { tx(); 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'); +} diff --git a/src/routes/entries.js b/src/routes/entries.js index 167689d..75523bf 100644 --- a/src/routes/entries.js +++ b/src/routes/entries.js @@ -6,6 +6,31 @@ function ensureDay(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) { const td = db.prepare('SELECT day, notes FROM training_days WHERE day = ?').get(day) || { day, notes: '' }; @@ -53,34 +78,31 @@ export default async function entryRoutes(app) { const { lastInsertRowid: entryId } = db.prepare( 'INSERT INTO entries (day, category_id, note) VALUES (?, ?, ?)' ).run(day, cat.id, String(note || '')); - if (Array.isArray(values)) { - 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; + writeValues(entryId, values, newRecords); }); tx(); 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) => { const id = Number(req.params.id); const row = db.prepare('SELECT day FROM entries WHERE id = ?').get(id); diff --git a/src/seed.js b/src/seed.js index 6034232..77ee998 100644 --- a/src/seed.js +++ b/src/seed.js @@ -26,7 +26,11 @@ const DEFAULTS = [ { name: 'Effort', unit: '/10', kind: 'score', step: 1 }, ] }, { 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() {