Merge branch 'main' of github.com:rafaelfoster/oikos

This commit is contained in:
Rafael Foster
2026-04-27 08:52:18 -03:00
41 changed files with 1138 additions and 381 deletions
+91
View File
@@ -7,6 +7,97 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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
- 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
- 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
- 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
- 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
- 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
- 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
- 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 `<main>` — 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
- 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
- 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
- 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
- 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 ## [0.25.2] - 2026-04-26
### Changed ### Changed
+3 -3
View File
@@ -1,11 +1,11 @@
services: services:
oikos: oikos:
# image: ghcr.io/ulsklyc/oikos:latest image: ghcr.io/ulsklyc/oikos:latest
build: . # optional: use --build to build locally instead build: . # optional: use --build to build locally instead
container_name: oikos container_name: oikos
restart: unless-stopped restart: unless-stopped
ports: ports:
- "0.0.0.0:3100:3000" - "0.0.0.0:3000:3000"
volumes: volumes:
- oikos_data:/data - oikos_data:/data
env_file: env_file:
@@ -19,7 +19,7 @@ services:
# Direct HTTP access (no reverse proxy): # Direct HTTP access (no reverse proxy):
- SESSION_SECURE=false - SESSION_SECURE=false
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.25.2", "version": "0.26.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "oikos", "name": "oikos",
"version": "0.25.2", "version": "0.26.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.25.2", "version": "0.26.5",
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
"main": "server/index.js", "main": "server/index.js",
"type": "module", "type": "module",
+70 -5
View File
@@ -16,6 +16,8 @@ import { t } from '/i18n.js';
let activeOverlay = null; let activeOverlay = null;
let previouslyFocused = null; let previouslyFocused = null;
let focusTrapHandler = null; let focusTrapHandler = null;
let _initialFormSnapshot = null;
let _isClosing = false;
// Overlay-Dimming: theme-color abdunkeln im Standalone-Modus // Overlay-Dimming: theme-color abdunkeln im Standalone-Modus
const OVERLAY_THEME_COLOR = '#1A1A1A'; const OVERLAY_THEME_COLOR = '#1A1A1A';
@@ -98,6 +100,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 // Escape-Handler
// -------------------------------------------------------- // --------------------------------------------------------
@@ -204,9 +220,10 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
// ID sofort entfernen damit getElementById() nach dem Einfügen des neuen Modals // ID sofort entfernen damit getElementById() nach dem Einfügen des neuen Modals
// nicht die noch animierende alte Instanz zurückgibt sonst landen alle // nicht die noch animierende alte Instanz zurückgibt sonst landen alle
// Event-Listener am falschen Element und Buttons reagieren nicht. // Event-Listener am falschen Element und Buttons reagieren nicht.
// force=true: kein Dirty-Check beim programmatischen Ersetzen (z.B. confirmModal öffnet sich).
if (activeOverlay) { if (activeOverlay) {
activeOverlay.removeAttribute('id'); activeOverlay.removeAttribute('id');
closeModal(); closeModal({ force: true });
} }
// Focus-Restore vorbereiten // Focus-Restore vorbereiten
@@ -243,6 +260,14 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
const panel = activeOverlay.querySelector('.modal-panel'); const panel = activeOverlay.querySelector('.modal-panel');
trapFocus(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 // Swipe-to-Close auf Mobile
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
_wireSheetSwipe(panel); _wireSheetSwipe(panel);
@@ -261,7 +286,7 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
// Close-Button // Close-Button
activeOverlay.querySelector('[data-action="close-modal"]') activeOverlay.querySelector('[data-action="close-modal"]')
?.addEventListener('click', closeModal); ?.addEventListener('click', () => closeModal());
// Escape // Escape
document.addEventListener('keydown', onEscape); document.addEventListener('keydown', onEscape);
@@ -269,6 +294,22 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
// Callback für Aufrufer (Form-Events binden etc.) // Callback für Aufrufer (Form-Events binden etc.)
if (typeof onSave === 'function') onSave(panel); 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) // Standalone: Statusbar abdunkeln (Overlay-Effekt)
if (window.oikos?.setThemeColor) { if (window.oikos?.setThemeColor) {
window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR); window.oikos.setThemeColor(OVERLAY_THEME_COLOR, OVERLAY_THEME_COLOR);
@@ -279,8 +320,28 @@ export function openModal({ title, content, onSave, onDelete, size = 'md' } = {}
// closeModal // closeModal
// -------------------------------------------------------- // --------------------------------------------------------
export function closeModal() { 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)) {
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; }
}
}
_initialFormSnapshot = null;
document.removeEventListener('keydown', onEscape); document.removeEventListener('keydown', onEscape);
@@ -304,14 +365,16 @@ export function closeModal() {
if (isMobile && panel) { if (isMobile && panel) {
panel.classList.add('modal-panel--closing'); panel.classList.add('modal-panel--closing');
// Fallback-Timer falls animationend nicht feuert (prefers-reduced-motion, Tab-Wechsel etc.) // 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', () => { panel.addEventListener('animationend', () => {
clearTimeout(fallback); clearTimeout(fallback);
_isClosing = false;
_doClose(capturedOverlay); _doClose(capturedOverlay);
}, { once: true }); }, { once: true });
return; return;
} }
_isClosing = false;
_doClose(capturedOverlay); _doClose(capturedOverlay);
} }
@@ -528,6 +591,7 @@ function _validateField(input) {
const hasValue = input.value.trim().length > 0; const hasValue = input.value.trim().length > 0;
group?.classList.toggle('form-field--error', !hasValue); group?.classList.toggle('form-field--error', !hasValue);
group?.classList.toggle('form-field--valid', hasValue); group?.classList.toggle('form-field--valid', hasValue);
input.setAttribute('aria-invalid', String(!hasValue));
return hasValue; return hasValue;
} }
@@ -573,6 +637,7 @@ export function validateAll(formContainer) {
* @param {string} [originalLabel] * @param {string} [originalLabel]
*/ */
export function btnSuccess(btn, originalLabel) { export function btnSuccess(btn, originalLabel) {
btn.classList.remove('btn--loading');
const label = originalLabel ?? btn.textContent; const label = originalLabel ?? btn.textContent;
btn.classList.add('btn--success'); btn.classList.add('btn--success');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "التقويم", "title": "التقويم",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "إغلاق", "closeLabel": "إغلاق",
"overlayLabel": "خلفية مربع الحوار" "overlayLabel": "خلفية مربع الحوار",
"unsavedChanges": "تجاهل التغييرات؟",
"discardChanges": "تجاهل"
}, },
"rrule": { "rrule": {
"freqNone": "بدون تكرار", "freqNone": "بدون تكرار",
@@ -854,5 +857,16 @@
"notificationEnabled": "الإشعارات نشطة", "notificationEnabled": "الإشعارات نشطة",
"notificationDenied": "الإشعارات محظورة", "notificationDenied": "الإشعارات محظورة",
"notificationHint": "احصل على إشعارات حتى عندما يكون التطبيق مفتوحًا." "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"
} }
} }
+40 -7
View File
@@ -36,7 +36,7 @@
"calendar": "Kalender", "calendar": "Kalender",
"meals": "Essen", "meals": "Essen",
"shopping": "Einkauf", "shopping": "Einkauf",
"notes": "Pinnwand", "notes": "Notizen",
"contacts": "Kontakte", "contacts": "Kontakte",
"birthdays": "Geburtstage", "birthdays": "Geburtstage",
"budget": "Budget", "budget": "Budget",
@@ -184,7 +184,8 @@
"filterGroupStatus": "Status", "filterGroupStatus": "Status",
"filterGroupPriority": "Priorität", "filterGroupPriority": "Priorität",
"filterGroupPerson": "Person", "filterGroupPerson": "Person",
"filterClearAll": "Alle Filter zurücksetzen" "filterClearAll": "Alle Filter zurücksetzen",
"navLabelOverdue": "Aufgaben, {{count}} überfällig"
}, },
"shopping": { "shopping": {
"title": "Einkauf", "title": "Einkauf",
@@ -278,7 +279,8 @@
"savedRecipeLabel": "Gespeichertes Rezept", "savedRecipeLabel": "Gespeichertes Rezept",
"savedRecipePlaceholder": "Rezept auswählen", "savedRecipePlaceholder": "Rezept auswählen",
"saveAsRecipe": "Als Rezept speichern", "saveAsRecipe": "Als Rezept speichern",
"recipeScaleLabel": "Zutaten skalieren" "recipeScaleLabel": "Zutaten skalieren",
"deletedToast": "Mahlzeit gelöscht"
}, },
"calendar": { "calendar": {
"title": "Kalender", "title": "Kalender",
@@ -312,7 +314,17 @@
"locationPlaceholder": "Optional", "locationPlaceholder": "Optional",
"assignedLabel": "Zugewiesen an", "assignedLabel": "Zugewiesen an",
"assignedNobody": "- Niemand -", "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", "descriptionLabel": "Beschreibung",
"descriptionPlaceholder": "Optional…", "descriptionPlaceholder": "Optional…",
"popupEdit": "Bearbeiten", "popupEdit": "Bearbeiten",
@@ -357,7 +369,7 @@
} }
}, },
"notes": { "notes": {
"title": "Pinnwand", "title": "Notizen",
"newNote": "Neue Notiz", "newNote": "Neue Notiz",
"editNote": "Notiz bearbeiten", "editNote": "Notiz bearbeiten",
"addNoteLabel": "Neue Notiz", "addNoteLabel": "Neue Notiz",
@@ -392,7 +404,15 @@
"formatLink": "Link", "formatLink": "Link",
"formatCode": "Code", "formatCode": "Code",
"formatQuote": "Zitat", "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": { "contacts": {
"title": "Kontakte", "title": "Kontakte",
@@ -757,7 +777,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Schließen", "closeLabel": "Schließen",
"overlayLabel": "Modaler Dialog-Hintergrund" "overlayLabel": "Modaler Dialog-Hintergrund",
"unsavedChanges": "Änderungen verwerfen?",
"discardChanges": "Verwerfen"
}, },
"rrule": { "rrule": {
"freqNone": "Keine Wiederholung", "freqNone": "Keine Wiederholung",
@@ -855,5 +877,16 @@
"duplicate": "Duplizieren", "duplicate": "Duplizieren",
"duplicated": "Rezept dupliziert.", "duplicated": "Rezept dupliziert.",
"copySuffix": "Kopie" "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "Ημερολόγιο", "title": "Ημερολόγιο",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Κλείσιμο", "closeLabel": "Κλείσιμο",
"overlayLabel": "Φόντο αναδυόμενου παραθύρου" "overlayLabel": "Φόντο αναδυόμενου παραθύρου",
"unsavedChanges": "Απόρριψη αλλαγών;",
"discardChanges": "Απόρριψη"
}, },
"rrule": { "rrule": {
"freqNone": "Χωρίς επανάληψη", "freqNone": "Χωρίς επανάληψη",
@@ -854,5 +857,16 @@
"notificationEnabled": "Ειδοποιήσεις ενεργές", "notificationEnabled": "Ειδοποιήσεις ενεργές",
"notificationDenied": "Ειδοποιήσεις αποκλεισμένες", "notificationDenied": "Ειδοποιήσεις αποκλεισμένες",
"notificationHint": "Λάβετε ειδοποιήσεις ακόμα και όταν η εφαρμογή είναι ανοιχτή." "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "Calendar", "title": "Calendar",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Close", "closeLabel": "Close",
"overlayLabel": "Modal dialog background" "overlayLabel": "Modal dialog background",
"unsavedChanges": "Discard changes?",
"discardChanges": "Discard"
}, },
"rrule": { "rrule": {
"freqNone": "No recurrence", "freqNone": "No recurrence",
@@ -855,5 +858,16 @@
"open": "Open search", "open": "Open search",
"placeholder": "Search…", "placeholder": "Search…",
"noResults": "No results found." "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "Calendario", "title": "Calendario",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Cerrar", "closeLabel": "Cerrar",
"overlayLabel": "Fondo del cuadro de diálogo modal" "overlayLabel": "Fondo del cuadro de diálogo modal",
"unsavedChanges": "¿Descartar cambios?",
"discardChanges": "Descartar"
}, },
"rrule": { "rrule": {
"freqNone": "Sin repetición", "freqNone": "Sin repetición",
@@ -854,5 +857,16 @@
"notificationEnabled": "Notificaciones activas", "notificationEnabled": "Notificaciones activas",
"notificationDenied": "Notificaciones bloqueadas", "notificationDenied": "Notificaciones bloqueadas",
"notificationHint": "Recibe notificaciones incluso cuando la aplicación está abierta." "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "Calendrier", "title": "Calendrier",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Fermer", "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": { "rrule": {
"freqNone": "Pas de répétition", "freqNone": "Pas de répétition",
@@ -854,5 +857,16 @@
"notificationEnabled": "Notifications actives", "notificationEnabled": "Notifications actives",
"notificationDenied": "Notifications bloquées", "notificationDenied": "Notifications bloquées",
"notificationHint": "Recevez des notifications même lorsque l'application est ouverte." "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "कैलेंडर", "title": "कैलेंडर",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "बंद करें", "closeLabel": "बंद करें",
"overlayLabel": "मोडल डायलॉग पृष्ठभूमि" "overlayLabel": "मोडल डायलॉग पृष्ठभूमि",
"unsavedChanges": "बदलाव छोड़ें?",
"discardChanges": "छोड़ें"
}, },
"rrule": { "rrule": {
"freqNone": "कोई दोहराव नहीं", "freqNone": "कोई दोहराव नहीं",
@@ -854,5 +857,16 @@
"notificationEnabled": "सूचनाएं सक्रिय", "notificationEnabled": "सूचनाएं सक्रिय",
"notificationDenied": "सूचनाएं अवरुद्ध", "notificationDenied": "सूचनाएं अवरुद्ध",
"notificationHint": "ऐप खुली होने पर भी सूचनाएं प्राप्त करें।" "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "Calendario", "title": "Calendario",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Chiudi", "closeLabel": "Chiudi",
"overlayLabel": "Sfondo del dialogo modale" "overlayLabel": "Sfondo del dialogo modale",
"unsavedChanges": "Annullare le modifiche?",
"discardChanges": "Annulla"
}, },
"rrule": { "rrule": {
"freqNone": "Nessuna ripetizione", "freqNone": "Nessuna ripetizione",
@@ -854,5 +857,16 @@
"notificationEnabled": "Notifiche attive", "notificationEnabled": "Notifiche attive",
"notificationDenied": "Notifiche bloccate", "notificationDenied": "Notifiche bloccate",
"notificationHint": "Ricevi notifiche anche quando l'app è aperta." "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "カレンダー", "title": "カレンダー",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "閉じる", "closeLabel": "閉じる",
"overlayLabel": "モーダルダイアログの背景" "overlayLabel": "モーダルダイアログの背景",
"unsavedChanges": "変更を破棄しますか?",
"discardChanges": "破棄"
}, },
"rrule": { "rrule": {
"freqNone": "繰り返しなし", "freqNone": "繰り返しなし",
@@ -854,5 +857,16 @@
"notificationEnabled": "通知が有効", "notificationEnabled": "通知が有効",
"notificationDenied": "通知がブロックされています", "notificationDenied": "通知がブロックされています",
"notificationHint": "アプリが開いているときでも通知を受け取ります。" "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "Calendário", "title": "Calendário",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Fechar", "closeLabel": "Fechar",
"overlayLabel": "Fundo do diálogo modal" "overlayLabel": "Fundo do diálogo modal",
"unsavedChanges": "Descartar alterações?",
"discardChanges": "Descartar"
}, },
"rrule": { "rrule": {
"freqNone": "Sem repetição", "freqNone": "Sem repetição",
@@ -855,5 +858,16 @@
"notificationEnabled": "Notificações ativas", "notificationEnabled": "Notificações ativas",
"notificationDenied": "Notificações bloqueadas", "notificationDenied": "Notificações bloqueadas",
"notificationHint": "Receba notificações mesmo quando a aplicação está aberta." "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "Календарь", "title": "Календарь",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Закрыть", "closeLabel": "Закрыть",
"overlayLabel": "Фон модального диалога" "overlayLabel": "Фон модального диалога",
"unsavedChanges": "Отменить изменения?",
"discardChanges": "Отменить"
}, },
"rrule": { "rrule": {
"freqNone": "Без повтора", "freqNone": "Без повтора",
@@ -854,5 +857,16 @@
"notificationEnabled": "Уведомления активны", "notificationEnabled": "Уведомления активны",
"notificationDenied": "Уведомления заблокированы", "notificationDenied": "Уведомления заблокированы",
"notificationHint": "Получайте уведомления, даже когда приложение открыто." "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Sparat recept", "savedRecipeLabel": "Sparat recept",
"savedRecipePlaceholder": "Välj recept", "savedRecipePlaceholder": "Välj recept",
"saveAsRecipe": "Spara som recept", "saveAsRecipe": "Spara som recept",
"recipeScaleLabel": "Skala ingredienser" "recipeScaleLabel": "Skala ingredienser",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "Kalender", "title": "Kalender",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Stäng", "closeLabel": "Stäng",
"overlayLabel": "Bakgrund för modal dialog" "overlayLabel": "Bakgrund för modal dialog",
"unsavedChanges": "Ignorera ändringar?",
"discardChanges": "Ignorera"
}, },
"rrule": { "rrule": {
"freqNone": "Ingen upprepning", "freqNone": "Ingen upprepning",
@@ -854,5 +857,16 @@
"notificationEnabled": "Notiser aktiva", "notificationEnabled": "Notiser aktiva",
"notificationDenied": "Notiser blockerade", "notificationDenied": "Notiser blockerade",
"notificationHint": "Få notiser även när appen är öppen." "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "Takvim", "title": "Takvim",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Kapat", "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": { "rrule": {
"freqNone": "Tekrar yok", "freqNone": "Tekrar yok",
@@ -854,5 +857,16 @@
"notificationEnabled": "Bildirimler etkin", "notificationEnabled": "Bildirimler etkin",
"notificationDenied": "Bildirimler engellendi", "notificationDenied": "Bildirimler engellendi",
"notificationHint": "Uygulama açıkken bile bildirim alın." "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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "Календар", "title": "Календар",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "Закрити", "closeLabel": "Закрити",
"overlayLabel": "Фон модального вікна" "overlayLabel": "Фон модального вікна",
"unsavedChanges": "Скасувати зміни?",
"discardChanges": "Скасувати"
}, },
"rrule": { "rrule": {
"freqNone": "Без повторення", "freqNone": "Без повторення",
@@ -854,5 +857,16 @@
"ageNoteToday": "Сьогодні виповнюється {{age}}.", "ageNoteToday": "Сьогодні виповнюється {{age}}.",
"ageNoteTomorrow": "Завтра виповниться {{age}}.", "ageNoteTomorrow": "Завтра виповниться {{age}}.",
"ageNoteDays": "За {{days}} дн. виповниться {{age}}." "ageNoteDays": "За {{days}} дн. виповниться {{age}}."
},
"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"
} }
} }
+16 -2
View File
@@ -272,7 +272,8 @@
"savedRecipeLabel": "Saved recipe", "savedRecipeLabel": "Saved recipe",
"savedRecipePlaceholder": "Select recipe", "savedRecipePlaceholder": "Select recipe",
"saveAsRecipe": "Save as recipe", "saveAsRecipe": "Save as recipe",
"recipeScaleLabel": "Scale ingredients" "recipeScaleLabel": "Scale ingredients",
"deletedToast": "Meal deleted"
}, },
"calendar": { "calendar": {
"title": "日历", "title": "日历",
@@ -751,7 +752,9 @@
}, },
"modal": { "modal": {
"closeLabel": "关闭", "closeLabel": "关闭",
"overlayLabel": "模态对话框背景" "overlayLabel": "模态对话框背景",
"unsavedChanges": "放弃更改?",
"discardChanges": "放弃"
}, },
"rrule": { "rrule": {
"freqNone": "不重复", "freqNone": "不重复",
@@ -854,5 +857,16 @@
"notificationEnabled": "通知已启用", "notificationEnabled": "通知已启用",
"notificationDenied": "通知已被阻止", "notificationDenied": "通知已被阻止",
"notificationHint": "即使应用程序打开时也能收到通知。" "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"
} }
} }
+22 -15
View File
@@ -65,11 +65,12 @@ function renderSuggestions() {
const items = suggestions(); const items = suggestions();
if (!items.length) { if (!items.length) {
dropdown.hidden = true; dropdown.hidden = true;
dropdown.innerHTML = ''; dropdown.replaceChildren();
return; return;
} }
dropdown.hidden = false; dropdown.hidden = false;
dropdown.innerHTML = items.map((birthday, idx) => ` dropdown.replaceChildren();
dropdown.insertAdjacentHTML('beforeend', items.map((birthday, idx) => `
<button class="birthday-suggestion" type="button" data-index="${idx}" data-name="${esc(birthday.name)}"> <button class="birthday-suggestion" type="button" data-index="${idx}" data-name="${esc(birthday.name)}">
${photoAvatar(birthday, 'birthday-avatar--xs')} ${photoAvatar(birthday, 'birthday-avatar--xs')}
<span> <span>
@@ -77,20 +78,22 @@ function renderSuggestions() {
<small>${esc(ageNote(birthday))}</small> <small>${esc(ageNote(birthday))}</small>
</span> </span>
</button> </button>
`).join(''); `).join(''));
} }
function renderUpcoming() { function renderUpcoming() {
const host = _container.querySelector('#birthdays-upcoming'); const host = _container.querySelector('#birthdays-upcoming');
if (!host) return; if (!host) return;
if (!state.upcoming.length) { if (!state.upcoming.length) {
host.innerHTML = `<div class="empty-state empty-state--compact"> host.replaceChildren();
host.insertAdjacentHTML('beforeend', `<div class="empty-state empty-state--compact">
<div class="empty-state__title">${t('birthdays.emptyTitle')}</div> <div class="empty-state__title">${t('birthdays.emptyTitle')}</div>
<div class="empty-state__description">${t('birthdays.emptyDescription')}</div> <div class="empty-state__description">${t('birthdays.emptyDescription')}</div>
</div>`; </div>`);
return; return;
} }
host.innerHTML = state.upcoming.map((birthday) => ` host.replaceChildren();
host.insertAdjacentHTML('beforeend', state.upcoming.map((birthday) => `
<article class="birthday-card"> <article class="birthday-card">
<div class="birthday-card__media">${photoAvatar(birthday)}</div> <div class="birthday-card__media">${photoAvatar(birthday)}</div>
<div class="birthday-card__body"> <div class="birthday-card__body">
@@ -106,7 +109,7 @@ function renderUpcoming() {
<div class="birthday-card__note">${esc(ageNote(birthday))}</div> <div class="birthday-card__note">${esc(ageNote(birthday))}</div>
</div> </div>
</article> </article>
`).join(''); `).join(''));
} }
function renderList() { function renderList() {
@@ -114,14 +117,16 @@ function renderList() {
if (!host) return; if (!host) return;
const list = filteredBirthdays(); const list = filteredBirthdays();
if (!list.length) { if (!list.length) {
host.innerHTML = `<div class="empty-state"> host.replaceChildren();
host.insertAdjacentHTML('beforeend', `<div class="empty-state">
<div class="empty-state__title">${t('birthdays.emptyTitle')}</div> <div class="empty-state__title">${t('birthdays.emptyTitle')}</div>
<div class="empty-state__description">${t('birthdays.emptyDescription')}</div> <div class="empty-state__description">${t('birthdays.emptyDescription')}</div>
</div>`; </div>`);
return; return;
} }
host.innerHTML = list.map((birthday) => ` host.replaceChildren();
host.insertAdjacentHTML('beforeend', list.map((birthday) => `
<article class="birthday-item" data-id="${birthday.id}"> <article class="birthday-item" data-id="${birthday.id}">
<div class="birthday-item__media">${photoAvatar(birthday)}</div> <div class="birthday-item__media">${photoAvatar(birthday)}</div>
<div class="birthday-item__body"> <div class="birthday-item__body">
@@ -142,14 +147,15 @@ function renderList() {
</button> </button>
</div> </div>
</article> </article>
`).join(''); `).join(''));
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
stagger(host.querySelectorAll('.birthday-item')); stagger(host.querySelectorAll('.birthday-item'));
} }
function renderPage() { function renderPage() {
_container.innerHTML = ` _container.replaceChildren();
_container.insertAdjacentHTML('beforeend', `
<div class="birthdays-page"> <div class="birthdays-page">
<h1 class="sr-only">${t('birthdays.title')}</h1> <h1 class="sr-only">${t('birthdays.title')}</h1>
<div class="birthdays-toolbar"> <div class="birthdays-toolbar">
@@ -194,7 +200,7 @@ function renderPage() {
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i> <i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
</button> </button>
</div> </div>
`; `);
renderUpcoming(); renderUpcoming();
renderList(); renderList();
@@ -304,7 +310,8 @@ function openBirthdayModal({ mode, birthday = null }) {
const nameInput = panel.querySelector('#bd-name'); const nameInput = panel.querySelector('#bd-name');
const preview = panel.querySelector('#birthday-preview'); const preview = panel.querySelector('#birthday-preview');
const renderPreview = () => { const renderPreview = () => {
preview.innerHTML = birthdayPreviewHtml(nameInput.value.trim(), photoData); preview.replaceChildren();
preview.insertAdjacentHTML('beforeend', birthdayPreviewHtml(nameInput.value.trim(), photoData));
}; };
nameInput.addEventListener('input', renderPreview); nameInput.addEventListener('input', renderPreview);
panel.querySelector('#bd-photo').addEventListener('change', async (e) => { panel.querySelector('#bd-photo').addEventListener('change', async (e) => {
@@ -359,7 +366,7 @@ function openBirthdayModal({ mode, birthday = null }) {
renderUpcoming(); renderUpcoming();
renderSuggestions(); renderSuggestions();
renderList(); renderList();
closeModal(); closeModal({ force: true });
} catch (err) { } catch (err) {
window.oikos?.showToast(err.message, 'danger'); window.oikos?.showToast(err.message, 'danger');
saveBtn.disabled = false; saveBtn.disabled = false;
+32 -15
View File
@@ -6,7 +6,7 @@
*/ */
import { api } from '/api.js'; 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 { stagger, vibrate } from '/utils/ux.js';
import { t, formatDate, getLocale } from '/i18n.js'; import { t, formatDate, getLocale } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
@@ -607,7 +607,7 @@ function openBudgetModal({ mode, entry = null }) {
panel.querySelector('#bm-cancel').addEventListener('click', closeModal); panel.querySelector('#bm-cancel').addEventListener('click', closeModal);
panel.querySelector('#bm-delete')?.addEventListener('click', async () => { panel.querySelector('#bm-delete')?.addEventListener('click', async () => {
closeModal(); closeModal({ force: true });
await deleteEntry(entry.id); await deleteEntry(entry.id);
}); });
@@ -642,7 +642,7 @@ function openBudgetModal({ mode, entry = null }) {
const sumRes = await api.get(`/budget/summary?month=${state.month}`); const sumRes = await api.get(`/budget/summary?month=${state.month}`);
state.summary = sumRes.data; state.summary = sumRes.data;
closeModal(); closeModal({ force: true });
renderBody(); renderBody();
window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success'); window.oikos?.showToast(mode === 'create' ? t('budget.addedToast') : t('budget.savedToast'), 'success');
} catch (err) { } catch (err) {
@@ -660,18 +660,35 @@ function openBudgetModal({ mode, entry = null }) {
// -------------------------------------------------------- // --------------------------------------------------------
async function deleteEntry(id) { async function deleteEntry(id) {
if (!await confirmModal(t('budget.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; const entry = state.entries.find((e) => e.id === id);
try { state.entries = state.entries.filter((e) => e.id !== id);
await api.delete(`/budget/${id}`); renderBody();
state.entries = state.entries.filter((e) => e.id !== id); vibrate([30, 50, 30]);
const sumRes = await api.get(`/budget/summary?month=${state.month}`);
state.summary = sumRes.data; let undone = false;
renderBody(); window.oikos?.showToast(t('budget.deletedToast'), 'default', 5000, () => {
vibrate([30, 50, 30]); undone = true;
window.oikos?.showToast(t('budget.deletedToast'), 'success'); if (entry) {
} catch (err) { state.entries = [...state.entries, entry].sort((a, b) => new Date(b.date) - new Date(a.date));
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); 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);
} }
// -------------------------------------------------------- // --------------------------------------------------------
+78 -26
View File
@@ -6,7 +6,7 @@
import { api } from '/api.js'; import { api } from '/api.js';
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.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 { stagger } from '/utils/ux.js';
import { t, formatTime } from '/i18n.js'; import { t, formatTime } from '/i18n.js';
import { esc, fmtLocation } from '/utils/html.js'; import { esc, fmtLocation } from '/utils/html.js';
@@ -46,6 +46,19 @@ const EVENT_COLORS = [
'#8E8E93', '#30B0C7', '#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 const HOUR_HEIGHT = 56; // px pro Stunde in Wochen-/Tagesansicht
/** /**
@@ -764,7 +777,6 @@ function showEventPopup(ev, anchor) {
}); });
popup.querySelector('#popup-delete').addEventListener('click', async () => { 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(); popup.remove();
await deleteEvent(ev.id); await deleteEvent(ev.id);
}); });
@@ -844,15 +856,36 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0]; const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0];
// Farb-Auswahl // Farb-Auswahl: Auswahl + ARIA + Keyboard (Roving Tabindex)
panel.querySelectorAll('.color-swatch').forEach((sw) => { function selectSwatch(target) {
sw.addEventListener('click', () => { panel.querySelectorAll('.color-swatch').forEach((s) => {
panel.querySelectorAll('.color-swatch').forEach((s) => s.classList.remove('color-swatch--active')); s.classList.remove('color-swatch--active');
sw.classList.add('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) => { 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 // Ganztägig-Toggle
@@ -868,8 +901,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
panel.querySelector('#modal-cancel').addEventListener('click', closeModal); panel.querySelector('#modal-cancel').addEventListener('click', closeModal);
panel.querySelector('#modal-delete')?.addEventListener('click', async () => { panel.querySelector('#modal-delete')?.addEventListener('click', async () => {
if (!await confirmModal(t('calendar.deleteConfirm', { title: event.title }), { danger: true, confirmLabel: t('common.delete') })) return; closeModal({ force: true });
closeModal();
await deleteEvent(event.id); await deleteEvent(event.id);
}); });
@@ -959,11 +991,14 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">${t('calendar.colorLabel')}</label> <label class="form-label" id="event-color-label">${t('calendar.colorLabel')}</label>
<div class="color-picker"> <div class="color-picker" role="radiogroup" aria-labelledby="event-color-label">
${EVENT_COLORS.map((c) => ` ${EVENT_COLORS.map((c, i) => `
<div class="color-swatch" data-color="${c}" style="background-color:${c};" <div class="color-swatch" data-color="${c}" style="background-color:${c};"
role="radio" tabindex="0" aria-label="${t('calendar.colorLabel', { color: c })}"></div> role="radio"
tabindex="${i === 0 ? '0' : '-1'}"
aria-checked="false"
aria-label="${EVENT_COLOR_NAMES()[c] ?? c}"></div>
`).join('')} `).join('')}
</div> </div>
</div> </div>
@@ -1062,7 +1097,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
} }
} }
closeModal(); closeModal({ force: true });
renderView(); renderView();
window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success'); window.oikos?.showToast(mode === 'create' ? t('calendar.createdToast') : t('calendar.savedToast'), 'success');
} catch (err) { } catch (err) {
@@ -1073,15 +1108,32 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null) {
} }
async function deleteEvent(id) { async function deleteEvent(id) {
try { const event = state.events.find((e) => e.id === id);
await api.delete(`/calendar/${id}`); state.events = state.events.filter((e) => e.id !== id);
api.delete(`/reminders?entity_type=event&entity_id=${id}`).catch(() => {}); renderView();
refreshReminders();
state.events = state.events.filter((e) => e.id !== id); let undone = false;
renderView(); window.oikos?.showToast(t('calendar.deletedToast'), 'default', 5000, () => {
window.oikos?.showToast(t('calendar.deletedToast'), 'success'); undone = true;
} catch (err) { if (event) {
window.oikos?.showToast(err.data?.error ?? t('calendar.deleteError'), 'error'); 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);
} }
+29 -13
View File
@@ -5,7 +5,7 @@
*/ */
import { api } from '/api.js'; 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 { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js'; import { t } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
@@ -304,7 +304,7 @@ function openContactModal({ mode, contact = null }) {
panel.querySelector('#cm-cancel').addEventListener('click', closeModal); panel.querySelector('#cm-cancel').addEventListener('click', closeModal);
panel.querySelector('#cm-delete')?.addEventListener('click', async () => { panel.querySelector('#cm-delete')?.addEventListener('click', async () => {
closeModal(); closeModal({ force: true });
await deleteContact(contact.id); await deleteContact(contact.id);
}); });
@@ -336,7 +336,7 @@ function openContactModal({ mode, contact = null }) {
const idx = state.contacts.findIndex((c) => c.id === contact.id); const idx = state.contacts.findIndex((c) => c.id === contact.id);
if (idx !== -1) state.contacts[idx] = res.data; if (idx !== -1) state.contacts[idx] = res.data;
} }
closeModal(); closeModal({ force: true });
renderList(); renderList();
window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success'); window.oikos?.showToast(mode === 'create' ? t('contacts.savedToast') : t('contacts.updatedToast'), 'success');
} catch (err) { } catch (err) {
@@ -350,16 +350,32 @@ function openContactModal({ mode, contact = null }) {
} }
async function deleteContact(id) { async function deleteContact(id) {
if (!await confirmModal(t('contacts.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; const contact = state.contacts.find((c) => c.id === id);
try { state.contacts = state.contacts.filter((c) => c.id !== id);
await api.delete(`/contacts/${id}`); renderList();
state.contacts = state.contacts.filter((c) => c.id !== id); vibrate([30, 50, 30]);
renderList();
vibrate([30, 50, 30]); let undone = false;
window.oikos?.showToast(t('contacts.deletedToast'), 'success'); window.oikos?.showToast(t('contacts.deletedToast'), 'default', 5000, () => {
} catch (err) { undone = true;
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); 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);
} }
+125 -173
View File
@@ -12,11 +12,105 @@ import { openModal, closeModal } from '/components/modal.js';
// Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert. // Hält den AbortController des aktuellen FAB-Listeners - wird bei jedem render() erneuert.
let _fabController = null; 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) // 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 })); const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id) => ({ id, visible: true }));
@@ -427,24 +521,9 @@ function renderQuickAction({ route, label, icon, tone = '' }) {
`; `;
} }
function renderKpiTile({ title, value, meta, icon, route, tone = '' }) {
return `
<button type="button" class="dashboard-kpi ${tone ? `dashboard-kpi--${tone}` : ''}" data-route="${route}">
<span class="dashboard-kpi__icon"><i data-lucide="${icon}" aria-hidden="true"></i></span>
<span class="dashboard-kpi__body">
<span class="dashboard-kpi__label">${title}</span>
<span class="dashboard-kpi__value">${value}</span>
<span class="dashboard-kpi__meta">${meta}</span>
</span>
</button>
`;
}
function renderDashboardOverview(user, stats = null, weather = null) { function renderDashboardOverview(user) {
const dateLabel = formatDate(new Date()); 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 = [ const actions = [
{ route: '/tasks', label: t('nav.tasks'), icon: 'check-square', tone: 'blue' }, { route: '/tasks', label: t('nav.tasks'), icon: 'check-square', tone: 'blue' },
@@ -453,64 +532,6 @@ function renderDashboardOverview(user, stats = null, weather = null) {
{ route: '/notes', label: t('nav.notes'), icon: 'sticky-note', tone: 'amber' }, { route: '/notes', label: t('nav.notes'), icon: 'sticky-note', tone: 'amber' },
].map(renderQuickAction).join(''); ].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('') : `
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
`;
return ` return `
<section class="dashboard-overview"> <section class="dashboard-overview">
<div class="dashboard-overview__header"> <div class="dashboard-overview__header">
@@ -526,35 +547,13 @@ function renderDashboardOverview(user, stats = null, weather = null) {
</button> </button>
</div> </div>
</div> </div>
<div class="dashboard-kpi-grid">
${kpis}
</div>
</section> </section>
`; `;
} }
function widgetRegion(id) {
return ['budget', 'family', 'weather', 'shopping', 'meals'].includes(id) ? 'side' : 'main';
}
function widgetTileClass(id) { function widgetTileClass(id) {
const map = { const wideIds = ['tasks', 'budget', 'notes', 'weather'];
tasks: 'dashboard-tile--wide', return wideIds.includes(id) ? 'widget--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 `<section class="dashboard-tile dashboard-tile--${id} ${widgetTileClass(id)}">${html}</section>`;
} }
function renderDashboardLayout(cfg, data, weather, currency) { function renderDashboardLayout(cfg, data, weather, currency) {
@@ -570,31 +569,16 @@ function renderDashboardLayout(cfg, data, weather, currency) {
weather: () => (weather ? renderWeatherWidget(weather) : ''), weather: () => (weather ? renderWeatherWidget(weather) : ''),
}; };
const visible = cfg.filter((w) => w.visible && widgetById[w.id]); const tiles = cfg
const mainTiles = visible .filter((w) => w.visible && widgetById[w.id])
.filter((w) => widgetRegion(w.id) === 'main') .map((w) => {
.map((w) => renderDashboardTile(w.id, widgetById[w.id]())) const html = widgetById[w.id]();
if (!html) return '';
return `<div class="widget-wrapper ${widgetTileClass(w.id)}">${html}</div>`;
})
.join(''); .join('');
const sideTiles = visible return `<div class="dashboard__grid">${tiles}</div>`;
.filter((w) => widgetRegion(w.id) === 'side')
.map((w) => renderDashboardTile(w.id, widgetById[w.id]()))
.join('');
return `
<section class="dashboard-workspace">
<div class="dashboard-workspace__main">
<div class="dashboard-widget-grid">
${mainTiles}
</div>
</div>
<aside class="dashboard-workspace__side">
<div class="dashboard-side-stack">
${sideTiles}
</div>
</aside>
</section>
`;
} }
function renderDashboardSkeleton() { function renderDashboardSkeleton() {
@@ -606,32 +590,15 @@ function renderDashboardSkeleton() {
<div class="skeleton skeleton-line skeleton-line--medium"></div> <div class="skeleton skeleton-line skeleton-line--medium"></div>
</div> </div>
</div> </div>
<div class="dashboard-kpi-grid">
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
</div>
</section>
<section class="dashboard-workspace">
<div class="dashboard-workspace__main">
<div class="dashboard-widget-grid">
${skeletonWidget(3)}
${skeletonWidget(3)}
${skeletonWidget(2)}
${skeletonWidget(3)}
</div>
</div>
<aside class="dashboard-workspace__side">
<div class="dashboard-side-stack">
${skeletonWidget(3)}
${skeletonWidget(3)}
${skeletonWidget(2)}
</div>
</aside>
</section> </section>
<div class="dashboard__grid">
${skeletonWidget(3)}
${skeletonWidget(3)}
${skeletonWidget(2)}
${skeletonWidget(3)}
${skeletonWidget(3)}
${skeletonWidget(2)}
</div>
`; `;
} }
@@ -919,7 +886,7 @@ function openCustomizeModal(currentConfig, onSave) {
saveBtn.disabled = true; saveBtn.disabled = true;
try { try {
await api.put('/preferences', { dashboard_widgets: draft }); await api.put('/preferences', { dashboard_widgets: draft });
closeModal(); closeModal({ force: true });
onSave(draft); onSave(draft);
window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500); window.oikos?.showToast(t('dashboard.customizeSaved'), 'success', 1500);
} catch { } catch {
@@ -956,7 +923,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) {
panel.querySelector('[data-action="done"]').addEventListener('click', async () => { panel.querySelector('[data-action="done"]').addEventListener('click', async () => {
try { try {
await api.patch(`/tasks/${taskId}/status`, { status: 'done' }); await api.patch(`/tasks/${taskId}/status`, { status: 'done' });
closeModal(); closeModal({ force: true });
window.oikos?.showToast(t('tasks.swipedDoneToast'), 'success'); window.oikos?.showToast(t('tasks.swipedDoneToast'), 'success');
rerender(); rerender();
} catch (err) { } catch (err) {
@@ -964,7 +931,7 @@ function openTaskQuickAction(taskId, taskTitle, rerender) {
} }
}); });
panel.querySelector('[data-action="edit"]').addEventListener('click', () => { panel.querySelector('[data-action="edit"]').addEventListener('click', () => {
closeModal(); closeModal({ force: true });
window.oikos.navigate(`/tasks?open=${taskId}`); window.oikos.navigate(`/tasks?open=${taskId}`);
}); });
}, },
@@ -1036,35 +1003,16 @@ export async function render(container, { user }) {
window.oikos?.showToast(t('dashboard.loadError'), 'warning'); 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 }); const rerender = () => render(container, { user });
function rebuildDashboard(cfg) { function rebuildDashboard(cfg) {
const shell = container.querySelector('#dashboard-shell'); const shell = container.querySelector('#dashboard-shell');
if (!shell) return; if (!shell) return;
shell.innerHTML = ` shell.replaceChildren();
${renderDashboardOverview(user, stats, weather)} shell.insertAdjacentHTML('beforeend', `
${renderDashboardOverview(user)}
${renderDashboardLayout(cfg, data, weather, currency)} ${renderDashboardLayout(cfg, data, weather, currency)}
`; `);
wireLinks(container, rerender); wireLinks(container, rerender);
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
wireWeatherRefresh(container, (updatedWeather) => { wireWeatherRefresh(container, (updatedWeather) => {
@@ -1096,6 +1044,10 @@ export async function render(container, { user }) {
const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000); const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000);
_fabController.signal.addEventListener('abort', () => clearInterval(timerId)); _fabController.signal.addEventListener('abort', () => clearInterval(timerId));
} }
if (!localStorage.getItem(ONBOARDING_KEY)) {
setTimeout(() => showOnboarding(container), 400);
}
} }
function wireWeatherRefresh(container, onUpdated = null) { function wireWeatherRefresh(container, onUpdated = null) {
+31 -4
View File
@@ -68,7 +68,7 @@ export async function render(container) {
<div class="login-error" id="login-error" role="alert" aria-live="polite" hidden></div> <div class="login-error" id="login-error" role="alert" aria-live="polite" hidden></div>
<button type="submit" class="btn btn--primary login-form__submit" id="login-btn"> <button type="submit" class="btn btn--primary login-form__submit" id="login-btn">
${t('login.loginButton')} <span class="login-btn__label">${t('login.loginButton')}</span>
</button> </button>
</form> </form>
</div> </div>
@@ -101,13 +101,30 @@ export async function render(container) {
const username = form.username.value.trim(); const username = form.username.value.trim();
const password = form.password.value; 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);
usernameInput.setAttribute('aria-invalid', String(!username));
passwordInput.setAttribute('aria-invalid', String(!password));
if (!username || !password) { if (!username || !password) {
showError(errorEl, t('common.allFieldsRequired')); if (!username) usernameInput.focus();
else passwordInput.focus();
return; return;
} }
const labelEl = submitBtn.querySelector('.login-btn__label');
submitBtn.disabled = true; 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 { try {
const result = await auth.login(username, password); const result = await auth.login(username, password);
@@ -119,9 +136,19 @@ export async function render(container) {
); );
} finally { } finally {
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = t('login.loginButton'); labelEl.textContent = t('login.loginButton');
spinner.remove();
} }
}); });
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', (e) => {
e.currentTarget.closest('.form-group').classList.remove('form-group--error');
e.currentTarget.removeAttribute('aria-invalid');
});
} }
function showError(el, message) { function showError(el, message) {
+26 -14
View File
@@ -5,7 +5,7 @@
*/ */
import { api } from '/api.js'; 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 { stagger } from '/utils/ux.js';
import { t, formatDate } from '/i18n.js'; import { t, formatDate } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
@@ -707,7 +707,7 @@ function openMealModal(opts) {
if (res.data.transferred > 0) { 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'); 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); await loadWeek(state.currentWeek);
closeModal(); closeModal({ force: true });
renderWeekGrid(); renderWeekGrid();
} else { } else {
window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info'); window.oikos?.showToast(t('meals.transferAlreadyDone'), 'info');
@@ -843,8 +843,8 @@ function ingredientRowHTML(name, qty, id, category = DEFAULT_CATEGORY_NAME) {
`; `;
} }
function closeModal() { function closeModal({ force = false } = {}) {
closeSharedModal(); closeSharedModal({ force });
state.modal = null; state.modal = null;
} }
@@ -894,7 +894,7 @@ async function saveModal(overlay) {
await loadWeek(state.currentWeek); await loadWeek(state.currentWeek);
} }
closeModal(); closeModal({ force: true });
renderWeekGrid(); renderWeekGrid();
window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success'); window.oikos?.showToast(mode === 'create' ? t('meals.addMealTitle') : t('meals.editMeal'), 'success');
} catch (err) { } catch (err) {
@@ -920,15 +920,27 @@ function collectModalIngredients(overlay) {
// -------------------------------------------------------- // --------------------------------------------------------
async function deleteMeal(mealId) { async function deleteMeal(mealId) {
if (!await confirmModal(t('meals.deleteMeal') + '?', { danger: true, confirmLabel: t('common.delete') })) return; const meal = state.meals.find((m) => m.id === mealId);
try { const itemEl = _container.querySelector(`.meal-slot--has-meal[data-meal-id="${mealId}"]`);
await api.delete(`/meals/${mealId}`); if (itemEl) itemEl.style.display = 'none';
state.meals = state.meals.filter((m) => m.id !== mealId);
renderWeekGrid(); let undone = false;
window.oikos?.showToast(t('meals.deleteMeal'), 'success'); window.oikos?.showToast(t('meals.deletedToast'), 'default', 5000, () => {
} catch (err) { undone = true;
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error'); 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);
} }
// -------------------------------------------------------- // --------------------------------------------------------
+73 -19
View File
@@ -5,7 +5,7 @@
*/ */
import { api } from '/api.js'; 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 { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js'; import { t } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
@@ -19,6 +19,17 @@ const NOTE_COLORS = [
'#90CAF9', '#CE93D8', '#FFAB91', '#FFFFFF', '#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 // State
// -------------------------------------------------------- // --------------------------------------------------------
@@ -368,13 +379,16 @@ function openNoteModal({ mode, note = null }) {
style="resize:vertical;">${esc(isEdit ? note.content : '')}</textarea> style="resize:vertical;">${esc(isEdit ? note.content : '')}</textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">${t('notes.colorLabel')}</label> <label class="form-label" id="note-color-label">${t('notes.colorLabel')}</label>
<div class="note-color-picker"> <div class="note-color-picker" role="radiogroup" aria-labelledby="note-color-label">
${NOTE_COLORS.map((c) => ` ${NOTE_COLORS.map((c) => `
<div class="note-color-swatch ${c === selColor ? 'note-color-swatch--active' : ''}" <div class="note-color-swatch ${c === selColor ? 'note-color-swatch--active' : ''}"
data-color="${c}" data-color="${c}"
style="background-color:${c};border:2px solid ${c === '#FFFFFF' ? '#E5E5EA' : c};" style="background-color:${c};border:2px solid ${c === '#FFFFFF' ? '#E5E5EA' : c};"
role="radio" tabindex="0" aria-label="Farbe ${c}"></div> role="radio"
tabindex="${c === selColor ? '0' : '-1'}"
aria-checked="${c === selColor ? 'true' : 'false'}"
aria-label="${NOTE_COLOR_NAMES()[c] ?? c}"></div>
`).join('')} `).join('')}
</div> </div>
</div> </div>
@@ -396,11 +410,34 @@ function openNoteModal({ mode, note = null }) {
content, content,
size: 'md', size: 'md',
onSave(panel) { 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) => { panel.querySelectorAll('.note-color-swatch').forEach((sw) => {
sw.addEventListener('click', () => { sw.addEventListener('click', () => { selectSwatch(sw); sw.focus(); });
panel.querySelectorAll('.note-color-swatch').forEach((s) => s.classList.remove('note-color-swatch--active')); sw.addEventListener('keydown', (e) => {
sw.classList.add('note-color-swatch--active'); 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);
}
}); });
}); });
@@ -445,7 +482,7 @@ function openNoteModal({ mode, note = null }) {
if (idx !== -1) state.notes[idx] = res.data; if (idx !== -1) state.notes[idx] = res.data;
state.notes.sort((a, b) => b.pinned - a.pinned); state.notes.sort((a, b) => b.pinned - a.pinned);
} }
closeModal(); closeModal({ force: true });
renderGrid(); renderGrid();
window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success'); window.oikos?.showToast(mode === 'create' ? t('notes.createdToast') : t('notes.savedToast'), 'success');
} catch (err) { } catch (err) {
@@ -476,16 +513,33 @@ async function togglePin(id) {
} }
async function deleteNote(id) { async function deleteNote(id) {
if (!await confirmModal(t('notes.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; closeModal({ force: true });
try { const note = state.notes.find((n) => n.id === id);
await api.delete(`/notes/${id}`); state.notes = state.notes.filter((n) => n.id !== id);
state.notes = state.notes.filter((n) => n.id !== id); renderGrid();
renderGrid(); vibrate([30, 50, 30]);
vibrate([30, 50, 30]);
window.oikos?.showToast(t('notes.deletedToast'), 'success'); let undone = false;
} catch (err) { window.oikos?.showToast(t('notes.deletedToast'), 'default', 5000, () => {
window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'error'); 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);
} }
// -------------------------------------------------------- // --------------------------------------------------------
+23 -17
View File
@@ -5,7 +5,7 @@
import { api } from '/api.js'; import { api } from '/api.js';
import { t } from '/i18n.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'; import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
let _container = null; let _container = null;
@@ -134,6 +134,7 @@ function renderRecipeList() {
for (const recipe of state.recipes) { for (const recipe of state.recipes) {
const card = document.createElement('article'); const card = document.createElement('article');
card.className = 'recipe-card'; card.className = 'recipe-card';
card.dataset.id = String(recipe.id);
const h = document.createElement('h2'); const h = document.createElement('h2');
h.className = 'recipe-card__title'; h.className = 'recipe-card__title';
@@ -325,8 +326,8 @@ function openRecipeModal(mode, recipe = null) {
}); });
} }
function closeModal() { function closeModal({ force = false } = {}) {
closeSharedModal(); closeSharedModal({ force });
} }
async function saveRecipe(panel, mode, recipe) { async function saveRecipe(panel, mode, recipe) {
@@ -360,7 +361,7 @@ async function saveRecipe(panel, mode, recipe) {
if (idx >= 0) state.recipes[idx] = res.data; if (idx >= 0) state.recipes[idx] = res.data;
} }
closeModal(); closeModal({ force: true });
renderRecipeList(); renderRecipeList();
window.oikos?.showToast(mode === 'create' ? t('recipes.created') : t('recipes.updated'), 'success'); window.oikos?.showToast(mode === 'create' ? t('recipes.created') : t('recipes.updated'), 'success');
} catch (err) { } catch (err) {
@@ -370,21 +371,26 @@ async function saveRecipe(panel, mode, recipe) {
} }
async function removeRecipe(recipe) { async function removeRecipe(recipe) {
const ok = await confirmModal(t('recipes.deleteConfirm', { title: recipe.title }), { const itemEl = _container.querySelector(`.recipe-card[data-id="${recipe.id}"]`);
danger: true, if (itemEl) itemEl.style.display = 'none';
confirmLabel: t('common.delete'),
let undone = false;
window.oikos?.showToast(t('recipes.deleted'), 'default', 5000, () => {
undone = true;
if (itemEl) itemEl.style.display = '';
}); });
if (!ok) return; setTimeout(async () => {
if (undone) return;
try { try {
await api.delete(`/recipes/${recipe.id}`); await api.delete(`/recipes/${recipe.id}`);
state.recipes = state.recipes.filter((r) => r.id !== recipe.id); state.recipes = state.recipes.filter((r) => r.id !== recipe.id);
renderRecipeList(); renderRecipeList();
window.oikos?.showToast(t('recipes.deleted'), 'success'); } catch (err) {
} catch (err) { if (itemEl) itemEl.style.display = '';
window.oikos?.showToast(err.data?.error ?? t('common.errorGeneric'), 'error'); window.oikos?.showToast(err.data?.error ?? t('common.unknownError'), 'danger');
} }
}, 5000);
} }
async function duplicateRecipe(recipe) { async function duplicateRecipe(recipe) {
+27 -16
View File
@@ -8,7 +8,7 @@ import { api } from '/api.js';
import { stagger, vibrate } from '/utils/ux.js'; import { stagger, vibrate } from '/utils/ux.js';
import { t } from '/i18n.js'; import { t } from '/i18n.js';
import { esc } from '/utils/html.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'; import { DEFAULT_CATEGORY_NAME, categoryLabel } from '/utils/shopping-categories.js';
// -------------------------------------------------------- // --------------------------------------------------------
@@ -781,23 +781,34 @@ function wireListContentEvents(container) {
// ---- Liste löschen ---- // ---- Liste löschen ----
if (action === 'delete-list') { if (action === 'delete-list') {
if (!await confirmModal(t('shopping.deleteListConfirm', { name: state.activeList?.name }), { danger: true, confirmLabel: t('common.delete') })) return; const deletedListId = state.activeListId;
try {
await api.delete(`/shopping/${state.activeListId}`); let undone = false;
state.lists = state.lists.filter((l) => l.id !== state.activeListId); window.oikos.showToast(t('shopping.deletedListToast'), 'default', 5000, () => {
state.activeListId = state.lists[0]?.id ?? null; undone = true;
if (state.activeListId) { // Liste wurde nie optimistisch ausgeblendet → kein visuelles Restore nötig
await switchList(state.activeListId, container); });
} else {
state.items = []; setTimeout(async () => {
state.activeList = null; if (undone) return;
try {
await api.delete(`/shopping/${deletedListId}`);
await loadLists();
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');
await loadLists();
renderTabs(container); renderTabs(container);
renderListContent(container);
} }
window.oikos.showToast(t('shopping.deletedListToast')); }, 5000);
} catch (err) {
window.oikos.showToast(err.message, 'danger');
}
} }
}); });
+33 -14
View File
@@ -6,7 +6,7 @@
import { api } from '/api.js'; import { api } from '/api.js';
import { renderRRuleFields, bindRRuleEvents, getRRuleValues } from '/rrule-ui.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 { stagger, vibrate } from '/utils/ux.js';
import { t, formatDate, formatTime } from '/i18n.js'; import { t, formatDate, formatTime } from '/i18n.js';
import { esc } from '/utils/html.js'; import { esc } from '/utils/html.js';
@@ -570,7 +570,7 @@ async function handleFormSubmit(e, container) {
} }
btnSuccess(submitBtn, originalLabel); btnSuccess(submitBtn, originalLabel);
setTimeout(() => closeModal(), 700); setTimeout(() => closeModal({ force: true }), 700);
await loadTasks(container); await loadTasks(container);
} catch (err) { } catch (err) {
errorEl.textContent = err.message; errorEl.textContent = err.message;
@@ -582,18 +582,29 @@ async function handleFormSubmit(e, container) {
} }
async function handleDeleteTask(id, container) { async function handleDeleteTask(id, container) {
if (!await confirmModal(t('tasks.deleteConfirm'), { danger: true, confirmLabel: t('common.delete') })) return; closeModal({ force: true });
try { const itemEl = container.querySelector(`[data-task-id="${id}"]`);
await api.delete(`/tasks/${id}`); if (itemEl) itemEl.style.display = 'none';
// Erinnerungen für diese Aufgabe ebenfalls entfernen
api.delete(`/reminders?entity_type=task&entity_id=${id}`).catch(() => {}); let undone = false;
refreshReminders(); window.oikos.showToast(t('tasks.deletedToast'), 'default', 5000, () => {
closeModal(); undone = true;
window.oikos.showToast(t('tasks.deletedToast'), 'default'); if (itemEl) itemEl.style.display = '';
await loadTasks(container); });
} catch (err) {
window.oikos.showToast(err.message, 'danger'); 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) { async function handleAddSubtask(parentId, container) {
@@ -1081,6 +1092,13 @@ function updateOverdueBadge() {
}).length; }).length;
document.querySelectorAll('[data-route="/tasks"] .nav-badge').forEach((el) => el.remove()); 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) { if (overdue > 0) {
document.querySelectorAll('[data-route="/tasks"]').forEach((navItem) => { document.querySelectorAll('[data-route="/tasks"]').forEach((navItem) => {
let anchor = navItem.querySelector('.nav-item__icon-wrap'); let anchor = navItem.querySelector('.nav-item__icon-wrap');
@@ -1097,6 +1115,7 @@ function updateOverdueBadge() {
} }
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'nav-badge'; badge.className = 'nav-badge';
badge.setAttribute('aria-hidden', 'true');
badge.textContent = String(overdue); badge.textContent = String(overdue);
anchor.appendChild(badge); anchor.appendChild(badge);
}); });
+7
View File
@@ -69,8 +69,15 @@ function showBrowserNotification(title, body) {
* @param {number} count * @param {number} count
*/ */
function updateBellBadge(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) => { document.querySelectorAll('.reminder-bell-badge').forEach((badge) => {
if (count > 0) { if (count > 0) {
badge.setAttribute('aria-hidden', 'true');
badge.textContent = count > 9 ? '9+' : String(count); badge.textContent = count > 9 ? '9+' : String(count);
badge.hidden = false; badge.hidden = false;
} else { } else {
+29 -3
View File
@@ -340,6 +340,14 @@ async function renderPage(route, previousPath = null) {
await module.render(pageWrapper, { user: currentUser }); 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 === route.path)?.label ?? route.path;
announcer.textContent = '';
setTimeout(() => { announcer.textContent = pageLabel; }, 50);
}
// Erst nach render() + CSS sichtbar machen und Animation starten // Erst nach render() + CSS sichtbar machen und Animation starten
pageWrapper.style.opacity = ''; pageWrapper.style.opacity = '';
pageWrapper.classList.add(inClass); pageWrapper.classList.add(inClass);
@@ -429,7 +437,6 @@ function renderAppShell(container) {
const main = document.createElement('main'); const main = document.createElement('main');
main.className = 'app-content'; main.className = 'app-content';
main.id = 'main-content'; main.id = 'main-content';
main.setAttribute('aria-live', 'polite');
const bottomNav = document.createElement('nav'); const bottomNav = document.createElement('nav');
bottomNav.className = 'nav-bottom'; bottomNav.className = 'nav-bottom';
@@ -514,7 +521,13 @@ function renderAppShell(container) {
toastContainer.id = 'toast-container'; toastContainer.id = 'toast-container';
toastContainer.setAttribute('aria-live', 'assertive'); 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);
updateBranding(currentPath || '/'); updateBranding(currentPath || '/');
// Klick-Handler für alle Nav-Links // Klick-Handler für alle Nav-Links
@@ -781,9 +794,22 @@ function updateNav(path) {
const moreBtn = document.querySelector('#more-btn'); const moreBtn = document.querySelector('#more-btn');
if (moreBtn) { 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.classList.toggle('nav-item--active', inMoreSheet);
moreBtn.toggleAttribute('aria-current', 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) { if (window.lucide) {
+112
View File
@@ -163,6 +163,17 @@
grid-template-columns: 1fr; 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) { @media (min-width: 768px) {
.dashboard__grid { .dashboard__grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@@ -427,6 +438,7 @@
padding: var(--space-2) 0; padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border-subtle); border-bottom: 1px solid var(--color-border-subtle);
cursor: pointer; cursor: pointer;
touch-action: pan-y;
transition: opacity var(--transition-fast); transition: opacity var(--transition-fast);
} }
@@ -507,6 +519,7 @@
padding: var(--space-2) 0; padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-border-subtle); border-bottom: 1px solid var(--color-border-subtle);
cursor: pointer; cursor: pointer;
touch-action: pan-y;
transition: opacity var(--transition-fast); transition: opacity var(--transition-fast);
} }
@@ -594,6 +607,7 @@
padding: var(--space-2) var(--space-1); padding: var(--space-2) var(--space-1);
background-color: var(--color-surface); background-color: var(--color-surface);
cursor: pointer; cursor: pointer;
touch-action: pan-y;
transition: background-color var(--transition-fast); transition: background-color var(--transition-fast);
text-align: center; text-align: center;
min-height: 72px; min-height: 72px;
@@ -683,6 +697,7 @@
* -------------------------------------------------------- */ * -------------------------------------------------------- */
.shopping-widget-list { .shopping-widget-list {
cursor: pointer; cursor: pointer;
touch-action: pan-y;
transition: background-color var(--transition-fast); transition: background-color var(--transition-fast);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: var(--space-2) 0; padding: var(--space-2) 0;
@@ -794,6 +809,7 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: var(--space-3); padding: var(--space-3);
cursor: pointer; cursor: pointer;
touch-action: pan-y;
transition: opacity var(--transition-fast), transform var(--transition-fast); transition: opacity var(--transition-fast), transform var(--transition-fast);
border-left: 3px solid var(--note-color, var(--color-accent)); 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)); background-color: color-mix(in srgb, var(--note-color, var(--color-accent)) 6%, var(--color-surface-2));
@@ -1338,6 +1354,101 @@
cursor: not-allowed; 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; }
}
/* -------------------------------------------------------- /* --------------------------------------------------------
* Modern Dashboard Skin * Modern Dashboard Skin
* -------------------------------------------------------- */ * -------------------------------------------------------- */
@@ -2167,6 +2278,7 @@
border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent); border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
background: var(--color-surface-2); background: var(--color-surface-2);
cursor: pointer; cursor: pointer;
touch-action: pan-y;
} }
.birthday-widget-item + .birthday-widget-item { .birthday-widget-item + .birthday-widget-item {
+10
View File
@@ -1647,10 +1647,20 @@
opacity: 0.85; opacity: 0.85;
} }
.toast__undo {
-webkit-tap-highlight-color: transparent;
transition: opacity var(--transition-fast), transform 0.08s ease;
}
.toast__undo:hover { .toast__undo:hover {
opacity: 1; opacity: 1;
} }
.toast__undo:active {
transform: scale(0.94);
opacity: 1;
}
.toast--success { background-color: var(--color-success); color: var(--toast-success-text); } .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--danger { background-color: var(--color-danger); color: var(--toast-danger-text); }
.toast--warning { background-color: var(--color-warning); color: var(--toast-warning-text); } .toast--warning { background-color: var(--color-warning); color: var(--toast-warning-text); }
+32
View File
@@ -45,6 +45,10 @@
.login-form__submit { .login-form__submit {
width: 100%; width: 100%;
margin-top: var(--space-2); margin-top: var(--space-2);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
} }
.login-error { .login-error {
@@ -63,3 +67,31 @@
text-align: center; text-align: center;
opacity: 0.6; 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;
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; }
}
+6 -1
View File
@@ -406,11 +406,16 @@
color: var(--color-text-primary); color: var(--color-text-primary);
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
cursor: pointer; 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 { .task-card--done .task-card__title {
text-decoration: line-through;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-decoration-color: var(--color-text-secondary);
} }
.task-card__meta { .task-card__meta {
+3 -3
View File
@@ -88,8 +88,8 @@ app.set('trust proxy', process.env.TRUST_PROXY !== undefined ? process.env.TRUST
// -------------------------------------------------------- // --------------------------------------------------------
// Request-Parsing // Request-Parsing
// -------------------------------------------------------- // --------------------------------------------------------
app.use(express.json({ limit: '1mb' })); app.use(express.json({ limit: '7mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' })); app.use(express.urlencoded({ extended: true, limit: '7mb' }));
// JSON-Parse-Fehler abfangen (gibt sonst HTML zurück) // JSON-Parse-Fehler abfangen (gibt sonst HTML zurück)
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
@@ -97,7 +97,7 @@ app.use((err, req, res, next) => {
return res.status(400).json({ error: 'Invalid JSON in request body.', code: 400 }); return res.status(400).json({ error: 'Invalid JSON in request body.', code: 400 });
} }
if (err.type === 'entity.too.large') { 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); next(err);
}); });
+1 -1
View File
@@ -6,7 +6,7 @@ import { deleteBirthdayArtifacts, hydrateBirthday, syncBirthdayArtifacts, syncAl
const log = createLogger('Birthdays'); const log = createLogger('Birthdays');
const router = express.Router(); 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+/=]+$/; const PHOTO_RE = /^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
function validatePhotoData(val) { function validatePhotoData(val) {
+1 -1
View File
@@ -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 AND si.is_checked = 0) AS open_count,
(SELECT COUNT(*) FROM shopping_items si WHERE si.list_id = sl.id) AS total_count (SELECT COUNT(*) FROM shopping_items si WHERE si.list_id = sl.id) AS total_count
FROM shopping_lists sl 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 ORDER BY sl.updated_at DESC
LIMIT 3 LIMIT 3
`).all(); `).all();
+7
View File
@@ -50,15 +50,19 @@ function makeField() {
function makeInput({ value = '', required = true } = {}) { function makeInput({ value = '', required = true } = {}) {
const listeners = {}; const listeners = {};
const attrs = {};
const field = makeField(); const field = makeField();
return { return {
value, value,
required, required,
_field: field, _field: field,
_listeners: listeners, _listeners: listeners,
_attrs: attrs,
addEventListener(event, fn) { listeners[event] = fn; }, addEventListener(event, fn) { listeners[event] = fn; },
closest() { return field; }, closest() { return field; },
parentElement: 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'](); input._listeners['blur']();
assert.ok(input._field._classes.has('form-field--error')); assert.ok(input._field._classes.has('form-field--error'));
assert.ok(!input._field._classes.has('form-field--valid')); 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', () => { 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'](); input._listeners['blur']();
assert.ok(input._field._classes.has('form-field--valid')); assert.ok(input._field._classes.has('form-field--valid'));
assert.ok(!input._field._classes.has('form-field--error')); 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', () => { 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])); wireBlurValidation(makeContainer([input]));
input._listeners['blur'](); input._listeners['blur']();
assert.ok(input._field._classes.has('form-field--error')); 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', () => { test('wireBlurValidation: kein Fehler wenn closest() null zurückgibt', () => {