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:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user