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:
Keysat
2026-06-04 08:43:11 -05:00
parent cf64a2dc50
commit 284c5ff079
7 changed files with 89 additions and 27 deletions
+1
View File
@@ -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
View File
@@ -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 (010)'),
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 cant 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 cant 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);