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:
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user