From 60055e2d4f1d785dc1a4173fc84b25fc6da3194a Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Sun, 26 Apr 2026 18:11:10 +0200 Subject: [PATCH 01/25] fix(i18n): rename Pinnwand to Notizen in German locale --- public/locales/de.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index 82ba5a1..9bdea2c 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -36,7 +36,7 @@ "calendar": "Kalender", "meals": "Essen", "shopping": "Einkauf", - "notes": "Pinnwand", + "notes": "Notizen", "contacts": "Kontakte", "budget": "Budget", "settings": "Einstellungen", @@ -343,7 +343,7 @@ } }, "notes": { - "title": "Pinnwand", + "title": "Notizen", "newNote": "Neue Notiz", "editNote": "Notiz bearbeiten", "addNoteLabel": "Neue Notiz", From 1cc1b6374595ed89d28b8ab9ba85ec6e509ec3d5 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Sun, 26 Apr 2026 18:14:15 +0200 Subject: [PATCH 02/25] feat(login): add spinner animation during authentication Co-Authored-By: Claude Sonnet 4.6 --- public/pages/login.js | 13 ++++++++++--- public/styles/login.css | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/public/pages/login.js b/public/pages/login.js index 464317e..8950593 100644 --- a/public/pages/login.js +++ b/public/pages/login.js @@ -54,7 +54,7 @@ export async function render(container) { @@ -84,8 +84,14 @@ export async function render(container) { return; } + const labelEl = submitBtn.querySelector('.login-btn__label'); + submitBtn.disabled = true; - submitBtn.textContent = t('login.loggingIn'); + labelEl.textContent = t('login.loggingIn'); + const spinner = document.createElement('span'); + spinner.className = 'login-spinner'; + spinner.setAttribute('aria-hidden', 'true'); + submitBtn.insertBefore(spinner, labelEl); try { const result = await auth.login(username, password); @@ -97,7 +103,8 @@ export async function render(container) { ); } finally { submitBtn.disabled = false; - submitBtn.textContent = t('login.loginButton'); + labelEl.textContent = t('login.loginButton'); + spinner.remove(); } }); } diff --git a/public/styles/login.css b/public/styles/login.css index 7a064a7..13775ae 100644 --- a/public/styles/login.css +++ b/public/styles/login.css @@ -45,6 +45,10 @@ .login-form__submit { width: 100%; margin-top: var(--space-2); + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); } .login-error { @@ -63,3 +67,21 @@ text-align: center; opacity: 0.6; } + +.login-spinner { + width: 16px; + height: 16px; + border: 2px solid color-mix(in srgb, currentColor 30%, transparent); + border-top-color: currentColor; + border-radius: 50%; + animation: login-spin 0.7s linear infinite; + flex-shrink: 0; +} + +@keyframes login-spin { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .login-spinner { animation: none; opacity: 0.6; } +} From 9fba1d7ae462f403d3ec77aa0dba25d415b156cb Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Sun, 26 Apr 2026 18:24:27 +0200 Subject: [PATCH 03/25] chore: release v0.25.3 --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- public/locales/ar.json | 3 ++- public/locales/de.json | 3 ++- public/locales/el.json | 3 ++- public/locales/en.json | 3 ++- public/locales/es.json | 3 ++- public/locales/fr.json | 3 ++- public/locales/hi.json | 3 ++- public/locales/it.json | 3 ++- public/locales/ja.json | 3 ++- public/locales/pt.json | 3 ++- public/locales/ru.json | 3 ++- public/locales/sv.json | 3 ++- public/locales/tr.json | 3 ++- public/locales/uk.json | 3 ++- public/locales/zh.json | 3 ++- public/pages/budget.js | 43 ++++++++++++++++++++++++++++------------ public/pages/calendar.js | 41 ++++++++++++++++++++++++++------------ public/pages/contacts.js | 38 +++++++++++++++++++++++++---------- public/pages/meals.js | 32 ++++++++++++++++++++---------- public/pages/notes.js | 39 ++++++++++++++++++++++++++---------- public/pages/recipes.js | 34 ++++++++++++++++++------------- public/pages/shopping.js | 42 +++++++++++++++++++++++---------------- public/pages/tasks.js | 37 ++++++++++++++++++++++------------ 26 files changed, 242 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f12a628..97434b6 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.25.3] - 2026-04-26 + +### Changed +- Delete actions in all seven modules (tasks, notes, budget, calendar, contacts, meals, recipes) and shopping list deletion no longer show a confirmation dialog; instead the item is removed immediately and a toast with an Undo button gives a 5-second window to reverse the action before the API call is made + ## [0.25.2] - 2026-04-26 ### Changed diff --git a/package-lock.json b/package-lock.json index 910da4e..df2cd7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.25.2", + "version": "0.25.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.25.2", + "version": "0.25.3", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 5c06a9f..19eeda1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.25.2", + "version": "0.25.3", "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/ar.json b/public/locales/ar.json index b2144a0..f65615d 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "التقويم", diff --git a/public/locales/de.json b/public/locales/de.json index 9bdea2c..294f40a 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -264,7 +264,8 @@ "savedRecipeLabel": "Gespeichertes Rezept", "savedRecipePlaceholder": "Rezept auswählen", "saveAsRecipe": "Als Rezept speichern", - "recipeScaleLabel": "Zutaten skalieren" + "recipeScaleLabel": "Zutaten skalieren", + "deletedToast": "Mahlzeit gelöscht" }, "calendar": { "title": "Kalender", diff --git a/public/locales/el.json b/public/locales/el.json index 2ed0b72..bb5c421 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "Ημερολόγιο", diff --git a/public/locales/en.json b/public/locales/en.json index 76505d6..083a815 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "Calendar", diff --git a/public/locales/es.json b/public/locales/es.json index e774628..4720f89 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "Calendario", diff --git a/public/locales/fr.json b/public/locales/fr.json index d30fb93..3134b52 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "Calendrier", diff --git a/public/locales/hi.json b/public/locales/hi.json index b42c0e8..0753297 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "कैलेंडर", diff --git a/public/locales/it.json b/public/locales/it.json index c6c13db..a6cb3fd 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "Calendario", diff --git a/public/locales/ja.json b/public/locales/ja.json index 31d97f9..953ecb1 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "カレンダー", diff --git a/public/locales/pt.json b/public/locales/pt.json index ff5808c..62d88ff 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "Calendário", diff --git a/public/locales/ru.json b/public/locales/ru.json index 9e8b56e..c78b3c0 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "Календарь", diff --git a/public/locales/sv.json b/public/locales/sv.json index fdde958..c1082a4 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Sparat recept", "savedRecipePlaceholder": "Välj recept", "saveAsRecipe": "Spara som recept", - "recipeScaleLabel": "Skala ingredienser" + "recipeScaleLabel": "Skala ingredienser", + "deletedToast": "Meal deleted" }, "calendar": { "title": "Kalender", diff --git a/public/locales/tr.json b/public/locales/tr.json index 63da054..e5ee568 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "Takvim", diff --git a/public/locales/uk.json b/public/locales/uk.json index edc73b9..fe01e5e 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "Календар", diff --git a/public/locales/zh.json b/public/locales/zh.json index cf18b5b..e603cca 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -258,7 +258,8 @@ "savedRecipeLabel": "Saved recipe", "savedRecipePlaceholder": "Select recipe", "saveAsRecipe": "Save as recipe", - "recipeScaleLabel": "Scale ingredients" + "recipeScaleLabel": "Scale ingredients", + "deletedToast": "Meal deleted" }, "calendar": { "title": "日历", diff --git a/public/pages/budget.js b/public/pages/budget.js index df34311..bdc69cf 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -6,7 +6,7 @@ */ import { api } from '/api.js'; -import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate, getLocale } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -660,18 +660,35 @@ function openBudgetModal({ mode, entry = null }) { // -------------------------------------------------------- async function deleteEntry(id) { - if (!await confirmModal(t('budget.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; - try { - await api.delete(`/budget/${id}`); - state.entries = state.entries.filter((e) => e.id !== id); - const sumRes = await api.get(`/budget/summary?month=${state.month}`); - state.summary = sumRes.data; - renderBody(); - vibrate([30, 50, 30]); - window.oikos?.showToast(t('budget.deletedToast'), 'success'); - } catch (err) { - window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); - } + const entry = state.entries.find((e) => e.id === id); + state.entries = state.entries.filter((e) => e.id !== id); + renderBody(); + vibrate([30, 50, 30]); + + let undone = false; + window.oikos?.showToast(t('budget.deletedToast'), 'default', 5000, () => { + undone = true; + if (entry) { + state.entries = [...state.entries, entry].sort((a, b) => new Date(b.date) - new Date(a.date)); + renderBody(); + } + }); + + setTimeout(async () => { + if (undone) return; + try { + await api.delete(`/budget/${id}`); + const sumRes = await api.get(`/budget/summary?month=${state.month}`); + state.summary = sumRes.data; + renderBody(); + } catch (err) { + if (entry) { + state.entries = [...state.entries, entry].sort((a, b) => new Date(b.date) - new Date(a.date)); + renderBody(); + } + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger'); + } + }, 5000); } // -------------------------------------------------------- diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 0041f21..698c7ac 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -6,7 +6,7 @@ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; -import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; import { t, formatTime } from '/i18n.js'; import { esc, fmtLocation } from '/utils/html.js'; @@ -764,7 +764,6 @@ function showEventPopup(ev, anchor) { }); popup.querySelector('#popup-delete').addEventListener('click', async () => { - if (!await confirmModal(t('calendar.deleteConfirm', { title: ev.title }), { danger: true, confirmLabel: t('common.delete') })) return; popup.remove(); await deleteEvent(ev.id); }); @@ -868,7 +867,6 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { panel.querySelector('#modal-cancel').addEventListener('click', closeModal); panel.querySelector('#modal-delete')?.addEventListener('click', async () => { - if (!await confirmModal(t('calendar.deleteConfirm', { title: event.title }), { danger: true, confirmLabel: t('common.delete') })) return; closeModal(); await deleteEvent(event.id); }); @@ -1073,15 +1071,32 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) { } async function deleteEvent(id) { - try { - await api.delete(`/calendar/${id}`); - api.delete(`/reminders?entity_type=event&entity_id=${id}`).catch(() => {}); - refreshReminders(); - state.events = state.events.filter((e) => e.id !== id); - renderView(); - window.oikos?.showToast(t('calendar.deletedToast'), 'success'); - } catch (err) { - window.oikos?.showToast(err.data?.error ?? t('calendar.deleteError'), 'error'); - } + const event = state.events.find((e) => e.id === id); + state.events = state.events.filter((e) => e.id !== id); + renderView(); + + let undone = false; + window.oikos?.showToast(t('calendar.deletedToast'), 'default', 5000, () => { + undone = true; + if (event) { + state.events = [...state.events, event]; + renderView(); + } + }); + + setTimeout(async () => { + if (undone) return; + try { + await api.delete(`/calendar/${id}`); + api.delete(`/reminders?entity_type=event&entity_id=${id}`).catch(() => {}); + refreshReminders(); + } catch (err) { + if (event) { + state.events = [...state.events, event]; + renderView(); + } + window.oikos?.showToast(err.data?.error ?? t('calendar.deleteError'), 'danger'); + } + }, 5000); } diff --git a/public/pages/contacts.js b/public/pages/contacts.js index 4c78bde..e4551a3 100644 --- a/public/pages/contacts.js +++ b/public/pages/contacts.js @@ -5,7 +5,7 @@ */ import { api } from '/api.js'; -import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -350,16 +350,32 @@ function openContactModal({ mode, contact = null }) { } async function deleteContact(id) { - if (!await confirmModal(t('contacts.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; - try { - await api.delete(`/contacts/${id}`); - state.contacts = state.contacts.filter((c) => c.id !== id); - renderList(); - vibrate([30, 50, 30]); - window.oikos?.showToast(t('contacts.deletedToast'), 'success'); - } catch (err) { - window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); - } + const contact = state.contacts.find((c) => c.id === id); + state.contacts = state.contacts.filter((c) => c.id !== id); + renderList(); + vibrate([30, 50, 30]); + + let undone = false; + window.oikos?.showToast(t('contacts.deletedToast'), 'default', 5000, () => { + undone = true; + if (contact) { + state.contacts = [...state.contacts, contact].sort((a, b) => a.name.localeCompare(b.name)); + renderList(); + } + }); + + setTimeout(async () => { + if (undone) return; + try { + await api.delete(`/contacts/${id}`); + } catch (err) { + if (contact) { + state.contacts = [...state.contacts, contact].sort((a, b) => a.name.localeCompare(b.name)); + renderList(); + } + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger'); + } + }, 5000); } diff --git a/public/pages/meals.js b/public/pages/meals.js index 6b0e50f..0dbea21 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -5,7 +5,7 @@ */ import { api } from '/api.js'; -import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal, confirmModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal as closeSharedModal, selectModal } from '/components/modal.js'; import { stagger } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -920,15 +920,27 @@ function collectModalIngredients(overlay) { // -------------------------------------------------------- async function deleteMeal(mealId) { - if (!await confirmModal(t('meals.deleteMeal') + '?', { danger: true, confirmLabel: t('common.delete') })) return; - try { - await api.delete(`/meals/${mealId}`); - state.meals = state.meals.filter((m) => m.id !== mealId); - renderWeekGrid(); - window.oikos?.showToast(t('meals.deleteMeal'), 'success'); - } catch (err) { - window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error'); - } + const meal = state.meals.find((m) => m.id === mealId); + const itemEl = _container.querySelector(`.meal-slot--has-meal[data-meal-id="${mealId}"]`); + if (itemEl) itemEl.style.display = 'none'; + + let undone = false; + window.oikos?.showToast(t('meals.deletedToast'), 'default', 5000, () => { + undone = true; + if (itemEl) itemEl.style.display = ''; + }); + + setTimeout(async () => { + if (undone) return; + try { + await api.delete(`/meals/${mealId}`); + state.meals = state.meals.filter((m) => m.id !== mealId); + renderWeekGrid(); + } catch (err) { + if (itemEl) itemEl.style.display = ''; + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger'); + } + }, 5000); } // -------------------------------------------------------- diff --git a/public/pages/notes.js b/public/pages/notes.js index 5fe3c37..f04dc89 100644 --- a/public/pages/notes.js +++ b/public/pages/notes.js @@ -5,7 +5,7 @@ */ import { api } from '/api.js'; -import { openModal as openSharedModal, closeModal, btnError, confirmModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal, btnError } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -476,16 +476,33 @@ async function togglePin(id) { } async function deleteNote(id) { - if (!await confirmModal(t('notes.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; - try { - await api.delete(`/notes/${id}`); - state.notes = state.notes.filter((n) => n.id !== id); - renderGrid(); - vibrate([30, 50, 30]); - window.oikos?.showToast(t('notes.deletedToast'), 'success'); - } catch (err) { - window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); - } + closeModal(); + const note = state.notes.find((n) => n.id === id); + state.notes = state.notes.filter((n) => n.id !== id); + renderGrid(); + vibrate([30, 50, 30]); + + let undone = false; + window.oikos?.showToast(t('notes.deletedToast'), 'default', 5000, () => { + undone = true; + if (note) { + state.notes = [...state.notes, note].sort((a, b) => b.pinned - a.pinned); + renderGrid(); + } + }); + + setTimeout(async () => { + if (undone) return; + try { + await api.delete(`/notes/${id}`); + } catch (err) { + if (note) { + state.notes = [...state.notes, note].sort((a, b) => b.pinned - a.pinned); + renderGrid(); + } + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger'); + } + }, 5000); } // -------------------------------------------------------- diff --git a/public/pages/recipes.js b/public/pages/recipes.js index 48e85cd..8ead862 100644 --- a/public/pages/recipes.js +++ b/public/pages/recipes.js @@ -5,7 +5,7 @@ import { api } from '/api.js'; import { t } from '/i18n.js'; -import { openModal as openSharedModal, closeModal as closeSharedModal, confirmModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal as closeSharedModal } from '/components/modal.js'; import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js'; let _container = null; @@ -134,6 +134,7 @@ function renderRecipeList() { for (const recipe of state.recipes) { const card = document.createElement('article'); card.className = 'recipe-card'; + card.dataset.id = String(recipe.id); const h = document.createElement('h2'); h.className = 'recipe-card__title'; @@ -370,21 +371,26 @@ async function saveRecipe(panel, mode, recipe) { } async function removeRecipe(recipe) { - const ok = await confirmModal(t('recipes.deleteConfirm', { title: recipe.title }), { - danger: true, - confirmLabel: t('common.delete'), + const itemEl = _container.querySelector(`.recipe-card[data-id="${recipe.id}"]`); + if (itemEl) itemEl.style.display = 'none'; + + let undone = false; + window.oikos?.showToast(t('recipes.deleted'), 'default', 5000, () => { + undone = true; + if (itemEl) itemEl.style.display = ''; }); - if (!ok) return; - - try { - await api.delete(`/recipes/${recipe.id}`); - state.recipes = state.recipes.filter((r) => r.id !== recipe.id); - renderRecipeList(); - window.oikos?.showToast(t('recipes.deleted'), 'success'); - } catch (err) { - window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error'); - } + setTimeout(async () => { + if (undone) return; + try { + await api.delete(`/recipes/${recipe.id}`); + state.recipes = state.recipes.filter((r) => r.id !== recipe.id); + renderRecipeList(); + } catch (err) { + if (itemEl) itemEl.style.display = ''; + window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger'); + } + }, 5000); } async function duplicateRecipe(recipe) { diff --git a/public/pages/shopping.js b/public/pages/shopping.js index 4c7379b..5ca1b7d 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -8,7 +8,7 @@ import { api } from '/api.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t } from '/i18n.js'; import { esc } from '/utils/html.js'; -import { promptModal, confirmModal } from '/components/modal.js'; +import { promptModal } from '/components/modal.js'; import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js'; // -------------------------------------------------------- @@ -781,23 +781,31 @@ function wireListContentEvents(container) { // ---- Liste löschen ---- if (action === 'delete-list') { - if (!await confirmModal(t('shopping.deleteListConfirm', { name: state.activeList?.name }), { danger: true, confirmLabel: t('common.delete') })) return; - try { - await api.delete(`/shopping/${state.activeListId}`); - state.lists = state.lists.filter((l) => l.id !== state.activeListId); - state.activeListId = state.lists[0]?.id ?? null; - if (state.activeListId) { - await switchList(state.activeListId, container); - } else { - state.items = []; - state.activeList = null; - renderTabs(container); - renderListContent(container); + const deletedListId = state.activeListId; + + let undone = false; + window.oikos.showToast(t('shopping.deletedListToast'), 'default', 5000, () => { + undone = true; + }); + + setTimeout(async () => { + if (undone) return; + try { + await api.delete(`/shopping/${deletedListId}`); + state.lists = state.lists.filter((l) => l.id !== deletedListId); + state.activeListId = state.lists[0]?.id ?? null; + if (state.activeListId) { + await switchList(state.activeListId, container); + } else { + state.items = []; + state.activeList = null; + renderTabs(container); + renderListContent(container); + } + } catch (err) { + window.oikos.showToast(err.message ?? t('common.unknownError'), 'danger'); } - window.oikos.showToast(t('shopping.deletedListToast')); - } catch (err) { - window.oikos.showToast(err.message, 'danger'); - } + }, 5000); } }); diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 91ce4ec..660d121 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -6,7 +6,7 @@ import { api } from '/api.js'; import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.js'; -import { openModal as openSharedModal, closeModal, wireBlurValidation, validateAll, btnSuccess, btnError, promptModal, confirmModal } from '/components/modal.js'; +import { openModal as openSharedModal, closeModal, wireBlurValidation, validateAll, btnSuccess, btnError, promptModal } from '/components/modal.js'; import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate, formatTime } from '/i18n.js'; import { esc } from '/utils/html.js'; @@ -582,18 +582,29 @@ async function handleFormSubmit(e, container) { } async function handleDeleteTask(id, container) { - if (!await confirmModal(t('tasks.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; - try { - await api.delete(`/tasks/${id}`); - // Erinnerungen für diese Aufgabe ebenfalls entfernen - api.delete(`/reminders?entity_type=task&entity_id=${id}`).catch(() => {}); - refreshReminders(); - closeModal(); - window.oikos.showToast(t('tasks.deletedToast'), 'default'); - await loadTasks(container); - } catch (err) { - window.oikos.showToast(err.message, 'danger'); - } + closeModal(); + const itemEl = container.querySelector(`[data-task-id="${id}"]`); + if (itemEl) itemEl.style.display = 'none'; + + let undone = false; + window.oikos.showToast(t('tasks.deletedToast'), 'default', 5000, () => { + undone = true; + if (itemEl) itemEl.style.display = ''; + }); + + setTimeout(async () => { + if (undone) return; + try { + await api.delete(`/tasks/${id}`); + // Erinnerungen für diese Aufgabe ebenfalls entfernen + api.delete(`/reminders?entity_type=task&entity_id=${id}`).catch(() => {}); + refreshReminders(); + await loadTasks(container); + } catch (err) { + if (itemEl) itemEl.style.display = ''; + window.oikos.showToast(err.message ?? t('common.unknownError'), 'danger'); + } + }, 5000); } async function handleAddSubtask(parentId, container) { From 28fe41962f55f516ce51514ba9fa57cff8ac54a4 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Sun, 26 Apr 2026 18:30:27 +0200 Subject: [PATCH 04/25] fix(shopping): fix undo callback for list deletion Replace local state mutation after list deletion with loadLists() so the tab bar stays in sync with the server. Also add a renderTabs() call in the error path so the UI recovers correctly on API failure. Co-Authored-By: Claude Sonnet 4.6 --- public/pages/shopping.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/public/pages/shopping.js b/public/pages/shopping.js index 5ca1b7d..5f05255 100644 --- a/public/pages/shopping.js +++ b/public/pages/shopping.js @@ -786,13 +786,14 @@ function wireListContentEvents(container) { let undone = false; window.oikos.showToast(t('shopping.deletedListToast'), 'default', 5000, () => { undone = true; + // Liste wurde nie optimistisch ausgeblendet → kein visuelles Restore nötig }); setTimeout(async () => { if (undone) return; try { await api.delete(`/shopping/${deletedListId}`); - state.lists = state.lists.filter((l) => l.id !== deletedListId); + await loadLists(); state.activeListId = state.lists[0]?.id ?? null; if (state.activeListId) { await switchList(state.activeListId, container); @@ -804,6 +805,8 @@ function wireListContentEvents(container) { } } catch (err) { window.oikos.showToast(err.message ?? t('common.unknownError'), 'danger'); + await loadLists(); + renderTabs(container); } }, 5000); } From 798f8ca87a234d768c6588c4e638b88b34a101db Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Sun, 26 Apr 2026 18:53:35 +0200 Subject: [PATCH 05/25] feat(login): field-specific validation errors instead of generic message Co-Authored-By: Claude Sonnet 4.6 --- public/pages/login.js | 18 +++++++++++++++++- public/styles/login.css | 10 ++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/public/pages/login.js b/public/pages/login.js index 8950593..9f72f54 100644 --- a/public/pages/login.js +++ b/public/pages/login.js @@ -79,8 +79,17 @@ export async function render(container) { const username = form.username.value.trim(); const password = form.password.value; + const usernameInput = form.querySelector('#username'); + const passwordInput = form.querySelector('#password'); + const usernameGroup = usernameInput.closest('.form-group'); + const passwordGroup = passwordInput.closest('.form-group'); + + usernameGroup.classList.toggle('form-group--error', !username); + passwordGroup.classList.toggle('form-group--error', !password); + if (!username || !password) { - showError(errorEl, t('common.allFieldsRequired')); + if (!username) usernameInput.focus(); + else passwordInput.focus(); return; } @@ -107,6 +116,13 @@ export async function render(container) { spinner.remove(); } }); + + form.querySelector('#username').addEventListener('input', () => { + form.querySelector('#username').closest('.form-group').classList.remove('form-group--error'); + }); + form.querySelector('#password').addEventListener('input', () => { + form.querySelector('#password').closest('.form-group').classList.remove('form-group--error'); + }); } function showError(el, message) { diff --git a/public/styles/login.css b/public/styles/login.css index 13775ae..2f2c91d 100644 --- a/public/styles/login.css +++ b/public/styles/login.css @@ -68,6 +68,16 @@ opacity: 0.6; } +/* Feld-Fehler-Zustand */ +.form-group--error .input { + border-color: var(--color-danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 20%, transparent); +} + +.form-group--error .label { + color: var(--color-danger); +} + .login-spinner { width: 16px; height: 16px; From ed0f8b2d571d3fcdc8dd95e5adebafb82b9ce675 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Sun, 26 Apr 2026 19:03:38 +0200 Subject: [PATCH 06/25] feat(modal): warn before closing with unsaved changes --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- public/components/modal.js | 41 ++++++++++++++++++++++++++++++++++++-- public/locales/ar.json | 4 +++- public/locales/de.json | 4 +++- public/locales/el.json | 4 +++- public/locales/en.json | 4 +++- public/locales/es.json | 4 +++- public/locales/fr.json | 4 +++- public/locales/hi.json | 4 +++- public/locales/it.json | 4 +++- public/locales/ja.json | 4 +++- public/locales/pt.json | 4 +++- public/locales/ru.json | 4 +++- public/locales/sv.json | 4 +++- public/locales/tr.json | 4 +++- public/locales/uk.json | 4 +++- public/locales/zh.json | 4 +++- public/pages/budget.js | 4 ++-- public/pages/calendar.js | 4 ++-- public/pages/contacts.js | 4 ++-- public/pages/dashboard.js | 6 +++--- public/pages/meals.js | 8 ++++---- public/pages/notes.js | 4 ++-- public/pages/recipes.js | 6 +++--- public/pages/tasks.js | 4 ++-- 27 files changed, 112 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97434b6..085a139 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.25.4] - 2026-04-26 + +### Added +- Modal: closing a modal (via Escape, swipe, overlay click, or X button) now shows a "Discard changes?" confirmation dialog when the form has been modified since it was opened; saving or deleting bypasses the prompt + ## [0.25.3] - 2026-04-26 ### Changed diff --git a/package-lock.json b/package-lock.json index df2cd7e..a021b8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.25.3", + "version": "0.25.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.25.3", + "version": "0.25.4", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 19eeda1..1acf5f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.25.3", + "version": "0.25.4", "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/components/modal.js b/public/components/modal.js index 6b267b2..31f723f 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -16,6 +16,7 @@ import { t } from '/i18n.js'; let activeOverlay = null; let previouslyFocused = null; let focusTrapHandler = null; +let _initialFormSnapshot = null; // Overlay-Dimming: theme-color abdunkeln im Standalone-Modus const OVERLAY_THEME_COLOR = '#1A1A1A'; @@ -98,6 +99,20 @@ function trapFocus(container) { } } +// -------------------------------------------------------- +// Dirty-Check Helpers +// -------------------------------------------------------- + +function serializeForm(container) { + const inputs = container.querySelectorAll('input, select, textarea'); + return Array.from(inputs).map((el) => `${el.name || el.id}=${el.value}`).join('&'); +} + +function isFormDirty(container) { + if (!_initialFormSnapshot) return false; + return serializeForm(container) !== _initialFormSnapshot; +} + // -------------------------------------------------------- // Escape-Handler // -------------------------------------------------------- @@ -204,9 +219,10 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} // ID sofort entfernen damit getElementById() nach dem Einfügen des neuen Modals // nicht die noch animierende alte Instanz zurückgibt – sonst landen alle // Event-Listener am falschen Element und Buttons reagieren nicht. + // force=true: kein Dirty-Check beim programmatischen Ersetzen (z.B. confirmModal öffnet sich). if (activeOverlay) { activeOverlay.removeAttribute('id'); - closeModal(); + closeModal({ force: true }); } // Focus-Restore vorbereiten @@ -243,6 +259,14 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} const panel = activeOverlay.querySelector('.modal-panel'); trapFocus(panel); + // Snapshot für Dirty-Check (kurzer Delay: Felder könnten noch per JS befüllt werden) + _initialFormSnapshot = null; + setTimeout(() => { + if (activeOverlay) { + _initialFormSnapshot = serializeForm(activeOverlay.querySelector('.modal-panel') ?? activeOverlay); + } + }, 150); + // Swipe-to-Close auf Mobile if (window.innerWidth < 768) { _wireSheetSwipe(panel); @@ -279,9 +303,22 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} // closeModal // -------------------------------------------------------- -export function closeModal() { +export async function closeModal({ force = false } = {}) { if (!activeOverlay) return; + if (!force) { + const panel = activeOverlay.querySelector('.modal-panel'); + if (panel && isFormDirty(panel)) { + const confirmed = await confirmModal(t('modal.unsavedChanges'), { + danger: false, + confirmLabel: t('modal.discardChanges'), + }); + if (!confirmed) return; + } + } + + _initialFormSnapshot = null; + document.removeEventListener('keydown', onEscape); // Overlay sofort sichern: Bei Mobile-Animation öffnet openModal() ein neues Modal diff --git a/public/locales/ar.json b/public/locales/ar.json index f65615d..b178083 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "إغلاق", - "overlayLabel": "خلفية مربع الحوار" + "overlayLabel": "خلفية مربع الحوار", + "unsavedChanges": "تجاهل التغييرات؟", + "discardChanges": "تجاهل" }, "rrule": { "freqNone": "بدون تكرار", diff --git a/public/locales/de.json b/public/locales/de.json index 294f40a..72888a0 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -709,7 +709,9 @@ }, "modal": { "closeLabel": "Schließen", - "overlayLabel": "Modaler Dialog-Hintergrund" + "overlayLabel": "Modaler Dialog-Hintergrund", + "unsavedChanges": "Änderungen verwerfen?", + "discardChanges": "Verwerfen" }, "rrule": { "freqNone": "Keine Wiederholung", diff --git a/public/locales/el.json b/public/locales/el.json index bb5c421..8c5924c 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Κλείσιμο", - "overlayLabel": "Φόντο αναδυόμενου παραθύρου" + "overlayLabel": "Φόντο αναδυόμενου παραθύρου", + "unsavedChanges": "Απόρριψη αλλαγών;", + "discardChanges": "Απόρριψη" }, "rrule": { "freqNone": "Χωρίς επανάληψη", diff --git a/public/locales/en.json b/public/locales/en.json index 083a815..569a1ad 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Close", - "overlayLabel": "Modal dialog background" + "overlayLabel": "Modal dialog background", + "unsavedChanges": "Discard changes?", + "discardChanges": "Discard" }, "rrule": { "freqNone": "No recurrence", diff --git a/public/locales/es.json b/public/locales/es.json index 4720f89..6db084f 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Cerrar", - "overlayLabel": "Fondo del cuadro de diálogo modal" + "overlayLabel": "Fondo del cuadro de diálogo modal", + "unsavedChanges": "¿Descartar cambios?", + "discardChanges": "Descartar" }, "rrule": { "freqNone": "Sin repetición", diff --git a/public/locales/fr.json b/public/locales/fr.json index 3134b52..0fc627e 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Fermer", - "overlayLabel": "Arrière-plan de la boîte de dialogue modale" + "overlayLabel": "Arrière-plan de la boîte de dialogue modale", + "unsavedChanges": "Abandonner les modifications ?", + "discardChanges": "Abandonner" }, "rrule": { "freqNone": "Pas de répétition", diff --git a/public/locales/hi.json b/public/locales/hi.json index 0753297..2ab1479 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "बंद करें", - "overlayLabel": "मोडल डायलॉग पृष्ठभूमि" + "overlayLabel": "मोडल डायलॉग पृष्ठभूमि", + "unsavedChanges": "बदलाव छोड़ें?", + "discardChanges": "छोड़ें" }, "rrule": { "freqNone": "कोई दोहराव नहीं", diff --git a/public/locales/it.json b/public/locales/it.json index a6cb3fd..fe7fa3a 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Chiudi", - "overlayLabel": "Sfondo del dialogo modale" + "overlayLabel": "Sfondo del dialogo modale", + "unsavedChanges": "Annullare le modifiche?", + "discardChanges": "Annulla" }, "rrule": { "freqNone": "Nessuna ripetizione", diff --git a/public/locales/ja.json b/public/locales/ja.json index 953ecb1..31a91b1 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "閉じる", - "overlayLabel": "モーダルダイアログの背景" + "overlayLabel": "モーダルダイアログの背景", + "unsavedChanges": "変更を破棄しますか?", + "discardChanges": "破棄" }, "rrule": { "freqNone": "繰り返しなし", diff --git a/public/locales/pt.json b/public/locales/pt.json index 62d88ff..d2e766e 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Fechar", - "overlayLabel": "Fundo do diálogo modal" + "overlayLabel": "Fundo do diálogo modal", + "unsavedChanges": "Descartar alterações?", + "discardChanges": "Descartar" }, "rrule": { "freqNone": "Sem repetição", diff --git a/public/locales/ru.json b/public/locales/ru.json index c78b3c0..6aabd07 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Закрыть", - "overlayLabel": "Фон модального диалога" + "overlayLabel": "Фон модального диалога", + "unsavedChanges": "Отменить изменения?", + "discardChanges": "Отменить" }, "rrule": { "freqNone": "Без повтора", diff --git a/public/locales/sv.json b/public/locales/sv.json index c1082a4..0ced812 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Stäng", - "overlayLabel": "Bakgrund för modal dialog" + "overlayLabel": "Bakgrund för modal dialog", + "unsavedChanges": "Ignorera ändringar?", + "discardChanges": "Ignorera" }, "rrule": { "freqNone": "Ingen upprepning", diff --git a/public/locales/tr.json b/public/locales/tr.json index e5ee568..8aecf1e 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Kapat", - "overlayLabel": "Modal iletişim kutusu arka planı" + "overlayLabel": "Modal iletişim kutusu arka planı", + "unsavedChanges": "Değişiklikler iptal edilsin mi?", + "discardChanges": "İptal et" }, "rrule": { "freqNone": "Tekrar yok", diff --git a/public/locales/uk.json b/public/locales/uk.json index fe01e5e..3e2efb8 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "Закрити", - "overlayLabel": "Фон модального вікна" + "overlayLabel": "Фон модального вікна", + "unsavedChanges": "Скасувати зміни?", + "discardChanges": "Скасувати" }, "rrule": { "freqNone": "Без повторення", diff --git a/public/locales/zh.json b/public/locales/zh.json index e603cca..87e8039 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -703,7 +703,9 @@ }, "modal": { "closeLabel": "关闭", - "overlayLabel": "模态对话框背景" + "overlayLabel": "模态对话框背景", + "unsavedChanges": "放弃更改?", + "discardChanges": "放弃" }, "rrule": { "freqNone": "不重复", diff --git a/public/pages/budget.js b/public/pages/budget.js index bdc69cf..b6c46dc 100644 --- a/public/pages/budget.js +++ b/public/pages/budget.js @@ -607,7 +607,7 @@ function openBudgetModal({ mode, entry = null }) { panel.querySelector('#bm-cancel').addEventListener('click', closeModal); panel.querySelector('#bm-delete')?.addEventListener('click', async () => { - closeModal(); + closeModal({ force: true }); await deleteEntry(entry.id); }); @@ -642,7 +642,7 @@ function openBudgetModal({ mode, entry = null }) { const sumRes = await api.get(`/budget/summary?month=${state.month}`); state.summary = sumRes.data; - closeModal(); + closeModal({ force: true }); renderBody(); window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success'); } catch (err) { diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 698c7ac..24ddb05 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -867,7 +867,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { panel.querySelector('#modal-cancel').addEventListener('click', closeModal); panel.querySelector('#modal-delete')?.addEventListener('click', async () => { - closeModal(); + closeModal({ force: true }); await deleteEvent(event.id); }); @@ -1060,7 +1060,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) { } } - closeModal(); + closeModal({ force: true }); renderView(); window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success'); } catch (err) { diff --git a/public/pages/contacts.js b/public/pages/contacts.js index e4551a3..79f50b9 100644 --- a/public/pages/contacts.js +++ b/public/pages/contacts.js @@ -304,7 +304,7 @@ function openContactModal({ mode, contact = null }) { panel.querySelector('#cm-cancel').addEventListener('click', closeModal); panel.querySelector('#cm-delete')?.addEventListener('click', async () => { - closeModal(); + closeModal({ force: true }); await deleteContact(contact.id); }); @@ -336,7 +336,7 @@ function openContactModal({ mode, contact = null }) { const idx = state.contacts.findIndex((c) => c.id === contact.id); if (idx !== -1) state.contacts[idx] = res.data; } - closeModal(); + closeModal({ force: true }); renderList(); window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success'); } catch (err) { diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index 5a706b9..68c42ff 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -637,7 +637,7 @@ function openCustomizeModal(currentConfig, onSave) { saveBtn.disabled = true; try { await api.put('/preferences', { dashboard_widgets: draft }); - closeModal(); + closeModal({ force: true }); onSave(draft); window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500); } catch { @@ -674,7 +674,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) { panel.querySelector('[data-action="done"]').addEventListener('click', async () => { try { await api.patch(`/tasks/${taskId}/status`, { status: 'done' }); - closeModal(); + closeModal({ force: true }); window.oikos?.showToast(t('tasks.swipedDoneToast'), 'success'); rerender(); } catch (err) { @@ -682,7 +682,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) { } }); panel.querySelector('[data-action="edit"]').addEventListener('click', () => { - closeModal(); + closeModal({ force: true }); window.oikos.navigate(`/tasks?open=${taskId}`); }); }, diff --git a/public/pages/meals.js b/public/pages/meals.js index 0dbea21..28ec46a 100644 --- a/public/pages/meals.js +++ b/public/pages/meals.js @@ -707,7 +707,7 @@ function openMealModal(opts) { if (res.data.transferred > 0) { window.oikos?.showToast(res.data.transferred !== 1 ? t('meals.transferSuccessPlural', { count: res.data.transferred }) : t('meals.transferSuccess', { count: res.data.transferred }), 'success'); await loadWeek(state.currentWeek); - closeModal(); + closeModal({ force: true }); renderWeekGrid(); } else { window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info'); @@ -843,8 +843,8 @@ function ingredientRowHTML(name, qty, id, category = DEFAULT_CATEGORY_NAME) { `; } -function closeModal() { - closeSharedModal(); +function closeModal({ force = false } = {}) { + closeSharedModal({ force }); state.modal = null; } @@ -894,7 +894,7 @@ async function saveModal(overlay) { await loadWeek(state.currentWeek); } - closeModal(); + closeModal({ force: true }); renderWeekGrid(); window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success'); } catch (err) { diff --git a/public/pages/notes.js b/public/pages/notes.js index f04dc89..c19e9ec 100644 --- a/public/pages/notes.js +++ b/public/pages/notes.js @@ -445,7 +445,7 @@ function openNoteModal({ mode, note = null }) { if (idx !== -1) state.notes[idx] = res.data; state.notes.sort((a, b) => b.pinned - a.pinned); } - closeModal(); + closeModal({ force: true }); renderGrid(); window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success'); } catch (err) { @@ -476,7 +476,7 @@ async function togglePin(id) { } async function deleteNote(id) { - closeModal(); + closeModal({ force: true }); const note = state.notes.find((n) => n.id === id); state.notes = state.notes.filter((n) => n.id !== id); renderGrid(); diff --git a/public/pages/recipes.js b/public/pages/recipes.js index 8ead862..04b2a8f 100644 --- a/public/pages/recipes.js +++ b/public/pages/recipes.js @@ -326,8 +326,8 @@ function openRecipeModal(mode, recipe = null) { }); } -function closeModal() { - closeSharedModal(); +function closeModal({ force = false } = {}) { + closeSharedModal({ force }); } async function saveRecipe(panel, mode, recipe) { @@ -361,7 +361,7 @@ async function saveRecipe(panel, mode, recipe) { if (idx >= 0) state.recipes[idx] = res.data; } - closeModal(); + closeModal({ force: true }); renderRecipeList(); window.oikos?.showToast(mode === 'create' ? t('recipes.created') : t('recipes.updated'), 'success'); } catch (err) { diff --git a/public/pages/tasks.js b/public/pages/tasks.js index 660d121..bbd079f 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -570,7 +570,7 @@ async function handleFormSubmit(e, container) { } btnSuccess(submitBtn, originalLabel); - setTimeout(() => closeModal(), 700); + setTimeout(() => closeModal({ force: true }), 700); await loadTasks(container); } catch (err) { errorEl.textContent = err.message; @@ -582,7 +582,7 @@ async function handleFormSubmit(e, container) { } async function handleDeleteTask(id, container) { - closeModal(); + closeModal({ force: true }); const itemEl = container.querySelector(`[data-task-id="${id}"]`); if (itemEl) itemEl.style.display = 'none'; From 93ac635835cfa61cabb20f1cd94ecc6bb0ce9706 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Sun, 26 Apr 2026 23:13:55 +0200 Subject: [PATCH 07/25] feat(nav): show active secondary module name on More button When navigating to a secondary module (Budget, Recipes, Contacts, Settings), the More button now displays the module's label and icon instead of "More". This provides clearer navigation feedback to the user. Co-Authored-By: Claude Sonnet 4.6 --- public/router.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/public/router.js b/public/router.js index 865b5b7..60fa990 100644 --- a/public/router.js +++ b/public/router.js @@ -706,9 +706,22 @@ function updateNav(path) { const moreBtn = document.querySelector('#more-btn'); if (moreBtn) { - const inMoreSheet = navItems().slice(PRIMARY_NAV).some((n) => n.path === path); + const secondaryItems = navItems().slice(PRIMARY_NAV); + const activeSecondary = secondaryItems.find((n) => n.path === path); + const inMoreSheet = !!activeSecondary; + moreBtn.classList.toggle('nav-item--active', inMoreSheet); moreBtn.toggleAttribute('aria-current', inMoreSheet); + + const moreBtnLabel = moreBtn.querySelector('.nav-item__label'); + const moreBtnIcon = moreBtn.querySelector('.nav-item__icon'); + + if (moreBtnLabel) { + moreBtnLabel.textContent = activeSecondary ? activeSecondary.label : t('nav.more'); + } + if (moreBtnIcon) { + moreBtnIcon.dataset.lucide = activeSecondary ? activeSecondary.icon : 'grid-2x2'; + } } if (window.lucide) { From 6cf6b9bec0ae0249a993189f82780b463c1a3943 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Sun, 26 Apr 2026 23:29:57 +0200 Subject: [PATCH 08/25] feat(ux): first-time onboarding overlay for new users Shows a 3-screen modal overlay on first dashboard visit explaining key features; dismissed state is persisted to localStorage so it never reappears. Co-Authored-By: Claude Sonnet 4.6 --- public/locales/ar.json | 11 +++++ public/locales/de.json | 11 +++++ public/locales/el.json | 11 +++++ public/locales/en.json | 11 +++++ public/locales/es.json | 11 +++++ public/locales/fr.json | 11 +++++ public/locales/hi.json | 11 +++++ public/locales/it.json | 11 +++++ public/locales/ja.json | 11 +++++ public/locales/pt.json | 11 +++++ public/locales/ru.json | 11 +++++ public/locales/sv.json | 11 +++++ public/locales/tr.json | 11 +++++ public/locales/uk.json | 11 +++++ public/locales/zh.json | 11 +++++ public/pages/dashboard.js | 98 +++++++++++++++++++++++++++++++++++++ public/styles/dashboard.css | 95 +++++++++++++++++++++++++++++++++++ 17 files changed, 358 insertions(+) diff --git a/public/locales/ar.json b/public/locales/ar.json index b178083..2ddfa8c 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -780,5 +780,16 @@ "notificationEnabled": "الإشعارات نشطة", "notificationDenied": "الإشعارات محظورة", "notificationHint": "احصل على إشعارات حتى عندما يكون التطبيق مفتوحًا." + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/de.json b/public/locales/de.json index 72888a0..aba3243 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -780,5 +780,16 @@ "duplicate": "Duplizieren", "duplicated": "Rezept dupliziert.", "copySuffix": "Kopie" + }, + "onboarding": { + "step1Title": "Willkommen bei Oikos", + "step1Body": "Dein persönlicher Familienplaner. Aufgaben, Kalender, Einkauf und mehr – alles an einem Ort.", + "step2Title": "Alles im Blick", + "step2Body": "Über die Navigation unten erreichst du alle Module. Mit dem +-Button erstellst du schnell neue Einträge.", + "step3Title": "Bereit loszulegen", + "step3Body": "Das Dashboard zeigt dir die wichtigsten Infos auf einen Blick. Du kannst es unter \"Anpassen\" nach deinen Wünschen einrichten.", + "next": "Weiter", + "done": "Loslegen", + "skip": "Überspringen" } } diff --git a/public/locales/el.json b/public/locales/el.json index 8c5924c..f9535b0 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -780,5 +780,16 @@ "notificationEnabled": "Ειδοποιήσεις ενεργές", "notificationDenied": "Ειδοποιήσεις αποκλεισμένες", "notificationHint": "Λάβετε ειδοποιήσεις ακόμα και όταν η εφαρμογή είναι ανοιχτή." + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/en.json b/public/locales/en.json index 569a1ad..cbed02d 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -780,5 +780,16 @@ "open": "Open search", "placeholder": "Search…", "noResults": "No results found." + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/es.json b/public/locales/es.json index 6db084f..4fdd6da 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -780,5 +780,16 @@ "notificationEnabled": "Notificaciones activas", "notificationDenied": "Notificaciones bloqueadas", "notificationHint": "Recibe notificaciones incluso cuando la aplicación está abierta." + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/fr.json b/public/locales/fr.json index 0fc627e..232f203 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -780,5 +780,16 @@ "notificationEnabled": "Notifications actives", "notificationDenied": "Notifications bloquées", "notificationHint": "Recevez des notifications même lorsque l'application est ouverte." + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/hi.json b/public/locales/hi.json index 2ab1479..1d7e695 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -780,5 +780,16 @@ "notificationEnabled": "सूचनाएं सक्रिय", "notificationDenied": "सूचनाएं अवरुद्ध", "notificationHint": "ऐप खुली होने पर भी सूचनाएं प्राप्त करें।" + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/it.json b/public/locales/it.json index fe7fa3a..802f018 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -780,5 +780,16 @@ "notificationEnabled": "Notifiche attive", "notificationDenied": "Notifiche bloccate", "notificationHint": "Ricevi notifiche anche quando l'app è aperta." + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/ja.json b/public/locales/ja.json index 31a91b1..29d0cbe 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -780,5 +780,16 @@ "notificationEnabled": "通知が有効", "notificationDenied": "通知がブロックされています", "notificationHint": "アプリが開いているときでも通知を受け取ります。" + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/pt.json b/public/locales/pt.json index d2e766e..d8be85c 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -780,5 +780,16 @@ "notificationEnabled": "Notificações ativas", "notificationDenied": "Notificações bloqueadas", "notificationHint": "Receba notificações mesmo quando a aplicação está aberta." + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/ru.json b/public/locales/ru.json index 6aabd07..44720cd 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -780,5 +780,16 @@ "notificationEnabled": "Уведомления активны", "notificationDenied": "Уведомления заблокированы", "notificationHint": "Получайте уведомления, даже когда приложение открыто." + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/sv.json b/public/locales/sv.json index 0ced812..ff89400 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -780,5 +780,16 @@ "notificationEnabled": "Notiser aktiva", "notificationDenied": "Notiser blockerade", "notificationHint": "Få notiser även när appen är öppen." + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/tr.json b/public/locales/tr.json index 8aecf1e..dfdebe8 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -780,5 +780,16 @@ "notificationEnabled": "Bildirimler etkin", "notificationDenied": "Bildirimler engellendi", "notificationHint": "Uygulama açıkken bile bildirim alın." + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/uk.json b/public/locales/uk.json index 3e2efb8..1b20ef2 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -780,5 +780,16 @@ "open": "Відкрити пошук", "placeholder": "Пошук…", "noResults": "Результатів не знайдено." + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/locales/zh.json b/public/locales/zh.json index 87e8039..22c3361 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -780,5 +780,16 @@ "notificationEnabled": "通知已启用", "notificationDenied": "通知已被阻止", "notificationHint": "即使应用程序打开时也能收到通知。" + }, + "onboarding": { + "step1Title": "Welcome to Oikos", + "step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.", + "step2Title": "Everything at a glance", + "step2Body": "Use the navigation below to reach all modules. The + button creates new entries quickly.", + "step3Title": "Ready to go", + "step3Body": "The dashboard shows you the most important information at a glance. Customize it under \"Customize\".", + "next": "Next", + "done": "Get started", + "skip": "Skip" } } diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index 68c42ff..ebaa8dd 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -12,6 +12,100 @@ import { openModal, closeModal } from '/components/modal.js'; // Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert. let _fabController = null; +// ── Onboarding ────────────────────────────────────────────────────────────── + +const ONBOARDING_KEY = 'oikos-onboarded'; + +function getOnboardingSteps() { + return [ + { icon: 'home', title: t('onboarding.step1Title'), body: t('onboarding.step1Body') }, + { icon: 'grid-2x2', title: t('onboarding.step2Title'), body: t('onboarding.step2Body') }, + { icon: 'circle-check', title: t('onboarding.step3Title'), body: t('onboarding.step3Body') }, + ]; +} + +function showOnboarding(appContainer) { + const steps = getOnboardingSteps(); + let current = 0; + + const overlay = document.createElement('div'); + overlay.className = 'onboarding-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + + function renderStep() { + const step = steps[current]; + const isLast = current === steps.length - 1; + overlay.replaceChildren(); + + const card = document.createElement('div'); + card.className = 'onboarding-card'; + + const icon = document.createElement('i'); + icon.dataset.lucide = step.icon; + icon.className = 'onboarding-icon'; + icon.setAttribute('aria-hidden', 'true'); + + const title = document.createElement('h2'); + title.className = 'onboarding-title'; + title.textContent = step.title; + + const body = document.createElement('p'); + body.className = 'onboarding-body'; + body.textContent = step.body; + + const dots = document.createElement('div'); + dots.className = 'onboarding-dots'; + steps.forEach((_, i) => { + const dot = document.createElement('span'); + dot.className = `onboarding-dot${i === current ? ' onboarding-dot--active' : ''}`; + dots.appendChild(dot); + }); + + const actions = document.createElement('div'); + actions.className = 'onboarding-actions'; + + const skipBtn = document.createElement('button'); + skipBtn.className = 'btn btn--ghost'; + skipBtn.textContent = t('onboarding.skip'); + skipBtn.addEventListener('click', finish); + + const nextBtn = document.createElement('button'); + nextBtn.className = 'btn btn--primary'; + nextBtn.textContent = isLast ? t('onboarding.done') : t('onboarding.next'); + nextBtn.addEventListener('click', () => { + if (isLast) { finish(); return; } + current++; + renderStep(); + if (window.lucide) window.lucide.createIcons({ el: overlay }); + nextBtn.focus(); + }); + + actions.appendChild(skipBtn); + actions.appendChild(nextBtn); + card.appendChild(icon); + card.appendChild(title); + card.appendChild(body); + card.appendChild(dots); + card.appendChild(actions); + overlay.appendChild(card); + + if (window.lucide) window.lucide.createIcons({ el: overlay }); + setTimeout(() => nextBtn.focus(), 50); + } + + function finish() { + localStorage.setItem(ONBOARDING_KEY, '1'); + overlay.classList.add('onboarding-overlay--out'); + overlay.addEventListener('animationend', () => overlay.remove(), { once: true }); + // Fallback falls animationend nicht feuert (prefers-reduced-motion): + setTimeout(() => overlay.remove(), 300); + } + + renderStep(); + appContainer.appendChild(overlay); +} + // -------------------------------------------------------- // Widget-Definitionen (Reihenfolge = Standard-Layout) // -------------------------------------------------------- @@ -823,6 +917,10 @@ export async function render(container, { user }) { const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000); _fabController.signal.addEventListener('abort', () => clearInterval(timerId)); } + + if (!localStorage.getItem(ONBOARDING_KEY)) { + setTimeout(() => showOnboarding(container), 400); + } } function wireWeatherRefresh(container) { diff --git a/public/styles/dashboard.css b/public/styles/dashboard.css index b6edb8e..086164f 100644 --- a/public/styles/dashboard.css +++ b/public/styles/dashboard.css @@ -1220,3 +1220,98 @@ opacity: 0.3; cursor: not-allowed; } + +/* ── Onboarding Overlay ── */ +.onboarding-overlay { + position: fixed; + inset: 0; + z-index: var(--z-modal); + background: color-mix(in srgb, var(--color-bg) 70%, transparent); + backdrop-filter: var(--blur-lg); + -webkit-backdrop-filter: var(--blur-lg); + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-6); + animation: onboarding-in 0.3s var(--ease-out); +} + +.onboarding-overlay--out { + animation: onboarding-out 0.25s ease forwards; +} + +@keyframes onboarding-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes onboarding-out { + from { opacity: 1; } + to { opacity: 0; } +} + +.onboarding-card { + background: var(--color-surface); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-xl); + padding: var(--space-8); + max-width: 360px; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-4); + text-align: center; + box-shadow: var(--shadow-lg); +} + +.onboarding-icon { + width: 48px; + height: 48px; + color: var(--color-accent); +} + +.onboarding-title { + font-size: var(--text-xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text); + margin: 0; +} + +.onboarding-body { + font-size: var(--text-base); + color: var(--color-text-secondary); + line-height: 1.6; + margin: 0; +} + +.onboarding-dots { + display: flex; + gap: var(--space-2); +} + +.onboarding-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-border); + transition: background 0.2s; +} + +.onboarding-dot--active { + background: var(--color-accent); +} + +.onboarding-actions { + display: flex; + gap: var(--space-3); + width: 100%; + justify-content: flex-end; + margin-top: var(--space-2); +} + +@media (prefers-reduced-motion: reduce) { + .onboarding-overlay, + .onboarding-overlay--out { animation: none; } + .onboarding-dot { transition: none; } +} From 0ff6bbb3f1669c646f331c8932d59f4d13ee5f02 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Sun, 26 Apr 2026 23:33:05 +0200 Subject: [PATCH 09/25] fix(modal): add _isClosing guard and async-safe closeModal listener Co-Authored-By: Claude Sonnet 4.6 --- public/components/modal.js | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/public/components/modal.js b/public/components/modal.js index 31f723f..bbd7d66 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -17,6 +17,7 @@ let activeOverlay = null; let previouslyFocused = null; let focusTrapHandler = null; let _initialFormSnapshot = null; +let _isClosing = false; // Overlay-Dimming: theme-color abdunkeln im Standalone-Modus const OVERLAY_THEME_COLOR = '#1A1A1A'; @@ -285,7 +286,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} // Close-Button activeOverlay.querySelector('[data-action="close-modal"]') - ?.addEventListener('click', closeModal); + ?.addEventListener('click', () => closeModal()); // Escape document.addEventListener('keydown', onEscape); @@ -304,16 +305,23 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} // -------------------------------------------------------- export async function closeModal({ force = false } = {}) { - if (!activeOverlay) return; + if (!activeOverlay || _isClosing) return; + _isClosing = true; if (!force) { const panel = activeOverlay.querySelector('.modal-panel'); if (panel && isFormDirty(panel)) { - const confirmed = await confirmModal(t('modal.unsavedChanges'), { - danger: false, - confirmLabel: t('modal.discardChanges'), - }); - if (!confirmed) return; + let confirmed; + try { + confirmed = await confirmModal(t('modal.unsavedChanges'), { + danger: false, + confirmLabel: t('modal.discardChanges'), + }); + } catch (err) { + _isClosing = false; + throw err; + } + if (!confirmed) { _isClosing = false; return; } } } @@ -341,14 +349,16 @@ export async function closeModal({ force = false } = {}) { if (isMobile && panel) { panel.classList.add('modal-panel--closing'); // Fallback-Timer falls animationend nicht feuert (prefers-reduced-motion, Tab-Wechsel etc.) - const fallback = setTimeout(() => _doClose(capturedOverlay), 300); + const fallback = setTimeout(() => { _isClosing = false; _doClose(capturedOverlay); }, 300); panel.addEventListener('animationend', () => { clearTimeout(fallback); + _isClosing = false; _doClose(capturedOverlay); }, { once: true }); return; } + _isClosing = false; _doClose(capturedOverlay); } From e8104adb1eec0caf01e2152117ac5f39cc340d2e Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Sun, 26 Apr 2026 23:33:39 +0200 Subject: [PATCH 10/25] chore: release v0.25.5 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 13 +++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 085a139..5726834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.5] - 2026-04-26 + +### Added +- Navigation: the "More" button now shows the name and icon of the active secondary module instead of the generic label, making it clear which module is open +- Dashboard: first-time onboarding overlay guides new users through the app's three core navigation areas + +### Changed +- Navigation: renamed "Pinnwand" to "Notizen" for clarity +- Login: submit button shows a spinner during authentication; empty fields are highlighted individually with red borders instead of a single generic error message + +### Fixed +- Modal: closing a modal when the form has unsaved changes no longer double-fires the guard due to a missing `_isClosing` flag; the close button now uses an arrow-function listener to avoid stale closure issues + ## [0.25.4] - 2026-04-26 ### Added diff --git a/package-lock.json b/package-lock.json index a021b8d..62126c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.25.4", + "version": "0.25.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.25.4", + "version": "0.25.5", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 1acf5f2..12ab998 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.25.4", + "version": "0.25.5", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", From 201fa05afd5d1d08d8853b7e277ef958802a2301 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 00:20:42 +0200 Subject: [PATCH 11/25] =?UTF-8?q?feat(ux):=20microinteraction=20polish=20?= =?UTF-8?q?=E2=80=94=20undo=20tap=20feedback,=20strikethrough=20transition?= =?UTF-8?q?,=20modal=20loading=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toast__undo: add :active scale + tap-highlight-color for reliable tap feedback - task titles: animate text-decoration-color instead of snapping for smoother done-state - modal forms: auto-add btn--loading on submit; rAF guard removes it on validation fail; MutationObserver removes it on error re-enable; btnSuccess clears it before checkmark Co-Authored-By: Claude Sonnet 4.6 --- public/components/modal.js | 17 +++++++++++++++++ public/styles/layout.css | 10 ++++++++++ public/styles/tasks.css | 7 ++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/public/components/modal.js b/public/components/modal.js index bbd7d66..7544985 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -294,6 +294,22 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {} // Callback für Aufrufer (Form-Events binden etc.) if (typeof onSave === 'function') onSave(panel); + // Loading-State: btn--loading auf Submit-Button während async-Save. + // rAF-Check: Validierung schlägt fehl → btn bleibt enabled → Loading sofort entfernen. + // MutationObserver: Error-Pfad → btn wird re-enabled → Loading entfernen. + panel.addEventListener('submit', (e) => { + const btn = e.target.querySelector('[type="submit"], .btn--primary'); + if (!btn || btn.disabled) return; + btn.classList.add('btn--loading'); + requestAnimationFrame(() => { + if (!btn.disabled) { btn.classList.remove('btn--loading'); return; } + const mo = new MutationObserver(() => { + if (!btn.disabled) { btn.classList.remove('btn--loading'); mo.disconnect(); } + }); + mo.observe(btn, { attributes: true, attributeFilter: ['disabled'] }); + }); + }, { capture: true }); + // Standalone: Statusbar abdunkeln (Overlay-Effekt) if (window.oikos?.setThemeColor) { window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR); @@ -620,6 +636,7 @@ export function validateAll(formContainer) { * @param {string} [originalLabel] */ export function btnSuccess(btn, originalLabel) { + btn.classList.remove('btn--loading'); const label = originalLabel ?? btn.textContent; btn.classList.add('btn--success'); const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; diff --git a/public/styles/layout.css b/public/styles/layout.css index 87faf07..1102fd9 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -1647,10 +1647,20 @@ opacity: 0.85; } +.toast__undo { + -webkit-tap-highlight-color: transparent; + transition: opacity var(--transition-fast), transform 0.08s ease; +} + .toast__undo:hover { opacity: 1; } +.toast__undo:active { + transform: scale(0.94); + opacity: 1; +} + .toast--success { background-color: var(--color-success); color: var(--toast-success-text); } .toast--danger { background-color: var(--color-danger); color: var(--toast-danger-text); } .toast--warning { background-color: var(--color-warning); color: var(--toast-warning-text); } diff --git a/public/styles/tasks.css b/public/styles/tasks.css index 46418c0..663625e 100644 --- a/public/styles/tasks.css +++ b/public/styles/tasks.css @@ -406,11 +406,16 @@ color: var(--color-text-primary); margin-bottom: var(--space-1); cursor: pointer; + text-decoration: line-through; + text-decoration-color: transparent; + transition: + color var(--transition-fast), + text-decoration-color var(--transition-base); } .task-card--done .task-card__title { - text-decoration: line-through; color: var(--color-text-secondary); + text-decoration-color: var(--color-text-secondary); } .task-card__meta { From ca5208341bd9ebccf85af53659eb7f4d51f2e401 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 00:20:58 +0200 Subject: [PATCH 12/25] chore: release v0.25.6 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 7 +++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5726834..bd18fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.6] - 2026-04-27 + +### Changed +- Tasks: completing a task now animates the strikethrough line instead of snapping it on instantly +- Modal: save button shows a spinner during async API calls; the spinner disappears immediately if form validation fails, and on API error when the button is re-enabled +- Toast: the Undo button now gives tactile press feedback (scale + removes browser tap highlight) for reliable interaction within the 5-second window + ## [0.25.5] - 2026-04-26 ### Added diff --git a/package-lock.json b/package-lock.json index 62126c6..5a9e952 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.25.5", + "version": "0.25.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.25.5", + "version": "0.25.6", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 12ab998..51d5a52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.25.5", + "version": "0.25.6", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", From efd4e8c92440f3cd5e5c4fea3d7658dfa6237b81 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 00:38:50 +0200 Subject: [PATCH 13/25] feat(a11y): WCAG 2.2 accessibility fixes across four areas - modal/_validateField: set aria-invalid on invalid inputs so screen readers announce field errors; login.js mirrors this for username/password fields - color pickers (notes, calendar): wrap swatches in role="radiogroup" with aria-labelledby, add aria-checked per swatch, localized aria-labels instead of hex values, roving tabindex with Arrow/Enter/Space keyboard navigation - nav badges: badge spans get aria-hidden="true"; nav link aria-label updated to include overdue count (tasks) or pending reminder count (reminders) - router: remove aria-live from
(caused full page re-reads on nav); add dedicated #route-announcer sr-only region with aria-live=polite + aria-atomic, announces page label 50ms after render completes Co-Authored-By: Claude Sonnet 4.6 --- public/components/modal.js | 1 + public/locales/de.json | 25 ++++++++++++++-- public/pages/calendar.js | 59 +++++++++++++++++++++++++++++++------- public/pages/login.js | 12 +++++--- public/pages/notes.js | 51 +++++++++++++++++++++++++++----- public/pages/tasks.js | 8 ++++++ public/reminders.js | 7 +++++ public/router.js | 17 +++++++++-- 8 files changed, 153 insertions(+), 27 deletions(-) diff --git a/public/components/modal.js b/public/components/modal.js index 7544985..4dcb51f 100644 --- a/public/components/modal.js +++ b/public/components/modal.js @@ -591,6 +591,7 @@ function _validateField(input) { const hasValue = input.value.trim().length > 0; group?.classList.toggle('form-field--error', !hasValue); group?.classList.toggle('form-field--valid', hasValue); + input.setAttribute('aria-invalid', String(!hasValue)); return hasValue; } diff --git a/public/locales/de.json b/public/locales/de.json index aba3243..5a47964 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -170,7 +170,8 @@ "filterGroupStatus": "Status", "filterGroupPriority": "Priorität", "filterGroupPerson": "Person", - "filterClearAll": "Alle Filter zurücksetzen" + "filterClearAll": "Alle Filter zurücksetzen", + "navLabelOverdue": "Aufgaben, {{count}} überfällig" }, "shopping": { "title": "Einkauf", @@ -299,7 +300,17 @@ "locationPlaceholder": "Optional", "assignedLabel": "Zugewiesen an", "assignedNobody": "- Niemand -", - "colorLabel": "Farbe {{color}}", + "colorLabel": "Farbe", + "colorBlue": "Blau", + "colorGreen": "Grün", + "colorOrange": "Orange", + "colorRed": "Rot", + "colorPurple": "Lila", + "colorCoral": "Korall", + "colorSkyBlue": "Hellblau", + "colorYellow": "Gelb", + "colorGray": "Grau", + "colorCyan": "Cyan", "descriptionLabel": "Beschreibung", "descriptionPlaceholder": "Optional…", "popupEdit": "Bearbeiten", @@ -379,7 +390,15 @@ "formatLink": "Link", "formatCode": "Code", "formatQuote": "Zitat", - "formatDivider": "Trennlinie" + "formatDivider": "Trennlinie", + "colorYellow": "Gelb", + "colorAmber": "Hellgelb", + "colorGreen": "Grün", + "colorTeal": "Türkis", + "colorBlue": "Blau", + "colorPurple": "Lila", + "colorOrange": "Orange", + "colorWhite": "Weiß" }, "contacts": { "title": "Kontakte", diff --git a/public/pages/calendar.js b/public/pages/calendar.js index 24ddb05..7e86cf4 100644 --- a/public/pages/calendar.js +++ b/public/pages/calendar.js @@ -46,6 +46,19 @@ const EVENT_COLORS = [ '#8E8E93', '#30B0C7', ]; +const EVENT_COLOR_NAMES = () => ({ + '#007AFF': t('calendar.colorBlue'), + '#34C759': t('calendar.colorGreen'), + '#FF9500': t('calendar.colorOrange'), + '#FF3B30': t('calendar.colorRed'), + '#AF52DE': t('calendar.colorPurple'), + '#FF6B35': t('calendar.colorCoral'), + '#5AC8FA': t('calendar.colorSkyBlue'), + '#FFCC00': t('calendar.colorYellow'), + '#8E8E93': t('calendar.colorGray'), + '#30B0C7': t('calendar.colorCyan'), +}); + const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht /** @@ -843,15 +856,36 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) { const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0]; - // Farb-Auswahl - panel.querySelectorAll('.color-swatch').forEach((sw) => { - sw.addEventListener('click', () => { - panel.querySelectorAll('.color-swatch').forEach((s) => s.classList.remove('color-swatch--active')); - sw.classList.add('color-swatch--active'); + // Farb-Auswahl: Auswahl + ARIA + Keyboard (Roving Tabindex) + function selectSwatch(target) { + panel.querySelectorAll('.color-swatch').forEach((s) => { + s.classList.remove('color-swatch--active'); + s.setAttribute('aria-checked', 'false'); + s.setAttribute('tabindex', '-1'); }); - }); + target.classList.add('color-swatch--active'); + target.setAttribute('aria-checked', 'true'); + target.setAttribute('tabindex', '0'); + } panel.querySelectorAll('.color-swatch').forEach((sw) => { - if (sw.dataset.color === selectedColor) sw.classList.add('color-swatch--active'); + if (sw.dataset.color === selectedColor) selectSwatch(sw); + sw.addEventListener('click', () => { selectSwatch(sw); sw.focus(); }); + sw.addEventListener('keydown', (e) => { + const swatches = [...panel.querySelectorAll('.color-swatch')]; + const idx = swatches.indexOf(sw); + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + const next = swatches[(idx + 1) % swatches.length]; + selectSwatch(next); next.focus(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + const prev = swatches[(idx - 1 + swatches.length) % swatches.length]; + selectSwatch(prev); prev.focus(); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + selectSwatch(sw); + } + }); }); // Ganztägig-Toggle @@ -957,11 +991,14 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
- -
- ${EVENT_COLORS.map((c) => ` + +
+ ${EVENT_COLORS.map((c, i) => ` + role="radio" + tabindex="${i === 0 ? '0' : '-1'}" + aria-checked="false" + aria-label="${EVENT_COLOR_NAMES()[c] ?? c}">
`).join('')}
diff --git a/public/pages/login.js b/public/pages/login.js index 9f72f54..95aef49 100644 --- a/public/pages/login.js +++ b/public/pages/login.js @@ -86,6 +86,8 @@ export async function render(container) { usernameGroup.classList.toggle('form-group--error', !username); passwordGroup.classList.toggle('form-group--error', !password); + usernameInput.setAttribute('aria-invalid', String(!username)); + passwordInput.setAttribute('aria-invalid', String(!password)); if (!username || !password) { if (!username) usernameInput.focus(); @@ -117,11 +119,13 @@ export async function render(container) { } }); - form.querySelector('#username').addEventListener('input', () => { - form.querySelector('#username').closest('.form-group').classList.remove('form-group--error'); + form.querySelector('#username').addEventListener('input', (e) => { + e.currentTarget.closest('.form-group').classList.remove('form-group--error'); + e.currentTarget.removeAttribute('aria-invalid'); }); - form.querySelector('#password').addEventListener('input', () => { - form.querySelector('#password').closest('.form-group').classList.remove('form-group--error'); + form.querySelector('#password').addEventListener('input', (e) => { + e.currentTarget.closest('.form-group').classList.remove('form-group--error'); + e.currentTarget.removeAttribute('aria-invalid'); }); } diff --git a/public/pages/notes.js b/public/pages/notes.js index c19e9ec..da552f2 100644 --- a/public/pages/notes.js +++ b/public/pages/notes.js @@ -19,6 +19,17 @@ const NOTE_COLORS = [ '#90CAF9', '#CE93D8', '#FFAB91', '#FFFFFF', ]; +const NOTE_COLOR_NAMES = () => ({ + '#FFEB3B': t('notes.colorYellow'), + '#FFD54F': t('notes.colorAmber'), + '#A5D6A7': t('notes.colorGreen'), + '#80DEEA': t('notes.colorTeal'), + '#90CAF9': t('notes.colorBlue'), + '#CE93D8': t('notes.colorPurple'), + '#FFAB91': t('notes.colorOrange'), + '#FFFFFF': t('notes.colorWhite'), +}); + // -------------------------------------------------------- // State // -------------------------------------------------------- @@ -368,13 +379,16 @@ function openNoteModal({ mode, note = null }) { style="resize:vertical;">${esc(isEdit ? note.content : '')}
- -
+ +
${NOTE_COLORS.map((c) => ` + role="radio" + tabindex="${c === selColor ? '0' : '-1'}" + aria-checked="${c === selColor ? 'true' : 'false'}" + aria-label="${NOTE_COLOR_NAMES()[c] ?? c}">
`).join('')}
@@ -396,11 +410,34 @@ function openNoteModal({ mode, note = null }) { content, size: 'md', onSave(panel) { - // Farb-Swatch + // Farb-Swatch: Auswahl + ARIA + Keyboard (Roving Tabindex) + function selectSwatch(target) { + panel.querySelectorAll('.note-color-swatch').forEach((s) => { + s.classList.remove('note-color-swatch--active'); + s.setAttribute('aria-checked', 'false'); + s.setAttribute('tabindex', '-1'); + }); + target.classList.add('note-color-swatch--active'); + target.setAttribute('aria-checked', 'true'); + target.setAttribute('tabindex', '0'); + } panel.querySelectorAll('.note-color-swatch').forEach((sw) => { - sw.addEventListener('click', () => { - panel.querySelectorAll('.note-color-swatch').forEach((s) => s.classList.remove('note-color-swatch--active')); - sw.classList.add('note-color-swatch--active'); + sw.addEventListener('click', () => { selectSwatch(sw); sw.focus(); }); + sw.addEventListener('keydown', (e) => { + const swatches = [...panel.querySelectorAll('.note-color-swatch')]; + const idx = swatches.indexOf(sw); + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + e.preventDefault(); + const next = swatches[(idx + 1) % swatches.length]; + selectSwatch(next); next.focus(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + e.preventDefault(); + const prev = swatches[(idx - 1 + swatches.length) % swatches.length]; + selectSwatch(prev); prev.focus(); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + selectSwatch(sw); + } }); }); diff --git a/public/pages/tasks.js b/public/pages/tasks.js index bbd079f..dd8abe7 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -1092,6 +1092,13 @@ function updateOverdueBadge() { }).length; document.querySelectorAll('[data-route="/tasks"] .nav-badge').forEach((el) => el.remove()); + document.querySelectorAll('[data-route="/tasks"]').forEach((navItem) => { + const baseLabel = t('tasks.title'); + navItem.setAttribute('aria-label', overdue > 0 + ? t('tasks.navLabelOverdue', { count: overdue }) + : baseLabel + ); + }); if (overdue > 0) { document.querySelectorAll('[data-route="/tasks"]').forEach((navItem) => { let anchor = navItem.querySelector('.nav-item__icon-wrap'); @@ -1108,6 +1115,7 @@ function updateOverdueBadge() { } const badge = document.createElement('span'); badge.className = 'nav-badge'; + badge.setAttribute('aria-hidden', 'true'); badge.textContent = String(overdue); anchor.appendChild(badge); }); diff --git a/public/reminders.js b/public/reminders.js index 660491b..72e5c26 100644 --- a/public/reminders.js +++ b/public/reminders.js @@ -69,8 +69,15 @@ function showBrowserNotification(title, body) { * @param {number} count */ function updateBellBadge(count) { + const navLabel = count > 0 + ? t(count === 1 ? 'reminders.pendingBadgeTitle' : 'reminders.pendingBadgeTitlePlural', { count }) + : t('nav.reminders'); + document.querySelectorAll('[data-route="/reminders"]').forEach((navItem) => { + navItem.setAttribute('aria-label', navLabel); + }); document.querySelectorAll('.reminder-bell-badge').forEach((badge) => { if (count > 0) { + badge.setAttribute('aria-hidden', 'true'); badge.textContent = count > 9 ? '9+' : String(count); badge.hidden = false; } else { diff --git a/public/router.js b/public/router.js index 60fa990..f07aabd 100644 --- a/public/router.js +++ b/public/router.js @@ -267,6 +267,14 @@ async function renderPage(route, previousPath = null) { await module.render(pageWrapper, { user: currentUser }); + // Route-Announcer: Screenreader über Seitenwechsel informieren (gezielt, nicht gesamter Inhalt) + const announcer = document.getElementById('route-announcer'); + if (announcer) { + const pageLabel = navItems().find((n) => n.path === path)?.label ?? path; + announcer.textContent = ''; + setTimeout(() => { announcer.textContent = pageLabel; }, 50); + } + // Erst nach render() + CSS sichtbar machen und Animation starten pageWrapper.style.opacity = ''; pageWrapper.classList.add(inClass); @@ -356,7 +364,6 @@ function renderAppShell(container) { const main = document.createElement('main'); main.className = 'app-content'; main.id = 'main-content'; - main.setAttribute('aria-live', 'polite'); const bottomNav = document.createElement('nav'); bottomNav.className = 'nav-bottom'; @@ -441,7 +448,13 @@ function renderAppShell(container) { toastContainer.id = 'toast-container'; toastContainer.setAttribute('aria-live', 'assertive'); - container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer); + const routeAnnouncer = document.createElement('div'); + routeAnnouncer.id = 'route-announcer'; + routeAnnouncer.className = 'sr-only'; + routeAnnouncer.setAttribute('aria-live', 'polite'); + routeAnnouncer.setAttribute('aria-atomic', 'true'); + + container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer, routeAnnouncer); // Klick-Handler für alle Nav-Links container.querySelectorAll('[data-route]').forEach((el) => { From 308489b9b0abc62f753450c69833bf329b0fc290 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 00:39:02 +0200 Subject: [PATCH 14/25] chore: release v0.25.7 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 13 +++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd18fad..9ef033a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.7] - 2026-04-27 + +### Added +- Navigation: a dedicated screen-reader announcer (`aria-live="polite"`) announces the page name on every route change instead of reading the entire page content + +### Changed +- Color pickers (notes, calendar): swatches now use `role="radiogroup"` with localized color names instead of hex codes, `aria-checked` reflects the selected state, and Arrow keys navigate between options +- Navigation badges: badge counts are now hidden from screen readers (`aria-hidden`); the parent nav link's `aria-label` is updated to include the count in plain text (e.g. "Aufgaben, 3 überfällig") +- Main content area: removed `aria-live="polite"` from `
` — it was causing screen readers to read the full page on every navigation + +### Fixed +- Form validation: `aria-invalid="true"` is now set on invalid inputs in all modals and on the login form so screen readers can announce field errors + ## [0.25.6] - 2026-04-27 ### Changed diff --git a/package-lock.json b/package-lock.json index 5a9e952..f07bf16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.25.6", + "version": "0.25.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.25.6", + "version": "0.25.7", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 51d5a52..75530cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.25.6", + "version": "0.25.7", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", From 143d451db03f2b32d14be34bbb28035fed9a9f6e Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 00:42:59 +0200 Subject: [PATCH 15/25] test: fix makeInput mock missing setAttribute for aria-invalid Co-Authored-By: Claude Sonnet 4.6 --- test-modal-utils.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test-modal-utils.js b/test-modal-utils.js index 0928414..6dd680e 100644 --- a/test-modal-utils.js +++ b/test-modal-utils.js @@ -50,15 +50,19 @@ function makeField() { function makeInput({ value = '', required = true } = {}) { const listeners = {}; + const attrs = {}; const field = makeField(); return { value, required, _field: field, _listeners: listeners, + _attrs: attrs, addEventListener(event, fn) { listeners[event] = fn; }, closest() { return field; }, parentElement: field, + setAttribute(k, v) { attrs[k] = v; }, + removeAttribute(k) { delete attrs[k]; }, }; } @@ -109,6 +113,7 @@ test('wireBlurValidation: blur mit leerem Wert setzt form-field--error', () => { input._listeners['blur'](); assert.ok(input._field._classes.has('form-field--error')); assert.ok(!input._field._classes.has('form-field--valid')); + assert.equal(input._attrs['aria-invalid'], 'true'); }); test('wireBlurValidation: blur mit gültigem Wert setzt form-field--valid', () => { @@ -117,6 +122,7 @@ test('wireBlurValidation: blur mit gültigem Wert setzt form-field--valid', () = input._listeners['blur'](); assert.ok(input._field._classes.has('form-field--valid')); assert.ok(!input._field._classes.has('form-field--error')); + assert.equal(input._attrs['aria-invalid'], 'false'); }); test('wireBlurValidation: Whitespace-only gilt als leer → form-field--error', () => { @@ -124,6 +130,7 @@ test('wireBlurValidation: Whitespace-only gilt als leer → form-field--error', wireBlurValidation(makeContainer([input])); input._listeners['blur'](); assert.ok(input._field._classes.has('form-field--error')); + assert.equal(input._attrs['aria-invalid'], 'true'); }); test('wireBlurValidation: kein Fehler wenn closest() null zurückgibt', () => { From 2a2b47364677181340b583c9f8fe3e630c6d689c Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 00:43:20 +0200 Subject: [PATCH 16/25] chore: release v0.25.8 --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ef033a..0301899 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.25.8] - 2026-04-27 + +### Fixed +- Test suite: `makeInput` mock in `test-modal-utils.js` now implements `setAttribute`/`removeAttribute` so blur-validation tests correctly verify the new `aria-invalid` attribute behaviour + ## [0.25.7] - 2026-04-27 ### Added diff --git a/package-lock.json b/package-lock.json index f07bf16..72c31be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.25.7", + "version": "0.25.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.25.7", + "version": "0.25.8", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 75530cc..424afe0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.25.7", + "version": "0.25.8", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", From c9ba68cc9b924a324af29df8218d10b8008fe09c Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 07:32:36 +0200 Subject: [PATCH 17/25] fix: replace innerHTML with insertAdjacentHTML/replaceChildren; revert docker-compose dev changes - birthdays.js: all innerHTML writes replaced with replaceChildren() + insertAdjacentHTML() - dashboard.js: shell.innerHTML replaced with replaceChildren() + insertAdjacentHTML() - docker-compose.yml: revert port to 3000 and restore image line (were personal dev changes) Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 6 +++--- public/pages/birthdays.js | 35 +++++++++++++++++++++-------------- public/pages/dashboard.js | 5 +++-- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 31eab39..cd71eb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,11 @@ services: oikos: -# image: ghcr.io/ulsklyc/oikos:latest + image: ghcr.io/ulsklyc/oikos:latest build: . # optional: use --build to build locally instead container_name: oikos restart: unless-stopped ports: - - "0.0.0.0:3100:3000" + - "0.0.0.0:3000:3000" volumes: - oikos_data:/data env_file: @@ -19,7 +19,7 @@ services: # Direct HTTP access (no reverse proxy): - SESSION_SECURE=false healthcheck: - test: ["CMD", "node", "-e", "require('http').get('http://localhost:3100/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"] + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"] interval: 30s timeout: 10s retries: 3 diff --git a/public/pages/birthdays.js b/public/pages/birthdays.js index 8ec7284..7f20434 100644 --- a/public/pages/birthdays.js +++ b/public/pages/birthdays.js @@ -65,11 +65,12 @@ function renderSuggestions() { const items = suggestions(); if (!items.length) { dropdown.hidden = true; - dropdown.innerHTML = ''; + dropdown.replaceChildren(); return; } dropdown.hidden = false; - dropdown.innerHTML = items.map((birthday, idx) => ` + dropdown.replaceChildren(); + dropdown.insertAdjacentHTML('beforeend', items.map((birthday, idx) => ` - `).join(''); + `).join('')); } function renderUpcoming() { const host = _container.querySelector('#birthdays-upcoming'); if (!host) return; if (!state.upcoming.length) { - host.innerHTML = `
+ host.replaceChildren(); + host.insertAdjacentHTML('beforeend', `
${t('birthdays.emptyTitle')}
${t('birthdays.emptyDescription')}
-
`; +
`); return; } - host.innerHTML = state.upcoming.map((birthday) => ` + host.replaceChildren(); + host.insertAdjacentHTML('beforeend', state.upcoming.map((birthday) => `
${photoAvatar(birthday)}
@@ -106,7 +109,7 @@ function renderUpcoming() {
${esc(ageNote(birthday))}
- `).join(''); + `).join('')); } function renderList() { @@ -114,14 +117,16 @@ function renderList() { if (!host) return; const list = filteredBirthdays(); if (!list.length) { - host.innerHTML = `
+ host.replaceChildren(); + host.insertAdjacentHTML('beforeend', `
${t('birthdays.emptyTitle')}
${t('birthdays.emptyDescription')}
-
`; +
`); return; } - host.innerHTML = list.map((birthday) => ` + host.replaceChildren(); + host.insertAdjacentHTML('beforeend', list.map((birthday) => `
${photoAvatar(birthday)}
@@ -142,14 +147,15 @@ function renderList() {
- `).join(''); + `).join('')); if (window.lucide) window.lucide.createIcons(); stagger(host.querySelectorAll('.birthday-item')); } function renderPage() { - _container.innerHTML = ` + _container.replaceChildren(); + _container.insertAdjacentHTML('beforeend', `

${t('birthdays.title')}

@@ -194,7 +200,7 @@ function renderPage() {
- `; + `); renderUpcoming(); renderList(); @@ -304,7 +310,8 @@ function openBirthdayModal({ mode, birthday = null }) { const nameInput = panel.querySelector('#bd-name'); const preview = panel.querySelector('#birthday-preview'); const renderPreview = () => { - preview.innerHTML = birthdayPreviewHtml(nameInput.value.trim(), photoData); + preview.replaceChildren(); + preview.insertAdjacentHTML('beforeend', birthdayPreviewHtml(nameInput.value.trim(), photoData)); }; nameInput.addEventListener('input', renderPreview); panel.querySelector('#bd-photo').addEventListener('change', async (e) => { diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index 8328ddc..6f744c9 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -1061,10 +1061,11 @@ export async function render(container, { user }) { function rebuildDashboard(cfg) { const shell = container.querySelector('#dashboard-shell'); if (!shell) return; - shell.innerHTML = ` + shell.replaceChildren(); + shell.insertAdjacentHTML('beforeend', ` ${renderDashboardOverview(user, stats, weather)} ${renderDashboardLayout(cfg, data, weather, currency)} - `; + `); wireLinks(container, rerender); if (window.lucide) window.lucide.createIcons(); wireWeatherRefresh(container, (updatedWeather) => { From 5d519129a91be3bc8d2b0926b3502f8e4c71ad3e Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 07:38:14 +0200 Subject: [PATCH 18/25] chore: release v0.26.0 --- CHANGELOG.md | 14 ++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0301899..63f860b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.0] - 2026-04-27 + +### Added +- Birthdays module: track family birthdays with name, birth date, optional photo and notes; each entry is automatically synced to the calendar as a yearly recurring event and to the reminder system +- Birthdays dashboard widget: shows the next upcoming birthdays at a glance with age and days-until labels +- Family Participants dashboard widget: displays the number of users added to the family with avatar initials +- Budget Overview dashboard widget: shows monthly income, expenses, balance, savings rate and top expense category +- Dashboard widget customisation extended to include the three new widgets (birthdays, budget, family) +- Settings › General: admin option to set a custom application name shown in the sidebar, browser title and login screen +- Birthday translations across all 16 supported locales + +### Changed +- Service worker: mutable JS and CSS assets now use network-first caching to eliminate stale-asset issues after deployments + ## [0.25.8] - 2026-04-27 ### Fixed diff --git a/package-lock.json b/package-lock.json index 72c31be..2407f1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.25.8", + "version": "0.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.25.8", + "version": "0.26.0", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 424afe0..5d2a211 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.25.8", + "version": "0.26.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", From 1821b7147a227aba8e143e8d3d60c5d832538a24 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 12:24:39 +0200 Subject: [PATCH 19/25] fix: path is not defined in renderPage and HAVING clause SQL error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - router.js: route-announcer used bare `path` variable which is not in scope inside renderPage(); replaced with `route.path` - dashboard.js: shoppingLists query used `HAVING open_count > 0` without GROUP BY; SQLite rejects this — replaced with a WHERE subquery Co-Authored-By: Claude Sonnet 4.6 --- public/router.js | 2 +- server/routes/dashboard.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/router.js b/public/router.js index 4d3b514..12889bd 100644 --- a/public/router.js +++ b/public/router.js @@ -343,7 +343,7 @@ async function renderPage(route, previousPath = null) { // Route-Announcer: Screenreader über Seitenwechsel informieren (gezielt, nicht gesamter Inhalt) const announcer = document.getElementById('route-announcer'); if (announcer) { - const pageLabel = navItems().find((n) => n.path === path)?.label ?? path; + const pageLabel = navItems().find((n) => n.path === route.path)?.label ?? route.path; announcer.textContent = ''; setTimeout(() => { announcer.textContent = pageLabel; }, 50); } diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 4e85d39..4d12b46 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -144,7 +144,7 @@ router.get('/', (req, res) => { (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 + WHERE (SELECT COUNT(*) FROM shopping_items si WHERE si.list_id = sl.id AND si.is_checked = 0) > 0 ORDER BY sl.updated_at DESC LIMIT 3 `).all(); From 45241a04f063e68e9d8fd52e066c4cd7b6819591 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 12:25:04 +0200 Subject: [PATCH 20/25] chore: release v0.26.1 --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f860b..6f6f8d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.1] - 2026-04-27 + +### Fixed +- Dashboard: `path is not defined` crash on every navigation — `renderPage()` referenced a bare `path` variable instead of `route.path` +- Dashboard: shopping lists widget caused a server-side SQL error (`HAVING` clause on non-aggregate query) resulting in an empty widget for all users + ## [0.26.0] - 2026-04-27 ### Added diff --git a/package-lock.json b/package-lock.json index 2407f1a..c146b10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.26.0", + "version": "0.26.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.26.0", + "version": "0.26.1", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 5d2a211..895d5fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.26.0", + "version": "0.26.1", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", From bb96ddb6f626db5842cc2cfbf772ef23c8a01208 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 12:40:36 +0200 Subject: [PATCH 21/25] fix: remove duplicate KPI bar and restore flat widget grid on dashboard - Removed the KPI tiles section from renderDashboardOverview: it showed Tasks/Calendar/Birthdays/etc. counts in a bar at the top, duplicating the full widgets directly below it - Replaced the main/side two-column workspace layout with the established flat dashboard__grid so all widgets align consistently in the web view - Removed now-unused renderKpiTile function and stats/today locals - Skeleton updated to match the simplified layout Co-Authored-By: Claude Sonnet 4.6 --- public/pages/dashboard.js | 187 ++++---------------------------------- 1 file changed, 20 insertions(+), 167 deletions(-) diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js index b346687..6cc5d15 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -521,24 +521,9 @@ function renderQuickAction({ route, label, icon, tone = '' }) { `; } -function renderKpiTile({ title, value, meta, icon, route, tone = '' }) { - return ` - - `; -} -function renderDashboardOverview(user, stats = null, weather = null) { +function renderDashboardOverview(user) { const dateLabel = formatDate(new Date()); - const weatherLabel = weather - ? `${esc(weather.city)} · ${esc(weather.current?.temp)}${weather.units === 'imperial' ? '°F' : weather.units === 'standard' ? 'K' : '°C'}` - : t('dashboard.weather'); const actions = [ { route: '/tasks', label: t('nav.tasks'), icon: 'check-square', tone: 'blue' }, @@ -547,64 +532,6 @@ function renderDashboardOverview(user, stats = null, weather = null) { { route: '/notes', label: t('nav.notes'), icon: 'sticky-note', tone: 'amber' }, ].map(renderQuickAction).join(''); - const kpis = stats ? [ - renderKpiTile({ - title: t('tasks.title'), - value: String(stats.overdueCount ?? 0), - meta: t('dashboard.overdue'), - icon: 'alert-circle', - route: '/tasks', - tone: 'danger', - }), - renderKpiTile({ - title: t('nav.calendar'), - value: String(stats.todayEventCount ?? 0), - meta: t('common.today'), - icon: 'calendar-days', - route: '/calendar', - tone: 'calendar', - }), - renderKpiTile({ - title: t('nav.meals'), - value: stats.todayMealTitle ? esc(stats.todayMealTitle) : '-', - meta: t('dashboard.todayMeals'), - icon: 'utensils', - route: '/meals', - tone: 'meals', - }), - renderKpiTile({ - title: t('dashboard.weather'), - value: weatherLabel, - meta: t('dashboard.weatherRefreshTitle'), - icon: 'cloud-sun', - route: '/', - tone: 'weather', - }), - renderKpiTile({ - title: t('nav.birthdays'), - value: String(stats.birthdayCount ?? 0), - meta: t('dashboard.upcomingBirthdays'), - icon: 'cake', - route: '/birthdays', - tone: 'birthdays', - }), - renderKpiTile({ - title: t('dashboard.familyMembers'), - value: String(stats.familyCount ?? 0), - meta: t('dashboard.participantsAdded'), - icon: 'users', - route: '/settings', - tone: 'family', - }), - ].join('') : ` -
-
-
-
-
-
- `; - return `
@@ -620,35 +547,13 @@ function renderDashboardOverview(user, stats = null, weather = null) {
-
- ${kpis} -
`; } -function widgetRegion(id) { - return ['budget', 'family', 'weather', 'shopping', 'meals'].includes(id) ? 'side' : 'main'; -} - function widgetTileClass(id) { - const map = { - tasks: 'dashboard-tile--wide', - calendar: 'dashboard-tile--compact', - birthdays: 'dashboard-tile--compact', - budget: 'dashboard-tile--wide', - family: 'dashboard-tile--compact', - meals: 'dashboard-tile--compact', - notes: 'dashboard-tile--wide', - shopping: 'dashboard-tile--compact', - weather: 'dashboard-tile--wide', - }; - return map[id] || 'dashboard-tile--compact'; -} - -function renderDashboardTile(id, html) { - if (!html) return ''; - return `
${html}
`; + const wideIds = ['tasks', 'budget', 'notes', 'weather']; + return wideIds.includes(id) ? 'widget--wide' : ''; } function renderDashboardLayout(cfg, data, weather, currency) { @@ -664,31 +569,16 @@ function renderDashboardLayout(cfg, data, weather, currency) { weather: () => (weather ? renderWeatherWidget(weather) : ''), }; - const visible = cfg.filter((w) => w.visible && widgetById[w.id]); - const mainTiles = visible - .filter((w) => widgetRegion(w.id) === 'main') - .map((w) => renderDashboardTile(w.id, widgetById[w.id]())) + const tiles = cfg + .filter((w) => w.visible && widgetById[w.id]) + .map((w) => { + const html = widgetById[w.id](); + if (!html) return ''; + return `
${html}
`; + }) .join(''); - const sideTiles = visible - .filter((w) => widgetRegion(w.id) === 'side') - .map((w) => renderDashboardTile(w.id, widgetById[w.id]())) - .join(''); - - return ` -
-
-
- ${mainTiles} -
-
- -
- `; + return `
${tiles}
`; } function renderDashboardSkeleton() { @@ -700,32 +590,15 @@ function renderDashboardSkeleton() {
-
-
-
-
-
-
-
-
- -
-
-
- ${skeletonWidget(3)} - ${skeletonWidget(3)} - ${skeletonWidget(2)} - ${skeletonWidget(3)} -
-
-
+
+ ${skeletonWidget(3)} + ${skeletonWidget(3)} + ${skeletonWidget(2)} + ${skeletonWidget(3)} + ${skeletonWidget(3)} + ${skeletonWidget(2)} +
`; } @@ -1130,26 +1003,6 @@ export async function render(container, { user }) { window.oikos?.showToast(t('dashboard.loadError'), 'warning'); } - const today = new Date().toDateString(); - const stats = { - overdueCount: (data.urgentTasks ?? []).filter((t) => { - const due = formatDueDate(t.due_date, t.due_time); - return due?.overdue === true; - }).length, - dueSoonCount: (data.urgentTasks ?? []).filter((t) => { - const due = formatDueDate(t.due_date, t.due_time); - return due?.soon === true; - }).length, - todayEventCount: (data.upcomingEvents ?? []).filter((e) => - new Date(e.start_datetime).toDateString() === today - ).length, - todayMealTitle: (data.todayMeals ?? []).find((m) => m.meal_type === 'lunch')?.title - ?? (data.todayMeals ?? [])[0]?.title - ?? null, - birthdayCount: data.birthdayCount ?? (data.birthdays ?? []).length, - familyCount: (data.users ?? []).length, - }; - const rerender = () => render(container, { user }); function rebuildDashboard(cfg) { @@ -1157,7 +1010,7 @@ export async function render(container, { user }) { if (!shell) return; shell.replaceChildren(); shell.insertAdjacentHTML('beforeend', ` - ${renderDashboardOverview(user, stats, weather)} + ${renderDashboardOverview(user)} ${renderDashboardLayout(cfg, data, weather, currency)} `); wireLinks(container, rerender); From 3747db10962e351ac7bfe1236ed4ee87d9a35fe6 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 12:40:54 +0200 Subject: [PATCH 22/25] chore: release v0.26.2 --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f6f8d8..07d8ce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.2] - 2026-04-27 + +### Fixed +- Dashboard: KPI summary bar removed — it duplicated the same widget categories (tasks, calendar, birthdays…) that are already visible as full widgets directly below +- Dashboard: replaced the two-column main/side workspace layout with the established flat responsive grid so all widgets are consistently left-aligned across all screen sizes in the web view + ## [0.26.1] - 2026-04-27 ### Fixed diff --git a/package-lock.json b/package-lock.json index c146b10..fc2af28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.26.1", + "version": "0.26.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.26.1", + "version": "0.26.2", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 895d5fa..a50279e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.26.1", + "version": "0.26.2", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", From d1e48857b62e9da64d8dfb5ff66c2dd76f939a8f Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 12:45:53 +0200 Subject: [PATCH 23/25] chore: release v0.26.3 --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- public/pages/birthdays.js | 2 +- public/styles/dashboard.css | 6 ++++++ 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d8ce5..313ef35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.3] - 2026-04-27 + +### Fixed +- Birthdays: "Discard changes?" dialog appeared immediately after successfully saving a birthday because `closeModal()` was called without `force: true`, triggering the dirty-form check on a programmatic close +- Dashboard (PWA): widget items (tasks, events, meals, notes, birthdays, shopping lists) occasionally blocked vertical swipe-to-scroll; added `touch-action: pan-y` so the browser passes vertical pan gestures through to the scroll container + ## [0.26.2] - 2026-04-27 ### Fixed diff --git a/package-lock.json b/package-lock.json index fc2af28..303df73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.26.2", + "version": "0.26.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.26.2", + "version": "0.26.3", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index a50279e..a269756 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.26.2", + "version": "0.26.3", "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/pages/birthdays.js b/public/pages/birthdays.js index 7f20434..08f707f 100644 --- a/public/pages/birthdays.js +++ b/public/pages/birthdays.js @@ -366,7 +366,7 @@ function openBirthdayModal({ mode, birthday = null }) { renderUpcoming(); renderSuggestions(); renderList(); - closeModal(); + closeModal({ force: true }); } catch (err) { window.oikos?.showToast(err.message, 'danger'); saveBtn.disabled = false; diff --git a/public/styles/dashboard.css b/public/styles/dashboard.css index e65356f..93f1a15 100644 --- a/public/styles/dashboard.css +++ b/public/styles/dashboard.css @@ -427,6 +427,7 @@ padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border-subtle); cursor: pointer; + touch-action: pan-y; transition: opacity var(--transition-fast); } @@ -507,6 +508,7 @@ padding: var(--space-2) 0; border-bottom: 1px solid var(--color-border-subtle); cursor: pointer; + touch-action: pan-y; transition: opacity var(--transition-fast); } @@ -594,6 +596,7 @@ padding: var(--space-2) var(--space-1); background-color: var(--color-surface); cursor: pointer; + touch-action: pan-y; transition: background-color var(--transition-fast); text-align: center; min-height: 72px; @@ -683,6 +686,7 @@ * -------------------------------------------------------- */ .shopping-widget-list { cursor: pointer; + touch-action: pan-y; transition: background-color var(--transition-fast); border-radius: var(--radius-sm); padding: var(--space-2) 0; @@ -794,6 +798,7 @@ border-radius: var(--radius-sm); padding: var(--space-3); cursor: pointer; + touch-action: pan-y; transition: opacity var(--transition-fast), transform var(--transition-fast); border-left: 3px solid var(--note-color, var(--color-accent)); background-color: color-mix(in srgb, var(--note-color, var(--color-accent)) 6%, var(--color-surface-2)); @@ -2262,6 +2267,7 @@ border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent); background: var(--color-surface-2); cursor: pointer; + touch-action: pan-y; } .birthday-widget-item + .birthday-widget-item { From 29d9d28ee57c61625903784b8e51f3eefa37e1d4 Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 12:47:43 +0200 Subject: [PATCH 24/25] chore: release v0.26.4 --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- public/pages/dashboard.js | 2 +- public/styles/dashboard.css | 11 +++++++++++ 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 313ef35..661e095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.4] - 2026-04-27 + +### Changed +- Dashboard: weather widget is now the first entry in the default widget order +- Dashboard: widgets in the same grid row now share the same height (via flex stretch), eliminating the patchwork gaps between shorter and taller widgets + ## [0.26.3] - 2026-04-27 ### Fixed diff --git a/package-lock.json b/package-lock.json index 303df73..77e069c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.26.3", + "version": "0.26.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.26.3", + "version": "0.26.4", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index a269756..6814249 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.26.3", + "version": "0.26.4", "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/pages/dashboard.js b/public/pages/dashboard.js index 6cc5d15..984b6d4 100644 --- a/public/pages/dashboard.js +++ b/public/pages/dashboard.js @@ -110,7 +110,7 @@ function showOnboarding(appContainer) { // Widget-Definitionen (Reihenfolge = Standard-Layout) // -------------------------------------------------------- -const WIDGET_IDS = ['tasks', 'calendar', 'birthdays', 'budget', 'family', 'weather', 'shopping', 'meals', 'notes']; +const WIDGET_IDS = ['weather', 'tasks', 'calendar', 'birthdays', 'budget', 'family', 'shopping', 'meals', 'notes']; const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id) => ({ id, visible: true })); diff --git a/public/styles/dashboard.css b/public/styles/dashboard.css index 93f1a15..6029bda 100644 --- a/public/styles/dashboard.css +++ b/public/styles/dashboard.css @@ -163,6 +163,17 @@ grid-template-columns: 1fr; } +/* Widgets füllen die gesamte Gridzeilen-Höhe, so dass alle Widgets + * einer Zeile gleich hoch sind und keine Lücken entstehen. */ +.widget-wrapper { + display: flex; + flex-direction: column; +} + +.widget-wrapper > .widget { + flex: 1; +} + @media (min-width: 768px) { .dashboard__grid { grid-template-columns: repeat(2, 1fr); From 6a575520aac6237a93b30c22b47a175bc29d4d5e Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 27 Apr 2026 12:51:10 +0200 Subject: [PATCH 25/25] chore: release v0.26.5 --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- server/index.js | 6 +++--- server/routes/birthdays.js | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 661e095..2204630 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.26.5] - 2026-04-27 + +### Changed +- Birthdays: increased maximum photo upload size from ~0.9 MB to 5 MB + ## [0.26.4] - 2026-04-27 ### Changed diff --git a/package-lock.json b/package-lock.json index 77e069c..dae52df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oikos", - "version": "0.26.4", + "version": "0.26.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oikos", - "version": "0.26.4", + "version": "0.26.5", "license": "MIT", "dependencies": { "bcrypt": "^6.0.0", diff --git a/package.json b/package.json index 6814249..908907b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.26.4", + "version": "0.26.5", "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/server/index.js b/server/index.js index be4759d..6b87d77 100644 --- a/server/index.js +++ b/server/index.js @@ -87,8 +87,8 @@ app.set('trust proxy', process.env.TRUST_PROXY !== undefined ? process.env.TRUST // -------------------------------------------------------- // Request-Parsing // -------------------------------------------------------- -app.use(express.json({ limit: '1mb' })); -app.use(express.urlencoded({ extended: true, limit: '1mb' })); +app.use(express.json({ limit: '7mb' })); +app.use(express.urlencoded({ extended: true, limit: '7mb' })); // JSON-Parse-Fehler abfangen (gibt sonst HTML zurück) app.use((err, req, res, next) => { @@ -96,7 +96,7 @@ app.use((err, req, res, next) => { return res.status(400).json({ error: 'Invalid JSON in request body.', code: 400 }); } if (err.type === 'entity.too.large') { - return res.status(413).json({ error: 'Request body too large (max. 1 MB).', code: 413 }); + return res.status(413).json({ error: 'Request body too large (max. 7 MB).', code: 413 }); } next(err); }); diff --git a/server/routes/birthdays.js b/server/routes/birthdays.js index 91141da..6b80e71 100644 --- a/server/routes/birthdays.js +++ b/server/routes/birthdays.js @@ -6,7 +6,7 @@ import { deleteBirthdayArtifacts, hydrateBirthday, syncBirthdayArtifacts, syncAl const log = createLogger('Birthdays'); const router = express.Router(); -const MAX_PHOTO_LENGTH = 900_000; +const MAX_PHOTO_LENGTH = 6_990_507; // ~5 MB raw image in base64 const PHOTO_RE = /^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/; function validatePhotoData(val) {