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]
|
## [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
|
## [0.7.7] - 2026-04-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"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.",
|
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -75,7 +75,8 @@
|
|||||||
"overdue": "Überfällig",
|
"overdue": "Überfällig",
|
||||||
"dueSoon": "Heute fällig",
|
"dueSoon": "Heute fällig",
|
||||||
"dueTomorrow": "Morgen fällig",
|
"dueTomorrow": "Morgen fällig",
|
||||||
"allDay": "Ganztägig"
|
"allDay": "Ganztägig",
|
||||||
|
"shoppingMore": "+{{count}} weitere"
|
||||||
},
|
},
|
||||||
|
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -75,7 +75,8 @@
|
|||||||
"overdue": "Overdue",
|
"overdue": "Overdue",
|
||||||
"dueSoon": "Due today",
|
"dueSoon": "Due today",
|
||||||
"dueTomorrow": "Due tomorrow",
|
"dueTomorrow": "Due tomorrow",
|
||||||
"allDay": "All day"
|
"allDay": "All day",
|
||||||
|
"shoppingMore": "+{{count}} more"
|
||||||
},
|
},
|
||||||
|
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -75,7 +75,8 @@
|
|||||||
"overdue": "Scaduto",
|
"overdue": "Scaduto",
|
||||||
"dueSoon": "Scade oggi",
|
"dueSoon": "Scade oggi",
|
||||||
"dueTomorrow": "Scade domani",
|
"dueTomorrow": "Scade domani",
|
||||||
"allDay": "Tutto il giorno"
|
"allDay": "Tutto il giorno",
|
||||||
|
"shoppingMore": "+{{count}} altri"
|
||||||
},
|
},
|
||||||
|
|
||||||
"tasks": {
|
"tasks": {
|
||||||
|
|||||||
@@ -270,6 +270,53 @@ function renderPinnedNotes(notes) {
|
|||||||
</div>`;
|
</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
|
// Wetter-Widget
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -431,7 +478,7 @@ export async function render(container, { user }) {
|
|||||||
${renderFab()}
|
${renderFab()}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [] };
|
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] };
|
||||||
let weather = null;
|
let weather = null;
|
||||||
try {
|
try {
|
||||||
const [dashRes, weatherRes] = await Promise.all([
|
const [dashRes, weatherRes] = await Promise.all([
|
||||||
@@ -464,6 +511,7 @@ export async function render(container, { user }) {
|
|||||||
${renderWeatherWidget(weather)}
|
${renderWeatherWidget(weather)}
|
||||||
${renderUrgentTasks(data.urgentTasks ?? [])}
|
${renderUrgentTasks(data.urgentTasks ?? [])}
|
||||||
${renderUpcomingEvents(data.upcomingEvents ?? [])}
|
${renderUpcomingEvents(data.upcomingEvents ?? [])}
|
||||||
|
${renderShoppingLists(data.shoppingLists ?? [])}
|
||||||
${renderTodayMeals(data.todayMeals ?? [])}
|
${renderTodayMeals(data.todayMeals ?? [])}
|
||||||
${renderPinnedNotes(data.pinnedNotes ?? [])}
|
${renderPinnedNotes(data.pinnedNotes ?? [])}
|
||||||
</div>
|
</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
|
* Notizen-Grid-Widget
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
@@ -112,6 +112,33 @@ router.get('/', (req, res) => {
|
|||||||
result.pinnedNotes = [];
|
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)
|
// Alle User (für Avatar-Farben in Widgets)
|
||||||
try {
|
try {
|
||||||
result.users = d.prepare(
|
result.users = d.prepare(
|
||||||
|
|||||||
Reference in New Issue
Block a user