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:
Keysat
2026-06-03 08:46:27 -05:00
parent 0265699504
commit 5868852686
17 changed files with 441 additions and 121 deletions
+59
View File
@@ -24,3 +24,62 @@ export function setSetting(key, value) {
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, String(value));
}
// ---------- lightweight migrations for already-created databases ----------
function hasColumn(table, col) {
return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col);
}
function addColumn(table, col, def) {
if (!hasColumn(table, col)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`);
}
// Columns added after the initial release (schema.sql covers fresh installs;
// these ALTERs bring existing databases up to date).
addColumn('category_metrics', 'track_record', 'INTEGER NOT NULL DEFAULT 0');
addColumn('category_metrics', 'record', 'REAL');
addColumn('entries', 'note', "TEXT NOT NULL DEFAULT ''");
// One-off data migration: turn on juggling records (step 1) and add the
// Elijah technical-skill / effort scores to databases seeded before these
// features existed. Guarded so it runs at most once.
if (getSetting('migr_records_scores') !== 'done') {
const tx = db.transaction(() => {
// Juggling: count by 1s and track a personal-best record.
const jugMetric = db.prepare(
`SELECT cm.id FROM category_metrics cm
JOIN categories c ON c.id = cm.category_id
WHERE c.name = 'Juggling' AND cm.name = 'Juggles'`
).get();
if (jugMetric) {
const best = db.prepare(
'SELECT MAX(value) AS v FROM entry_values WHERE metric_id = ?'
).get(jugMetric.id).v;
db.prepare(
'UPDATE category_metrics SET step = 1, track_record = 1, record = ? WHERE id = ?'
).run(best ?? null, jugMetric.id);
}
// 1-on-1 with Elijah: add Technical Skill + Effort score metrics if missing.
const elijah = db.prepare("SELECT id FROM categories WHERE name = '1-on-1 with Elijah'").get();
if (elijah) {
const addScore = (name) => {
const exists = db.prepare(
'SELECT 1 FROM category_metrics WHERE category_id = ? AND name = ?'
).get(elijah.id, name);
if (!exists) {
const maxOrder = db.prepare(
'SELECT COALESCE(MAX(sort_order), -1) AS m FROM category_metrics WHERE category_id = ?'
).get(elijah.id).m;
db.prepare(
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, sort_order) ' +
"VALUES (?, ?, '/10', 'score', 1, 1, ?)"
).run(elijah.id, name, maxOrder + 1);
}
};
addScore('Technical Skill');
addScore('Effort');
}
});
tx();
setSetting('migr_records_scores', 'done');
}
+35 -7
View File
@@ -34,9 +34,10 @@ export default async function categoryRoutes(app) {
: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }];
list.forEach((m, i) => {
db.prepare(
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, sort_order) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(catId, m.name || 'Value', m.unit || '', m.kind || 'count', m.step || 1, m.higher_is_better ?? 1, i);
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, track_record, sort_order) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(catId, m.name || 'Value', m.unit || '', m.kind || 'count', m.step || 1,
m.higher_is_better ?? 1, m.track_record ? 1 : 0, i);
});
return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === Number(catId));
});
@@ -58,17 +59,44 @@ export default async function categoryRoutes(app) {
const id = Number(req.params.id);
const cat = db.prepare('SELECT id FROM categories WHERE id = ?').get(id);
if (!cat) return reply.code(404).send({ error: 'Not found' });
const { name, unit, kind, step, higher_is_better } = req.body || {};
const { name, unit, kind, step, higher_is_better, track_record } = req.body || {};
const maxOrder = db.prepare(
'SELECT COALESCE(MAX(sort_order), -1) AS m FROM category_metrics WHERE category_id = ?'
).get(id).m;
db.prepare(
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, sort_order) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(id, name || 'Value', unit || '', kind || 'count', step || 1, higher_is_better ?? 1, maxOrder + 1);
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, track_record, sort_order) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, name || 'Value', unit || '', kind || 'count', step || 1,
higher_is_better ?? 1, track_record ? 1 : 0, maxOrder + 1);
return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === id);
});
// Update an existing metric (step, record tracking, manual record value, etc.).
app.put('/api/metrics/:id', async (req, reply) => {
const id = Number(req.params.id);
const m = db.prepare('SELECT * FROM category_metrics WHERE id = ?').get(id);
if (!m) return reply.code(404).send({ error: 'Not found' });
const b = req.body || {};
// `record` may be explicitly set to null to clear it.
const record = 'record' in b
? (b.record === null || b.record === '' ? null : Number(b.record))
: m.record;
db.prepare(
'UPDATE category_metrics SET name = ?, unit = ?, kind = ?, step = ?, ' +
'higher_is_better = ?, track_record = ?, record = ? WHERE id = ?'
).run(
b.name != null ? String(b.name) : m.name,
b.unit != null ? String(b.unit) : m.unit,
b.kind != null ? String(b.kind) : m.kind,
b.step != null ? Number(b.step) || 1 : m.step,
b.higher_is_better != null ? (b.higher_is_better ? 1 : 0) : m.higher_is_better,
b.track_record != null ? (b.track_record ? 1 : 0) : m.track_record,
record,
id,
);
return db.prepare('SELECT * FROM category_metrics WHERE id = ?').get(id);
});
app.delete('/api/metrics/:id', async (req, reply) => {
const id = Number(req.params.id);
db.prepare('DELETE FROM category_metrics WHERE id = ?').run(id);
+23 -6
View File
@@ -10,7 +10,7 @@ function dayPayload(day) {
const td = db.prepare('SELECT day, notes FROM training_days WHERE day = ?').get(day)
|| { day, notes: '' };
const entries = db.prepare(
'SELECT id, category_id, created_at FROM entries WHERE day = ? ORDER BY id'
'SELECT id, category_id, note, created_at FROM entries WHERE day = ? ORDER BY id'
).all(day);
const valuesByEntry = new Map();
if (entries.length) {
@@ -42,26 +42,43 @@ export default async function entryRoutes(app) {
});
app.post('/api/entries', async (req, reply) => {
const { day, category_id, values } = req.body || {};
const { day, category_id, values, note } = req.body || {};
if (!ISO.test(day || '')) return reply.code(400).send({ error: 'Bad date' });
const cat = db.prepare('SELECT id FROM categories WHERE id = ?').get(Number(category_id));
if (!cat) return reply.code(400).send({ error: 'Unknown category' });
const newRecords = [];
const tx = db.transaction(() => {
ensureDay(day);
const { lastInsertRowid: entryId } = db.prepare(
'INSERT INTO entries (day, category_id) VALUES (?, ?)'
).run(day, cat.id);
'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) ins.run(entryId, Number(v.metric_id), Number(v.value) || 0);
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();
return dayPayload(day);
return { ...dayPayload(day), newRecords };
});
app.delete('/api/entries/:id', async (req, reply) => {
+10
View File
@@ -79,6 +79,15 @@ export default async function statsRoutes(app) {
const goals = db.prepare('SELECT * FROM goals ORDER BY is_main DESC, sort_order, id').all()
.map(goalProgress);
// Personal-best records for metrics that track them.
const records = db.prepare(
`SELECT cm.id AS metric_id, cm.name, cm.unit, cm.record, cm.higher_is_better,
c.id AS category_id, c.name AS category, c.emoji, c.color
FROM category_metrics cm JOIN categories c ON c.id = cm.category_id
WHERE cm.track_record = 1 AND c.archived = 0
ORDER BY c.sort_order, c.id, cm.sort_order, cm.id`
).all();
return {
totalSessions,
totalDays,
@@ -88,6 +97,7 @@ export default async function statsRoutes(app) {
radar,
series,
goals,
records,
};
});
}
+3
View File
@@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS category_metrics (
kind TEXT NOT NULL DEFAULT 'count', -- count | duration | score
step INTEGER NOT NULL DEFAULT 1,
higher_is_better INTEGER NOT NULL DEFAULT 1,
track_record INTEGER NOT NULL DEFAULT 0, -- 1 = keep a personal-best record
record REAL, -- current personal best (NULL = unset)
sort_order INTEGER NOT NULL DEFAULT 0
);
@@ -34,6 +36,7 @@ CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY,
day TEXT NOT NULL,
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_entries_day ON entries(day);
+9 -5
View File
@@ -3,7 +3,7 @@ import { db } from './db.js';
// Default categories Gunner starts with. He can edit/add/archive these later.
const DEFAULTS = [
{ name: 'Juggling', emoji: '🤹', color: '#EF0107',
metrics: [{ name: 'Juggles', unit: 'reps', kind: 'count', step: 5 }] },
metrics: [{ name: 'Juggles', unit: 'reps', kind: 'count', step: 1, track_record: 1 }] },
{ name: 'Left Foot', emoji: '🦶', color: '#0a58ca',
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
{ name: 'Shooting', emoji: '🥅', color: '#d63384',
@@ -20,7 +20,11 @@ const DEFAULTS = [
{ name: 'Backyard with Dad', emoji: '🏡', color: '#20c997',
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
{ name: '1-on-1 with Elijah', emoji: '👟', color: '#0dcaf0',
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
metrics: [
{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 },
{ name: 'Technical Skill', unit: '/10', kind: 'score', step: 1 },
{ name: 'Effort', unit: '/10', kind: 'score', step: 1 },
] },
{ name: 'EPA Agility & Speed', emoji: '⚡', color: '#ffc107',
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
];
@@ -33,8 +37,8 @@ export function seedIfEmpty() {
'INSERT INTO categories (name, emoji, color, sort_order) VALUES (?, ?, ?, ?)'
);
const insMetric = db.prepare(
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, sort_order) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, track_record, sort_order) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
const seed = db.transaction(() => {
@@ -42,7 +46,7 @@ export function seedIfEmpty() {
const { lastInsertRowid: catId } = insCat.run(cat.name, cat.emoji, cat.color, ci);
cat.metrics.forEach((m, mi) => {
insMetric.run(catId, m.name, m.unit || '', m.kind || 'count',
m.step || 1, m.higher_is_better ?? 1, mi);
m.step || 1, m.higher_is_better ?? 1, m.track_record ? 1 : 0, mi);
});
});