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; }
|
||||
.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; }
|
||||
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
+64
-18
@@ -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);
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'premier-gunner-v5';
|
||||
const CACHE = 'premier-gunner-v6';
|
||||
const SHELL = [
|
||||
'/', '/index.html', '/login.html',
|
||||
'/css/styles.css',
|
||||
|
||||
Reference in New Issue
Block a user