Files
premier-gunner/src/routes/categories.js
T
Keysat 5868852686 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.
2026-06-03 08:46:27 -05:00

106 lines
4.8 KiB
JavaScript

import { db } from '../db.js';
function categoriesWithMetrics({ includeArchived = false } = {}) {
const cats = db.prepare(
`SELECT * FROM categories ${includeArchived ? '' : 'WHERE archived = 0'} ORDER BY sort_order, id`
).all();
const metrics = db.prepare('SELECT * FROM category_metrics ORDER BY sort_order, id').all();
const byCat = new Map();
for (const m of metrics) {
if (!byCat.has(m.category_id)) byCat.set(m.category_id, []);
byCat.get(m.category_id).push(m);
}
return cats.map((c) => ({ ...c, metrics: byCat.get(c.id) || [] }));
}
export { categoriesWithMetrics };
export default async function categoryRoutes(app) {
app.get('/api/categories', async (req) => {
const includeArchived = req.query?.all === '1';
return categoriesWithMetrics({ includeArchived });
});
app.post('/api/categories', async (req, reply) => {
const { name, emoji, color, metrics } = req.body || {};
if (!name) return reply.code(400).send({ error: 'Name required' });
const maxOrder = db.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM categories').get().m;
const result = db.prepare(
'INSERT INTO categories (name, emoji, color, sort_order) VALUES (?, ?, ?, ?)'
).run(name, emoji || '⚽', color || '#EF0107', maxOrder + 1);
const catId = result.lastInsertRowid;
const list = Array.isArray(metrics) && metrics.length
? metrics
: [{ 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, 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));
});
app.put('/api/categories/:id', async (req, reply) => {
const id = Number(req.params.id);
const cat = db.prepare('SELECT * FROM categories WHERE id = ?').get(id);
if (!cat) return reply.code(404).send({ error: 'Not found' });
const { name, emoji, color, archived } = req.body || {};
db.prepare(
'UPDATE categories SET name = ?, emoji = ?, color = ?, archived = ? WHERE id = ?'
).run(name ?? cat.name, emoji ?? cat.emoji, color ?? cat.color,
archived !== undefined ? (archived ? 1 : 0) : cat.archived, id);
return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === id);
});
// Add a metric to an existing category.
app.post('/api/categories/:id/metrics', async (req, reply) => {
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, 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, 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);
return reply.send({ ok: true });
});
}