From 2dc8984c3e37cfeb2dfad56464af2d95f533dce0 Mon Sep 17 00:00:00 2001 From: Ulas Date: Sun, 5 Apr 2026 17:24:06 +0200 Subject: [PATCH] feat(shopping): custom categories - add, rename, delete and reorder (#26) - New DB table shopping_categories (migration v5) seeds 9 default categories with Lucide icons and sort_order - Backend CRUD routes: GET/POST/PUT/DELETE /shopping/categories plus PATCH /shopping/categories/reorder - Category validation now uses DB instead of hardcoded constant; items of deleted category are moved to the next available one - Frontend shopping page loads categories from API, dropdown and grouping reflect custom order dynamically - Settings -> Shopping section: list categories with up/down buttons, click-to-rename, delete with confirmation; add new categories inline - i18n keys added in de/en/sv/it --- CHANGELOG.md | 8 ++ package.json | 2 +- public/locales/de.json | 13 +++ public/locales/en.json | 13 +++ public/locales/it.json | 13 +++ public/locales/sv.json | 13 +++ public/pages/settings.js | 168 +++++++++++++++++++++++++++++++- public/pages/shopping.js | 86 +++++++++------- public/styles/settings.css | 67 +++++++++++++ server/db.js | 24 +++++ server/routes/shopping.js | 194 ++++++++++++++++++++++++++++++++++--- 11 files changed, 545 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d2bb9..97ab076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0] - 2026-04-05 + +### Added +- Shopping: custom categories - add, rename, delete and reorder shopping list categories in Settings → Shopping (#26) +- Shopping: categories are now stored in the database (`shopping_categories` table, migration v5) and fully customizable per household +- Shopping: category order in the shopping list reflects the custom sort order from Settings +- Shopping: items belonging to a deleted category are automatically moved to the next available category + ## [0.11.9] - 2026-04-05 ### Changed diff --git a/package.json b/package.json index 96e828d..51423a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.11.9", + "version": "0.12.0", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", diff --git a/public/locales/de.json b/public/locales/de.json index 78b6799..9b917b5 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -455,6 +455,19 @@ "settings": { "title": "Einstellungen", "sectionDesign": "Design", + "sectionShopping": "Einkauf", + "shoppingCategoriesLabel": "Einkaufskategorien", + "shoppingCategoriesHint": "Kategorien hinzufügen, umbenennen, löschen oder sortieren.", + "shoppingCategoryPlaceholder": "Neue Kategorie…", + "shoppingCategoryRenameHint": "Klicken zum Umbenennen", + "shoppingCategoryRenamePrompt": "Neuer Kategoriename:", + "shoppingCategoryMoveUp": "Kategorie nach oben", + "shoppingCategoryMoveDown": "Kategorie nach unten", + "shoppingCategoryDelete": "Kategorie löschen", + "shoppingCategoryDeleteConfirm": "Kategorie \"{{name}}\" löschen? Vorhandene Artikel werden der nächsten Kategorie zugeordnet.", + "shoppingCategoryAdded": "Kategorie hinzugefügt.", + "shoppingCategoryRenamed": "Kategorie umbenannt.", + "shoppingCategoryDeleted": "Kategorie gelöscht.", "sectionAccount": "Mein Konto", "sectionCalendarSync": "Kalender-Synchronisation", "sectionFamily": "Familienmitglieder", diff --git a/public/locales/en.json b/public/locales/en.json index fdeab0a..353964e 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -455,6 +455,19 @@ "settings": { "title": "Settings", "sectionDesign": "Appearance", + "sectionShopping": "Shopping", + "shoppingCategoriesLabel": "Shopping Categories", + "shoppingCategoriesHint": "Add, rename, delete or reorder categories.", + "shoppingCategoryPlaceholder": "New category…", + "shoppingCategoryRenameHint": "Click to rename", + "shoppingCategoryRenamePrompt": "New category name:", + "shoppingCategoryMoveUp": "Move category up", + "shoppingCategoryMoveDown": "Move category down", + "shoppingCategoryDelete": "Delete category", + "shoppingCategoryDeleteConfirm": "Delete category \"{{name}}\"? Existing items will be moved to the next category.", + "shoppingCategoryAdded": "Category added.", + "shoppingCategoryRenamed": "Category renamed.", + "shoppingCategoryDeleted": "Category deleted.", "sectionAccount": "My Account", "sectionCalendarSync": "Calendar Sync", "sectionFamily": "Family Members", diff --git a/public/locales/it.json b/public/locales/it.json index eb99f2f..51202c1 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -455,6 +455,19 @@ "settings": { "title": "Impostazioni", "sectionDesign": "Aspetto", + "sectionShopping": "Spesa", + "shoppingCategoriesLabel": "Categorie spesa", + "shoppingCategoriesHint": "Aggiungi, rinomina, elimina o riordina le categorie.", + "shoppingCategoryPlaceholder": "Nuova categoria…", + "shoppingCategoryRenameHint": "Clicca per rinominare", + "shoppingCategoryRenamePrompt": "Nuovo nome categoria:", + "shoppingCategoryMoveUp": "Sposta categoria su", + "shoppingCategoryMoveDown": "Sposta categoria giu", + "shoppingCategoryDelete": "Elimina categoria", + "shoppingCategoryDeleteConfirm": "Eliminare la categoria \"{{name}}\"? Gli articoli esistenti verranno spostati alla categoria successiva.", + "shoppingCategoryAdded": "Categoria aggiunta.", + "shoppingCategoryRenamed": "Categoria rinominata.", + "shoppingCategoryDeleted": "Categoria eliminata.", "sectionAccount": "Il mio account", "sectionCalendarSync": "Sincronizzazione calendario", "sectionFamily": "Membri della famiglia", diff --git a/public/locales/sv.json b/public/locales/sv.json index 661de94..4f87a89 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -455,6 +455,19 @@ "settings": { "title": "Inställningar", "sectionDesign": "Utseende", + "sectionShopping": "Inköp", + "shoppingCategoriesLabel": "Inköpskategorier", + "shoppingCategoriesHint": "Lägg till, byt namn, ta bort eller sortera om kategorier.", + "shoppingCategoryPlaceholder": "Ny kategori…", + "shoppingCategoryRenameHint": "Klicka för att byta namn", + "shoppingCategoryRenamePrompt": "Nytt kategorinamn:", + "shoppingCategoryMoveUp": "Flytta kategori uppåt", + "shoppingCategoryMoveDown": "Flytta kategori nedåt", + "shoppingCategoryDelete": "Ta bort kategori", + "shoppingCategoryDeleteConfirm": "Ta bort kategorin \"{{name}}\"? Befintliga artiklar flyttas till nästa kategori.", + "shoppingCategoryAdded": "Kategori tillagd.", + "shoppingCategoryRenamed": "Kategori omdöpt.", + "shoppingCategoryDeleted": "Kategori borttagen.", "sectionAccount": "Mitt konto", "sectionCalendarSync": "Kalendersynkronisering", "sectionFamily": "Familjemedlemmar", diff --git a/public/pages/settings.js b/public/pages/settings.js index c06723b..66dbde8 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -40,18 +40,21 @@ export async function render(container, { user }) { let googleStatus = { configured: false, connected: false, lastSync: null }; let appleStatus = { configured: false, lastSync: null }; let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' }; + let categories = []; try { - const [usersRes, gStatus, aStatus, prefsRes] = await Promise.allSettled([ + const [usersRes, gStatus, aStatus, prefsRes, catsRes] = await Promise.allSettled([ user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }), api.get('/calendar/google/status'), api.get('/calendar/apple/status'), api.get('/preferences'), + api.get('/shopping/categories'), ]); if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? []; if (gStatus.status === 'fulfilled') googleStatus = gStatus.value; if (aStatus.status === 'fulfilled') appleStatus = aStatus.value; if (prefsRes.status === 'fulfilled') prefs = prefsRes.value.data ?? prefs; + if (catsRes.status === 'fulfilled') categories = catsRes.value.data ?? []; } catch (_) { /* non-critical */ } const googleStatusText = googleStatus.connected @@ -142,6 +145,24 @@ export async function render(container, { user }) { + +
+

${t('settings.sectionShopping')}

+
+

${t('settings.shoppingCategoriesLabel')}

+

${t('settings.shoppingCategoriesHint')}

+
    + ${categories.map((c, i) => categoryRowHtml(c, i === 0, i === categories.length - 1)).join('')} +
+
+ + +
+
+
+

${t('settings.sectionAccount')}

@@ -317,14 +338,15 @@ export async function render(container, { user }) { }); } - bindEvents(container, user); + bindEvents(container, user, categories); } // -------------------------------------------------------- // Event-Binding // -------------------------------------------------------- -function bindEvents(container, user) { +function bindEvents(container, user, categories) { + bindCategoryEvents(container); // Theme-Toggle const themeToggle = container.querySelector('#theme-toggle'); if (themeToggle) { @@ -585,6 +607,146 @@ function bindDeleteButtons(container, user) { } +// -------------------------------------------------------- +// Kategorie-Verwaltung +// -------------------------------------------------------- + +function categoryRowHtml(cat, isFirst, isLast) { + return ` +
  • + + ${esc(cat.name)} +
    + + + +
    +
  • `; +} + +function renderCatList(container, cats) { + const list = container.querySelector('#cat-list'); + if (!list) return; + // DOM-API statt innerHTML (Security-Constraint des Projekts) + list.replaceChildren(); + cats.forEach((c, i) => { + const tmp = document.createElement('template'); + tmp.innerHTML = categoryRowHtml(c, i === 0, i === cats.length - 1); + list.appendChild(tmp.content.firstElementChild); + }); + if (window.lucide) window.lucide.createIcons(); +} + +function bindCategoryEvents(container) { + let cats = []; + + api.get('/shopping/categories').then((res) => { + cats = res.data ?? []; + renderCatList(container, cats); + }).catch(() => {}); + + const addForm = container.querySelector('#cat-add-form'); + if (addForm) { + addForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const input = container.querySelector('#cat-add-input'); + const name = input.value.trim(); + if (!name) return; + try { + const res = await api.post('/shopping/categories', { name }); + cats.push(res.data); + renderCatList(container, cats); + input.value = ''; + input.focus(); + window.oikos?.showToast(t('settings.shoppingCategoryAdded'), 'success'); + } catch (err) { + window.oikos?.showToast(err.message, 'danger'); + } + }); + } + + const catList = container.querySelector('#cat-list'); + if (!catList) return; + + catList.addEventListener('click', async (e) => { + const target = e.target.closest('[data-action]'); + if (!target) return; + const action = target.dataset.action; + const rowEl = target.closest('[data-cat-id]'); + const id = rowEl ? Number(rowEl.dataset.catId) : Number(target.dataset.id); + + if (action === 'rename-cat') { + const cat = cats.find((c) => c.id === id); + if (!cat) return; + const { promptModal } = await import('/components/modal.js'); + const newName = await promptModal(t('settings.shoppingCategoryRenamePrompt'), cat.name); + if (!newName || newName === cat.name) return; + try { + const res = await api.put(`/shopping/categories/${id}`, { name: newName }); + const idx = cats.findIndex((c) => c.id === id); + if (idx >= 0) cats[idx] = res.data; + renderCatList(container, cats); + window.oikos?.showToast(t('settings.shoppingCategoryRenamed'), 'success'); + } catch (err) { + window.oikos?.showToast(err.message, 'danger'); + } + } + + if (action === 'move-cat-up') { + const idx = cats.findIndex((c) => c.id === id); + if (idx <= 0) return; + [cats[idx - 1], cats[idx]] = [cats[idx], cats[idx - 1]]; + renderCatList(container, cats); + try { + await api.patch('/shopping/categories/reorder', { order: cats.map((c) => c.id) }); + } catch (err) { + window.oikos?.showToast(err.message, 'danger'); + } + } + + if (action === 'move-cat-down') { + const idx = cats.findIndex((c) => c.id === id); + if (idx < 0 || idx >= cats.length - 1) return; + [cats[idx], cats[idx + 1]] = [cats[idx + 1], cats[idx]]; + renderCatList(container, cats); + try { + await api.patch('/shopping/categories/reorder', { order: cats.map((c) => c.id) }); + } catch (err) { + window.oikos?.showToast(err.message, 'danger'); + } + } + + if (action === 'delete-cat') { + const cat = cats.find((c) => c.id === id); + if (!cat) return; + const { confirmModal: confirmDel } = await import('/components/modal.js'); + if (!await confirmDel( + t('settings.shoppingCategoryDeleteConfirm', { name: cat.name }), + { danger: true, confirmLabel: t('common.delete') } + )) return; + try { + await api.delete(`/shopping/categories/${id}`); + cats = cats.filter((c) => c.id !== id); + renderCatList(container, cats); + window.oikos?.showToast(t('settings.shoppingCategoryDeleted'), 'default'); + } catch (err) { + window.oikos?.showToast(err.message, 'danger'); + } + } + }); +} + function memberHtml(u) { return `
  • diff --git a/public/pages/shopping.js b/public/pages/shopping.js index a164b9e..1cd43b3 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -19,35 +19,35 @@ const SWIPE_THRESHOLD = 80; // px - Mindestweg für Aktion const SWIPE_MAX_VERT = 12; // px - vertikaler Toleranzbereich const SWIPE_LOCK_VERT = 30; // px - ab diesem Weg gilt es als Scroll -const ITEM_CATEGORIES = [ - 'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch', - 'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges', -]; - -const CATEGORY_LABELS = () => ({ - 'Obst & Gemüse': t('shopping.catFruitVeg'), - 'Backwaren': t('shopping.catBakery'), - 'Milchprodukte': t('shopping.catDairy'), - 'Fleisch & Fisch': t('shopping.catMeatFish'), - 'Tiefkühl': t('shopping.catFrozen'), - 'Getränke': t('shopping.catDrinks'), - 'Haushalt': t('shopping.catHousehold'), - 'Drogerie': t('shopping.catDrugstore'), - 'Sonstiges': t('shopping.catMisc'), -}); - -const CATEGORY_ICONS = { - 'Obst & Gemüse': 'apple', - 'Backwaren': 'wheat', - 'Milchprodukte': 'milk', - 'Fleisch & Fisch':'beef', - 'Tiefkühl': 'snowflake', - 'Getränke': 'cup-soda', - 'Haushalt': 'spray-can', - 'Drogerie': 'pill', - 'Sonstiges': 'shopping-basket', +// Übersetzungs-Map für die Standard-Kategorien (DB-Name → i18n-Key) +const DEFAULT_CATEGORY_I18N = { + 'Obst & Gemüse': 'shopping.catFruitVeg', + 'Backwaren': 'shopping.catBakery', + 'Milchprodukte': 'shopping.catDairy', + 'Fleisch & Fisch': 'shopping.catMeatFish', + 'Tiefkühl': 'shopping.catFrozen', + 'Getränke': 'shopping.catDrinks', + 'Haushalt': 'shopping.catHousehold', + 'Drogerie': 'shopping.catDrugstore', + 'Sonstiges': 'shopping.catMisc', }; +/** Übersetzten Label für eine Kategorie zurückgeben. */ +function catLabel(name) { + const key = DEFAULT_CATEGORY_I18N[name]; + return key ? t(key) : name; +} + +/** Icon für eine Kategorie (aus state.categories, Fallback 'tag'). */ +function catIcon(name) { + return state.categories.find((c) => c.name === name)?.icon ?? 'tag'; +} + +/** Kategorienamen in DB-Reihenfolge. */ +function categoryNames() { + return state.categories.map((c) => c.name); +} + // -------------------------------------------------------- // State // -------------------------------------------------------- @@ -57,6 +57,7 @@ const state = { activeListId: null, items: [], activeList: null, + categories: [], // { id, name, icon, sort_order }[] }; // -------------------------------------------------------- @@ -66,13 +67,14 @@ const state = { function groupItemsByCategory(items) { const grouped = {}; for (const item of items) { - const cat = item.category || 'Sonstiges'; + const cat = item.category || (state.categories[0]?.name ?? 'Sonstiges'); (grouped[cat] = grouped[cat] || []).push(item); } - // In Supermarkt-Gang-Reihenfolge zurückgeben - return ITEM_CATEGORIES - .filter((c) => grouped[c]) - .map((c) => [c, grouped[c]]); + // In DB-Reihenfolge zurückgeben; unbekannte Kategorien ans Ende + const names = categoryNames(); + const known = names.filter((c) => grouped[c]).map((c) => [c, grouped[c]]); + const unknown = Object.keys(grouped).filter((c) => !names.includes(c)).map((c) => [c, grouped[c]]); + return [...known, ...unknown]; } // -------------------------------------------------------- @@ -155,7 +157,7 @@ function renderListContent(container) {