Add full category & metric management in Settings
- New Settings editor: rename categories (emoji/color too), rename metrics, change unit and type, add/remove metrics, set step + record tracking, archive/unarchive, and permanently delete categories. - Settings now lists archived categories so they can be restored/deleted. - Backend: add DELETE /api/categories/:id (cascades metrics, entries, entry_values, plans, goals). - Bump StartOS package to 0.1.6:0; service worker cache to v6.
This commit is contained in:
@@ -142,9 +142,13 @@ input:focus, select:focus, textarea:focus { outline: none; border-color: var(--a
|
|||||||
.stepper .unit { color: var(--muted); font-size: .9rem; }
|
.stepper .unit { color: var(--muted); font-size: .9rem; }
|
||||||
.record-line { color: var(--arsenal-red); font-weight: 700; font-size: .9rem; margin-bottom: 8px; }
|
.record-line { color: var(--arsenal-red); font-weight: 700; font-size: .9rem; margin-bottom: 8px; }
|
||||||
|
|
||||||
/* ---------- Metric / record editor (settings) ---------- */
|
/* ---------- Category / metric editor (settings) ---------- */
|
||||||
.metric-edit { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 0; border-top: 1px solid var(--line); }
|
.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-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 { display: flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--muted); font-weight: 600; }
|
||||||
.metric-edit .mini input[type="number"] { height: 38px; }
|
.metric-edit .mini input[type="number"] { height: 38px; }
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const api = {
|
|||||||
categories: (all = false) => req('GET', `/api/categories${all ? '?all=1' : ''}`),
|
categories: (all = false) => req('GET', `/api/categories${all ? '?all=1' : ''}`),
|
||||||
addCategory: (c) => req('POST', '/api/categories', c),
|
addCategory: (c) => req('POST', '/api/categories', c),
|
||||||
updateCategory: (id, c) => req('PUT', `/api/categories/${id}`, 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),
|
addMetric: (catId, m) => req('POST', `/api/categories/${catId}/metrics`, m),
|
||||||
updateMetric: (id, m) => req('PUT', `/api/metrics/${id}`, m),
|
updateMetric: (id, m) => req('PUT', `/api/metrics/${id}`, m),
|
||||||
deleteMetric: (id) => req('DELETE', `/api/metrics/${id}`),
|
deleteMetric: (id) => req('DELETE', `/api/metrics/${id}`),
|
||||||
|
|||||||
+64
-18
@@ -349,43 +349,89 @@ function openGoalModal(view) {
|
|||||||
// ---------- SETTINGS ----------
|
// ---------- SETTINGS ----------
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
const body = h('div', {});
|
const body = h('div', {});
|
||||||
body.append(h('h3', {}, 'Categories & records'));
|
body.append(h('h3', {}, 'Categories & metrics'));
|
||||||
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.'));
|
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 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')));
|
|
||||||
|
|
||||||
|
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 || [])) {
|
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 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) : '',
|
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; });
|
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, {
|
await api.updateMetric(m.id, {
|
||||||
|
name: mName.value.trim(), unit: mUnit.value.trim(), kind: mKind.value,
|
||||||
step: Number(stepIn.value) || 1,
|
step: Number(stepIn.value) || 1,
|
||||||
track_record: trackChk.checked,
|
track_record: trackChk.checked,
|
||||||
record: trackChk.checked ? (recordIn.value === '' ? null : Number(recordIn.value)) : null,
|
record: trackChk.checked ? (recordIn.value === '' ? null : Number(recordIn.value)) : null,
|
||||||
});
|
});
|
||||||
await loadCategories(); renderList(); refreshActive(); toast('Saved');
|
await loadCategories(); renderList(); refreshActive(); toast('Metric saved');
|
||||||
} }, 'Save');
|
} }, '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' },
|
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' }, 'Step', stepIn),
|
||||||
h('label', { class: 'mini' }, '🏆 Track', trackChk),
|
h('label', { class: 'mini' }, '🏆', trackChk),
|
||||||
h('label', { class: 'mini' }, 'Record', recordIn),
|
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);
|
list.append(card);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
renderList();
|
renderList();
|
||||||
body.append(list);
|
body.append(list);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'premier-gunner-v5';
|
const CACHE = 'premier-gunner-v6';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/', '/index.html', '/login.html',
|
'/', '/index.html', '/login.html',
|
||||||
'/css/styles.css',
|
'/css/styles.css',
|
||||||
|
|||||||
@@ -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).
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Planning, goals, and a dashboard** with a streak calendar, training-spread radar, improvement charts, a records list, and the main-goal thermometer.
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { IMPOSSIBLE, utils, VersionInfo } from '@start9labs/start-sdk'
|
|||||||
import { store } from '../fileModels/store'
|
import { store } from '../fileModels/store'
|
||||||
|
|
||||||
export const current = VersionInfo.of({
|
export const current = VersionInfo.of({
|
||||||
version: '0.1.5:0',
|
version: '0.1.6:0',
|
||||||
releaseNotes: {
|
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.',
|
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: '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.',
|
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: '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.',
|
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: '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.',
|
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: "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.",
|
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: {
|
migrations: {
|
||||||
up: async ({ effects }) => {
|
up: async ({ effects }) => {
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ export default async function categoryRoutes(app) {
|
|||||||
return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === id);
|
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.
|
// Add a metric to an existing category.
|
||||||
app.post('/api/categories/:id/metrics', async (req, reply) => {
|
app.post('/api/categories/:id/metrics', async (req, reply) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user