Initial commit: Premier Gunner tracker + StartOS 0.4.0 s9pk package

This commit is contained in:
Keysat
2026-05-31 21:04:48 -05:00
commit 0265699504
67 changed files with 4578 additions and 0 deletions
+88
View File
@@ -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();
}
+25
View File
@@ -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');
+26
View File
@@ -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));
}
+45
View File
@@ -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 };
});
}
+77
View File
@@ -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 });
});
}
+82
View File
@@ -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 };
});
}
+48
View File
@@ -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 });
});
}
+34
View File
@@ -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 });
});
}
+93
View File
@@ -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,
};
});
}
+84
View File
@@ -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
View File
@@ -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.');
}
+59
View File
@@ -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);
}