Initial commit: Premier Gunner tracker + StartOS 0.4.0 s9pk package
This commit is contained in:
+88
@@ -0,0 +1,88 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { db, getSetting, setSetting } from './db.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
export const COOKIE_NAME = 'pg_session';
|
||||
|
||||
// Resolve a stable cookie-signing secret (persisted so sessions survive restarts).
|
||||
export function getCookieSecret() {
|
||||
if (config.cookieSecret) return config.cookieSecret;
|
||||
let secret = getSetting('cookie_secret');
|
||||
if (!secret) {
|
||||
secret = randomBytes(32).toString('hex');
|
||||
setSetting('cookie_secret', secret);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
// Resolve the bcrypt password hash. The environment (PG_PASSWORD / PG_PASSWORD_HASH)
|
||||
// is authoritative: when set, it overwrites the stored hash on every boot so that
|
||||
// platform-managed password changes (e.g. the StartOS "Set Login Password" action,
|
||||
// which restarts the service with a new PG_PASSWORD) actually take effect.
|
||||
export function initPassword() {
|
||||
if (config.passwordHash) {
|
||||
setSetting('password_hash', config.passwordHash);
|
||||
return config.passwordHash;
|
||||
}
|
||||
|
||||
const stored = getSetting('password_hash');
|
||||
|
||||
if (config.password) {
|
||||
// Re-hash only when the password actually changed (bcrypt salts differ each run).
|
||||
if (!stored || !bcrypt.compareSync(config.password, stored)) {
|
||||
const hash = bcrypt.hashSync(config.password, 10);
|
||||
setSetting('password_hash', hash);
|
||||
// Password changed out from under existing sessions — invalidate them.
|
||||
if (stored) db.prepare('DELETE FROM sessions').run();
|
||||
return hash;
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
if (stored) return stored;
|
||||
|
||||
const hash = bcrypt.hashSync('gunner', 10);
|
||||
setSetting('password_hash', hash);
|
||||
console.warn('\n⚠ No PG_PASSWORD set — using default password "gunner". Set PG_PASSWORD before deploying.\n');
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function verifyPassword(plain) {
|
||||
const hash = config.passwordHash || getSetting('password_hash');
|
||||
if (!hash) return false;
|
||||
return bcrypt.compareSync(String(plain || ''), hash);
|
||||
}
|
||||
|
||||
export function setPassword(plain) {
|
||||
const hash = bcrypt.hashSync(String(plain), 10);
|
||||
setSetting('password_hash', hash);
|
||||
// Invalidate existing sessions when the password changes.
|
||||
db.prepare('DELETE FROM sessions').run();
|
||||
}
|
||||
|
||||
export function createSession() {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const expires = new Date(Date.now() + config.sessionDays * 86400_000).toISOString();
|
||||
db.prepare('INSERT INTO sessions (token, expires_at) VALUES (?, ?)').run(token, expires);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function isValidSession(token) {
|
||||
if (!token) return false;
|
||||
const row = db.prepare('SELECT expires_at FROM sessions WHERE token = ?').get(token);
|
||||
if (!row) return false;
|
||||
if (new Date(row.expires_at).getTime() < Date.now()) {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function destroySession(token) {
|
||||
if (token) db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
}
|
||||
|
||||
export function cleanupExpiredSessions() {
|
||||
db.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')").run();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
function resolveDir(p, fallback) {
|
||||
const dir = p || fallback;
|
||||
return isAbsolute(dir) ? dir : join(root, dir);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
host: process.env.PG_HOST || '0.0.0.0',
|
||||
port: Number(process.env.PG_PORT || 3000),
|
||||
dataDir: resolveDir(process.env.PG_DATA_DIR, 'data'),
|
||||
// Auth: prefer a pre-hashed value; otherwise hash PG_PASSWORD at boot.
|
||||
// Defaults to "gunner" for local dev (a warning is logged).
|
||||
passwordHash: process.env.PG_PASSWORD_HASH || '',
|
||||
password: process.env.PG_PASSWORD || '',
|
||||
cookieSecret: process.env.PG_COOKIE_SECRET || '',
|
||||
sessionDays: Number(process.env.PG_SESSION_DAYS || 30),
|
||||
};
|
||||
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
|
||||
export const dbPath = join(config.dataDir, 'premier-gunner.db');
|
||||
@@ -0,0 +1,26 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { dbPath } from './config.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf8');
|
||||
db.exec(schema);
|
||||
|
||||
export function getSetting(key, fallback = null) {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||
return row ? row.value : fallback;
|
||||
}
|
||||
|
||||
export function setSetting(key, value) {
|
||||
db.prepare(
|
||||
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
).run(key, String(value));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
COOKIE_NAME, verifyPassword, createSession, destroySession, setPassword,
|
||||
} from '../auth.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const cookieOpts = {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
signed: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: config.sessionDays * 86400,
|
||||
};
|
||||
|
||||
export default async function authRoutes(app) {
|
||||
app.post('/api/login', async (req, reply) => {
|
||||
const { password } = req.body || {};
|
||||
if (!verifyPassword(password)) {
|
||||
return reply.code(401).send({ error: 'Wrong password' });
|
||||
}
|
||||
const token = createSession();
|
||||
reply.setCookie(COOKIE_NAME, token, cookieOpts);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/logout', async (req, reply) => {
|
||||
const raw = req.cookies[COOKIE_NAME];
|
||||
const unsigned = raw ? reply.unsignCookie(raw) : null;
|
||||
if (unsigned && unsigned.valid) destroySession(unsigned.value);
|
||||
reply.clearCookie(COOKIE_NAME, { path: '/' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/me', async () => ({ ok: true }));
|
||||
|
||||
app.post('/api/password', async (req, reply) => {
|
||||
const { current, next } = req.body || {};
|
||||
if (!verifyPassword(current)) return reply.code(401).send({ error: 'Wrong current password' });
|
||||
if (!next || String(next).length < 4) return reply.code(400).send({ error: 'New password too short' });
|
||||
setPassword(next);
|
||||
const token = createSession();
|
||||
reply.setCookie(COOKIE_NAME, token, cookieOpts);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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, sort_order) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(catId, m.name || 'Value', m.unit || '', m.kind || 'count', m.step || 1, m.higher_is_better ?? 1, 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 } = 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);
|
||||
return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === 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 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { db } from '../db.js';
|
||||
|
||||
const ISO = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function ensureDay(day) {
|
||||
db.prepare('INSERT OR IGNORE INTO training_days (day) VALUES (?)').run(day);
|
||||
}
|
||||
|
||||
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'
|
||||
).all(day);
|
||||
const valuesByEntry = new Map();
|
||||
if (entries.length) {
|
||||
const ids = entries.map((e) => e.id);
|
||||
const rows = db.prepare(
|
||||
`SELECT entry_id, metric_id, value FROM entry_values WHERE entry_id IN (${ids.map(() => '?').join(',')})`
|
||||
).all(...ids);
|
||||
for (const r of rows) {
|
||||
if (!valuesByEntry.has(r.entry_id)) valuesByEntry.set(r.entry_id, []);
|
||||
valuesByEntry.get(r.entry_id).push({ metric_id: r.metric_id, value: r.value });
|
||||
}
|
||||
}
|
||||
const plans = db.prepare(
|
||||
'SELECT category_id, note FROM plans WHERE day = ?'
|
||||
).all(day);
|
||||
return {
|
||||
day: td.day,
|
||||
notes: td.notes,
|
||||
entries: entries.map((e) => ({ ...e, values: valuesByEntry.get(e.id) || [] })),
|
||||
plans,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function entryRoutes(app) {
|
||||
app.get('/api/day/:day', async (req, reply) => {
|
||||
const { day } = req.params;
|
||||
if (!ISO.test(day)) return reply.code(400).send({ error: 'Bad date' });
|
||||
return dayPayload(day);
|
||||
});
|
||||
|
||||
app.post('/api/entries', async (req, reply) => {
|
||||
const { day, category_id, values } = 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 tx = db.transaction(() => {
|
||||
ensureDay(day);
|
||||
const { lastInsertRowid: entryId } = db.prepare(
|
||||
'INSERT INTO entries (day, category_id) VALUES (?, ?)'
|
||||
).run(day, cat.id);
|
||||
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);
|
||||
}
|
||||
}
|
||||
return entryId;
|
||||
});
|
||||
tx();
|
||||
return dayPayload(day);
|
||||
});
|
||||
|
||||
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);
|
||||
db.prepare('DELETE FROM entries WHERE id = ?').run(id);
|
||||
return reply.send(row ? dayPayload(row.day) : { ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/day/:day/notes', async (req, reply) => {
|
||||
const { day } = req.params;
|
||||
if (!ISO.test(day)) return reply.code(400).send({ error: 'Bad date' });
|
||||
const notes = String((req.body && req.body.notes) || '');
|
||||
ensureDay(day);
|
||||
db.prepare('UPDATE training_days SET notes = ? WHERE day = ?').run(notes, day);
|
||||
return { ok: true, notes };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { db } from '../db.js';
|
||||
|
||||
const KINDS = new Set(['session_count', 'metric_best', 'metric_total']);
|
||||
|
||||
export default async function goalRoutes(app) {
|
||||
app.get('/api/goals', async () => db.prepare('SELECT * FROM goals ORDER BY is_main DESC, sort_order, id').all());
|
||||
|
||||
app.post('/api/goals', async (req, reply) => {
|
||||
const b = req.body || {};
|
||||
const scope = b.scope === 'overall' ? 'overall' : 'category';
|
||||
const kind = KINDS.has(b.kind) ? b.kind : 'session_count';
|
||||
if (kind !== 'session_count' && !b.metric_id) {
|
||||
return reply.code(400).send({ error: 'metric_id required for metric goals' });
|
||||
}
|
||||
if (scope === 'category' && !b.category_id) {
|
||||
return reply.code(400).send({ error: 'category_id required for category goals' });
|
||||
}
|
||||
// Only one main goal at a time.
|
||||
if (b.is_main) db.prepare('UPDATE goals SET is_main = 0').run();
|
||||
const maxOrder = db.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM goals').get().m;
|
||||
const result = db.prepare(
|
||||
'INSERT INTO goals (scope, category_id, metric_id, kind, target, label, reward, is_main, sort_order) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(scope, b.category_id || null, b.metric_id || null, kind, Number(b.target) || 0,
|
||||
String(b.label || ''), String(b.reward || ''), b.is_main ? 1 : 0, maxOrder + 1);
|
||||
return db.prepare('SELECT * FROM goals WHERE id = ?').get(result.lastInsertRowid);
|
||||
});
|
||||
|
||||
app.put('/api/goals/:id', async (req, reply) => {
|
||||
const id = Number(req.params.id);
|
||||
const g = db.prepare('SELECT * FROM goals WHERE id = ?').get(id);
|
||||
if (!g) return reply.code(404).send({ error: 'Not found' });
|
||||
const b = req.body || {};
|
||||
if (b.is_main) db.prepare('UPDATE goals SET is_main = 0').run();
|
||||
db.prepare(
|
||||
'UPDATE goals SET target = ?, label = ?, reward = ?, is_main = ? WHERE id = ?'
|
||||
).run(b.target != null ? Number(b.target) : g.target,
|
||||
b.label != null ? String(b.label) : g.label,
|
||||
b.reward != null ? String(b.reward) : g.reward,
|
||||
b.is_main != null ? (b.is_main ? 1 : 0) : g.is_main, id);
|
||||
return db.prepare('SELECT * FROM goals WHERE id = ?').get(id);
|
||||
});
|
||||
|
||||
app.delete('/api/goals/:id', async (req, reply) => {
|
||||
db.prepare('DELETE FROM goals WHERE id = ?').run(Number(req.params.id));
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { db } from '../db.js';
|
||||
|
||||
const ISO = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export default async function planRoutes(app) {
|
||||
// Plans within a date range (inclusive). Defaults to next 14 days handled client-side.
|
||||
app.get('/api/plans', async (req) => {
|
||||
const { from, to } = req.query || {};
|
||||
if (ISO.test(from || '') && ISO.test(to || '')) {
|
||||
return db.prepare(
|
||||
'SELECT id, day, category_id, note FROM plans WHERE day BETWEEN ? AND ? ORDER BY day, id'
|
||||
).all(from, to);
|
||||
}
|
||||
return db.prepare('SELECT id, day, category_id, note FROM plans ORDER BY day, id').all();
|
||||
});
|
||||
|
||||
app.post('/api/plans', async (req, reply) => {
|
||||
const { day, category_id, 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' });
|
||||
db.prepare(
|
||||
'INSERT INTO plans (day, category_id, note) VALUES (?, ?, ?) ' +
|
||||
'ON CONFLICT(day, category_id) DO UPDATE SET note = excluded.note'
|
||||
).run(day, cat.id, String(note || ''));
|
||||
return db.prepare('SELECT id, day, category_id, note FROM plans WHERE day = ? AND category_id = ?')
|
||||
.get(day, cat.id);
|
||||
});
|
||||
|
||||
app.delete('/api/plans/:id', async (req, reply) => {
|
||||
db.prepare('DELETE FROM plans WHERE id = ?').run(Number(req.params.id));
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { db } from '../db.js';
|
||||
|
||||
function todayISO() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function goalProgress(goal) {
|
||||
let current = 0;
|
||||
if (goal.kind === 'session_count') {
|
||||
current = goal.scope === 'overall'
|
||||
? db.prepare('SELECT COUNT(*) AS n FROM entries').get().n
|
||||
: db.prepare('SELECT COUNT(*) AS n FROM entries WHERE category_id = ?').get(goal.category_id).n;
|
||||
} else if (goal.kind === 'metric_best' && goal.metric_id) {
|
||||
current = db.prepare('SELECT COALESCE(MAX(value), 0) AS v FROM entry_values WHERE metric_id = ?')
|
||||
.get(goal.metric_id).v;
|
||||
} else if (goal.kind === 'metric_total' && goal.metric_id) {
|
||||
current = db.prepare('SELECT COALESCE(SUM(value), 0) AS v FROM entry_values WHERE metric_id = ?')
|
||||
.get(goal.metric_id).v;
|
||||
}
|
||||
const pct = goal.target > 0 ? Math.min(100, Math.round((current / goal.target) * 100)) : 0;
|
||||
return { ...goal, current, pct };
|
||||
}
|
||||
|
||||
function streaks(days) {
|
||||
// days: ISO strings sorted ascending, distinct
|
||||
if (!days.length) return { current: 0, longest: 0 };
|
||||
const set = new Set(days);
|
||||
let longest = 0;
|
||||
for (const d of days) {
|
||||
const prev = new Date(d); prev.setDate(prev.getDate() - 1);
|
||||
if (!set.has(prev.toISOString().slice(0, 10))) {
|
||||
// start of a run
|
||||
let len = 0; const cur = new Date(d);
|
||||
while (set.has(cur.toISOString().slice(0, 10))) { len++; cur.setDate(cur.getDate() + 1); }
|
||||
if (len > longest) longest = len;
|
||||
}
|
||||
}
|
||||
// current streak ending today or yesterday
|
||||
let current = 0; const cur = new Date(todayISO());
|
||||
if (!set.has(cur.toISOString().slice(0, 10))) cur.setDate(cur.getDate() - 1);
|
||||
while (set.has(cur.toISOString().slice(0, 10))) { current++; cur.setDate(cur.getDate() - 1); }
|
||||
return { current, longest };
|
||||
}
|
||||
|
||||
export default async function statsRoutes(app) {
|
||||
app.get('/api/stats', async () => {
|
||||
const totalSessions = db.prepare('SELECT COUNT(*) AS n FROM entries').get().n;
|
||||
const dayRows = db.prepare('SELECT DISTINCT day FROM entries ORDER BY day').all().map((r) => r.day);
|
||||
const totalDays = dayRows.length;
|
||||
|
||||
// Heatmap: entries per day.
|
||||
const heatmap = db.prepare(
|
||||
'SELECT day, COUNT(*) AS count FROM entries GROUP BY day ORDER BY day'
|
||||
).all();
|
||||
|
||||
// Radar: sessions per category (all-time + last 30 days).
|
||||
const since = new Date(); since.setDate(since.getDate() - 30);
|
||||
const since30 = since.toISOString().slice(0, 10);
|
||||
const radar = db.prepare(
|
||||
`SELECT c.id AS category_id, c.name, c.emoji, c.color,
|
||||
COUNT(e.id) AS sessions,
|
||||
SUM(CASE WHEN e.day >= ? THEN 1 ELSE 0 END) AS sessions30
|
||||
FROM categories c LEFT JOIN entries e ON e.category_id = c.id
|
||||
WHERE c.archived = 0
|
||||
GROUP BY c.id ORDER BY c.sort_order, c.id`
|
||||
).all(since30);
|
||||
|
||||
// Per-metric time series for line charts.
|
||||
const seriesRows = db.prepare(
|
||||
`SELECT ev.metric_id, e.day, ev.value, e.id AS entry_id
|
||||
FROM entry_values ev JOIN entries e ON e.id = ev.entry_id
|
||||
ORDER BY e.day, e.id`
|
||||
).all();
|
||||
const series = {};
|
||||
for (const r of seriesRows) {
|
||||
(series[r.metric_id] ||= []).push({ day: r.day, value: r.value, entry_id: r.entry_id });
|
||||
}
|
||||
|
||||
const goals = db.prepare('SELECT * FROM goals ORDER BY is_main DESC, sort_order, id').all()
|
||||
.map(goalProgress);
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
totalDays,
|
||||
...streaks(dayRows),
|
||||
today: todayISO(),
|
||||
heatmap,
|
||||
radar,
|
||||
series,
|
||||
goals,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL DEFAULT '⚽',
|
||||
color TEXT NOT NULL DEFAULT '#EF0107',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- What gets measured for a category (juggles, minutes, shots, etc.)
|
||||
CREATE TABLE IF NOT EXISTS category_metrics (
|
||||
id INTEGER PRIMARY KEY,
|
||||
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
unit TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT 'count', -- count | duration | score
|
||||
step INTEGER NOT NULL DEFAULT 1,
|
||||
higher_is_better INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- One row per calendar day Gunner trains; holds the daily notes.
|
||||
CREATE TABLE IF NOT EXISTS training_days (
|
||||
id INTEGER PRIMARY KEY,
|
||||
day TEXT NOT NULL UNIQUE, -- ISO YYYY-MM-DD
|
||||
notes TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- A single logged practice of a category on a day.
|
||||
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,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_day ON entries(day);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_cat ON entries(category_id);
|
||||
|
||||
-- The metric readings captured for an entry.
|
||||
CREATE TABLE IF NOT EXISTS entry_values (
|
||||
id INTEGER PRIMARY KEY,
|
||||
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
metric_id INTEGER NOT NULL REFERENCES category_metrics(id) ON DELETE CASCADE,
|
||||
value REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_values_entry ON entry_values(entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_values_metric ON entry_values(metric_id);
|
||||
|
||||
-- Planned categories for a (usually future) day.
|
||||
CREATE TABLE IF NOT EXISTS plans (
|
||||
id INTEGER PRIMARY KEY,
|
||||
day TEXT NOT NULL,
|
||||
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE(day, category_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_plans_day ON plans(day);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS goals (
|
||||
id INTEGER PRIMARY KEY,
|
||||
scope TEXT NOT NULL DEFAULT 'category', -- overall | category
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE CASCADE,
|
||||
metric_id INTEGER REFERENCES category_metrics(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL DEFAULT 'session_count', -- session_count | metric_best | metric_total
|
||||
target REAL NOT NULL DEFAULT 0,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
reward TEXT NOT NULL DEFAULT '',
|
||||
is_main INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
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 }] },
|
||||
{ name: 'Left Foot', emoji: '🦶', color: '#0a58ca',
|
||||
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
|
||||
{ name: 'Shooting', emoji: '🥅', color: '#d63384',
|
||||
metrics: [
|
||||
{ name: 'Shots', unit: 'shots', kind: 'count', step: 5 },
|
||||
{ name: 'Goals', unit: 'goals', kind: 'count', step: 1 },
|
||||
] },
|
||||
{ name: 'Soccer Tennis', emoji: '🎾', color: '#198754',
|
||||
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
|
||||
{ name: 'Dribbling Drills', emoji: '🌀', color: '#fd7e14',
|
||||
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
|
||||
{ name: 'Soccer Golf', emoji: '⛳', color: '#6f42c1',
|
||||
metrics: [{ name: 'Strokes', unit: 'strokes', kind: 'count', step: 1, higher_is_better: 0 }] },
|
||||
{ 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 }] },
|
||||
{ name: 'EPA Agility & Speed', emoji: '⚡', color: '#ffc107',
|
||||
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
|
||||
];
|
||||
|
||||
export function seedIfEmpty() {
|
||||
const count = db.prepare('SELECT COUNT(*) AS n FROM categories').get().n;
|
||||
if (count > 0) return false;
|
||||
|
||||
const insCat = db.prepare(
|
||||
'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 (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
|
||||
const seed = db.transaction(() => {
|
||||
DEFAULTS.forEach((cat, ci) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// Main goal: the London / Arsenal reward, tracked as overall sessions.
|
||||
db.prepare(
|
||||
'INSERT INTO goals (scope, kind, target, label, reward, is_main, sort_order) ' +
|
||||
"VALUES ('overall', 'session_count', 100, ?, ?, 1, 0)"
|
||||
).run('Road to London', '✈️ Trip to London to see Arsenal play in person!');
|
||||
|
||||
// A starter juggling personal-best goal as an example.
|
||||
const jug = db.prepare("SELECT id FROM categories WHERE name = 'Juggling'").get();
|
||||
const jugMetric = db.prepare(
|
||||
'SELECT id FROM category_metrics WHERE category_id = ? AND name = ?'
|
||||
).get(jug.id, 'Juggles');
|
||||
db.prepare(
|
||||
'INSERT INTO goals (scope, category_id, metric_id, kind, target, label, sort_order) ' +
|
||||
"VALUES ('category', ?, ?, 'metric_best', 50, ?, 1)"
|
||||
).run(jug.id, jugMetric.id, 'Juggle 50 in a row');
|
||||
});
|
||||
|
||||
seed();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow `npm run seed` to run standalone.
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const seeded = seedIfEmpty();
|
||||
console.log(seeded ? 'Seeded default categories and goals.' : 'Categories already exist — nothing to seed.');
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Fastify from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { config } from './config.js';
|
||||
import { seedIfEmpty } from './seed.js';
|
||||
import {
|
||||
COOKIE_NAME, getCookieSecret, initPassword, isValidSession, cleanupExpiredSessions,
|
||||
} from './auth.js';
|
||||
|
||||
import authRoutes from './routes/auth.js';
|
||||
import categoryRoutes from './routes/categories.js';
|
||||
import entryRoutes from './routes/entries.js';
|
||||
import planRoutes from './routes/plans.js';
|
||||
import goalRoutes from './routes/goals.js';
|
||||
import statsRoutes from './routes/stats.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const publicDir = join(__dirname, '..', 'public');
|
||||
|
||||
// First-boot setup.
|
||||
initPassword();
|
||||
seedIfEmpty();
|
||||
cleanupExpiredSessions();
|
||||
|
||||
const app = Fastify({ logger: { level: process.env.LOG_LEVEL || 'info' } });
|
||||
|
||||
await app.register(fastifyCookie, { secret: getCookieSecret() });
|
||||
await app.register(fastifyStatic, { root: publicDir, index: ['index.html'] });
|
||||
|
||||
// Gate every /api route except login. Static assets stay public; the data is what's protected.
|
||||
const OPEN = new Set(['/api/login']);
|
||||
app.addHook('preHandler', async (req, reply) => {
|
||||
if (!req.url.startsWith('/api/')) return;
|
||||
const path = req.url.split('?')[0];
|
||||
if (OPEN.has(path)) return;
|
||||
const raw = req.cookies[COOKIE_NAME];
|
||||
const unsigned = raw ? reply.unsignCookie(raw) : null;
|
||||
if (!unsigned || !unsigned.valid || !isValidSession(unsigned.value)) {
|
||||
return reply.code(401).send({ error: 'Not authenticated' });
|
||||
}
|
||||
});
|
||||
|
||||
await app.register(authRoutes);
|
||||
await app.register(categoryRoutes);
|
||||
await app.register(entryRoutes);
|
||||
await app.register(planRoutes);
|
||||
await app.register(goalRoutes);
|
||||
await app.register(statsRoutes);
|
||||
|
||||
try {
|
||||
await app.listen({ host: config.host, port: config.port });
|
||||
console.log(`⚽ Premier Gunner running at http://${config.host}:${config.port}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user