diff --git a/public/css/styles.css b/public/css/styles.css index 9927f65..330bfd0 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -142,9 +142,13 @@ input:focus, select:focus, textarea:focus { outline: none; border-color: var(--a .stepper .unit { color: var(--muted); font-size: .9rem; } .record-line { color: var(--arsenal-red); font-weight: 700; font-size: .9rem; margin-bottom: 8px; } -/* ---------- Metric / record editor (settings) ---------- */ -.metric-edit { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 0; border-top: 1px solid var(--line); } +/* ---------- Category / metric editor (settings) ---------- */ +.cat-edit.archived { opacity: .65; } +.cat-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.cat-head input[type="text"], .cat-head input:not([type]) { height: 44px; } +.metric-edit { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 0; border-top: 1px solid var(--line); } .metric-edit-name { flex: 1 1 100%; font-weight: 600; } +.metric-edit input, .metric-edit select { height: 40px; } .metric-edit .mini { display: flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--muted); font-weight: 600; } .metric-edit .mini input[type="number"] { height: 38px; } diff --git a/public/js/api.js b/public/js/api.js index cbc7f27..a1de1f6 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -25,6 +25,7 @@ export const api = { categories: (all = false) => req('GET', `/api/categories${all ? '?all=1' : ''}`), addCategory: (c) => req('POST', '/api/categories', c), updateCategory: (id, c) => req('PUT', `/api/categories/${id}`, c), + deleteCategory: (id) => req('DELETE', `/api/categories/${id}`), addMetric: (catId, m) => req('POST', `/api/categories/${catId}/metrics`, m), updateMetric: (id, m) => req('PUT', `/api/metrics/${id}`, m), deleteMetric: (id) => req('DELETE', `/api/metrics/${id}`), diff --git a/public/js/app.js b/public/js/app.js index 383aed7..29d3134 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -349,43 +349,89 @@ function openGoalModal(view) { // ---------- SETTINGS ---------- function openSettings() { const body = h('div', {}); - body.append(h('h3', {}, 'Categories & records')); - body.append(h('p', { class: 'muted small' }, 'Set how each metric counts, turn on 🏆 record tracking, and set the current record by hand. Records auto-update when a session beats them.')); - const list = h('div', {}); - const renderList = () => { - list.innerHTML = ''; - for (const c of state.categories) { - const card = h('div', { class: 'card', style: 'margin:10px 0' }); - card.append(h('div', { class: 'row spread' }, - h('strong', {}, `${c.emoji} ${c.name}`), - h('button', { class: 'btn-danger', onclick: async () => { await api.updateCategory(c.id, { archived: 1 }); await loadCategories(); renderList(); refreshActive(); } }, 'Archive'))); + body.append(h('h3', {}, 'Categories & metrics')); + body.append(h('p', { class: 'muted small' }, 'Rename categories and metrics, change units & type, add or remove metrics, set 🏆 record tracking, and archive or delete categories.')); + const kindOptions = () => [ + h('option', { value: 'duration' }, 'Time'), + h('option', { value: 'count' }, 'Count'), + h('option', { value: 'score' }, 'Score (0–10)'), + h('option', { value: 'decimal' }, 'Number (decimal)'), + ]; + + const list = h('div', {}); + async function renderList() { + const cats = await api.categories(true); // include archived so they can be restored/deleted + list.innerHTML = ''; + for (const c of cats) { + const card = h('div', { class: 'card cat-edit' + (c.archived ? ' archived' : ''), style: 'margin:10px 0' }); + + // ---- category header: emoji, name, color ---- + const emojiIn = h('input', { value: c.emoji, maxlength: '4', style: 'max-width:54px;text-align:center' }); + const nameIn = h('input', { value: c.name, style: 'flex:1;min-width:120px' }); + const colorIn = h('input', { type: 'color', value: c.color, style: 'max-width:54px;height:44px;padding:4px' }); + const catSave = h('button', { class: 'btn-ghost', onclick: async () => { + if (!nameIn.value.trim()) return alert('Name can’t be empty'); + await api.updateCategory(c.id, { name: nameIn.value.trim(), emoji: emojiIn.value || '⚽', color: colorIn.value }); + await loadCategories(); renderList(); refreshActive(); toast('Category saved'); + } }, 'Save'); + card.append(h('div', { class: 'cat-head' }, emojiIn, nameIn, colorIn, catSave, + c.archived ? h('span', { class: 'badge' }, 'Archived') : null)); + + // ---- metrics ---- + card.append(h('div', { class: 'muted small', style: 'margin-top:6px' }, 'Metrics')); for (const m of (c.metrics || [])) { - const stepIn = h('input', { type: 'number', min: '1', value: String(m.step || 1), style: 'max-width:80px' }); + const mName = h('input', { value: m.name, placeholder: 'Name', style: 'flex:1 1 100%;min-width:120px' }); + const mUnit = h('input', { value: m.unit || '', placeholder: 'unit', style: 'max-width:80px' }); + const mKind = h('select', {}, kindOptions()); mKind.value = m.kind; + const stepIn = h('input', { type: 'number', min: '1', value: String(m.step || 1), style: 'max-width:64px' }); const trackChk = h('input', { type: 'checkbox', ...(m.track_record ? { checked: true } : {}) }); const recordIn = h('input', { type: 'number', placeholder: 'record', value: m.record != null ? String(m.record) : '', - style: 'max-width:100px', ...(m.track_record ? {} : { disabled: true }) }); + style: 'max-width:90px', ...(m.track_record ? {} : { disabled: true }) }); trackChk.addEventListener('change', () => { recordIn.disabled = !trackChk.checked; }); - const save = h('button', { class: 'btn-ghost', onclick: async () => { + const mSave = h('button', { class: 'btn-ghost', onclick: async () => { + if (!mName.value.trim()) return alert('Metric name can’t be empty'); await api.updateMetric(m.id, { + name: mName.value.trim(), unit: mUnit.value.trim(), kind: mKind.value, step: Number(stepIn.value) || 1, track_record: trackChk.checked, record: trackChk.checked ? (recordIn.value === '' ? null : Number(recordIn.value)) : null, }); - await loadCategories(); renderList(); refreshActive(); toast('Saved'); + await loadCategories(); renderList(); refreshActive(); toast('Metric saved'); } }, 'Save'); + const mDel = h('button', { class: 'btn-danger', onclick: async () => { + if (!confirm(`Remove the "${m.name}" metric? Its logged values will be deleted.`)) return; + await api.deleteMetric(m.id); await loadCategories(); renderList(); refreshActive(); toast('Metric removed'); + } }, '🗑'); card.append(h('div', { class: 'metric-edit' }, - h('div', { class: 'metric-edit-name' }, `${m.name}${m.unit ? ' (' + m.unit + ')' : ''}`), + mName, mUnit, mKind, h('label', { class: 'mini' }, 'Step', stepIn), - h('label', { class: 'mini' }, '🏆 Track', trackChk), + h('label', { class: 'mini' }, '🏆', trackChk), h('label', { class: 'mini' }, 'Record', recordIn), - save)); + mSave, mDel)); } + + const addMetricBtn = h('button', { class: 'btn-ghost', style: 'margin-top:8px', onclick: async () => { + await api.addMetric(c.id, { name: 'New metric', unit: '', kind: 'count', step: 1 }); + await loadCategories(); renderList(); refreshActive(); + } }, '➕ Add metric'); + + // ---- category actions ---- + const archiveBtn = h('button', { class: 'btn-ghost', onclick: async () => { + await api.updateCategory(c.id, { archived: c.archived ? 0 : 1 }); + await loadCategories(); renderList(); refreshActive(); + } }, c.archived ? 'Unarchive' : 'Archive'); + const deleteBtn = h('button', { class: 'btn-danger', onclick: async () => { + if (!confirm(`Delete "${c.name}" and ALL of its logged data permanently? This cannot be undone.`)) return; + await api.deleteCategory(c.id); await loadCategories(); renderList(); refreshActive(); toast('Category deleted'); + } }, 'Delete category'); + + card.append(addMetricBtn, h('div', { class: 'row', style: 'gap:10px;margin-top:10px' }, archiveBtn, deleteBtn)); list.append(card); } - }; + } renderList(); body.append(list); diff --git a/public/sw.js b/public/sw.js index 4960d33..c2c4860 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'premier-gunner-v5'; +const CACHE = 'premier-gunner-v6'; const SHELL = [ '/', '/index.html', '/login.html', '/css/styles.css', diff --git a/s9pk/instructions.md b/s9pk/instructions.md index a4dfbfe..8188f87 100644 --- a/s9pk/instructions.md +++ b/s9pk/instructions.md @@ -14,6 +14,7 @@ Premier Gunner is a kid-friendly, mobile-friendly soccer training tracker built - **A single-user web app**, password protected, reachable over your StartOS networking (Tor, LAN, or a clearnet domain via StartTunnel). - **Daily logging** by category with point-and-click steppers, plus optional per-session notes. +- **Full category management** in Settings — rename categories, change their emoji/color, add or remove the metrics each one tracks, rename metrics, and change their unit and type (count, time, 0–10 score, or decimal). Categories can be archived (hidden, data kept) or deleted permanently. - **Personal-best records** — turn on 🏆 tracking for any metric (juggling is on by default). Set the current record by hand in Settings, and it updates automatically whenever a session beats it. - **1-on-1 with Elijah scores** — log a Technical Skill score and an Effort score (out of 10) alongside the session note. - **Planning, goals, and a dashboard** with a streak calendar, training-spread radar, improvement charts, a records list, and the main-goal thermometer. diff --git a/s9pk/startos/versions/current.ts b/s9pk/startos/versions/current.ts index 8a4b0e9..b30a978 100644 --- a/s9pk/startos/versions/current.ts +++ b/s9pk/startos/versions/current.ts @@ -2,13 +2,13 @@ import { IMPOSSIBLE, utils, VersionInfo } from '@start9labs/start-sdk' import { store } from '../fileModels/store' export const current = VersionInfo.of({ - version: '0.1.5:0', + version: '0.1.6:0', releaseNotes: { - en_US: 'Every value field is now tap-to-type: tap the number to enter an exact value (decimals supported for speeds) or use the +/- buttons as before.', - es_ES: 'Cada campo de valor ahora permite escribir: toca el número para introducir un valor exacto (con decimales para las velocidades) o usa los botones +/- como antes.', - de_DE: 'Jedes Wertefeld kann jetzt direkt eingetippt werden: Tippe auf die Zahl für einen exakten Wert (Dezimalstellen für Geschwindigkeiten) oder nutze wie bisher die +/- Tasten.', - pl_PL: 'Każde pole wartości można teraz wpisać dotykiem: dotknij liczby, aby wprowadzić dokładną wartość (z dziesiętnymi dla prędkości) lub użyj przycisków +/- jak wcześniej.', - fr_FR: "Chaque champ de valeur est désormais modifiable au clavier : touchez le nombre pour saisir une valeur exacte (décimales pour les vitesses) ou utilisez les boutons +/- comme avant.", + en_US: 'Full category management in Settings: rename categories and metrics, change units & type, add or remove metrics, and archive or permanently delete categories.', + es_ES: 'Gestión completa de categorías en Ajustes: renombra categorías y métricas, cambia unidades y tipo, añade o elimina métricas, y archiva o elimina categorías permanentemente.', + de_DE: 'Vollständige Kategorienverwaltung in den Einstellungen: Kategorien und Metriken umbenennen, Einheiten & Typ ändern, Metriken hinzufügen/entfernen sowie Kategorien archivieren oder endgültig löschen.', + pl_PL: 'Pełne zarządzanie kategoriami w Ustawieniach: zmiana nazw kategorii i metryk, zmiana jednostek i typu, dodawanie/usuwanie metryk oraz archiwizacja lub trwałe usuwanie kategorii.', + fr_FR: "Gestion complète des catégories dans les Réglages : renommer catégories et métriques, changer unités et type, ajouter ou supprimer des métriques, et archiver ou supprimer définitivement des catégories.", }, migrations: { up: async ({ effects }) => { diff --git a/src/routes/categories.js b/src/routes/categories.js index 58e8ab1..78260d1 100644 --- a/src/routes/categories.js +++ b/src/routes/categories.js @@ -54,6 +54,16 @@ export default async function categoryRoutes(app) { return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === id); }); + // Permanently delete a category and everything under it (metrics, entries, + // entry values, plans, goals) via ON DELETE CASCADE. + app.delete('/api/categories/:id', 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' }); + db.prepare('DELETE FROM categories WHERE id = ?').run(id); + return reply.send({ ok: true }); + }); + // Add a metric to an existing category. app.post('/api/categories/:id/metrics', async (req, reply) => { const id = Number(req.params.id);