From c93be9049cbfd0cd54ce5aea3f99413002697636 Mon Sep 17 00:00:00 2001 From: Ulas Date: Sat, 4 Apr 2026 14:30:31 +0200 Subject: [PATCH] feat(dashboard): add shopping list widget Show shopping lists with open items directly on the dashboard. Each list displays a progress bar, the first few unchecked items, and a "+N more" overflow indicator. Widget only appears when there are lists with open items. Backend: new shoppingLists query in /api/v1/dashboard (up to 3 lists, 6 open items each). Frontend: renderShoppingLists() widget following existing widget pattern. CSS: compact list/progress/item styles. i18n: shoppingMore key added to de/en/it. Requested in discussion #9 --- CHANGELOG.md | 5 ++ package.json | 2 +- public/locales/de.json | 3 +- public/locales/en.json | 3 +- public/locales/it.json | 3 +- public/pages/dashboard.js | 50 ++++++++++++++++++- public/styles/dashboard.css | 96 +++++++++++++++++++++++++++++++++++++ server/routes/dashboard.js | 27 +++++++++++ 8 files changed, 184 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8383717..124fcb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.0] - 2026-04-04 + +### Added +- Shopping list widget on dashboard - shows lists with open items, progress bar, and item preview (discussion #9) + ## [0.7.7] - 2026-04-04 ### Fixed diff --git a/package.json b/package.json index 65fb9ad..2466e5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.7.7", + "version": "0.8.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 7df2190..a6f7341 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -75,7 +75,8 @@ "overdue": "Überfällig", "dueSoon": "Heute fällig", "dueTomorrow": "Morgen fällig", - "allDay": "Ganztägig" + "allDay": "Ganztägig", + "shoppingMore": "+{{count}} weitere" }, "tasks": { diff --git a/public/locales/en.json b/public/locales/en.json index f448e81..be1d695 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -75,7 +75,8 @@ "overdue": "Overdue", "dueSoon": "Due today", "dueTomorrow": "Due tomorrow", - "allDay": "All day" + "allDay": "All day", + "shoppingMore": "+{{count}} more" }, "tasks": { diff --git a/public/locales/it.json b/public/locales/it.json index 9a1f693..283e5b2 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -75,7 +75,8 @@ "overdue": "Scaduto", "dueSoon": "Scade oggi", "dueTomorrow": "Scade domani", - "allDay": "Tutto il giorno" + "allDay": "Tutto il giorno", + "shoppingMore": "+{{count}} altri" }, "tasks": { diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index b355087..d5a2cdd 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -270,6 +270,53 @@ function renderPinnedNotes(notes) { `; } +// -------------------------------------------------------- +// Shopping-Widget +// -------------------------------------------------------- + +function renderShoppingLists(lists) { + if (!lists.length) return ''; + + const totalOpen = lists.reduce((sum, l) => sum + l.open_count, 0); + + const listsHtml = lists.map((list) => { + const progress = list.total_count > 0 + ? Math.round(((list.total_count - list.open_count) / list.total_count) * 100) + : 0; + + const itemsHtml = list.items.map((item) => ` +
+ + ${esc(item.name)} + ${item.quantity ? `${esc(item.quantity)}` : ''} +
+ `).join(''); + + const moreCount = list.open_count - list.items.length; + + return ` +
+
+ ${esc(list.name)} + ${list.total_count - list.open_count}/${list.total_count} +
+
+
+
+
+ ${itemsHtml} + ${moreCount > 0 ? `
${t('dashboard.shoppingMore', { count: moreCount })}
` : ''} +
+
+ `; + }).join(''); + + return `
+ ${widgetHeader('shopping-cart', t('nav.shopping'), totalOpen, '/shopping')} +
${listsHtml}
+
`; +} + // -------------------------------------------------------- // Wetter-Widget // -------------------------------------------------------- @@ -431,7 +478,7 @@ export async function render(container, { user }) { ${renderFab()} `; - let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [] }; + let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] }; let weather = null; try { const [dashRes, weatherRes] = await Promise.all([ @@ -464,6 +511,7 @@ export async function render(container, { user }) { ${renderWeatherWidget(weather)} ${renderUrgentTasks(data.urgentTasks ?? [])} ${renderUpcomingEvents(data.upcomingEvents ?? [])} + ${renderShoppingLists(data.shoppingLists ?? [])} ${renderTodayMeals(data.todayMeals ?? [])} ${renderPinnedNotes(data.pinnedNotes ?? [])} diff --git a/public/styles/dashboard.css b/public/styles/dashboard.css index b01ed61..820c1e5 100644 --- a/public/styles/dashboard.css +++ b/public/styles/dashboard.css @@ -486,6 +486,102 @@ } } +/* -------------------------------------------------------- + * Shopping-Widget + * -------------------------------------------------------- */ +.shopping-widget-list { + cursor: pointer; + transition: background-color var(--transition-fast); + border-radius: var(--radius-sm); + padding: var(--space-2) 0; +} + +.shopping-widget-list:hover { + background-color: var(--color-surface-2); +} + +.shopping-widget-list + .shopping-widget-list { + border-top: 1px solid var(--color-border-subtle); + margin-top: var(--space-2); + padding-top: var(--space-3); +} + +.shopping-widget-list__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-1); +} + +.shopping-widget-list__name { + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.shopping-widget-list__count { + font-size: var(--text-xs); + color: var(--color-text-tertiary); + font-weight: var(--font-weight-medium); +} + +.shopping-widget-list__progress { + height: 4px; + background-color: var(--color-surface-3); + border-radius: var(--radius-full); + overflow: hidden; + margin-bottom: var(--space-2); +} + +.shopping-widget-list__bar { + height: 100%; + background-color: var(--color-success); + border-radius: var(--radius-full); + transition: width 0.3s ease; +} + +.shopping-widget-list__items { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.shopping-widget-item { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-xs); + color: var(--color-text-secondary); + line-height: 1.4; +} + +.shopping-widget-item__dot { + width: 5px; + height: 5px; + border-radius: var(--radius-full); + background-color: var(--color-text-disabled); + flex-shrink: 0; +} + +.shopping-widget-item__name { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.shopping-widget-item__qty { + color: var(--color-text-tertiary); + font-size: var(--text-xs); + flex-shrink: 0; +} + +.shopping-widget-item--more { + color: var(--color-text-tertiary); + font-style: italic; +} + /* -------------------------------------------------------- * Notizen-Grid-Widget * -------------------------------------------------------- */ diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 7f81777..b8b912e 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -112,6 +112,33 @@ router.get('/', (req, res) => { result.pinnedNotes = []; } + // Einkaufslisten mit offenen Artikeln (max. 3 Listen, je bis zu 6 offene Items) + try { + const lists = d.prepare(` + SELECT sl.id, sl.name, + (SELECT COUNT(*) FROM shopping_items si WHERE si.list_id = sl.id AND si.is_checked = 0) AS open_count, + (SELECT COUNT(*) FROM shopping_items si WHERE si.list_id = sl.id) AS total_count + FROM shopping_lists sl + HAVING open_count > 0 + ORDER BY sl.updated_at DESC + LIMIT 3 + `).all(); + + for (const list of lists) { + list.items = d.prepare(` + SELECT id, name, quantity, is_checked + FROM shopping_items + WHERE list_id = ? AND is_checked = 0 + ORDER BY id ASC + LIMIT 6 + `).all(list.id); + } + result.shoppingLists = lists; + } catch (err) { + log.error('shoppingLists-Fehler:', err.message); + result.shoppingLists = []; + } + // Alle User (für Avatar-Farben in Widgets) try { result.users = d.prepare(