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
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -75,7 +75,8 @@
|
||||
"overdue": "Overdue",
|
||||
"dueSoon": "Due today",
|
||||
"dueTomorrow": "Due tomorrow",
|
||||
"allDay": "All day"
|
||||
"allDay": "All day",
|
||||
"shoppingMore": "+{{count}} more"
|
||||
},
|
||||
|
||||
"tasks": {
|
||||
|
||||
@@ -75,7 +75,8 @@
|
||||
"overdue": "Scaduto",
|
||||
"dueSoon": "Scade oggi",
|
||||
"dueTomorrow": "Scade domani",
|
||||
"allDay": "Tutto il giorno"
|
||||
"allDay": "Tutto il giorno",
|
||||
"shoppingMore": "+{{count}} altri"
|
||||
},
|
||||
|
||||
"tasks": {
|
||||
|
||||
@@ -270,6 +270,53 @@ function renderPinnedNotes(notes) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 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) => `
|
||||
<div class="shopping-widget-item">
|
||||
<span class="shopping-widget-item__dot"></span>
|
||||
<span class="shopping-widget-item__name">${esc(item.name)}</span>
|
||||
${item.quantity ? `<span class="shopping-widget-item__qty">${esc(item.quantity)}</span>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
const moreCount = list.open_count - list.items.length;
|
||||
|
||||
return `
|
||||
<div class="shopping-widget-list" data-route="/shopping" role="button" tabindex="0">
|
||||
<div class="shopping-widget-list__header">
|
||||
<span class="shopping-widget-list__name">${esc(list.name)}</span>
|
||||
<span class="shopping-widget-list__count">${list.total_count - list.open_count}/${list.total_count}</span>
|
||||
</div>
|
||||
<div class="shopping-widget-list__progress">
|
||||
<div class="shopping-widget-list__bar" style="width:${progress}%"></div>
|
||||
</div>
|
||||
<div class="shopping-widget-list__items">
|
||||
${itemsHtml}
|
||||
${moreCount > 0 ? `<div class="shopping-widget-item shopping-widget-item--more">${t('dashboard.shoppingMore', { count: moreCount })}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="widget">
|
||||
${widgetHeader('shopping-cart', t('nav.shopping'), totalOpen, '/shopping')}
|
||||
<div class="widget__body">${listsHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 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 ?? [])}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user