diff --git a/.gitignore b/.gitignore
index 7e74964..3c03c07 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,8 @@ data/
.idea/
*.swp
*.swo
+.codex
+
# Claude Code — share skills/agents/rules/hooks/settings; keep local permissions and worktrees out
.claude/settings.local.json
diff --git a/public/i18n.js b/public/i18n.js
index c14da31..64b88d9 100644
--- a/public/i18n.js
+++ b/public/i18n.js
@@ -8,6 +8,8 @@
const SUPPORTED_LOCALES = ['de', 'en', 'es', 'fr', 'it', 'sv', 'el', 'ru', 'tr', 'zh', 'ja', 'ar', 'hi', 'pt', 'uk'];
const DEFAULT_LOCALE = 'de';
const STORAGE_KEY = 'oikos-locale';
+const DATE_FORMAT_KEY = 'oikos-date-format';
+const DEFAULT_DATE_FORMAT = 'mdy';
let currentLocale = DEFAULT_LOCALE;
let translations = {};
@@ -78,6 +80,28 @@ export function t(key, params = {}) {
return str;
}
+function isDateOnlyString(value) {
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
+}
+
+function getDateFormatPreference() {
+ const stored = localStorage.getItem(DATE_FORMAT_KEY);
+ return ['mdy', 'dmy', 'ymd'].includes(stored) ? stored : DEFAULT_DATE_FORMAT;
+}
+
+function formatDateParts(date, useUtc = false) {
+ const d = date instanceof Date ? date : new Date(date);
+ if (isNaN(d.getTime())) return '';
+ const year = useUtc ? d.getUTCFullYear() : d.getFullYear();
+ const month = String((useUtc ? d.getUTCMonth() : d.getMonth()) + 1).padStart(2, '0');
+ const day = String(useUtc ? d.getUTCDate() : d.getDate()).padStart(2, '0');
+ switch (getDateFormatPreference()) {
+ case 'dmy': return `${day}/${month}/${year}`;
+ case 'ymd': return `${year}-${month}-${day}`;
+ default: return `${month}/${day}/${year}`;
+ }
+}
+
/** Aktuelle Locale abfragen */
export function getLocale() {
return currentLocale;
@@ -91,13 +115,10 @@ export function getSupportedLocales() {
/** Datum locale-aware formatieren */
export function formatDate(date) {
if (date == null) return '';
- const d = date instanceof Date ? date : new Date(date);
- if (isNaN(d.getTime())) return '';
- return new Intl.DateTimeFormat(currentLocale, {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- }).format(d);
+ if (isDateOnlyString(date)) {
+ return formatDateParts(new Date(`${date}T00:00:00Z`), true);
+ }
+ return formatDateParts(date);
}
/** Uhrzeit locale-aware formatieren */
diff --git a/public/locales/ar.json b/public/locales/ar.json
index 2ddfa8c..8bcf591 100644
--- a/public/locales/ar.json
+++ b/public/locales/ar.json
@@ -38,6 +38,7 @@
"shopping": "التسوق",
"notes": "الملاحظات",
"contacts": "جهات الاتصال",
+ "birthdays": "أعياد الميلاد",
"budget": "الميزانية",
"settings": "الإعدادات",
"main": "القائمة الرئيسية",
@@ -82,6 +83,19 @@
"allDay": "طوال اليوم",
"shoppingMore": "+{{count}} أخرى",
"weather": "الطقس",
+ "familyMembers": "أفراد العائلة",
+ "participantsAdded": "مشاركون مضافون",
+ "upcomingBirthdays": "أعياد الميلاد القادمة",
+ "noBirthdays": "لا توجد أعياد ميلاد بعد",
+ "daysLeft": "{{count}} أيام",
+ "budgetOverview": "نظرة عامة على الميزانية",
+ "monthlyIncome": "الدخل",
+ "monthlyExpenses": "المصروفات",
+ "monthlyBalance": "الرصيد",
+ "savingsRate": "معدل الادخار",
+ "topExpense": "أكبر مصروف",
+ "budgetEntries": "القيود",
+ "noBudgetData": "لا توجد بيانات ميزانية لهذا الشهر.",
"customize": "تخصيص",
"customizeTitle": "تخصيص الأدوات",
"customizeReset": "الافتراضي",
@@ -537,6 +551,7 @@
"tabAccount": "الحساب",
"tabsAriaLabel": "أقسام الإعدادات",
"sectionDesign": "التصميم",
+ "sectionAppName": "اسم التطبيق",
"sectionShopping": "التسوق",
"shoppingCategoriesLabel": "فئات التسوق",
"shoppingCategoriesHint": "إضافة الفئات أو إعادة تسميتها أو حذفها أو ترتيبها.",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "مزامنة التقويم",
"sectionFamily": "أفراد العائلة",
"cardAppearance": "المظهر",
+ "appNameTitle": "اسم التطبيق",
+ "appNameLabel": "اسم التطبيق",
+ "appNameHint": "يظهر هذا الاسم في الشريط الجانبي وعنوان المتصفح وشاشة تسجيل الدخول.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "تم حفظ اسم التطبيق.",
+ "sectionDate": "التاريخ",
+ "dateFormatTitle": "تنسيق التاريخ",
+ "dateFormatLabel": "تنسيق التاريخ المفضل",
+ "dateFormatHint": "اختر كيف تظهر التواريخ في التطبيق.",
+ "dateFormatSavedToast": "تم حفظ تنسيق التاريخ.",
"themeSystem": "النظام",
"themeSysLabel": "استخدام إعداد النظام",
"themeLight": "فاتح",
@@ -760,6 +785,34 @@
"placeholder": "بحث…",
"noResults": "لم يتم العثور على نتائج."
},
+ "birthdays": {
+ "title": "أعياد الميلاد",
+ "addButton": "إضافة عيد ميلاد",
+ "searchPlaceholder": "ابحث عن أعياد الميلاد…",
+ "upcomingTitle": "أعياد الميلاد القادمة",
+ "upcomingHint": "الاحتفالات القادمة، وهي متزامنة بالفعل مع التقويم.",
+ "peopleTitle": "الأشخاص",
+ "peopleHint": "ابحث وراجع وعدّل جميع أعياد الميلاد المحفوظة.",
+ "emptyTitle": "لا توجد أعياد ميلاد بعد",
+ "emptyDescription": "أضف عيد ميلاد ليبقى ظاهرًا في التقويم والتذكيرات.",
+ "newTitle": "عيد ميلاد جديد",
+ "editTitle": "تعديل عيد الميلاد",
+ "nameLabel": "الاسم",
+ "birthDateLabel": "تاريخ الميلاد",
+ "photoLabel": "الصورة الشخصية",
+ "removePhoto": "إزالة الصورة",
+ "notesLabel": "ملاحظات",
+ "notesPlaceholder": "أفكار هدايا، الكعكة المفضلة، ملاحظات عائلية…",
+ "calendarHint": "يتم إضافة كل عيد ميلاد تلقائيًا إلى التقويم ونظام التذكيرات.",
+ "requiredFields": "الاسم وتاريخ الميلاد مطلوبان.",
+ "createdToast": "تم حفظ عيد الميلاد.",
+ "updatedToast": "تم تحديث عيد الميلاد.",
+ "deletedToast": "تم حذف عيد الميلاد.",
+ "deleteConfirm": "هل تريد حذف عيد ميلاد \"{{name}}\"؟",
+ "ageNoteToday": "سيكمل {{age}} عامًا اليوم.",
+ "ageNoteTomorrow": "سيكمل {{age}} عامًا غدًا.",
+ "ageNoteDays": "سيكمل {{age}} عامًا بعد {{days}} يومًا."
+ },
"reminders": {
"sectionTitle": "تذكير",
"enableLabel": "تعيين تذكير",
diff --git a/public/locales/de.json b/public/locales/de.json
index 5a47964..e12c37b 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -38,6 +38,7 @@
"shopping": "Einkauf",
"notes": "Notizen",
"contacts": "Kontakte",
+ "birthdays": "Geburtstage",
"budget": "Budget",
"settings": "Einstellungen",
"main": "Hauptnavigation",
@@ -88,6 +89,19 @@
"allDay": "Ganztägig",
"shoppingMore": "+{{count}} weitere",
"weather": "Wetter",
+ "familyMembers": "Familienmitglieder",
+ "participantsAdded": "Teilnehmer hinzugefügt",
+ "upcomingBirthdays": "Nächste Geburtstage",
+ "noBirthdays": "Noch keine Geburtstage",
+ "daysLeft": "{{count}} Tage",
+ "budgetOverview": "Budgetübersicht",
+ "monthlyIncome": "Einnahmen",
+ "monthlyExpenses": "Ausgaben",
+ "monthlyBalance": "Saldo",
+ "savingsRate": "Sparquote",
+ "topExpense": "Größte Ausgabe",
+ "budgetEntries": "Einträge",
+ "noBudgetData": "Keine Budgetdaten in diesem Monat.",
"customize": "Anpassen",
"customizeTitle": "Widgets anpassen",
"customizeReset": "Standard",
@@ -562,6 +576,7 @@
"tabAccount": "Konto",
"tabsAriaLabel": "Einstellungsbereiche",
"sectionDesign": "Design",
+ "sectionAppName": "Anwendungsname",
"sectionShopping": "Einkauf",
"shoppingCategoriesLabel": "Einkaufskategorien",
"shoppingCategoriesHint": "Kategorien hinzufügen, umbenennen, löschen oder sortieren.",
@@ -579,6 +594,16 @@
"sectionCalendarSync": "Kalender-Synchronisation",
"sectionFamily": "Familienmitglieder",
"cardAppearance": "Darstellung",
+ "appNameTitle": "App-Name",
+ "appNameLabel": "Anwendungsname",
+ "appNameHint": "Dieser Name erscheint in der Seitenleiste, im Browser-Titel und auf dem Login-Bildschirm.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "Anwendungsname gespeichert.",
+ "sectionDate": "Datum",
+ "dateFormatTitle": "Datumsformat",
+ "dateFormatLabel": "Bevorzugtes Datumsformat",
+ "dateFormatHint": "Wähle, wie Daten in der App angezeigt werden.",
+ "dateFormatSavedToast": "Datumsformat gespeichert.",
"themeSystem": "System",
"themeSysLabel": "System-Einstellung verwenden",
"themeLight": "Hell",
@@ -776,6 +801,35 @@
"pendingBadgeTitle": "{{count}} fällige Erinnerung",
"pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen"
},
+ "birthdays": {
+ "title": "Geburtstage",
+ "addButton": "Geburtstag hinzufügen",
+ "searchPlaceholder": "Geburtstage suchen…",
+ "upcomingTitle": "Nächste Geburtstage",
+ "upcomingHint": "Die nächsten Feiern, bereits mit Kalender und Erinnerungen verknüpft.",
+ "peopleTitle": "Personen",
+ "peopleHint": "Alle gespeicherten Geburtstage durchsuchen, prüfen und bearbeiten.",
+ "emptyTitle": "Noch keine Geburtstage",
+ "emptyDescription": "Füge einen Geburtstag hinzu, damit er im Kalender und bei Erinnerungen erscheint.",
+ "newTitle": "Neuer Geburtstag",
+ "editTitle": "Geburtstag bearbeiten",
+ "nameLabel": "Name",
+ "birthDateLabel": "Geburtsdatum",
+ "photoLabel": "Profilbild",
+ "photoOptional": "Optional: Du kannst auch ohne Profilbild speichern.",
+ "removePhoto": "Bild entfernen",
+ "notesLabel": "Notizen",
+ "notesPlaceholder": "Geschenkideen, Lieblingskuchen, Familiennotizen…",
+ "calendarHint": "Jeder Geburtstag wird automatisch zum Kalender und Erinnerungssystem hinzugefügt.",
+ "requiredFields": "Name und Geburtsdatum sind erforderlich.",
+ "createdToast": "Geburtstag gespeichert.",
+ "updatedToast": "Geburtstag aktualisiert.",
+ "deletedToast": "Geburtstag gelöscht.",
+ "deleteConfirm": "Geburtstag von \"{{name}}\" löschen?",
+ "ageNoteToday": "Wird heute {{age}} Jahre alt.",
+ "ageNoteTomorrow": "Wird morgen {{age}} Jahre alt.",
+ "ageNoteDays": "Wird in {{days}} Tagen {{age}} Jahre alt."
+ },
"recipes": {
"title": "Rezepte",
"addRecipe": "Rezept hinzufügen",
diff --git a/public/locales/el.json b/public/locales/el.json
index f9535b0..e1c2687 100644
--- a/public/locales/el.json
+++ b/public/locales/el.json
@@ -38,6 +38,7 @@
"shopping": "Αγορές",
"notes": "Σημειώσεις",
"contacts": "Επαφές",
+ "birthdays": "Γενέθλια",
"budget": "Προϋπολογισμός",
"settings": "Ρυθμίσεις",
"main": "Κύρια πλοήγηση",
@@ -82,6 +83,19 @@
"allDay": "Όλη μέρα",
"shoppingMore": "+{{count}} ακόμα",
"weather": "Καιρός",
+ "familyMembers": "Μέλη οικογένειας",
+ "participantsAdded": "συμμετέχοντες προστέθηκαν",
+ "upcomingBirthdays": "Επόμενα γενέθλια",
+ "noBirthdays": "Δεν υπάρχουν γενέθλια ακόμα",
+ "daysLeft": "{{count}} ημέρες",
+ "budgetOverview": "Επισκόπηση προϋπολογισμού",
+ "monthlyIncome": "Έσοδα",
+ "monthlyExpenses": "Έξοδα",
+ "monthlyBalance": "Υπόλοιπο",
+ "savingsRate": "Ποσοστό αποταμίευσης",
+ "topExpense": "Μεγαλύτερο έξοδο",
+ "budgetEntries": "Καταχωρήσεις",
+ "noBudgetData": "Δεν υπάρχουν δεδομένα προϋπολογισμού αυτόν τον μήνα.",
"customize": "Προσαρμογή",
"customizeTitle": "Προσαρμογή widgets",
"customizeReset": "Επαναφορά",
@@ -537,6 +551,7 @@
"tabAccount": "Λογαριασμός",
"tabsAriaLabel": "Τμήματα ρυθμίσεων",
"sectionDesign": "Εμφάνιση",
+ "sectionAppName": "Όνομα εφαρμογής",
"sectionShopping": "Αγορές",
"shoppingCategoriesLabel": "Κατηγορίες αγορών",
"shoppingCategoriesHint": "Προσθέστε, μετονομάστε, διαγράψτε ή ταξινομήστε κατηγορίες.",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "Συγχρονισμός ημερολογίου",
"sectionFamily": "Μέλη οικογένειας",
"cardAppearance": "Εμφάνιση",
+ "appNameTitle": "Όνομα εφαρμογής",
+ "appNameLabel": "Όνομα εφαρμογής",
+ "appNameHint": "Αυτό το όνομα εμφανίζεται στην πλαϊνή μπάρα, στον τίτλο του προγράμματος περιήγησης και στην οθόνη σύνδεσης.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "Το όνομα εφαρμογής αποθηκεύτηκε.",
+ "sectionDate": "Ημερομηνία",
+ "dateFormatTitle": "Μορφή ημερομηνίας",
+ "dateFormatLabel": "Προτιμώμενη μορφή ημερομηνίας",
+ "dateFormatHint": "Επιλέξτε πώς εμφανίζονται οι ημερομηνίες στην εφαρμογή.",
+ "dateFormatSavedToast": "Η μορφή ημερομηνίας αποθηκεύτηκε.",
"themeSystem": "Σύστημα",
"themeSysLabel": "Χρήση ρύθμισης συστήματος",
"themeLight": "Ανοιχτό",
@@ -760,6 +785,34 @@
"placeholder": "Αναζήτηση…",
"noResults": "Δεν βρέθηκαν αποτελέσματα."
},
+ "birthdays": {
+ "title": "Γενέθλια",
+ "addButton": "Προσθήκη γενεθλίων",
+ "searchPlaceholder": "Αναζήτηση γενεθλίων…",
+ "upcomingTitle": "Επόμενα γενέθλια",
+ "upcomingHint": "Οι επόμενοι εορτασμοί, ήδη συγχρονισμένοι με το ημερολόγιο.",
+ "peopleTitle": "Άτομα",
+ "peopleHint": "Αναζητήστε, ελέγξτε και επεξεργαστείτε όλα τα αποθηκευμένα γενέθλια.",
+ "emptyTitle": "Δεν υπάρχουν γενέθλια ακόμη",
+ "emptyDescription": "Προσθέστε ένα γενέθλιο ώστε να εμφανίζεται στο ημερολόγιο και στις υπενθυμίσεις.",
+ "newTitle": "Νέα γενέθλια",
+ "editTitle": "Επεξεργασία γενεθλίων",
+ "nameLabel": "Όνομα",
+ "birthDateLabel": "Ημερομηνία γέννησης",
+ "photoLabel": "Φωτογραφία προφίλ",
+ "removePhoto": "Αφαίρεση φωτογραφίας",
+ "notesLabel": "Σημειώσεις",
+ "notesPlaceholder": "Ιδέες δώρων, αγαπημένη τούρτα, οικογενειακές σημειώσεις…",
+ "calendarHint": "Κάθε γενέθλιο προστίθεται αυτόματα στο ημερολόγιο και στο σύστημα υπενθυμίσεων.",
+ "requiredFields": "Το όνομα και η ημερομηνία γέννησης είναι υποχρεωτικά.",
+ "createdToast": "Τα γενέθλια αποθηκεύτηκαν.",
+ "updatedToast": "Τα γενέθλια ενημερώθηκαν.",
+ "deletedToast": "Τα γενέθλια διαγράφηκαν.",
+ "deleteConfirm": "Διαγραφή γενεθλίων για τον/την \"{{name}}\";",
+ "ageNoteToday": "Γίνεται {{age}} ετών σήμερα.",
+ "ageNoteTomorrow": "Γίνεται {{age}} ετών αύριο.",
+ "ageNoteDays": "Γίνεται {{age}} ετών σε {{days}} ημέρες."
+ },
"reminders": {
"sectionTitle": "Υπενθύμιση",
"enableLabel": "Ορισμός υπενθύμισης",
diff --git a/public/locales/en.json b/public/locales/en.json
index cbed02d..2d1210d 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -38,6 +38,7 @@
"shopping": "Shopping",
"notes": "Board",
"contacts": "Contacts",
+ "birthdays": "Birthdays",
"budget": "Budget",
"settings": "Settings",
"main": "Main navigation",
@@ -82,6 +83,19 @@
"allDay": "All day",
"shoppingMore": "+{{count}} more",
"weather": "Weather",
+ "familyMembers": "Family members",
+ "participantsAdded": "participants added",
+ "upcomingBirthdays": "Upcoming birthdays",
+ "noBirthdays": "No birthdays yet",
+ "daysLeft": "{{count}} days",
+ "budgetOverview": "Budget overview",
+ "monthlyIncome": "Income",
+ "monthlyExpenses": "Expenses",
+ "monthlyBalance": "Balance",
+ "savingsRate": "Savings rate",
+ "topExpense": "Top expense",
+ "budgetEntries": "Entries",
+ "noBudgetData": "No budget data this month.",
"customize": "Customize",
"customizeTitle": "Customize widgets",
"customizeReset": "Reset",
@@ -537,6 +551,7 @@
"tabAccount": "Account",
"tabsAriaLabel": "Settings sections",
"sectionDesign": "Appearance",
+ "sectionAppName": "Application name",
"sectionShopping": "Shopping",
"shoppingCategoriesLabel": "Shopping Categories",
"shoppingCategoriesHint": "Add, rename, delete or reorder categories.",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "Calendar Sync",
"sectionFamily": "Family Members",
"cardAppearance": "Display",
+ "appNameTitle": "App name",
+ "appNameLabel": "Application name",
+ "appNameHint": "This name appears in the sidebar, browser title and login screen.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "Application name saved.",
+ "sectionDate": "Date",
+ "dateFormatTitle": "Date format",
+ "dateFormatLabel": "Preferred date format",
+ "dateFormatHint": "Choose how dates are displayed throughout the app.",
+ "dateFormatSavedToast": "Date format saved.",
"themeSystem": "System",
"themeSysLabel": "Use system setting",
"themeLight": "Light",
@@ -751,6 +776,35 @@
"pendingBadgeTitle": "{{count}} reminder due",
"pendingBadgeTitlePlural": "{{count}} reminders due"
},
+ "birthdays": {
+ "title": "Birthdays",
+ "addButton": "Add birthday",
+ "searchPlaceholder": "Search birthdays…",
+ "upcomingTitle": "Next birthdays",
+ "upcomingHint": "The next people to celebrate, already synced to the calendar.",
+ "peopleTitle": "People",
+ "peopleHint": "Search, review and edit every saved birthday.",
+ "emptyTitle": "No birthdays yet",
+ "emptyDescription": "Add a birthday to keep it visible in the calendar and reminders.",
+ "newTitle": "New birthday",
+ "editTitle": "Edit birthday",
+ "nameLabel": "Name",
+ "birthDateLabel": "Birth date",
+ "photoLabel": "Profile picture",
+ "photoOptional": "Optional: you can save without a profile picture.",
+ "removePhoto": "Remove picture",
+ "notesLabel": "Notes",
+ "notesPlaceholder": "Gift ideas, favorite cake, family notes…",
+ "calendarHint": "Each birthday is automatically added to the calendar and reminder system.",
+ "requiredFields": "Name and birth date are required.",
+ "createdToast": "Birthday saved.",
+ "updatedToast": "Birthday updated.",
+ "deletedToast": "Birthday deleted.",
+ "deleteConfirm": "Delete birthday for \"{{name}}\"?",
+ "ageNoteToday": "Turns {{age}} today.",
+ "ageNoteTomorrow": "Turns {{age}} tomorrow.",
+ "ageNoteDays": "Turns {{age}} in {{days}} days."
+ },
"recipes": {
"title": "Recipes",
"addRecipe": "Add recipe",
diff --git a/public/locales/es.json b/public/locales/es.json
index 4fdd6da..bb47d35 100644
--- a/public/locales/es.json
+++ b/public/locales/es.json
@@ -38,6 +38,7 @@
"shopping": "Compras",
"notes": "Notas",
"contacts": "Contactos",
+ "birthdays": "Cumpleaños",
"budget": "Presupuesto",
"settings": "Ajustes",
"main": "Navegación principal",
@@ -82,6 +83,19 @@
"allDay": "Todo el día",
"shoppingMore": "+{{count}} más",
"weather": "Clima",
+ "familyMembers": "Miembros de la familia",
+ "participantsAdded": "participantes añadidos",
+ "upcomingBirthdays": "Próximos cumpleaños",
+ "noBirthdays": "Aún no hay cumpleaños",
+ "daysLeft": "{{count}} días",
+ "budgetOverview": "Resumen del presupuesto",
+ "monthlyIncome": "Ingresos",
+ "monthlyExpenses": "Gastos",
+ "monthlyBalance": "Saldo",
+ "savingsRate": "Tasa de ahorro",
+ "topExpense": "Mayor gasto",
+ "budgetEntries": "Movimientos",
+ "noBudgetData": "No hay datos de presupuesto este mes.",
"customize": "Personalizar",
"customizeTitle": "Personalizar widgets",
"customizeReset": "Restablecer",
@@ -537,6 +551,7 @@
"tabAccount": "Cuenta",
"tabsAriaLabel": "Secciones de configuración",
"sectionDesign": "Diseño",
+ "sectionAppName": "Nombre de la aplicación",
"sectionShopping": "Compras",
"shoppingCategoriesLabel": "Categorías de compra",
"shoppingCategoriesHint": "Añade, renombra, elimina u ordena las categorías.",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "Sincronización de calendario",
"sectionFamily": "Miembros de la familia",
"cardAppearance": "Apariencia",
+ "appNameTitle": "Nombre de la app",
+ "appNameLabel": "Nombre de la aplicación",
+ "appNameHint": "Este nombre aparece en la barra lateral, el título del navegador y la pantalla de inicio de sesión.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "Nombre de la aplicación guardado.",
+ "sectionDate": "Fecha",
+ "dateFormatTitle": "Formato de fecha",
+ "dateFormatLabel": "Formato de fecha preferido",
+ "dateFormatHint": "Elige cómo se muestran las fechas en toda la app.",
+ "dateFormatSavedToast": "Formato de fecha guardado.",
"themeSystem": "Sistema",
"themeSysLabel": "Usar configuración del sistema",
"themeLight": "Claro",
@@ -760,6 +785,34 @@
"placeholder": "Buscar…",
"noResults": "No se encontraron resultados."
},
+ "birthdays": {
+ "title": "Cumpleaños",
+ "addButton": "Añadir cumpleaños",
+ "searchPlaceholder": "Buscar cumpleaños…",
+ "upcomingTitle": "Próximos cumpleaños",
+ "upcomingHint": "Las próximas celebraciones, ya sincronizadas con el calendario.",
+ "peopleTitle": "Personas",
+ "peopleHint": "Busca, revisa y edita todos los cumpleaños guardados.",
+ "emptyTitle": "Todavía no hay cumpleaños",
+ "emptyDescription": "Añade un cumpleaños para mantenerlo visible en el calendario y en los recordatorios.",
+ "newTitle": "Nuevo cumpleaños",
+ "editTitle": "Editar cumpleaños",
+ "nameLabel": "Nombre",
+ "birthDateLabel": "Fecha de nacimiento",
+ "photoLabel": "Foto de perfil",
+ "removePhoto": "Eliminar foto",
+ "notesLabel": "Notas",
+ "notesPlaceholder": "Ideas de regalo, tarta favorita, notas familiares…",
+ "calendarHint": "Cada cumpleaños se añade automáticamente al calendario y al sistema de recordatorios.",
+ "requiredFields": "El nombre y la fecha de nacimiento son obligatorios.",
+ "createdToast": "Cumpleaños guardado.",
+ "updatedToast": "Cumpleaños actualizado.",
+ "deletedToast": "Cumpleaños eliminado.",
+ "deleteConfirm": "¿Eliminar el cumpleaños de \"{{name}}\"?",
+ "ageNoteToday": "Cumple {{age}} años hoy.",
+ "ageNoteTomorrow": "Cumple {{age}} años mañana.",
+ "ageNoteDays": "Cumplirá {{age}} años en {{days}} días."
+ },
"reminders": {
"sectionTitle": "Recordatorio",
"enableLabel": "Establecer recordatorio",
diff --git a/public/locales/fr.json b/public/locales/fr.json
index 232f203..8c9c827 100644
--- a/public/locales/fr.json
+++ b/public/locales/fr.json
@@ -38,6 +38,7 @@
"shopping": "Courses",
"notes": "Notes",
"contacts": "Contacts",
+ "birthdays": "Anniversaires",
"budget": "Budget",
"settings": "Paramètres",
"main": "Navigation principale",
@@ -82,6 +83,19 @@
"allDay": "Toute la journée",
"shoppingMore": "+{{count}} de plus",
"weather": "Météo",
+ "familyMembers": "Membres de la famille",
+ "participantsAdded": "participants ajoutés",
+ "upcomingBirthdays": "Prochains anniversaires",
+ "noBirthdays": "Aucun anniversaire pour l'instant",
+ "daysLeft": "{{count}} jours",
+ "budgetOverview": "Aperçu du budget",
+ "monthlyIncome": "Revenus",
+ "monthlyExpenses": "Dépenses",
+ "monthlyBalance": "Solde",
+ "savingsRate": "Taux d'épargne",
+ "topExpense": "Plus grosse dépense",
+ "budgetEntries": "Écritures",
+ "noBudgetData": "Aucune donnée de budget ce mois-ci.",
"customize": "Personnaliser",
"customizeTitle": "Personnaliser les widgets",
"customizeReset": "Réinitialiser",
@@ -537,6 +551,7 @@
"tabAccount": "Compte",
"tabsAriaLabel": "Sections des paramètres",
"sectionDesign": "Apparence",
+ "sectionAppName": "Nom de l'application",
"sectionShopping": "Courses",
"shoppingCategoriesLabel": "Catégories de courses",
"shoppingCategoriesHint": "Ajoutez, renommez, supprimez ou réorganisez les catégories.",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "Synchronisation du calendrier",
"sectionFamily": "Membres de la famille",
"cardAppearance": "Affichage",
+ "appNameTitle": "Nom de l'application",
+ "appNameLabel": "Nom de l'application",
+ "appNameHint": "Ce nom apparaît dans la barre latérale, le titre du navigateur et l'écran de connexion.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "Nom de l'application enregistré.",
+ "sectionDate": "Date",
+ "dateFormatTitle": "Format de date",
+ "dateFormatLabel": "Format de date préféré",
+ "dateFormatHint": "Choisissez comment les dates sont affichées dans l'application.",
+ "dateFormatSavedToast": "Format de date enregistré.",
"themeSystem": "Système",
"themeSysLabel": "Utiliser le paramètre système",
"themeLight": "Clair",
@@ -760,6 +785,34 @@
"placeholder": "Rechercher…",
"noResults": "Aucun résultat trouvé."
},
+ "birthdays": {
+ "title": "Anniversaires",
+ "addButton": "Ajouter un anniversaire",
+ "searchPlaceholder": "Rechercher des anniversaires…",
+ "upcomingTitle": "Prochains anniversaires",
+ "upcomingHint": "Les prochaines célébrations, déjà synchronisées avec le calendrier.",
+ "peopleTitle": "Personnes",
+ "peopleHint": "Recherchez, vérifiez et modifiez tous les anniversaires enregistrés.",
+ "emptyTitle": "Aucun anniversaire pour le moment",
+ "emptyDescription": "Ajoutez un anniversaire pour le garder visible dans le calendrier et les rappels.",
+ "newTitle": "Nouvel anniversaire",
+ "editTitle": "Modifier l'anniversaire",
+ "nameLabel": "Nom",
+ "birthDateLabel": "Date de naissance",
+ "photoLabel": "Photo de profil",
+ "removePhoto": "Supprimer la photo",
+ "notesLabel": "Notes",
+ "notesPlaceholder": "Idées de cadeaux, gâteau préféré, notes familiales…",
+ "calendarHint": "Chaque anniversaire est automatiquement ajouté au calendrier et au système de rappels.",
+ "requiredFields": "Le nom et la date de naissance sont obligatoires.",
+ "createdToast": "Anniversaire enregistré.",
+ "updatedToast": "Anniversaire mis à jour.",
+ "deletedToast": "Anniversaire supprimé.",
+ "deleteConfirm": "Supprimer l'anniversaire de \"{{name}}\" ?",
+ "ageNoteToday": "Fête ses {{age}} ans aujourd'hui.",
+ "ageNoteTomorrow": "Fêtera ses {{age}} ans demain.",
+ "ageNoteDays": "Fêtera ses {{age}} ans dans {{days}} jours."
+ },
"reminders": {
"sectionTitle": "Rappel",
"enableLabel": "Définir un rappel",
diff --git a/public/locales/hi.json b/public/locales/hi.json
index 1d7e695..9d89235 100644
--- a/public/locales/hi.json
+++ b/public/locales/hi.json
@@ -38,6 +38,7 @@
"shopping": "खरीदारी",
"notes": "नोट्स",
"contacts": "संपर्क",
+ "birthdays": "जन्मदिन",
"budget": "बजट",
"settings": "सेटिंग्स",
"main": "मुख्य नेविगेशन",
@@ -82,6 +83,19 @@
"allDay": "पूरे दिन",
"shoppingMore": "+{{count}} और",
"weather": "मौसम",
+ "familyMembers": "परिवार के सदस्य",
+ "participantsAdded": "प्रतिभागी जोड़े गए",
+ "upcomingBirthdays": "आने वाले जन्मदिन",
+ "noBirthdays": "अभी कोई जन्मदिन नहीं",
+ "daysLeft": "{{count}} दिन",
+ "budgetOverview": "बजट अवलोकन",
+ "monthlyIncome": "आय",
+ "monthlyExpenses": "खर्च",
+ "monthlyBalance": "शेष",
+ "savingsRate": "बचत दर",
+ "topExpense": "सबसे बड़ा खर्च",
+ "budgetEntries": "प्रविष्टियां",
+ "noBudgetData": "इस महीने बजट डेटा नहीं है।",
"customize": "अनुकूलित करें",
"customizeTitle": "विजेट अनुकूलित करें",
"customizeReset": "डिफ़ॉल्ट",
@@ -537,6 +551,7 @@
"tabAccount": "खाता",
"tabsAriaLabel": "सेटिंग्स अनुभाग",
"sectionDesign": "डिज़ाइन",
+ "sectionAppName": "ऐप का नाम",
"sectionShopping": "खरीदारी",
"shoppingCategoriesLabel": "खरीदारी श्रेणियां",
"shoppingCategoriesHint": "श्रेणियां जोड़ें, नाम बदलें, हटाएं या क्रम बदलें।",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "कैलेंडर सिंक",
"sectionFamily": "परिवार के सदस्य",
"cardAppearance": "दिखावट",
+ "appNameTitle": "ऐप का नाम",
+ "appNameLabel": "ऐप का नाम",
+ "appNameHint": "यह नाम साइडबार, ब्राउज़र शीर्षक और लॉगिन स्क्रीन में दिखाई देगा।",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "ऐप का नाम सहेजा गया।",
+ "sectionDate": "तारीख",
+ "dateFormatTitle": "तारीख प्रारूप",
+ "dateFormatLabel": "पसंदीदा तारीख प्रारूप",
+ "dateFormatHint": "चुनें कि ऐप में तारीखें कैसे दिखाई दें।",
+ "dateFormatSavedToast": "तारीख प्रारूप सहेजा गया।",
"themeSystem": "सिस्टम",
"themeSysLabel": "सिस्टम सेटिंग का उपयोग करें",
"themeLight": "हल्का",
@@ -760,6 +785,34 @@
"placeholder": "खोजें…",
"noResults": "कोई परिणाम नहीं मिला।"
},
+ "birthdays": {
+ "title": "जन्मदिन",
+ "addButton": "जन्मदिन जोड़ें",
+ "searchPlaceholder": "जन्मदिन खोजें…",
+ "upcomingTitle": "आने वाले जन्मदिन",
+ "upcomingHint": "आने वाले समारोह, जो पहले से कैलेंडर से सिंक हैं।",
+ "peopleTitle": "लोग",
+ "peopleHint": "सहेजे गए सभी जन्मदिन खोजें, देखें और संपादित करें।",
+ "emptyTitle": "अभी तक कोई जन्मदिन नहीं",
+ "emptyDescription": "जन्मदिन जोड़ें ताकि वह कैलेंडर और रिमाइंडर में दिखाई दे।",
+ "newTitle": "नया जन्मदिन",
+ "editTitle": "जन्मदिन संपादित करें",
+ "nameLabel": "नाम",
+ "birthDateLabel": "जन्म तिथि",
+ "photoLabel": "प्रोफ़ाइल तस्वीर",
+ "removePhoto": "तस्वीर हटाएँ",
+ "notesLabel": "नोट्स",
+ "notesPlaceholder": "उपहार के विचार, पसंदीदा केक, परिवार के नोट्स…",
+ "calendarHint": "हर जन्मदिन अपने आप कैलेंडर और रिमाइंडर सिस्टम में जोड़ दिया जाता है।",
+ "requiredFields": "नाम और जन्म तिथि आवश्यक हैं।",
+ "createdToast": "जन्मदिन सहेज लिया गया।",
+ "updatedToast": "जन्मदिन अपडेट किया गया।",
+ "deletedToast": "जन्मदिन हटाया गया।",
+ "deleteConfirm": "\"{{name}}\" का जन्मदिन हटाएँ?",
+ "ageNoteToday": "आज {{age}} वर्ष का/की होगा/होगी।",
+ "ageNoteTomorrow": "कल {{age}} वर्ष का/की होगा/होगी।",
+ "ageNoteDays": "{{days}} दिनों में {{age}} वर्ष का/की होगा/होगी।"
+ },
"reminders": {
"sectionTitle": "अनुस्मारक",
"enableLabel": "अनुस्मारक सेट करें",
diff --git a/public/locales/it.json b/public/locales/it.json
index 802f018..a403b53 100644
--- a/public/locales/it.json
+++ b/public/locales/it.json
@@ -38,6 +38,7 @@
"shopping": "Spesa",
"notes": "Bacheca",
"contacts": "Contatti",
+ "birthdays": "Compleanni",
"budget": "Bilancio",
"settings": "Impostazioni",
"main": "Navigazione principale",
@@ -82,6 +83,19 @@
"allDay": "Tutto il giorno",
"shoppingMore": "+{{count}} altri",
"weather": "Meteo",
+ "familyMembers": "Membri della famiglia",
+ "participantsAdded": "partecipanti aggiunti",
+ "upcomingBirthdays": "Prossimi compleanni",
+ "noBirthdays": "Ancora nessun compleanno",
+ "daysLeft": "{{count}} giorni",
+ "budgetOverview": "Panoramica budget",
+ "monthlyIncome": "Entrate",
+ "monthlyExpenses": "Uscite",
+ "monthlyBalance": "Saldo",
+ "savingsRate": "Tasso di risparmio",
+ "topExpense": "Spesa principale",
+ "budgetEntries": "Movimenti",
+ "noBudgetData": "Nessun dato di budget questo mese.",
"customize": "Personalizza",
"customizeTitle": "Personalizza widget",
"customizeReset": "Ripristina",
@@ -537,6 +551,7 @@
"tabAccount": "Account",
"tabsAriaLabel": "Sezioni impostazioni",
"sectionDesign": "Aspetto",
+ "sectionAppName": "Nome dell'applicazione",
"sectionShopping": "Spesa",
"shoppingCategoriesLabel": "Categorie spesa",
"shoppingCategoriesHint": "Aggiungi, rinomina, elimina o riordina le categorie.",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "Sincronizzazione calendario",
"sectionFamily": "Membri della famiglia",
"cardAppearance": "Visualizzazione",
+ "appNameTitle": "Nome dell'app",
+ "appNameLabel": "Nome dell'applicazione",
+ "appNameHint": "Questo nome appare nella barra laterale, nel titolo del browser e nella schermata di accesso.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "Nome dell'applicazione salvato.",
+ "sectionDate": "Data",
+ "dateFormatTitle": "Formato data",
+ "dateFormatLabel": "Formato data preferito",
+ "dateFormatHint": "Scegli come vengono mostrate le date nell'app.",
+ "dateFormatSavedToast": "Formato data salvato.",
"themeSystem": "Sistema",
"themeSysLabel": "Usa impostazione di sistema",
"themeLight": "Chiaro",
@@ -760,6 +785,34 @@
"placeholder": "Cerca…",
"noResults": "Nessun risultato trovato."
},
+ "birthdays": {
+ "title": "Compleanni",
+ "addButton": "Aggiungi compleanno",
+ "searchPlaceholder": "Cerca compleanni…",
+ "upcomingTitle": "Prossimi compleanni",
+ "upcomingHint": "Le prossime ricorrenze, già sincronizzate con il calendario.",
+ "peopleTitle": "Persone",
+ "peopleHint": "Cerca, controlla e modifica tutti i compleanni salvati.",
+ "emptyTitle": "Nessun compleanno ancora",
+ "emptyDescription": "Aggiungi un compleanno per mantenerlo visibile nel calendario e nei promemoria.",
+ "newTitle": "Nuovo compleanno",
+ "editTitle": "Modifica compleanno",
+ "nameLabel": "Nome",
+ "birthDateLabel": "Data di nascita",
+ "photoLabel": "Foto profilo",
+ "removePhoto": "Rimuovi foto",
+ "notesLabel": "Note",
+ "notesPlaceholder": "Idee regalo, torta preferita, note di famiglia…",
+ "calendarHint": "Ogni compleanno viene aggiunto automaticamente al calendario e al sistema di promemoria.",
+ "requiredFields": "Nome e data di nascita sono obbligatori.",
+ "createdToast": "Compleanno salvato.",
+ "updatedToast": "Compleanno aggiornato.",
+ "deletedToast": "Compleanno eliminato.",
+ "deleteConfirm": "Eliminare il compleanno di \"{{name}}\"?",
+ "ageNoteToday": "Compie {{age}} anni oggi.",
+ "ageNoteTomorrow": "Compirà {{age}} anni domani.",
+ "ageNoteDays": "Compirà {{age}} anni tra {{days}} giorni."
+ },
"reminders": {
"sectionTitle": "Promemoria",
"enableLabel": "Imposta promemoria",
diff --git a/public/locales/ja.json b/public/locales/ja.json
index 29d0cbe..3924b0f 100644
--- a/public/locales/ja.json
+++ b/public/locales/ja.json
@@ -38,6 +38,7 @@
"shopping": "買い物",
"notes": "メモ",
"contacts": "連絡先",
+ "birthdays": "誕生日",
"budget": "家計",
"settings": "設定",
"main": "メインナビゲーション",
@@ -82,6 +83,19 @@
"allDay": "終日",
"shoppingMore": "+{{count}} 件",
"weather": "天気",
+ "familyMembers": "家族メンバー",
+ "participantsAdded": "人が追加済み",
+ "upcomingBirthdays": "今後の誕生日",
+ "noBirthdays": "誕生日はまだありません",
+ "daysLeft": "{{count}}日",
+ "budgetOverview": "予算の概要",
+ "monthlyIncome": "収入",
+ "monthlyExpenses": "支出",
+ "monthlyBalance": "残高",
+ "savingsRate": "貯蓄率",
+ "topExpense": "最大の支出",
+ "budgetEntries": "記録",
+ "noBudgetData": "今月の予算データはありません。",
"customize": "カスタマイズ",
"customizeTitle": "ウィジェットのカスタマイズ",
"customizeReset": "デフォルト",
@@ -537,6 +551,7 @@
"tabAccount": "アカウント",
"tabsAriaLabel": "設定カテゴリー",
"sectionDesign": "デザイン",
+ "sectionAppName": "アプリ名",
"sectionShopping": "買い物",
"shoppingCategoriesLabel": "買い物カテゴリー",
"shoppingCategoriesHint": "カテゴリーの追加、名前変更、削除、並び替えができます。",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "カレンダー同期",
"sectionFamily": "家族メンバー",
"cardAppearance": "外観",
+ "appNameTitle": "アプリ名",
+ "appNameLabel": "アプリ名",
+ "appNameHint": "この名前はサイドバー、ブラウザのタイトル、ログイン画面に表示されます。",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "アプリ名を保存しました。",
+ "sectionDate": "日付",
+ "dateFormatTitle": "日付形式",
+ "dateFormatLabel": "希望する日付形式",
+ "dateFormatHint": "アプリ内で日付をどう表示するかを選択します。",
+ "dateFormatSavedToast": "日付形式を保存しました。",
"themeSystem": "システム設定",
"themeSysLabel": "システム設定を使用",
"themeLight": "ライト",
@@ -760,6 +785,34 @@
"placeholder": "検索…",
"noResults": "結果が見つかりませんでした。"
},
+ "birthdays": {
+ "title": "誕生日",
+ "addButton": "誕生日を追加",
+ "searchPlaceholder": "誕生日を検索…",
+ "upcomingTitle": "次の誕生日",
+ "upcomingHint": "次に祝う誕生日。すでにカレンダーに同期されています。",
+ "peopleTitle": "人物",
+ "peopleHint": "保存されたすべての誕生日を検索、確認、編集できます。",
+ "emptyTitle": "まだ誕生日はありません",
+ "emptyDescription": "誕生日を追加すると、カレンダーとリマインダーに表示されます。",
+ "newTitle": "新しい誕生日",
+ "editTitle": "誕生日を編集",
+ "nameLabel": "名前",
+ "birthDateLabel": "生年月日",
+ "photoLabel": "プロフィール画像",
+ "removePhoto": "画像を削除",
+ "notesLabel": "メモ",
+ "notesPlaceholder": "プレゼント案、好きなケーキ、家族メモ…",
+ "calendarHint": "各誕生日は自動的にカレンダーとリマインダーシステムに追加されます。",
+ "requiredFields": "名前と生年月日は必須です。",
+ "createdToast": "誕生日を保存しました。",
+ "updatedToast": "誕生日を更新しました。",
+ "deletedToast": "誕生日を削除しました。",
+ "deleteConfirm": "「{{name}}」の誕生日を削除しますか?",
+ "ageNoteToday": "今日で{{age}}歳になります。",
+ "ageNoteTomorrow": "明日で{{age}}歳になります。",
+ "ageNoteDays": "{{days}}日後に{{age}}歳になります。"
+ },
"reminders": {
"sectionTitle": "リマインダー",
"enableLabel": "リマインダーを設定",
diff --git a/public/locales/pt.json b/public/locales/pt.json
index d8be85c..ca2eb34 100644
--- a/public/locales/pt.json
+++ b/public/locales/pt.json
@@ -38,6 +38,7 @@
"shopping": "Compras",
"notes": "Notas",
"contacts": "Contatos",
+ "birthdays": "Aniversários",
"budget": "Orçamento",
"settings": "Configurações",
"main": "Navegação principal",
@@ -82,6 +83,19 @@
"allDay": "Dia inteiro",
"shoppingMore": "+{{count}} mais",
"weather": "Clima",
+ "familyMembers": "Membros da família",
+ "participantsAdded": "participantes adicionados",
+ "upcomingBirthdays": "Próximos aniversários",
+ "noBirthdays": "Nenhum aniversário ainda",
+ "daysLeft": "{{count}} dias",
+ "budgetOverview": "Visão do orçamento",
+ "monthlyIncome": "Receitas",
+ "monthlyExpenses": "Despesas",
+ "monthlyBalance": "Saldo",
+ "savingsRate": "Taxa de poupança",
+ "topExpense": "Maior despesa",
+ "budgetEntries": "Lançamentos",
+ "noBudgetData": "Sem dados de orçamento neste mês.",
"customize": "Personalizar",
"customizeTitle": "Personalizar widgets",
"customizeReset": "Padrão",
@@ -537,6 +551,7 @@
"tabAccount": "Conta",
"tabsAriaLabel": "Seções de configurações",
"sectionDesign": "Design",
+ "sectionAppName": "Nome da aplicação",
"sectionShopping": "Compras",
"shoppingCategoriesLabel": "Categorias de compras",
"shoppingCategoriesHint": "Adicione, renomeie, exclua ou ordene categorias.",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "Sincronização de calendário",
"sectionFamily": "Membros da família",
"cardAppearance": "Aparência",
+ "appNameTitle": "Nome do app",
+ "appNameLabel": "Nome da aplicação",
+ "appNameHint": "Este nome aparece na barra lateral, no título do navegador e no ecrã de login.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "Nome da aplicação guardado.",
+ "sectionDate": "Data",
+ "dateFormatTitle": "Formato da data",
+ "dateFormatLabel": "Formato preferido da data",
+ "dateFormatHint": "Escolha como as datas aparecem em toda a aplicação.",
+ "dateFormatSavedToast": "Formato da data salvo.",
"themeSystem": "Sistema",
"themeSysLabel": "Usar configuração do sistema",
"themeLight": "Claro",
@@ -760,6 +785,35 @@
"placeholder": "Pesquisar…",
"noResults": "Nenhum resultado encontrado."
},
+ "birthdays": {
+ "title": "Aniversários",
+ "addButton": "Adicionar aniversário",
+ "searchPlaceholder": "Buscar aniversários…",
+ "upcomingTitle": "Próximos aniversários",
+ "upcomingHint": "As próximas comemorações, já sincronizadas com o calendário.",
+ "peopleTitle": "Pessoas",
+ "peopleHint": "Pesquise, revise e edite todos os aniversários salvos.",
+ "emptyTitle": "Nenhum aniversário ainda",
+ "emptyDescription": "Adicione um aniversário para mantê-lo visível no calendário e nos lembretes.",
+ "newTitle": "Novo aniversário",
+ "editTitle": "Editar aniversário",
+ "nameLabel": "Nome",
+ "birthDateLabel": "Data de nascimento",
+ "photoLabel": "Foto de perfil",
+ "photoOptional": "Opcional: você também pode salvar sem foto de perfil.",
+ "removePhoto": "Remover foto",
+ "notesLabel": "Notas",
+ "notesPlaceholder": "Ideias de presente, bolo favorito, notas da família…",
+ "calendarHint": "Cada aniversário é adicionado automaticamente ao calendário e ao sistema de lembretes.",
+ "requiredFields": "Nome e data de nascimento são obrigatórios.",
+ "createdToast": "Aniversário salvo.",
+ "updatedToast": "Aniversário atualizado.",
+ "deletedToast": "Aniversário excluído.",
+ "deleteConfirm": "Excluir o aniversário de \"{{name}}\"?",
+ "ageNoteToday": "Completa {{age}} anos hoje.",
+ "ageNoteTomorrow": "Completa {{age}} anos amanhã.",
+ "ageNoteDays": "Completa {{age}} anos em {{days}} dias."
+ },
"reminders": {
"sectionTitle": "Lembrete",
"enableLabel": "Definir lembrete",
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 44720cd..abf9e2e 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -38,6 +38,7 @@
"shopping": "Покупки",
"notes": "Заметки",
"contacts": "Контакты",
+ "birthdays": "Дни рождения",
"budget": "Бюджет",
"settings": "Настройки",
"main": "Главная навигация",
@@ -82,6 +83,19 @@
"allDay": "Весь день",
"shoppingMore": "+{{count}} ещё",
"weather": "Погода",
+ "familyMembers": "Члены семьи",
+ "participantsAdded": "участников добавлено",
+ "upcomingBirthdays": "Ближайшие дни рождения",
+ "noBirthdays": "Дней рождения пока нет",
+ "daysLeft": "{{count}} дн.",
+ "budgetOverview": "Обзор бюджета",
+ "monthlyIncome": "Доходы",
+ "monthlyExpenses": "Расходы",
+ "monthlyBalance": "Баланс",
+ "savingsRate": "Норма сбережений",
+ "topExpense": "Крупнейший расход",
+ "budgetEntries": "Записи",
+ "noBudgetData": "Нет данных бюджета за этот месяц.",
"customize": "Настроить",
"customizeTitle": "Настроить виджеты",
"customizeReset": "Сбросить",
@@ -537,6 +551,7 @@
"tabAccount": "Аккаунт",
"tabsAriaLabel": "Разделы настроек",
"sectionDesign": "Внешний вид",
+ "sectionAppName": "Название приложения",
"sectionShopping": "Покупки",
"shoppingCategoriesLabel": "Категории покупок",
"shoppingCategoriesHint": "Добавляйте, переименовывайте, удаляйте или сортируйте категории.",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "Синхронизация календаря",
"sectionFamily": "Члены семьи",
"cardAppearance": "Отображение",
+ "appNameTitle": "Название приложения",
+ "appNameLabel": "Название приложения",
+ "appNameHint": "Это название отображается в боковом меню, заголовке браузера и на экране входа.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "Название приложения сохранено.",
+ "sectionDate": "Дата",
+ "dateFormatTitle": "Формат даты",
+ "dateFormatLabel": "Предпочитаемый формат даты",
+ "dateFormatHint": "Выберите, как даты отображаются в приложении.",
+ "dateFormatSavedToast": "Формат даты сохранён.",
"themeSystem": "Система",
"themeSysLabel": "Использовать системную настройку",
"themeLight": "Светлая",
@@ -760,6 +785,34 @@
"placeholder": "Поиск…",
"noResults": "Результаты не найдены."
},
+ "birthdays": {
+ "title": "Дни рождения",
+ "addButton": "Добавить день рождения",
+ "searchPlaceholder": "Поиск дней рождения…",
+ "upcomingTitle": "Ближайшие дни рождения",
+ "upcomingHint": "Ближайшие праздники, уже синхронизированные с календарём.",
+ "peopleTitle": "Люди",
+ "peopleHint": "Ищите, просматривайте и редактируйте все сохранённые дни рождения.",
+ "emptyTitle": "Дней рождения пока нет",
+ "emptyDescription": "Добавьте день рождения, чтобы он отображался в календаре и напоминаниях.",
+ "newTitle": "Новый день рождения",
+ "editTitle": "Редактировать день рождения",
+ "nameLabel": "Имя",
+ "birthDateLabel": "Дата рождения",
+ "photoLabel": "Фото профиля",
+ "removePhoto": "Удалить фото",
+ "notesLabel": "Заметки",
+ "notesPlaceholder": "Идеи подарков, любимый торт, семейные заметки…",
+ "calendarHint": "Каждый день рождения автоматически добавляется в календарь и систему напоминаний.",
+ "requiredFields": "Имя и дата рождения обязательны.",
+ "createdToast": "День рождения сохранён.",
+ "updatedToast": "День рождения обновлён.",
+ "deletedToast": "День рождения удалён.",
+ "deleteConfirm": "Удалить день рождения \"{{name}}\"?",
+ "ageNoteToday": "Исполняется {{age}} сегодня.",
+ "ageNoteTomorrow": "Исполнится {{age}} завтра.",
+ "ageNoteDays": "Исполнится {{age}} через {{days}} дн."
+ },
"reminders": {
"sectionTitle": "Напоминание",
"enableLabel": "Установить напоминание",
diff --git a/public/locales/sv.json b/public/locales/sv.json
index ff89400..78816a9 100644
--- a/public/locales/sv.json
+++ b/public/locales/sv.json
@@ -38,6 +38,7 @@
"shopping": "Shopping",
"notes": "Anteckningar",
"contacts": "Kontakter",
+ "birthdays": "Födelsedagar",
"budget": "Budget",
"settings": "Inställningar",
"main": "Huvudnavigering",
@@ -82,6 +83,19 @@
"allDay": "Hela dagen",
"shoppingMore": "+{{count}} till",
"weather": "Väder",
+ "familyMembers": "Familjemedlemmar",
+ "participantsAdded": "deltagare tillagda",
+ "upcomingBirthdays": "Kommande födelsedagar",
+ "noBirthdays": "Inga födelsedagar ännu",
+ "daysLeft": "{{count}} dagar",
+ "budgetOverview": "Budgetöversikt",
+ "monthlyIncome": "Inkomster",
+ "monthlyExpenses": "Utgifter",
+ "monthlyBalance": "Saldo",
+ "savingsRate": "Sparandegrad",
+ "topExpense": "Största utgift",
+ "budgetEntries": "Poster",
+ "noBudgetData": "Ingen budgetdata denna månad.",
"customize": "Anpassa",
"customizeTitle": "Anpassa widgets",
"customizeReset": "Återställ",
@@ -537,6 +551,7 @@
"tabAccount": "Konto",
"tabsAriaLabel": "Inställningsavsnitt",
"sectionDesign": "Utseende",
+ "sectionAppName": "Appnamn",
"sectionShopping": "Inköp",
"shoppingCategoriesLabel": "Inköpskategorier",
"shoppingCategoriesHint": "Lägg till, byt namn, ta bort eller sortera om kategorier.",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "Kalendersynkronisering",
"sectionFamily": "Familjemedlemmar",
"cardAppearance": "Visa",
+ "appNameTitle": "Appnamn",
+ "appNameLabel": "Appnamn",
+ "appNameHint": "Det här namnet visas i sidomenyn, webbläsarens titel och inloggningsskärmen.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "Appnamn sparat.",
+ "sectionDate": "Datum",
+ "dateFormatTitle": "Datumformat",
+ "dateFormatLabel": "Önskat datumformat",
+ "dateFormatHint": "Välj hur datum visas i appen.",
+ "dateFormatSavedToast": "Datumformat sparat.",
"themeSystem": "System",
"themeSysLabel": "Använd systeminställning",
"themeLight": "Ljus",
@@ -760,6 +785,34 @@
"placeholder": "Sök…",
"noResults": "Inga resultat hittades."
},
+ "birthdays": {
+ "title": "Födelsedagar",
+ "addButton": "Lägg till födelsedag",
+ "searchPlaceholder": "Sök födelsedagar…",
+ "upcomingTitle": "Kommande födelsedagar",
+ "upcomingHint": "Nästa firanden, redan synkade med kalendern.",
+ "peopleTitle": "Personer",
+ "peopleHint": "Sök, granska och redigera alla sparade födelsedagar.",
+ "emptyTitle": "Inga födelsedagar ännu",
+ "emptyDescription": "Lägg till en födelsedag så att den syns i kalendern och påminnelserna.",
+ "newTitle": "Ny födelsedag",
+ "editTitle": "Redigera födelsedag",
+ "nameLabel": "Namn",
+ "birthDateLabel": "Födelsedatum",
+ "photoLabel": "Profilbild",
+ "removePhoto": "Ta bort bild",
+ "notesLabel": "Anteckningar",
+ "notesPlaceholder": "Presentidéer, favoritårta, familjeanteckningar…",
+ "calendarHint": "Varje födelsedag läggs automatiskt till i kalendern och påminnelsesystemet.",
+ "requiredFields": "Namn och födelsedatum krävs.",
+ "createdToast": "Födelsedag sparad.",
+ "updatedToast": "Födelsedag uppdaterad.",
+ "deletedToast": "Födelsedag borttagen.",
+ "deleteConfirm": "Ta bort födelsedagen för \"{{name}}\"?",
+ "ageNoteToday": "Fyller {{age}} år idag.",
+ "ageNoteTomorrow": "Fyller {{age}} år i morgon.",
+ "ageNoteDays": "Fyller {{age}} år om {{days}} dagar."
+ },
"reminders": {
"sectionTitle": "Påminnelse",
"enableLabel": "Ange påminnelse",
diff --git a/public/locales/tr.json b/public/locales/tr.json
index dfdebe8..dd0a825 100644
--- a/public/locales/tr.json
+++ b/public/locales/tr.json
@@ -38,6 +38,7 @@
"shopping": "Alışveriş",
"notes": "Notlar",
"contacts": "Kişiler",
+ "birthdays": "Doğum Günleri",
"budget": "Bütçe",
"settings": "Ayarlar",
"main": "Ana gezinme",
@@ -82,6 +83,19 @@
"allDay": "Tüm gün",
"shoppingMore": "+{{count}} daha",
"weather": "Hava",
+ "familyMembers": "Aile üyeleri",
+ "participantsAdded": "katılımcı eklendi",
+ "upcomingBirthdays": "Yaklaşan doğum günleri",
+ "noBirthdays": "Henüz doğum günü yok",
+ "daysLeft": "{{count}} gün",
+ "budgetOverview": "Bütçe özeti",
+ "monthlyIncome": "Gelir",
+ "monthlyExpenses": "Giderler",
+ "monthlyBalance": "Bakiye",
+ "savingsRate": "Tasarruf oranı",
+ "topExpense": "En büyük gider",
+ "budgetEntries": "Kayıtlar",
+ "noBudgetData": "Bu ay bütçe verisi yok.",
"customize": "Özelleştir",
"customizeTitle": "Widget'ları özelleştir",
"customizeReset": "Sıfırla",
@@ -537,6 +551,7 @@
"tabAccount": "Hesap",
"tabsAriaLabel": "Ayar bölümleri",
"sectionDesign": "Görünüm",
+ "sectionAppName": "Uygulama adı",
"sectionShopping": "Alışveriş",
"shoppingCategoriesLabel": "Alışveriş Kategorileri",
"shoppingCategoriesHint": "Kategorileri ekleyin, yeniden adlandırın, silin veya sıralayın.",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "Takvim Senkronizasyonu",
"sectionFamily": "Aile Üyeleri",
"cardAppearance": "Görünüm",
+ "appNameTitle": "Uygulama adı",
+ "appNameLabel": "Uygulama adı",
+ "appNameHint": "Bu ad kenar çubuğunda, tarayıcı başlığında ve giriş ekranında görünür.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "Uygulama adı kaydedildi.",
+ "sectionDate": "Tarih",
+ "dateFormatTitle": "Tarih biçimi",
+ "dateFormatLabel": "Tercih edilen tarih biçimi",
+ "dateFormatHint": "Tarihlerin uygulamada nasıl görüneceğini seçin.",
+ "dateFormatSavedToast": "Tarih biçimi kaydedildi.",
"themeSystem": "Sistem",
"themeSysLabel": "Sistem ayarını kullan",
"themeLight": "Açık",
@@ -760,6 +785,34 @@
"placeholder": "Ara…",
"noResults": "Sonuç bulunamadı."
},
+ "birthdays": {
+ "title": "Doğum Günleri",
+ "addButton": "Doğum günü ekle",
+ "searchPlaceholder": "Doğum günlerinde ara…",
+ "upcomingTitle": "Yaklaşan doğum günleri",
+ "upcomingHint": "Takvimle zaten senkronize edilmiş sıradaki kutlamalar.",
+ "peopleTitle": "Kişiler",
+ "peopleHint": "Kaydedilen tüm doğum günlerini arayın, inceleyin ve düzenleyin.",
+ "emptyTitle": "Henüz doğum günü yok",
+ "emptyDescription": "Takvimde ve hatırlatıcılarda görünür kalması için bir doğum günü ekleyin.",
+ "newTitle": "Yeni doğum günü",
+ "editTitle": "Doğum gününü düzenle",
+ "nameLabel": "Ad",
+ "birthDateLabel": "Doğum tarihi",
+ "photoLabel": "Profil resmi",
+ "removePhoto": "Resmi kaldır",
+ "notesLabel": "Notlar",
+ "notesPlaceholder": "Hediye fikirleri, favori pasta, aile notları…",
+ "calendarHint": "Her doğum günü otomatik olarak takvime ve hatırlatma sistemine eklenir.",
+ "requiredFields": "Ad ve doğum tarihi gereklidir.",
+ "createdToast": "Doğum günü kaydedildi.",
+ "updatedToast": "Doğum günü güncellendi.",
+ "deletedToast": "Doğum günü silindi.",
+ "deleteConfirm": "\"{{name}}\" için doğum günü silinsin mi?",
+ "ageNoteToday": "Bugün {{age}} yaşına giriyor.",
+ "ageNoteTomorrow": "Yarın {{age}} yaşına giriyor.",
+ "ageNoteDays": "{{days}} gün içinde {{age}} yaşına girecek."
+ },
"reminders": {
"sectionTitle": "Hatırlatıcı",
"enableLabel": "Hatırlatıcı ayarla",
diff --git a/public/locales/uk.json b/public/locales/uk.json
index 1b20ef2..8e4f123 100644
--- a/public/locales/uk.json
+++ b/public/locales/uk.json
@@ -38,6 +38,7 @@
"shopping": "Покупки",
"notes": "Нотатки",
"contacts": "Контакти",
+ "birthdays": "Дні народження",
"budget": "Бюджет",
"settings": "Налаштування",
"main": "Головна навігація",
@@ -82,6 +83,19 @@
"allDay": "Весь день",
"shoppingMore": "+{{count}} ще",
"weather": "Погода",
+ "familyMembers": "Члени родини",
+ "participantsAdded": "учасників додано",
+ "upcomingBirthdays": "Найближчі дні народження",
+ "noBirthdays": "Днів народження ще немає",
+ "daysLeft": "{{count}} дн.",
+ "budgetOverview": "Огляд бюджету",
+ "monthlyIncome": "Доходи",
+ "monthlyExpenses": "Витрати",
+ "monthlyBalance": "Баланс",
+ "savingsRate": "Рівень заощаджень",
+ "topExpense": "Найбільша витрата",
+ "budgetEntries": "Записи",
+ "noBudgetData": "Немає бюджетних даних за цей місяць.",
"customize": "Налаштувати",
"customizeTitle": "Налаштувати віджети",
"customizeReset": "Скинути",
@@ -537,6 +551,7 @@
"tabAccount": "Обліковий запис",
"tabsAriaLabel": "Розділи налаштувань",
"sectionDesign": "Зовнішній вигляд",
+ "sectionAppName": "Назва застосунку",
"sectionShopping": "Покупки",
"shoppingCategoriesLabel": "Категорії покупок",
"shoppingCategoriesHint": "Додавайте, перейменовуйте, видаляйте або змінюйте порядок категорій.",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "Синхронізація календаря",
"sectionFamily": "Члени родини",
"cardAppearance": "Відображення",
+ "appNameTitle": "Назва застосунку",
+ "appNameLabel": "Назва застосунку",
+ "appNameHint": "Ця назва відображається в бічному меню, заголовку браузера та на екрані входу.",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "Назву застосунку збережено.",
+ "sectionDate": "Дата",
+ "dateFormatTitle": "Формат дати",
+ "dateFormatLabel": "Бажаний формат дати",
+ "dateFormatHint": "Виберіть, як дати відображаються в застосунку.",
+ "dateFormatSavedToast": "Формат дати збережено.",
"themeSystem": "Системна",
"themeSysLabel": "Використовувати системні налаштування",
"themeLight": "Світла",
@@ -781,6 +806,34 @@
"placeholder": "Пошук…",
"noResults": "Результатів не знайдено."
},
+ "birthdays": {
+ "title": "Дні народження",
+ "addButton": "Додати день народження",
+ "searchPlaceholder": "Шукати дні народження…",
+ "upcomingTitle": "Найближчі дні народження",
+ "upcomingHint": "Найближчі святкування, уже синхронізовані з календарем.",
+ "peopleTitle": "Люди",
+ "peopleHint": "Шукайте, переглядайте й редагуйте всі збережені дні народження.",
+ "emptyTitle": "Поки що немає днів народження",
+ "emptyDescription": "Додайте день народження, щоб він відображався в календарі та нагадуваннях.",
+ "newTitle": "Новий день народження",
+ "editTitle": "Редагувати день народження",
+ "nameLabel": "Ім'я",
+ "birthDateLabel": "Дата народження",
+ "photoLabel": "Фото профілю",
+ "removePhoto": "Видалити фото",
+ "notesLabel": "Нотатки",
+ "notesPlaceholder": "Ідеї подарунків, улюблений торт, сімейні нотатки…",
+ "calendarHint": "Кожен день народження автоматично додається до календаря та системи нагадувань.",
+ "requiredFields": "Ім'я та дата народження є обов'язковими.",
+ "createdToast": "День народження збережено.",
+ "updatedToast": "День народження оновлено.",
+ "deletedToast": "День народження видалено.",
+ "deleteConfirm": "Видалити день народження для \"{{name}}\"?",
+ "ageNoteToday": "Сьогодні виповнюється {{age}}.",
+ "ageNoteTomorrow": "Завтра виповниться {{age}}.",
+ "ageNoteDays": "За {{days}} дн. виповниться {{age}}."
+ },
"onboarding": {
"step1Title": "Welcome to Oikos",
"step1Body": "Your personal family planner. Tasks, calendar, shopping and more – all in one place.",
diff --git a/public/locales/zh.json b/public/locales/zh.json
index 22c3361..cf4d784 100644
--- a/public/locales/zh.json
+++ b/public/locales/zh.json
@@ -38,6 +38,7 @@
"shopping": "购物",
"notes": "便签",
"contacts": "联系人",
+ "birthdays": "生日",
"budget": "预算",
"settings": "设置",
"main": "主导航",
@@ -82,6 +83,19 @@
"allDay": "全天",
"shoppingMore": "+{{count}} 更多",
"weather": "天气",
+ "familyMembers": "家庭成员",
+ "participantsAdded": "位参与者已添加",
+ "upcomingBirthdays": "即将到来的生日",
+ "noBirthdays": "还没有生日",
+ "daysLeft": "{{count}} 天",
+ "budgetOverview": "预算概览",
+ "monthlyIncome": "收入",
+ "monthlyExpenses": "支出",
+ "monthlyBalance": "余额",
+ "savingsRate": "储蓄率",
+ "topExpense": "最大支出",
+ "budgetEntries": "记录",
+ "noBudgetData": "本月没有预算数据。",
"customize": "自定义",
"customizeTitle": "自定义小组件",
"customizeReset": "重置",
@@ -537,6 +551,7 @@
"tabAccount": "账户",
"tabsAriaLabel": "设置类别",
"sectionDesign": "外观",
+ "sectionAppName": "应用名称",
"sectionShopping": "购物",
"shoppingCategoriesLabel": "购物分类",
"shoppingCategoriesHint": "添加、重命名、删除或排序分类。",
@@ -554,6 +569,16 @@
"sectionCalendarSync": "日历同步",
"sectionFamily": "家庭成员",
"cardAppearance": "外观",
+ "appNameTitle": "应用名称",
+ "appNameLabel": "应用名称",
+ "appNameHint": "此名称会显示在侧边栏、浏览器标题和登录界面中。",
+ "appNamePlaceholder": "Oikos",
+ "appNameSavedToast": "应用名称已保存。",
+ "sectionDate": "日期",
+ "dateFormatTitle": "日期格式",
+ "dateFormatLabel": "首选日期格式",
+ "dateFormatHint": "选择日期在应用中的显示方式。",
+ "dateFormatSavedToast": "日期格式已保存。",
"themeSystem": "跟随系统",
"themeSysLabel": "使用系统设置",
"themeLight": "浅色",
@@ -760,6 +785,34 @@
"placeholder": "搜索…",
"noResults": "未找到结果。"
},
+ "birthdays": {
+ "title": "生日",
+ "addButton": "添加生日",
+ "searchPlaceholder": "搜索生日…",
+ "upcomingTitle": "即将到来的生日",
+ "upcomingHint": "接下来的生日庆祝,已同步到日历。",
+ "peopleTitle": "人物",
+ "peopleHint": "搜索、查看并编辑所有已保存的生日。",
+ "emptyTitle": "还没有生日",
+ "emptyDescription": "添加一个生日,让它显示在日历和提醒中。",
+ "newTitle": "新建生日",
+ "editTitle": "编辑生日",
+ "nameLabel": "姓名",
+ "birthDateLabel": "出生日期",
+ "photoLabel": "头像",
+ "removePhoto": "删除照片",
+ "notesLabel": "备注",
+ "notesPlaceholder": "礼物想法、最喜欢的蛋糕、家庭备注…",
+ "calendarHint": "每个生日都会自动添加到日历和提醒系统中。",
+ "requiredFields": "姓名和出生日期为必填项。",
+ "createdToast": "生日已保存。",
+ "updatedToast": "生日已更新。",
+ "deletedToast": "生日已删除。",
+ "deleteConfirm": "删除“{{name}}”的生日?",
+ "ageNoteToday": "今天满 {{age}} 岁。",
+ "ageNoteTomorrow": "明天满 {{age}} 岁。",
+ "ageNoteDays": "{{days}} 天后满 {{age}} 岁。"
+ },
"reminders": {
"sectionTitle": "提醒",
"enableLabel": "设置提醒",
diff --git a/public/pages/birthdays.js b/public/pages/birthdays.js
new file mode 100644
index 0000000..7f20434
--- /dev/null
+++ b/public/pages/birthdays.js
@@ -0,0 +1,397 @@
+import { api } from '/api.js';
+import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js';
+import { stagger } from '/utils/ux.js';
+import { t, formatDate } from '/i18n.js';
+import { esc } from '/utils/html.js';
+
+let state = {
+ birthdays: [],
+ upcoming: [],
+ query: '',
+};
+let _container = null;
+
+function initials(name) {
+ return String(name || '')
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase() || '')
+ .join('') || '?';
+}
+
+function ageNote(birthday) {
+ if (birthday.days_until === 0) return t('birthdays.ageNoteToday', { age: birthday.next_age });
+ if (birthday.days_until === 1) return t('birthdays.ageNoteTomorrow', { age: birthday.next_age });
+ return t('birthdays.ageNoteDays', { age: birthday.next_age, days: birthday.days_until });
+}
+
+function photoAvatar(birthday, extraClass = '') {
+ if (birthday.photo_data) {
+ return ``;
+ }
+ return ``;
+}
+
+function filteredBirthdays() {
+ const q = state.query.trim().toLowerCase();
+ const list = !q ? state.birthdays : state.birthdays.filter((birthday) =>
+ birthday.name.toLowerCase().includes(q) ||
+ (birthday.notes || '').toLowerCase().includes(q)
+ );
+ return [...list].sort((a, b) => a.name.localeCompare(b.name));
+}
+
+function suggestions() {
+ const q = state.query.trim().toLowerCase();
+ if (!q) return [];
+ return state.birthdays
+ .filter((birthday) => birthday.name.toLowerCase().includes(q))
+ .slice(0, 6);
+}
+
+async function loadData() {
+ const [allRes, upcomingRes] = await Promise.all([
+ api.get('/birthdays'),
+ api.get('/birthdays/upcoming?limit=4'),
+ ]);
+ state.birthdays = allRes.data ?? [];
+ state.upcoming = upcomingRes.data ?? [];
+}
+
+function renderSuggestions() {
+ const dropdown = _container.querySelector('#birthdays-autocomplete');
+ if (!dropdown) return;
+ const items = suggestions();
+ if (!items.length) {
+ dropdown.hidden = true;
+ dropdown.replaceChildren();
+ return;
+ }
+ dropdown.hidden = false;
+ dropdown.replaceChildren();
+ dropdown.insertAdjacentHTML('beforeend', items.map((birthday, idx) => `
+
+ `).join(''));
+}
+
+function renderUpcoming() {
+ const host = _container.querySelector('#birthdays-upcoming');
+ if (!host) return;
+ if (!state.upcoming.length) {
+ host.replaceChildren();
+ host.insertAdjacentHTML('beforeend', `
+
${t('birthdays.emptyTitle')}
+
${t('birthdays.emptyDescription')}
+
`);
+ return;
+ }
+ host.replaceChildren();
+ host.insertAdjacentHTML('beforeend', state.upcoming.map((birthday) => `
+
+ ${photoAvatar(birthday)}
+
+
+
+
${esc(birthday.name)}
+
${esc(formatDate(birthday.next_birthday))}
+
+
+ ${birthday.days_until === 0 ? esc(t('common.today')) : birthday.days_until === 1 ? esc(t('common.tomorrow')) : esc(`${birthday.days_until}d`)}
+
+
+
${esc(ageNote(birthday))}
+
+
+ `).join(''));
+}
+
+function renderList() {
+ const host = _container.querySelector('#birthdays-list');
+ if (!host) return;
+ const list = filteredBirthdays();
+ if (!list.length) {
+ host.replaceChildren();
+ host.insertAdjacentHTML('beforeend', `
+
${t('birthdays.emptyTitle')}
+
${t('birthdays.emptyDescription')}
+
`);
+ return;
+ }
+
+ host.replaceChildren();
+ host.insertAdjacentHTML('beforeend', list.map((birthday) => `
+
+ ${photoAvatar(birthday)}
+
+
+ ${esc(birthday.name)}
+ ${esc(formatDate(birthday.next_birthday))}
+
+
${esc(formatDate(birthday.birth_date))}
+
${esc(ageNote(birthday))}
+ ${birthday.notes ? `
${esc(birthday.notes)}
` : ''}
+
+
+
+
+
+
+ `).join(''));
+
+ if (window.lucide) window.lucide.createIcons();
+ stagger(host.querySelectorAll('.birthday-item'));
+}
+
+function renderPage() {
+ _container.replaceChildren();
+ _container.insertAdjacentHTML('beforeend', `
+
+
${t('birthdays.title')}
+
+
${t('birthdays.calendarHint')}
+
+
+
+
+
+ `);
+
+ renderUpcoming();
+ renderList();
+ renderSuggestions();
+ if (window.lucide) window.lucide.createIcons();
+}
+
+function bindEvents() {
+ const openCreate = () => openBirthdayModal({ mode: 'create' });
+ _container.querySelector('#birthdays-add-btn').addEventListener('click', openCreate);
+ _container.querySelector('#fab-new-birthday').addEventListener('click', openCreate);
+
+ const search = _container.querySelector('#birthdays-search');
+ search.addEventListener('input', (e) => {
+ state.query = e.target.value;
+ renderSuggestions();
+ renderList();
+ });
+ search.addEventListener('focus', renderSuggestions);
+ search.addEventListener('blur', () => {
+ setTimeout(() => {
+ const dropdown = _container.querySelector('#birthdays-autocomplete');
+ if (dropdown) dropdown.hidden = true;
+ }, 100);
+ });
+
+ _container.querySelector('#birthdays-autocomplete').addEventListener('click', (e) => {
+ const btn = e.target.closest('.birthday-suggestion');
+ if (!btn) return;
+ state.query = btn.dataset.name;
+ search.value = state.query;
+ renderList();
+ renderSuggestions();
+ });
+
+ _container.querySelector('#birthdays-list').addEventListener('click', async (e) => {
+ const action = e.target.closest('[data-action]');
+ if (!action) return;
+ const id = Number(action.dataset.id);
+ const birthday = state.birthdays.find((item) => item.id === id);
+ if (!birthday) return;
+ if (action.dataset.action === 'edit') {
+ openBirthdayModal({ mode: 'edit', birthday });
+ return;
+ }
+ if (action.dataset.action === 'delete') {
+ await deleteBirthday(id, birthday.name);
+ }
+ });
+}
+
+function readFileAsDataUrl(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result || ''));
+ reader.onerror = () => reject(new Error('Failed to read image.'));
+ reader.readAsDataURL(file);
+ });
+}
+
+function birthdayPreviewHtml(name, photoData) {
+ if (photoData) return `
`;
+ return `${esc(initials(name))}`;
+}
+
+function openBirthdayModal({ mode, birthday = null }) {
+ const isEdit = mode === 'edit';
+ let photoData = birthday?.photo_data || null;
+
+ openSharedModal({
+ title: isEdit ? t('birthdays.editTitle') : t('birthdays.newTitle'),
+ content: `
+
+
${birthdayPreviewHtml(birthday?.name || '', photoData)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${t('birthdays.calendarHint')}
+
+
+ `,
+ size: 'md',
+ onSave(panel) {
+ const nameInput = panel.querySelector('#bd-name');
+ const preview = panel.querySelector('#birthday-preview');
+ const renderPreview = () => {
+ preview.replaceChildren();
+ preview.insertAdjacentHTML('beforeend', birthdayPreviewHtml(nameInput.value.trim(), photoData));
+ };
+ nameInput.addEventListener('input', renderPreview);
+ panel.querySelector('#bd-photo').addEventListener('change', async (e) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ try {
+ photoData = await readFileAsDataUrl(file);
+ renderPreview();
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+ panel.querySelector('#bd-remove-photo').addEventListener('click', () => {
+ photoData = null;
+ panel.querySelector('#bd-photo').value = '';
+ renderPreview();
+ });
+ panel.querySelector('#bd-cancel').addEventListener('click', closeModal);
+ panel.querySelector('#bd-delete')?.addEventListener('click', async () => {
+ closeModal();
+ await deleteBirthday(birthday.id, birthday.name);
+ });
+ panel.querySelector('#bd-save').addEventListener('click', async () => {
+ const saveBtn = panel.querySelector('#bd-save');
+ const body = {
+ name: panel.querySelector('#bd-name').value.trim(),
+ birth_date: panel.querySelector('#bd-birth-date').value,
+ notes: panel.querySelector('#bd-notes').value.trim(),
+ photo_data: photoData,
+ };
+
+ if (!body.name || !body.birth_date) {
+ window.oikos?.showToast(t('birthdays.requiredFields'), 'warning');
+ return;
+ }
+
+ saveBtn.disabled = true;
+ try {
+ if (isEdit) {
+ const res = await api.put(`/birthdays/${birthday.id}`, body);
+ const idx = state.birthdays.findIndex((item) => item.id === birthday.id);
+ if (idx !== -1) state.birthdays[idx] = res.data;
+ window.oikos?.showToast(t('birthdays.updatedToast'), 'success');
+ } else {
+ const res = await api.post('/birthdays', body);
+ state.birthdays.push(res.data);
+ window.oikos?.showToast(t('birthdays.createdToast'), 'success');
+ }
+ state.birthdays.sort((a, b) => a.name.localeCompare(b.name));
+ const upcomingRes = await api.get('/birthdays/upcoming?limit=4');
+ state.upcoming = upcomingRes.data ?? [];
+ renderUpcoming();
+ renderSuggestions();
+ renderList();
+ closeModal();
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ saveBtn.disabled = false;
+ }
+ });
+ },
+ });
+}
+
+async function deleteBirthday(id, name) {
+ if (!await confirmModal(t('birthdays.deleteConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return;
+ await api.delete(`/birthdays/${id}`);
+ state.birthdays = state.birthdays
+ .filter((birthday) => birthday.id !== id)
+ .sort((a, b) => a.name.localeCompare(b.name));
+ state.upcoming = state.upcoming.filter((birthday) => birthday.id !== id);
+ renderUpcoming();
+ renderSuggestions();
+ renderList();
+ window.oikos?.showToast(t('birthdays.deletedToast'), 'success');
+}
+
+export async function render(container) {
+ _container = container;
+ await loadData();
+ renderPage();
+ bindEvents();
+}
diff --git a/public/pages/dashboard.js b/public/pages/dashboard.js
index ebaa8dd..b346687 100644
--- a/public/pages/dashboard.js
+++ b/public/pages/dashboard.js
@@ -110,7 +110,7 @@ function showOnboarding(appContainer) {
// Widget-Definitionen (Reihenfolge = Standard-Layout)
// --------------------------------------------------------
-const WIDGET_IDS = ['weather', 'tasks', 'calendar', 'shopping', 'meals', 'notes'];
+const WIDGET_IDS = ['tasks', 'calendar', 'birthdays', 'budget', 'family', 'weather', 'shopping', 'meals', 'notes'];
const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id) => ({ id, visible: true }));
@@ -122,15 +122,34 @@ function widgetLabel(id) {
meals: () => t('nav.meals'),
notes: () => t('nav.notes'),
weather: () => t('dashboard.weather'),
+ birthdays: () => t('nav.birthdays'),
+ budget: () => t('nav.budget'),
+ family: () => t('dashboard.familyMembers'),
};
return (map[id] ?? (() => id))();
}
function widgetIcon(id) {
- const map = { tasks: 'check-square', calendar: 'calendar', shopping: 'shopping-cart', meals: 'utensils', notes: 'pin', weather: 'cloud-sun' };
+ const map = { tasks: 'check-square', calendar: 'calendar', birthdays: 'cake', budget: 'wallet', family: 'users', shopping: 'shopping-cart', meals: 'utensils', notes: 'pin', weather: 'cloud-sun' };
return map[id] ?? 'layout-dashboard';
}
+const BUDGET_CATEGORY_LABEL_KEYS = {
+ housing: 'catHousing',
+ food: 'catFood',
+ transport: 'catTransport',
+ personal_health: 'catPersonalHealth',
+ leisure: 'catLeisure',
+ shopping_clothing: 'catShoppingClothing',
+ education: 'catEducation',
+ financial_other: 'catFinancialOther',
+ 'Erwerbseinkommen': 'catEarnedIncome',
+ 'Kapitalerträge': 'catInvestmentIncome',
+ 'Geschenke & Transfers': 'catTransferGiftIncome',
+ 'Sozialleistungen': 'catGovernmentBenefits',
+ 'Sonstiges Einkommen': 'catOtherIncome',
+};
+
// --------------------------------------------------------
// Hilfsfunktionen
// --------------------------------------------------------
@@ -225,6 +244,19 @@ function initials(name = '') {
return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
}
+function budgetCategoryLabel(category) {
+ const key = BUDGET_CATEGORY_LABEL_KEYS[category];
+ return key ? t(`budget.${key}`) : (category || '-');
+}
+
+function formatCurrency(amount, currency = 'EUR') {
+ return new Intl.NumberFormat(getLocale(), {
+ style: 'currency',
+ currency,
+ maximumFractionDigits: Math.abs(amount) >= 1000 ? 0 : 2,
+ }).format(amount || 0);
+}
+
function widgetHeader(icon, title, count, linkHref, linkLabel) {
linkLabel = linkLabel ?? t('dashboard.allLink');
const badge = count != null
@@ -264,51 +296,6 @@ function skeletonWidget(lines = 3) {
// Widget-Renderer
// --------------------------------------------------------
-function renderGreeting(user, stats = {}) {
- const { overdueCount = 0, dueSoonCount = 0, todayEventCount = 0, todayMealTitle = null } = stats;
-
- const statChips = [];
- if (overdueCount > 0)
- statChips.push(`
-
- ${overdueCount > 1 ? t('dashboard.overdueTasksChipPlural', { count: overdueCount }) : t('dashboard.overdueTasksChip', { count: overdueCount })}
- `);
- if (dueSoonCount > 0)
- statChips.push(`
-
- ${dueSoonCount > 1 ? t('dashboard.urgentTasksChipPlural', { count: dueSoonCount }) : t('dashboard.urgentTasksChip', { count: dueSoonCount })}
- `);
- if (todayEventCount > 0)
- statChips.push(`
-
- ${todayEventCount > 1 ? t('dashboard.eventsChipPlural', { count: todayEventCount }) : t('dashboard.eventsChip', { count: todayEventCount })}
- `);
- if (todayMealTitle)
- statChips.push(`
-
- ${t('dashboard.todayMealChip', { title: esc(todayMealTitle) })}
- `);
-
- const time = formatTime(new Date());
- const hour = new Date().getHours();
- const timeVariant = hour < 11 ? 'morning' : hour < 18 ? 'day' : 'evening';
-
- return `
-
- `;
-}
-
function renderUrgentTasks(tasks) {
if (!tasks.length) {
return `
@@ -382,6 +369,43 @@ function renderUpcomingEvents(events) {
`;
}
+function renderUpcomingBirthdays(birthdays) {
+ if (!birthdays.length) {
+ return ``;
+ }
+
+ const items = birthdays.map((b) => {
+ const daysLabel = b.days_until === 0
+ ? t('common.today')
+ : b.days_until === 1
+ ? t('common.tomorrow')
+ : t('dashboard.daysLeft', { count: b.days_until });
+ return `
+
+ `;
+ }).join('');
+
+ return ``;
+}
+
function renderTodayMeals(meals) {
const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack'];
@@ -428,6 +452,283 @@ function renderPinnedNotes(notes) {
`;
}
+function renderFamilyWidget(users) {
+ const visible = users.slice(0, 6);
+ const avatars = visible.map((u) => `
+
+ ${esc(initials(u.display_name))}
+
+ `).join('');
+
+ return ``;
+}
+
+function renderBudgetWidget(budget, currency) {
+ const income = budget?.income || 0;
+ const expenses = budget?.expenses || 0;
+ const balance = budget?.balance || 0;
+ const savingsRate = income > 0 ? Math.round((balance / income) * 100) : 0;
+ const balanceTone = balance >= 0 ? 'positive' : 'negative';
+ const hasData = (budget?.entryCount || 0) > 0;
+
+ return ``;
+}
+
+function renderQuickAction({ route, label, icon, tone = '' }) {
+ return `
+
+ `;
+}
+
+function renderKpiTile({ title, value, meta, icon, route, tone = '' }) {
+ return `
+
+ `;
+}
+
+function renderDashboardOverview(user, stats = null, weather = null) {
+ const dateLabel = formatDate(new Date());
+ const weatherLabel = weather
+ ? `${esc(weather.city)} · ${esc(weather.current?.temp)}${weather.units === 'imperial' ? '°F' : weather.units === 'standard' ? 'K' : '°C'}`
+ : t('dashboard.weather');
+
+ const actions = [
+ { route: '/tasks', label: t('nav.tasks'), icon: 'check-square', tone: 'blue' },
+ { route: '/calendar', label: t('nav.calendar'), icon: 'calendar', tone: 'violet' },
+ { route: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart', tone: 'green' },
+ { route: '/notes', label: t('nav.notes'), icon: 'sticky-note', tone: 'amber' },
+ ].map(renderQuickAction).join('');
+
+ const kpis = stats ? [
+ renderKpiTile({
+ title: t('tasks.title'),
+ value: String(stats.overdueCount ?? 0),
+ meta: t('dashboard.overdue'),
+ icon: 'alert-circle',
+ route: '/tasks',
+ tone: 'danger',
+ }),
+ renderKpiTile({
+ title: t('nav.calendar'),
+ value: String(stats.todayEventCount ?? 0),
+ meta: t('common.today'),
+ icon: 'calendar-days',
+ route: '/calendar',
+ tone: 'calendar',
+ }),
+ renderKpiTile({
+ title: t('nav.meals'),
+ value: stats.todayMealTitle ? esc(stats.todayMealTitle) : '-',
+ meta: t('dashboard.todayMeals'),
+ icon: 'utensils',
+ route: '/meals',
+ tone: 'meals',
+ }),
+ renderKpiTile({
+ title: t('dashboard.weather'),
+ value: weatherLabel,
+ meta: t('dashboard.weatherRefreshTitle'),
+ icon: 'cloud-sun',
+ route: '/',
+ tone: 'weather',
+ }),
+ renderKpiTile({
+ title: t('nav.birthdays'),
+ value: String(stats.birthdayCount ?? 0),
+ meta: t('dashboard.upcomingBirthdays'),
+ icon: 'cake',
+ route: '/birthdays',
+ tone: 'birthdays',
+ }),
+ renderKpiTile({
+ title: t('dashboard.familyMembers'),
+ value: String(stats.familyCount ?? 0),
+ meta: t('dashboard.participantsAdded'),
+ icon: 'users',
+ route: '/settings',
+ tone: 'family',
+ }),
+ ].join('') : `
+
+
+
+
+
+
+ `;
+
+ return `
+
+ `;
+}
+
+function widgetRegion(id) {
+ return ['budget', 'family', 'weather', 'shopping', 'meals'].includes(id) ? 'side' : 'main';
+}
+
+function widgetTileClass(id) {
+ const map = {
+ tasks: 'dashboard-tile--wide',
+ calendar: 'dashboard-tile--compact',
+ birthdays: 'dashboard-tile--compact',
+ budget: 'dashboard-tile--wide',
+ family: 'dashboard-tile--compact',
+ meals: 'dashboard-tile--compact',
+ notes: 'dashboard-tile--wide',
+ shopping: 'dashboard-tile--compact',
+ weather: 'dashboard-tile--wide',
+ };
+ return map[id] || 'dashboard-tile--compact';
+}
+
+function renderDashboardTile(id, html) {
+ if (!html) return '';
+ return ``;
+}
+
+function renderDashboardLayout(cfg, data, weather, currency) {
+ const widgetById = {
+ tasks: () => renderUrgentTasks(data.urgentTasks ?? []),
+ calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []),
+ birthdays: () => renderUpcomingBirthdays(data.birthdays ?? []),
+ budget: () => renderBudgetWidget(data.budget ?? {}, currency),
+ family: () => renderFamilyWidget(data.users ?? []),
+ meals: () => renderTodayMeals(data.todayMeals ?? []),
+ notes: () => renderPinnedNotes(data.pinnedNotes ?? []),
+ shopping: () => renderShoppingLists(data.shoppingLists ?? []),
+ weather: () => (weather ? renderWeatherWidget(weather) : ''),
+ };
+
+ const visible = cfg.filter((w) => w.visible && widgetById[w.id]);
+ const mainTiles = visible
+ .filter((w) => widgetRegion(w.id) === 'main')
+ .map((w) => renderDashboardTile(w.id, widgetById[w.id]()))
+ .join('');
+
+ const sideTiles = visible
+ .filter((w) => widgetRegion(w.id) === 'side')
+ .map((w) => renderDashboardTile(w.id, widgetById[w.id]()))
+ .join('');
+
+ return `
+
+ `;
+}
+
+function renderDashboardSkeleton() {
+ return `
+
+
+
+
+ ${skeletonWidget(3)}
+ ${skeletonWidget(3)}
+ ${skeletonWidget(2)}
+ ${skeletonWidget(3)}
+
+
+
+
+ `;
+}
+
// --------------------------------------------------------
// Shopping-Widget
// --------------------------------------------------------
@@ -609,25 +910,6 @@ function initFab(container, signal) {
document.addEventListener('click', () => { if (open) toggleFab(false); }, { signal });
}
-// --------------------------------------------------------
-// Widget-Rendering nach Konfiguration
-// --------------------------------------------------------
-
-function renderWidgets(cfg, data, weather) {
- const renderers = {
- tasks: () => renderUrgentTasks(data.urgentTasks ?? []),
- calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []),
- shopping: () => renderShoppingLists(data.shoppingLists ?? []),
- meals: () => renderTodayMeals(data.todayMeals ?? []),
- notes: () => renderPinnedNotes(data.pinnedNotes ?? []),
- weather: () => (weather ? renderWeatherWidget(weather) : ''),
- };
- return cfg
- .filter((w) => w.visible)
- .map((w) => (renderers[w.id] ? renderers[w.id]() : ''))
- .join('');
-}
-
// --------------------------------------------------------
// Customize-Modal
// --------------------------------------------------------
@@ -822,20 +1104,17 @@ export async function render(container, { user }) {
container.innerHTML = `
${t('dashboard.title')}
-
- ${renderGreeting(user, {})}
- ${skeletonWidget(3)}
- ${skeletonWidget(3)}
- ${skeletonWidget(2)}
- ${skeletonWidget(3)}
+
+ ${renderDashboardSkeleton()}
${renderFab()}
`;
- let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] };
+ let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [], birthdays: [], users: [], budget: {} };
let weather = null;
let widgetConfig = DEFAULT_WIDGET_CONFIG;
+ let currency = 'EUR';
try {
const [dashRes, weatherRes, prefsRes] = await Promise.all([
api.get('/dashboard'),
@@ -845,6 +1124,7 @@ export async function render(container, { user }) {
data = dashRes;
weather = weatherRes.data ?? null;
widgetConfig = prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG;
+ currency = prefsRes.data?.currency ?? 'EUR';
} catch (err) {
console.error('[Dashboard] Ladefehler:', err.message, 'Status:', err.status ?? 'network');
window.oikos?.showToast(t('dashboard.loadError'), 'warning');
@@ -866,52 +1146,46 @@ export async function render(container, { user }) {
todayMealTitle: (data.todayMeals ?? []).find((m) => m.meal_type === 'lunch')?.title
?? (data.todayMeals ?? [])[0]?.title
?? null,
+ birthdayCount: data.birthdayCount ?? (data.birthdays ?? []).length,
+ familyCount: (data.users ?? []).length,
};
const rerender = () => render(container, { user });
- function rebuildGrid(cfg) {
- const grid = container.querySelector('.dashboard__grid');
- if (!grid) return;
- const greeting = grid.querySelector('.widget-greeting');
- grid.replaceChildren(...(greeting ? [greeting] : []));
- grid.insertAdjacentHTML('beforeend', renderWidgets(cfg, data, weather));
+ function rebuildDashboard(cfg) {
+ const shell = container.querySelector('#dashboard-shell');
+ if (!shell) return;
+ shell.replaceChildren();
+ shell.insertAdjacentHTML('beforeend', `
+ ${renderDashboardOverview(user, stats, weather)}
+ ${renderDashboardLayout(cfg, data, weather, currency)}
+ `);
wireLinks(container, rerender);
if (window.lucide) window.lucide.createIcons();
- wireWeatherRefresh(container);
+ wireWeatherRefresh(container, (updatedWeather) => {
+ weather = updatedWeather;
+ rebuildDashboard(cfg);
+ });
+ container.querySelector('#dashboard-customize-btn')?.addEventListener('click', () => {
+ openCustomizeModal(widgetConfig, (newConfig) => {
+ widgetConfig = newConfig;
+ rebuildDashboard(widgetConfig);
+ });
+ }, { signal: _fabController.signal });
}
- // Greeting in-place aktualisieren (Stats-Chips hinzufügen), kein Gesamt-Reset
- const greetingEl = container.querySelector('.widget-greeting');
- if (greetingEl) greetingEl.outerHTML = renderGreeting(user, stats);
-
- // Skeletons durch echte Widgets ersetzen
- rebuildGrid(widgetConfig);
+ rebuildDashboard(widgetConfig);
initFab(container, _fabController.signal);
- container.querySelector('#dashboard-customize-btn')?.addEventListener(
- 'click',
- () => openCustomizeModal(widgetConfig, (newConfig) => {
- widgetConfig = newConfig;
- rebuildGrid(widgetConfig);
- }),
- { signal: _fabController.signal },
- );
-
// 30-Minuten Auto-Refresh für Wetter
const refreshBtn = container.querySelector('#weather-refresh-btn');
if (refreshBtn) {
const doAutoRefresh = async () => {
try {
const res = await api.get('/weather').catch(() => ({ data: null }));
- const wWidget = container.querySelector('#weather-widget');
- if (wWidget) {
- wWidget.outerHTML = renderWeatherWidget(res.data ?? null);
- const newWidget = container.querySelector('#weather-widget');
- if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
- wireWeatherRefresh(container);
- }
+ weather = res.data ?? null;
+ rebuildDashboard(widgetConfig);
} catch { /* silently ignore */ }
};
const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000);
@@ -923,7 +1197,7 @@ export async function render(container, { user }) {
}
}
-function wireWeatherRefresh(container) {
+function wireWeatherRefresh(container, onUpdated = null) {
const refreshBtn = container.querySelector('#weather-refresh-btn');
if (!refreshBtn) return;
const doWeatherRefresh = async () => {
@@ -936,7 +1210,7 @@ function wireWeatherRefresh(container) {
wWidget.outerHTML = renderWeatherWidget(res.data ?? null);
const newWidget = container.querySelector('#weather-widget');
if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
- wireWeatherRefresh(container);
+ onUpdated?.(res.data ?? null);
window.oikos?.showToast(t('dashboard.weatherUpdated'), 'success', 1500);
}
} catch { /* silently ignore */ }
diff --git a/public/pages/login.js b/public/pages/login.js
index 95aef49..439b5f7 100644
--- a/public/pages/login.js
+++ b/public/pages/login.js
@@ -8,16 +8,30 @@ import { auth } from '/api.js';
import { t } from '/i18n.js';
const VERSION_URL = '/api/v1/version';
+const DEFAULT_APP_NAME = 'Oikos';
+const APP_NAME_STORAGE_KEY = 'oikos-app-name';
+
+function getStoredAppName() {
+ return localStorage.getItem(APP_NAME_STORAGE_KEY) || DEFAULT_APP_NAME;
+}
+
+function setAppBranding(appName) {
+ const name = String(appName || '').trim() || DEFAULT_APP_NAME;
+ document.title = name;
+ const titleEl = document.querySelector('.login-hero__title');
+ if (titleEl) titleEl.textContent = name;
+}
/**
* Rendert die Login-Seite in den gegebenen Container.
* @param {HTMLElement} container
*/
export async function render(container) {
+ const storedAppName = getStoredAppName();
container.innerHTML = `
-
Oikos
+
${storedAppName}
${t('login.tagline')}
@@ -67,9 +81,17 @@ export async function render(container) {
const submitBtn = container.querySelector('#login-btn');
const versionEl = container.querySelector('#login-version');
- fetch(VERSION_URL)
+ setAppBranding(storedAppName);
+
+ fetch(VERSION_URL, { cache: 'no-store' })
.then((r) => r.json())
- .then((d) => { versionEl.textContent = t('login.version', { version: d.version }); })
+ .then((d) => {
+ if (d?.app_name) {
+ try { localStorage.setItem(APP_NAME_STORAGE_KEY, d.app_name); } catch (_) {}
+ setAppBranding(d.app_name);
+ }
+ versionEl.textContent = t('login.version', { version: d.version });
+ })
.catch(() => {});
form.addEventListener('submit', async (e) => {
diff --git a/public/pages/settings.js b/public/pages/settings.js
index 8a3d45d..7d1f6bd 100644
--- a/public/pages/settings.js
+++ b/public/pages/settings.js
@@ -12,6 +12,8 @@ import '/components/oikos-locale-picker.js';
const SUPPORTED_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
const SETTINGS_TAB_KEY = 'oikos:settings:tab';
+const APP_NAME_STORAGE_KEY = 'oikos-app-name';
+const DEFAULT_APP_NAME = 'Oikos';
const CATEGORY_I18N = {
'Obst & Gemüse': 'shopping.catFruitVeg',
@@ -56,7 +58,7 @@ export async function render(container, { user }) {
let users = [];
let googleStatus = { configured: false, connected: false, lastSync: null };
let appleStatus = { configured: false, lastSync: null };
- let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' };
+ let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', app_name: DEFAULT_APP_NAME };
let categories = [];
let icsSubscriptions = [];
let apiTokens = [];
@@ -80,6 +82,13 @@ export async function render(container, { user }) {
if (apiTokensRes.status === 'fulfilled') apiTokens = apiTokensRes.value.data ?? [];
} catch (_) { /* non-critical */ }
+ if (prefs.date_format) {
+ try { localStorage.setItem('oikos-date-format', prefs.date_format); } catch (_) {}
+ }
+ if (prefs.app_name) {
+ try { localStorage.setItem(APP_NAME_STORAGE_KEY, prefs.app_name); } catch (_) {}
+ }
+
const googleStatusText = googleStatus.connected
? (googleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(googleStatus.lastSync) }) : t('settings.connected'))
: googleStatus.configured ? t('settings.notConnected') : t('settings.notConfigured');
@@ -139,6 +148,48 @@ export async function render(container, { user }) {
+ ${user?.role === 'admin' ? `
+
+ ${t('settings.sectionAppName')}
+
+
${t('settings.appNameTitle')}
+
${t('settings.appNameHint')}
+
+
+
+ ` : ''}
+
+
+ ${t('settings.sectionDate')}
+
+
${t('settings.dateFormatTitle')}
+
${t('settings.dateFormatHint')}
+
+
+
+
+
${t('settings.languageTitle')}
@@ -514,6 +565,58 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) {
});
}
+ const dateFormatSelect = container.querySelector('#date-format-select');
+ if (dateFormatSelect) {
+ dateFormatSelect.addEventListener('change', async () => {
+ try {
+ await api.put('/preferences', { date_format: dateFormatSelect.value });
+ try { localStorage.setItem('oikos-date-format', dateFormatSelect.value); } catch (_) {}
+ window.dispatchEvent(new CustomEvent('date-format-changed', { detail: { dateFormat: dateFormatSelect.value } }));
+ window.oikos?.showToast(t('settings.dateFormatSavedToast'), 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger');
+ }
+ });
+ }
+
+ const appNameForm = container.querySelector('#app-name-form');
+ if (appNameForm) {
+ appNameForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = container.querySelector('#app-name-error');
+ const input = container.querySelector('#app-name-input');
+ errorEl.hidden = true;
+ const value = input.value.trim();
+ try {
+ await api.put('/preferences', { app_name: value });
+ try {
+ if (value) localStorage.setItem(APP_NAME_STORAGE_KEY, value);
+ else localStorage.removeItem(APP_NAME_STORAGE_KEY);
+ } catch (_) {}
+ input.value = value || DEFAULT_APP_NAME;
+ window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: value || DEFAULT_APP_NAME } }));
+ window.oikos?.showToast(t('settings.appNameSavedToast'), 'success');
+ } catch (err) {
+ showError(errorEl, err.message ?? t('common.errorGeneric'));
+ }
+ });
+
+ container.querySelector('#app-name-reset-btn')?.addEventListener('click', async () => {
+ const errorEl = container.querySelector('#app-name-error');
+ const input = container.querySelector('#app-name-input');
+ errorEl.hidden = true;
+ input.value = DEFAULT_APP_NAME;
+ try {
+ await api.put('/preferences', { app_name: '' });
+ try { localStorage.removeItem(APP_NAME_STORAGE_KEY); } catch (_) {}
+ window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: DEFAULT_APP_NAME } }));
+ window.oikos?.showToast(t('settings.appNameSavedToast'), 'success');
+ } catch (err) {
+ showError(errorEl, err.message ?? t('common.errorGeneric'));
+ }
+ });
+ }
+
// Passwort ändern
const passwordForm = container.querySelector('#password-form');
if (passwordForm) {
diff --git a/public/router.js b/public/router.js
index f07aabd..4d3b514 100644
--- a/public/router.js
+++ b/public/router.js
@@ -19,6 +19,7 @@ const ROUTES = [
{ path: '/shopping', page: '/pages/shopping.js', requiresAuth: true, module: 'shopping' },
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true, module: 'meals' },
{ path: '/calendar', page: '/pages/calendar.js', requiresAuth: true, module: 'calendar' },
+ { path: '/birthdays', page: '/pages/birthdays.js', requiresAuth: true, module: 'birthdays' },
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' },
{ path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' },
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
@@ -116,6 +117,7 @@ async function importPage(pagePath) {
let currentUser = null;
let currentPath = null;
let isNavigating = false;
+let _preferencesLoaded = false;
// Gesetzt wenn auth:expired waehrend einer laufenden Navigation feuert.
// Die Weiterleitung zu /login wird nach Abschluss der Navigation nachgeholt.
let _pendingLoginRedirect = false;
@@ -124,11 +126,14 @@ let _pendingLoginRedirect = false;
// Router
// --------------------------------------------------------
-const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/recipes', '/shopping',
+const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/birthdays', '/meals', '/recipes', '/shopping',
'/notes', '/contacts', '/budget', '/settings'];
const PRIMARY_NAV = 4;
+const DEFAULT_APP_NAME = 'Oikos';
+const APP_NAME_STORAGE_KEY = 'oikos-app-name';
+
function getDirection(fromPath, toPath) {
const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/');
const toIdx = ROUTE_ORDER.indexOf(toPath);
@@ -136,6 +141,53 @@ function getDirection(fromPath, toPath) {
return toIdx > fromIdx ? 'right' : 'left';
}
+function getAppName() {
+ return localStorage.getItem(APP_NAME_STORAGE_KEY) || DEFAULT_APP_NAME;
+}
+
+function setAppName(name) {
+ const next = String(name || '').trim();
+ if (next) {
+ localStorage.setItem(APP_NAME_STORAGE_KEY, next);
+ } else {
+ localStorage.removeItem(APP_NAME_STORAGE_KEY);
+ }
+}
+
+function routeTitle(path) {
+ const map = {
+ '/': t('dashboard.title'),
+ '/tasks': t('nav.tasks'),
+ '/calendar': t('nav.calendar'),
+ '/birthdays': t('nav.birthdays'),
+ '/meals': t('nav.meals'),
+ '/recipes': t('nav.recipes'),
+ '/shopping': t('nav.shopping'),
+ '/notes': t('nav.notes'),
+ '/contacts': t('nav.contacts'),
+ '/budget': t('nav.budget'),
+ '/settings': t('nav.settings'),
+ };
+ return map[path] || getAppName();
+}
+
+function updateBranding(path = currentPath) {
+ const appName = getAppName();
+ const sidebarLogoSpan = document.querySelector('.nav-sidebar__logo span');
+ if (sidebarLogoSpan) sidebarLogoSpan.textContent = appName;
+
+ const loginTitle = document.querySelector('.login-hero__title');
+ if (path === '/login' && loginTitle) loginTitle.textContent = appName;
+
+ document.title = path === '/login'
+ ? appName
+ : `${routeTitle(path || '/')} · ${appName}`;
+
+ document.querySelectorAll('meta[name="apple-mobile-web-app-title"]').forEach((meta) => {
+ meta.setAttribute('content', appName);
+ });
+}
+
/**
* Navigiert zu einem Pfad und rendert die entsprechende Seite.
* @param {string} path
@@ -151,6 +203,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
// Überlastung: navigate(path, user) nach Login vs navigate(path, false) beim Init
if (typeof userOrPushState === 'object' && userOrPushState !== null) {
currentUser = userOrPushState;
+ await syncPreferencesOnce();
initReminders();
} else {
pushState = userOrPushState;
@@ -168,6 +221,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
try {
const result = await auth.me();
currentUser = result.user;
+ await syncPreferencesOnce();
initReminders();
} catch {
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
@@ -198,6 +252,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
await renderPage(route, previousPath);
updateNav(basePath);
updateThemeColorForRoute(route);
+ updateBranding(basePath);
} finally {
isNavigating = false;
// auth:expired kann waehrend einer Navigation gefeuert haben (z.B. wenn ein
@@ -210,6 +265,24 @@ async function navigate(path, userOrPushState = true, pushState = true) {
}
}
+async function syncPreferencesOnce() {
+ if (_preferencesLoaded) return;
+ _preferencesLoaded = true;
+ try {
+ const res = await api.get('/preferences');
+ const dateFormat = res?.data?.date_format;
+ if (dateFormat) {
+ localStorage.setItem('oikos-date-format', dateFormat);
+ }
+ if (res?.data?.app_name) {
+ setAppName(res.data.app_name);
+ updateBranding();
+ }
+ } catch {
+ // Non-critical. The settings page can refresh this later.
+ }
+}
+
/**
* Lädt und rendert eine Seite dynamisch.
* @param {{ path: string, page: string }} route
@@ -352,7 +425,7 @@ function renderAppShell(container) {
sidebarLogo.appendChild(logomark);
const sidebarLogoSpan = document.createElement('span');
- sidebarLogoSpan.textContent = 'Oikos';
+ sidebarLogoSpan.textContent = getAppName();
sidebarLogo.appendChild(sidebarLogoSpan);
const sidebarItems = document.createElement('div');
sidebarItems.className = 'nav-sidebar__items';
@@ -455,6 +528,7 @@ function renderAppShell(container) {
routeAnnouncer.setAttribute('aria-atomic', 'true');
container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer, routeAnnouncer);
+ updateBranding(currentPath || '/');
// Klick-Handler für alle Nav-Links
container.querySelectorAll('[data-route]').forEach((el) => {
@@ -658,6 +732,7 @@ function navItems() {
return [
{ path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard' },
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
+ { path: '/birthdays', label: t('nav.birthdays'), icon: 'cake' },
{ path: '/calendar', label: t('nav.calendar'), icon: 'calendar' },
{ path: '/meals', label: t('nav.meals'), icon: 'utensils' },
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-text' },
@@ -900,6 +975,11 @@ window.addEventListener('locale-changed', () => {
});
updateNav(currentPath);
+ updateBranding(currentPath || '/');
+});
+
+window.addEventListener('app-name-changed', () => {
+ updateBranding(currentPath || '/');
});
// --------------------------------------------------------
diff --git a/public/styles/birthdays.css b/public/styles/birthdays.css
new file mode 100644
index 0000000..fec9324
--- /dev/null
+++ b/public/styles/birthdays.css
@@ -0,0 +1,331 @@
+.birthdays-page { --module-accent: var(--module-birthdays); }
+
+.birthdays-page {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ max-width: var(--content-max-width);
+ margin: 0 auto;
+ padding-bottom: calc(var(--nav-bottom-height) + var(--space-6));
+}
+
+.birthdays-grid {
+ display: grid;
+ grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
+ gap: var(--space-4);
+ padding: 0 var(--space-4);
+}
+
+.birthdays-panel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: calc(var(--radius-md) + 4px);
+ background:
+ radial-gradient(circle at top left, color-mix(in srgb, var(--module-accent) 10%, transparent), transparent 45%),
+ var(--color-surface);
+ box-shadow: var(--shadow-sm);
+}
+
+.birthdays-panel--upcoming {
+ position: sticky;
+ top: var(--space-4);
+ align-self: start;
+}
+
+.birthdays-panel--list {
+ min-width: 0;
+}
+
+.birthdays-toolbar {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border);
+ border-top: 3px solid var(--module-accent);
+ background: var(--color-surface);
+}
+
+.birthdays-toolbar__title {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+ flex: 1;
+ font-size: var(--text-lg);
+ font-weight: var(--font-weight-bold);
+}
+
+.birthdays-toolbar__title-icon {
+ width: 20px;
+ height: 20px;
+ color: var(--module-accent);
+ flex-shrink: 0;
+}
+
+.birthdays-toolbar__subtitle {
+ margin: 0 var(--space-4) var(--space-2);
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+}
+
+.birthdays-toolbar--embedded {
+ padding: 0;
+ border: none;
+ background: transparent;
+}
+
+.birthdays-toolbar__search {
+ flex: 1;
+ position: relative;
+}
+
+.birthdays-toolbar__search-icon {
+ position: absolute;
+ left: var(--space-3);
+ top: 50%;
+ transform: translateY(-50%);
+ width: 16px;
+ height: 16px;
+ color: var(--color-text-disabled);
+ pointer-events: none;
+}
+
+.birthdays-toolbar__search-input {
+ width: 100%;
+ min-height: 40px;
+ padding: var(--space-2) var(--space-3) var(--space-2) 36px;
+ border-radius: var(--radius-glass-button);
+ border: 1.5px solid var(--glass-border-subtle);
+ background: var(--color-surface-2);
+}
+
+.birthdays-section {
+ padding: 0 var(--space-4);
+}
+
+.birthdays-section__header h2,
+.birthdays-section__header h3 {
+ margin: 0;
+ font-size: var(--text-lg);
+}
+
+.birthdays-section__header p {
+ margin: var(--space-1) 0 0;
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+}
+
+.birthday-cards {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-3);
+}
+
+.birthday-card,
+.birthday-item {
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-sm);
+}
+
+.birthday-card {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ background: linear-gradient(180deg, color-mix(in srgb, var(--module-accent) 6%, var(--color-surface)), var(--color-surface));
+}
+
+.birthday-card__body,
+.birthday-item__body {
+ min-width: 0;
+ flex: 1;
+}
+
+.birthday-card__top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
+.birthday-card__name,
+.birthday-item__name {
+ font-size: var(--text-base);
+ font-weight: var(--font-weight-semibold);
+}
+
+.birthday-card__date,
+.birthday-item__meta,
+.birthday-item__next {
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+}
+
+.birthday-card__note,
+.birthday-item__note,
+.birthday-item__notes {
+ margin-top: var(--space-1);
+ font-size: var(--text-sm);
+}
+
+.birthday-card__pill {
+ padding: 0.35rem 0.6rem;
+ border-radius: var(--radius-full);
+ background: color-mix(in srgb, var(--module-accent) 12%, transparent);
+ color: var(--module-accent);
+ font-size: var(--text-xs);
+ font-weight: var(--font-weight-semibold);
+ white-space: nowrap;
+}
+
+.birthday-item__notes {
+ color: var(--color-text-secondary);
+}
+
+.birthdays-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.birthday-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-4);
+}
+
+.birthday-item__row {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-2);
+}
+
+.birthday-item__actions {
+ display: flex;
+ gap: var(--space-1);
+}
+
+.birthday-avatar {
+ width: 56px;
+ height: 56px;
+ border-radius: var(--radius-full);
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.birthday-avatar--fallback {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--module-accent) 16%, white);
+ color: var(--module-accent);
+ font-weight: var(--font-weight-bold);
+}
+
+.birthday-avatar--xs {
+ width: 34px;
+ height: 34px;
+ font-size: var(--text-sm);
+}
+
+.birthdays-autocomplete {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ z-index: var(--z-dropdown);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border);
+ background: var(--color-surface-elevated);
+ box-shadow: var(--shadow-lg);
+ overflow: hidden;
+}
+
+.birthday-suggestion {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border: none;
+ background: transparent;
+ text-align: left;
+ cursor: pointer;
+}
+
+.birthday-suggestion:hover {
+ background: var(--color-surface-hover);
+}
+
+.birthday-suggestion span {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+}
+
+.birthday-suggestion small {
+ color: var(--color-text-secondary);
+}
+
+.birthday-preview {
+ width: 84px;
+ height: 84px;
+ margin: 0 auto var(--space-3);
+ border-radius: var(--radius-full);
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--module-accent) 16%, white);
+}
+
+.birthday-preview__image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.birthday-preview__fallback {
+ color: var(--module-accent);
+ font-size: var(--text-xl);
+ font-weight: var(--font-weight-bold);
+}
+
+.birthday-modal__photo-actions {
+ margin-top: var(--space-2);
+}
+
+.birthday-modal__hint {
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+}
+
+@media (max-width: 960px) {
+ .birthdays-grid {
+ grid-template-columns: 1fr;
+ }
+ .birthdays-panel--upcoming {
+ position: static;
+ }
+}
+
+.contact-action-btn {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--radius-full);
+ border: none;
+ background: var(--color-surface-2);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-secondary);
+}
diff --git a/public/styles/dashboard.css b/public/styles/dashboard.css
index 086164f..e65356f 100644
--- a/public/styles/dashboard.css
+++ b/public/styles/dashboard.css
@@ -34,6 +34,121 @@
}
}
+.dashboard-hero {
+ display: grid;
+ gap: var(--space-4);
+ margin-bottom: var(--space-5);
+}
+
+@media (min-width: 1024px) {
+ .dashboard-hero {
+ grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr);
+ align-items: stretch;
+ }
+}
+
+.dashboard-hero .widget-greeting {
+ box-shadow: var(--glass-shadow-md);
+ min-height: 100%;
+}
+
+.dashboard-hero__rail {
+ display: grid;
+ gap: var(--space-3);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+@media (max-width: 767px) {
+ .dashboard-hero__rail {
+ grid-template-columns: 1fr;
+ }
+}
+
+.dashboard-metric {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ width: 100%;
+ min-height: 96px;
+ padding: var(--space-4);
+ border-radius: var(--radius-md);
+ background: color-mix(in srgb, var(--color-surface) 92%, transparent);
+ border: 1px solid var(--glass-border-subtle);
+ box-shadow: var(--glass-shadow-sm);
+ text-align: left;
+ appearance: none;
+ color: inherit;
+ cursor: pointer;
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast);
+}
+
+.dashboard-metric:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--glass-shadow-md);
+}
+
+.dashboard-metric__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 42px;
+ height: 42px;
+ border-radius: var(--radius-full);
+ background: color-mix(in srgb, var(--color-accent) 16%, transparent);
+ color: var(--color-accent);
+ flex-shrink: 0;
+}
+
+.dashboard-metric--warn .dashboard-metric__icon {
+ background: color-mix(in srgb, var(--color-warning) 18%, transparent);
+ color: var(--color-warning);
+}
+
+.dashboard-metric--calendar .dashboard-metric__icon {
+ background: color-mix(in srgb, var(--module-calendar) 18%, transparent);
+ color: var(--module-calendar);
+}
+
+.dashboard-metric--meals .dashboard-metric__icon {
+ background: color-mix(in srgb, var(--module-meals) 18%, transparent);
+ color: var(--module-meals);
+}
+
+.dashboard-metric--weather .dashboard-metric__icon {
+ background: color-mix(in srgb, var(--module-dashboard) 18%, transparent);
+ color: var(--module-dashboard);
+}
+
+.dashboard-metric__body {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.dashboard-metric__title {
+ font-size: var(--text-xs);
+ color: var(--color-text-secondary);
+}
+
+.dashboard-metric__value {
+ font-size: var(--text-base);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashboard-metric__hint {
+ font-size: var(--text-xs);
+ color: var(--color-text-tertiary);
+}
+
+.dashboard-metric--skeleton {
+ cursor: default;
+}
+
/* --------------------------------------------------------
* Widget-Grid
*
@@ -181,6 +296,8 @@
.widget--budget { --widget-accent: var(--module-budget); }
.widget--contacts { --widget-accent: var(--module-contacts); }
.widget--weather { --widget-accent: var(--module-dashboard); }
+.widget--birthdays { --widget-accent: var(--module-birthdays); }
+.widget--family { --widget-accent: var(--module-contacts); }
/* --------------------------------------------------------
* Basis-Widget (Card)
@@ -1315,3 +1432,1054 @@
.onboarding-overlay--out { animation: none; }
.onboarding-dot { transition: none; }
}
+
+/* --------------------------------------------------------
+ * Modern Dashboard Skin
+ * -------------------------------------------------------- */
+.dashboard {
+ position: relative;
+ isolation: isolate;
+ overflow: clip;
+}
+
+.dashboard::before,
+.dashboard::after {
+ content: '';
+ position: absolute;
+ inset: auto;
+ z-index: -1;
+ pointer-events: none;
+ filter: blur(28px);
+ opacity: 0.55;
+}
+
+.dashboard::before {
+ top: -80px;
+ right: -120px;
+ width: min(42vw, 520px);
+ height: min(42vw, 520px);
+ background: radial-gradient(circle, color-mix(in srgb, var(--module-dashboard) 22%, transparent) 0%, transparent 70%);
+}
+
+.dashboard::after {
+ left: -140px;
+ bottom: 8%;
+ width: min(36vw, 440px);
+ height: min(36vw, 440px);
+ background: radial-gradient(circle, color-mix(in srgb, var(--module-calendar) 18%, transparent) 0%, transparent 72%);
+}
+
+.dashboard-shell {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+.dashboard-hero {
+ display: grid;
+ gap: var(--space-4);
+}
+
+@media (min-width: 1100px) {
+ .dashboard-hero {
+ grid-template-columns: minmax(0, 1.45fr) minmax(340px, 0.85fr);
+ }
+}
+
+.dashboard-hero__panel {
+ position: relative;
+ overflow: hidden;
+ border-radius: clamp(22px, 3vw, 34px);
+ padding: clamp(1.25rem, 2vw, 2rem);
+ border: 1px solid color-mix(in srgb, var(--color-border) 30%, transparent);
+ box-shadow: 0 18px 60px color-mix(in srgb, var(--color-shadow) 22%, transparent);
+ backdrop-filter: blur(18px);
+ -webkit-backdrop-filter: blur(18px);
+}
+
+.dashboard-hero__panel::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(circle at top right, color-mix(in srgb, var(--module-dashboard) 20%, transparent), transparent 42%),
+ radial-gradient(circle at bottom left, color-mix(in srgb, var(--module-calendar) 14%, transparent), transparent 38%);
+ opacity: 0.9;
+ pointer-events: none;
+}
+
+.dashboard-hero__panel > * {
+ position: relative;
+ z-index: 1;
+}
+
+.dashboard-hero__panel--intro {
+ background:
+ linear-gradient(135deg,
+ color-mix(in srgb, var(--module-dashboard) 20%, var(--color-surface)) 0%,
+ color-mix(in srgb, var(--module-calendar) 10%, var(--color-surface)) 48%,
+ color-mix(in srgb, var(--module-shopping) 8%, var(--color-surface)) 100%);
+}
+
+.dashboard-hero__panel--summary {
+ background:
+ linear-gradient(180deg,
+ color-mix(in srgb, var(--color-surface) 90%, transparent) 0%,
+ color-mix(in srgb, var(--color-surface-2) 78%, transparent) 100%);
+}
+
+.dashboard-hero__eyebrow {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius-full);
+ background: color-mix(in srgb, var(--color-accent) 14%, transparent);
+ color: var(--color-accent);
+ font-size: var(--text-xs);
+ font-weight: var(--font-weight-semibold);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.dashboard-hero__title {
+ margin: var(--space-3) 0 0;
+ font-size: clamp(2.1rem, 4.5vw, 4.3rem);
+ line-height: 0.98;
+ letter-spacing: -0.04em;
+ font-weight: var(--font-weight-bold);
+ max-width: 14ch;
+}
+
+.dashboard-hero__subtitle {
+ margin: var(--space-3) 0 0;
+ color: var(--color-text-secondary);
+ font-size: var(--text-base);
+}
+
+.dashboard-hero__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-top: var(--space-4);
+}
+
+.dashboard-chip {
+ display: inline-flex;
+ align-items: center;
+ min-height: 32px;
+ padding: 0 var(--space-3);
+ border-radius: var(--radius-full);
+ background: color-mix(in srgb, var(--color-text-primary) 7%, transparent);
+ color: var(--color-text-primary);
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-medium);
+}
+
+.dashboard-chip--danger {
+ background: color-mix(in srgb, var(--color-danger) 16%, transparent);
+ color: var(--color-danger);
+}
+
+.dashboard-chip--warning {
+ background: color-mix(in srgb, var(--color-warning) 18%, transparent);
+ color: var(--color-warning);
+}
+
+.dashboard-chip--accent {
+ background: color-mix(in srgb, var(--module-dashboard) 16%, transparent);
+ color: var(--module-dashboard);
+}
+
+.dashboard-hero__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ margin-top: var(--space-5);
+}
+
+.dashboard-action {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-height: 44px;
+ padding: 0 var(--space-3);
+ border-radius: var(--radius-full);
+ border: 1px solid color-mix(in srgb, var(--color-border) 40%, transparent);
+ background: color-mix(in srgb, var(--color-surface) 76%, transparent);
+ color: var(--color-text-primary);
+ box-shadow: var(--shadow-xs);
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast);
+}
+
+.dashboard-action:hover {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-sm);
+ border-color: color-mix(in srgb, var(--color-accent) 30%, transparent);
+}
+
+.dashboard-action__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--color-accent) 10%, transparent);
+ color: var(--color-accent);
+ flex-shrink: 0;
+}
+
+.dashboard-action--blue .dashboard-action__icon {
+ background: color-mix(in srgb, var(--module-tasks) 14%, transparent);
+ color: var(--module-tasks);
+}
+
+.dashboard-action--violet .dashboard-action__icon {
+ background: color-mix(in srgb, var(--module-calendar) 14%, transparent);
+ color: var(--module-calendar);
+}
+
+.dashboard-action--green .dashboard-action__icon {
+ background: color-mix(in srgb, var(--module-shopping) 14%, transparent);
+ color: var(--module-shopping);
+}
+
+.dashboard-action--amber .dashboard-action__icon {
+ background: color-mix(in srgb, var(--module-notes) 14%, transparent);
+ color: var(--module-notes);
+}
+
+.dashboard-action__label {
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-semibold);
+}
+
+.dashboard-hero__summary-head {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
+.dashboard-hero__summary-label {
+ font-size: var(--text-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--color-text-tertiary);
+}
+
+.dashboard-hero__summary-value {
+ margin-top: var(--space-1);
+ font-size: var(--text-lg);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-primary);
+}
+
+.dashboard-hero__summary-grid {
+ display: grid;
+ gap: var(--space-3);
+ grid-template-columns: 1fr;
+ margin-top: var(--space-4);
+}
+
+@media (min-width: 680px) {
+ .dashboard-hero__summary-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.dashboard-stat {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ min-height: 88px;
+ padding: var(--space-3);
+ border-radius: 24px;
+ background: color-mix(in srgb, var(--color-surface) 88%, transparent);
+ border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
+ box-shadow: var(--shadow-xs);
+}
+
+.dashboard-stat__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 42px;
+ height: 42px;
+ border-radius: 999px;
+ background: color-mix(in srgb, var(--color-accent) 14%, transparent);
+ color: var(--color-accent);
+ flex-shrink: 0;
+}
+
+.dashboard-stat--danger .dashboard-stat__icon {
+ background: color-mix(in srgb, var(--color-danger) 16%, transparent);
+ color: var(--color-danger);
+}
+
+.dashboard-stat--calendar .dashboard-stat__icon {
+ background: color-mix(in srgb, var(--module-calendar) 16%, transparent);
+ color: var(--module-calendar);
+}
+
+.dashboard-stat--meals .dashboard-stat__icon {
+ background: color-mix(in srgb, var(--module-meals) 16%, transparent);
+ color: var(--module-meals);
+}
+
+.dashboard-stat--weather .dashboard-stat__icon {
+ background: color-mix(in srgb, var(--module-dashboard) 16%, transparent);
+ color: var(--module-dashboard);
+}
+
+.dashboard-stat__text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.dashboard-stat__title {
+ font-size: var(--text-xs);
+ color: var(--color-text-tertiary);
+}
+
+.dashboard-stat__value {
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashboard-stat--skeleton {
+ min-height: 88px;
+ background: linear-gradient(90deg,
+ color-mix(in srgb, var(--color-surface-2) 40%, transparent),
+ color-mix(in srgb, var(--color-surface) 88%, transparent),
+ color-mix(in srgb, var(--color-surface-2) 40%, transparent));
+ background-size: 200% 100%;
+ animation: dashboard-sheen 1.8s ease-in-out infinite;
+}
+
+.dashboard-layout {
+ display: grid;
+ gap: var(--space-5);
+}
+
+@media (min-width: 1100px) {
+ .dashboard-layout {
+ grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.92fr);
+ align-items: start;
+ }
+}
+
+.dashboard-layout__main,
+.dashboard-layout__side {
+ min-width: 0;
+}
+
+@media (min-width: 1100px) {
+ .dashboard-layout__side {
+ position: sticky;
+ top: calc(var(--space-4) + 72px);
+ align-self: start;
+ }
+}
+
+.dashboard-layout__grid {
+ display: grid;
+ gap: var(--space-4);
+ grid-template-columns: minmax(0, 1fr);
+}
+
+@media (min-width: 780px) {
+ .dashboard-layout__grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.dashboard-layout__stack {
+ display: grid;
+ gap: var(--space-4);
+}
+
+.dashboard-tile {
+ min-width: 0;
+}
+
+.dashboard-tile--wide {
+ grid-column: 1 / -1;
+}
+
+.dashboard .widget {
+ border-radius: 28px;
+ border: 1px solid color-mix(in srgb, var(--color-border) 42%, transparent);
+ background:
+ linear-gradient(180deg,
+ color-mix(in srgb, var(--color-surface) 96%, transparent) 0%,
+ color-mix(in srgb, var(--color-surface-2) 88%, transparent) 100%);
+ box-shadow: 0 16px 48px color-mix(in srgb, var(--color-shadow) 18%, transparent);
+}
+
+.dashboard .widget::before {
+ height: 4px;
+ background: linear-gradient(90deg, var(--widget-accent, var(--color-accent)), transparent 78%);
+ opacity: 1;
+}
+
+.dashboard .widget__header {
+ padding: var(--space-4) var(--space-4) var(--space-2);
+}
+
+.dashboard .widget__body,
+.dashboard .notes-grid-widget {
+ padding: 0 var(--space-4) var(--space-4);
+}
+
+.dashboard .widget__empty {
+ padding: 0 var(--space-4) var(--space-4);
+}
+
+.dashboard .task-item,
+.dashboard .event-item,
+.dashboard .meal-slot,
+.dashboard .shopping-widget-list,
+.dashboard .note-item {
+ border-radius: 20px;
+}
+
+.dashboard .task-item:hover,
+.dashboard .event-item:hover,
+.dashboard .meal-slot:hover,
+.dashboard .shopping-widget-list:hover,
+.dashboard .note-item:hover {
+ transform: translateY(-2px);
+}
+
+.dashboard .weather-widget {
+ border-radius: 28px;
+}
+
+.dashboard .weather-widget__inner {
+ padding: 0 var(--space-4) var(--space-4);
+}
+
+.dashboard .weather-widget__refresh {
+ top: var(--space-3);
+ right: var(--space-3);
+}
+
+@keyframes dashboard-sheen {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* --------------------------------------------------------
+ * Admin Dashboard Layout
+ * -------------------------------------------------------- */
+.dashboard {
+ max-width: min(1680px, 100%);
+ overflow: visible;
+}
+
+.dashboard::before,
+.dashboard::after {
+ display: none;
+}
+
+.dashboard-shell {
+ gap: var(--space-5);
+}
+
+.dashboard-overview {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ border-top: 3px solid var(--module-accent);
+ border-bottom: 1px solid var(--color-border);
+ background:
+ linear-gradient(180deg,
+ color-mix(in srgb, var(--color-surface) 96%, transparent),
+ color-mix(in srgb, var(--color-surface-2) 86%, transparent));
+ padding: var(--space-4);
+ box-shadow: var(--shadow-sm);
+}
+
+@media (min-width: 1024px) {
+ .dashboard-overview {
+ border: 1px solid var(--color-border);
+ border-top: 3px solid var(--module-accent);
+ border-radius: 8px;
+ padding: var(--space-5);
+ }
+}
+
+.dashboard-overview__header {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+@media (min-width: 900px) {
+ .dashboard-overview__header {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ }
+}
+
+.dashboard-overview__heading {
+ min-width: 0;
+}
+
+.dashboard-overview__date {
+ display: block;
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-medium);
+}
+
+.dashboard-overview__title {
+ margin: var(--space-1) 0 0;
+ color: var(--color-text-primary);
+ font-size: var(--text-2xl);
+ line-height: 1.2;
+ letter-spacing: 0;
+ font-weight: var(--font-weight-bold);
+ overflow-wrap: anywhere;
+}
+
+.dashboard-overview__tools {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+.dashboard-overview__actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ overflow-x: auto;
+ scrollbar-width: none;
+ min-width: 0;
+ padding-bottom: 1px;
+}
+
+.dashboard-overview__actions::-webkit-scrollbar {
+ display: none;
+}
+
+.dashboard-action {
+ min-height: 36px;
+ padding: 0 var(--space-2);
+ border-radius: 8px;
+ border: 1px solid var(--color-border);
+ background: var(--color-surface);
+ color: var(--color-text-primary);
+ box-shadow: none;
+ flex: 0 0 auto;
+}
+
+.dashboard-action:hover {
+ transform: none;
+ box-shadow: var(--shadow-xs);
+ border-color: color-mix(in srgb, var(--module-accent) 42%, var(--color-border));
+}
+
+.dashboard-action__icon {
+ width: 24px;
+ height: 24px;
+ border-radius: 6px;
+}
+
+.dashboard-action__icon svg {
+ width: 15px;
+ height: 15px;
+}
+
+.dashboard-action__label {
+ font-size: var(--text-sm);
+ white-space: nowrap;
+}
+
+.dashboard-icon-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: 8px;
+ border: 1px solid var(--color-border);
+ background: var(--color-surface);
+ color: var(--color-text-secondary);
+ flex: 0 0 auto;
+ transition: color var(--transition-fast), border-color var(--transition-fast), background-color var(--transition-fast);
+}
+
+.dashboard-icon-btn:hover,
+.dashboard-icon-btn:focus-visible {
+ color: var(--color-text-primary);
+ border-color: color-mix(in srgb, var(--module-accent) 42%, var(--color-border));
+ background: var(--color-surface-hover);
+}
+
+.dashboard-icon-btn svg {
+ width: 18px;
+ height: 18px;
+}
+
+.dashboard-kpi-grid {
+ display: grid;
+ gap: var(--space-3);
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
+}
+
+.dashboard-kpi {
+ display: grid;
+ grid-template-columns: 38px minmax(0, 1fr);
+ align-items: center;
+ gap: var(--space-3);
+ min-height: 92px;
+ padding: var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ background: var(--color-surface);
+ color: var(--color-text-primary);
+ text-align: left;
+ box-shadow: var(--shadow-xs);
+ min-width: 0;
+}
+
+.dashboard-kpi:hover {
+ border-color: color-mix(in srgb, var(--module-accent) 36%, var(--color-border));
+ background: var(--color-surface-hover);
+}
+
+.dashboard-kpi__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 38px;
+ height: 38px;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--module-accent) 14%, transparent);
+ color: var(--module-accent);
+}
+
+.dashboard-kpi__icon svg {
+ width: 19px;
+ height: 19px;
+}
+
+.dashboard-kpi--danger .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--color-danger) 14%, transparent);
+ color: var(--color-danger);
+}
+
+.dashboard-kpi--calendar .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--module-calendar) 14%, transparent);
+ color: var(--module-calendar);
+}
+
+.dashboard-kpi--meals .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--module-meals) 14%, transparent);
+ color: var(--module-meals);
+}
+
+.dashboard-kpi--weather .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--module-dashboard) 14%, transparent);
+ color: var(--module-dashboard);
+}
+
+.dashboard-kpi--birthdays .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--module-birthdays) 14%, transparent);
+ color: var(--module-birthdays);
+}
+
+.dashboard-kpi--family .dashboard-kpi__icon {
+ background: color-mix(in srgb, var(--module-contacts) 14%, transparent);
+ color: var(--module-contacts);
+}
+
+.dashboard-kpi__body {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ gap: 2px;
+}
+
+.dashboard-kpi__label {
+ color: var(--color-text-secondary);
+ font-size: var(--text-xs);
+ font-weight: var(--font-weight-semibold);
+}
+
+.dashboard-kpi__value {
+ color: var(--color-text-primary);
+ font-size: var(--text-lg);
+ font-weight: var(--font-weight-bold);
+ line-height: 1.25;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashboard-kpi__meta {
+ color: var(--color-text-tertiary);
+ font-size: var(--text-xs);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashboard-kpi--skeleton {
+ min-height: 92px;
+ background: linear-gradient(90deg, var(--color-surface-2), var(--color-surface), var(--color-surface-2));
+ background-size: 200% 100%;
+ animation: dashboard-sheen 1.6s ease-in-out infinite;
+}
+
+.dashboard-workspace {
+ display: grid;
+ gap: var(--space-4);
+ align-items: start;
+}
+
+@media (min-width: 1180px) {
+ .dashboard-workspace {
+ grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
+ }
+}
+
+.dashboard-workspace__main,
+.dashboard-workspace__side {
+ min-width: 0;
+}
+
+.dashboard-widget-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--space-4);
+ align-items: start;
+}
+
+@media (min-width: 820px) {
+ .dashboard-widget-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.dashboard-side-stack {
+ display: grid;
+ gap: var(--space-4);
+ align-items: start;
+}
+
+.dashboard-tile {
+ min-width: 0;
+}
+
+@media (min-width: 820px) {
+ .dashboard-tile--wide {
+ grid-column: 1 / -1;
+ }
+}
+
+.dashboard .widget,
+.dashboard .widget-skeleton {
+ border-radius: 8px;
+ border: 1px solid var(--color-border);
+ background: var(--color-surface);
+ box-shadow: var(--shadow-sm);
+ min-width: 0;
+}
+
+.dashboard .widget::before {
+ height: 3px;
+ background: var(--widget-accent, var(--module-accent));
+ opacity: 1;
+}
+
+.dashboard .widget__header {
+ min-height: 54px;
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
+}
+
+.dashboard .widget__title,
+.dashboard .widget__link {
+ min-width: 0;
+}
+
+.dashboard .widget__body,
+.dashboard .notes-grid-widget,
+.dashboard .weather-widget__inner {
+ padding: var(--space-3) var(--space-4) var(--space-4);
+}
+
+.dashboard .widget__empty {
+ padding: var(--space-4);
+}
+
+.dashboard .task-item,
+.dashboard .event-item,
+.dashboard .meal-slot,
+.dashboard .shopping-widget-list,
+.dashboard .note-item {
+ border-radius: 8px;
+}
+
+.dashboard .task-item:hover,
+.dashboard .event-item:hover,
+.dashboard .meal-slot:hover,
+.dashboard .shopping-widget-list:hover,
+.dashboard .note-item:hover {
+ transform: none;
+}
+
+.dashboard .weather-widget {
+ border-radius: 8px;
+}
+
+.dashboard .weather-widget__refresh {
+ top: var(--space-3);
+ right: var(--space-3);
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+}
+
+.birthday-widget-item {
+ display: grid;
+ grid-template-columns: 40px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2);
+ border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
+ background: var(--color-surface-2);
+ cursor: pointer;
+}
+
+.birthday-widget-item + .birthday-widget-item {
+ margin-top: var(--space-2);
+}
+
+.birthday-widget-item__avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ overflow: hidden;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--module-birthdays) 14%, transparent);
+ color: var(--module-birthdays);
+ font-weight: var(--font-weight-bold);
+ font-size: var(--text-sm);
+}
+
+.birthday-widget-item__avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.birthday-widget-item__body {
+ min-width: 0;
+}
+
+.birthday-widget-item__name {
+ color: var(--color-text-primary);
+ font-weight: var(--font-weight-semibold);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.birthday-widget-item__meta {
+ margin-top: 2px;
+ color: var(--color-text-secondary);
+ font-size: var(--text-xs);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.birthday-widget-item__age {
+ min-width: 34px;
+ text-align: center;
+ padding: var(--space-1) var(--space-2);
+ border-radius: 8px;
+ background: var(--color-surface);
+ border: 1px solid var(--color-border);
+ color: var(--color-text-primary);
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-bold);
+}
+
+.family-widget {
+ padding: var(--space-4);
+}
+
+.family-widget__count {
+ color: var(--color-text-primary);
+ font-size: var(--text-3xl);
+ line-height: 1;
+ font-weight: var(--font-weight-bold);
+}
+
+.family-widget__meta {
+ margin-top: var(--space-1);
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+}
+
+.family-widget__avatars {
+ display: flex;
+ align-items: center;
+ margin-top: var(--space-4);
+ padding-left: var(--space-1);
+}
+
+.family-widget-avatar {
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: var(--font-weight-bold);
+ font-size: var(--text-xs);
+ border: 2px solid var(--color-surface);
+ box-shadow: var(--shadow-xs);
+}
+
+.family-widget-avatar + .family-widget-avatar {
+ margin-left: -8px;
+}
+
+.budget-widget {
+ padding: var(--space-4);
+}
+
+.budget-widget__headline {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-3);
+ padding-bottom: var(--space-3);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.budget-widget__headline span {
+ color: var(--color-text-secondary);
+ font-size: var(--text-sm);
+ font-weight: var(--font-weight-medium);
+}
+
+.budget-widget__balance {
+ color: var(--color-text-primary);
+ font-size: var(--text-xl);
+ white-space: nowrap;
+}
+
+.budget-widget__balance--positive {
+ color: var(--color-success);
+}
+
+.budget-widget__balance--negative {
+ color: var(--color-danger);
+}
+
+.budget-widget__grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--space-2);
+ margin-top: var(--space-3);
+}
+
+.budget-widget-metric {
+ min-width: 0;
+ padding: var(--space-3);
+ border-radius: 8px;
+ border: 1px solid var(--color-border);
+ background: var(--color-surface-2);
+}
+
+.budget-widget-metric span {
+ display: block;
+ color: var(--color-text-secondary);
+ font-size: var(--text-xs);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.budget-widget-metric strong {
+ display: block;
+ margin-top: 2px;
+ color: var(--color-text-primary);
+ font-size: var(--text-sm);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.budget-widget-metric--income strong {
+ color: var(--color-success);
+}
+
+.budget-widget-metric--expense strong {
+ color: var(--color-danger);
+}
+
+.budget-widget__footer {
+ margin-top: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--module-budget) 10%, transparent);
+ color: var(--color-text-secondary);
+ font-size: var(--text-xs);
+ line-height: 1.4;
+}
+
+@media (max-width: 520px) {
+ .dashboard {
+ padding-left: var(--space-3);
+ padding-right: var(--space-3);
+ }
+
+ .dashboard-overview {
+ margin-left: calc(var(--space-3) * -1);
+ margin-right: calc(var(--space-3) * -1);
+ border-left: 0;
+ border-right: 0;
+ }
+
+ .dashboard-overview__tools {
+ align-items: stretch;
+ }
+
+ .dashboard-overview__actions {
+ flex: 1;
+ }
+
+ .dashboard-action__label {
+ display: none;
+ }
+
+ .dashboard-action {
+ width: 38px;
+ justify-content: center;
+ padding: 0;
+ }
+
+ .dashboard-kpi {
+ min-height: 82px;
+ }
+}
diff --git a/public/styles/tokens.css b/public/styles/tokens.css
index 358d655..064848f 100644
--- a/public/styles/tokens.css
+++ b/public/styles/tokens.css
@@ -168,6 +168,8 @@
--module-notes: var(--_module-notes); /* Amber-700 - Notizen (6.3:1 auf weiß — WCAG AA) */
--_module-contacts: #0969DA;
--module-contacts: var(--_module-contacts); /* Kräftiges Blau - Kontakte */
+ --_module-birthdays: #E11D48;
+ --module-birthdays: var(--_module-birthdays); /* Rose - Geburtstage */
--_module-budget: #0F766E;
--module-budget: var(--_module-budget); /* Teal-700 - Finanzen, Stabilität */
--_module-settings: #6E7781;
@@ -527,6 +529,7 @@
--_module-shopping: #F472B6;
--_module-notes: #FCD34D;
--_module-contacts: #60A5FA;
+ --_module-birthdays: #FB7185;
--_module-budget: #2DD4BF;
--_module-settings: #94A3B8;
--_module-reminders: #22D3EE; /* Cyan-400 */
@@ -631,6 +634,7 @@
--_module-shopping: #F472B6; /* Pink-400 */
--_module-notes: #FCD34D;
--_module-contacts: #60A5FA;
+ --_module-birthdays: #FB7185;
--_module-budget: #2DD4BF; /* Teal-400 */
--_module-settings: #94A3B8;
--_module-reminders: #22D3EE; /* Cyan-400 */
diff --git a/public/sw-register.js b/public/sw-register.js
index 555823d..dab33ee 100644
--- a/public/sw-register.js
+++ b/public/sw-register.js
@@ -7,9 +7,11 @@
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
- navigator.serviceWorker.register('/sw.js').catch((err) => {
- console.warn('[SW] Registrierung fehlgeschlagen:', err);
- });
+ navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
+ .then((registration) => registration.update())
+ .catch((err) => {
+ console.warn('[SW] Registrierung fehlgeschlagen:', err);
+ });
});
// SW-Update: Auf iOS-PWA fuehrt ein sofortiger Reload bei controllerchange
@@ -24,4 +26,15 @@ if ('serviceWorker' in navigator) {
// Auf iOS-Standalone verhindert das den "leere Seite"-Bug.
setTimeout(() => window.location.reload(), 200);
});
+
+ const refreshSw = () => {
+ navigator.serviceWorker.getRegistration()
+ .then((registration) => registration?.update())
+ .catch(() => {});
+ };
+
+ window.addEventListener('focus', refreshSw);
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') refreshSw();
+ });
}
diff --git a/public/sw.js b/public/sw.js
index 96ab73d..1124058 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -13,11 +13,12 @@
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
*/
-const SHELL_CACHE = 'oikos-shell-v52';
-const PAGES_CACHE = 'oikos-pages-v47';
-const ASSETS_CACHE = 'oikos-assets-v47';
+const SHELL_CACHE = 'oikos-shell-v56';
+const PAGES_CACHE = 'oikos-pages-v51';
+const LOCALES_CACHE = 'oikos-locales-v3';
+const ASSETS_CACHE = 'oikos-assets-v51';
const BYPASS_CACHE = 'oikos-bypass-flag';
-const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
+const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, LOCALES_CACHE, ASSETS_CACHE];
// App-Shell: sofort benötigt für ersten Render
const APP_SHELL = [
@@ -27,12 +28,6 @@ const APP_SHELL = [
'/router.js',
'/i18n.js',
'/rrule-ui.js',
- '/locales/de.json',
- '/locales/en.json',
- '/locales/ja.json',
- '/locales/ar.json',
- '/locales/hi.json',
- '/locales/pt.json',
'/reminders.js',
'/sw-register.js',
'/lucide.min.js',
@@ -50,6 +45,7 @@ const APP_SHELL = [
'/styles/calendar.css',
'/styles/notes.css',
'/styles/contacts.css',
+ '/styles/birthdays.css',
'/styles/budget.css',
'/styles/settings.css',
'/styles/recipes.css',
@@ -65,6 +61,24 @@ const APP_SHELL = [
'/icons/icon-maskable-512.png',
];
+const APP_LOCALES = [
+ '/locales/ar.json',
+ '/locales/de.json',
+ '/locales/el.json',
+ '/locales/en.json',
+ '/locales/es.json',
+ '/locales/fr.json',
+ '/locales/hi.json',
+ '/locales/it.json',
+ '/locales/ja.json',
+ '/locales/pt.json',
+ '/locales/ru.json',
+ '/locales/sv.json',
+ '/locales/tr.json',
+ '/locales/uk.json',
+ '/locales/zh.json',
+];
+
// Seiten-Module: lazy geladen, aber vorab gecacht für Offline
const PAGE_MODULES = [
'/pages/dashboard.js',
@@ -74,6 +88,7 @@ const PAGE_MODULES = [
'/pages/calendar.js',
'/pages/notes.js',
'/pages/contacts.js',
+ '/pages/birthdays.js',
'/pages/budget.js',
'/pages/settings.js',
'/pages/login.js',
@@ -112,10 +127,12 @@ const _bypassInit = (async () => {
self.addEventListener('install', (event) => {
const freshShell = APP_SHELL.map((url) => new Request(url, { cache: 'reload' }));
const freshModules = PAGE_MODULES.map((url) => new Request(url, { cache: 'reload' }));
+ const freshLocales = APP_LOCALES.map((url) => new Request(url, { cache: 'reload' }));
event.waitUntil(
Promise.all([
caches.open(SHELL_CACHE).then((c) => c.addAll(freshShell)),
caches.open(PAGES_CACHE).then((c) => c.addAll(freshModules)),
+ caches.open(LOCALES_CACHE).then((c) => c.addAll(freshLocales)),
]).then(() => self.skipWaiting())
);
});
@@ -205,12 +222,20 @@ function dispatchFetch(request, url) {
return networkFirst(request, SHELL_CACHE);
}
- if (isAsset(url.pathname) && url.origin === self.location.origin) {
- return cacheFirst(request, ASSETS_CACHE);
+ if (url.pathname.startsWith('/locales/')) {
+ return networkFirst(request, LOCALES_CACHE);
}
if (url.pathname.startsWith('/pages/')) {
- return cacheFirst(request, PAGES_CACHE);
+ return networkFirst(request, PAGES_CACHE);
+ }
+
+ if (url.origin === self.location.origin && isMutableAppResource(url.pathname)) {
+ return networkFirst(request, SHELL_CACHE);
+ }
+
+ if (isAsset(url.pathname) && url.origin === self.location.origin) {
+ return cacheFirst(request, ASSETS_CACHE);
}
return cacheFirst(request, SHELL_CACHE);
@@ -268,3 +293,10 @@ async function cacheFirst(request, cacheName) {
function isAsset(pathname) {
return /\.(png|jpg|jpeg|ico|svg|webp|woff2?|gif)$/i.test(pathname);
}
+
+function isMutableAppResource(pathname) {
+ return pathname === '/'
+ || pathname === '/index.html'
+ || pathname === '/manifest.json'
+ || /\.(css|js|json|html)$/i.test(pathname);
+}
diff --git a/server/db-schema-test.js b/server/db-schema-test.js
index 981dde1..ec9386e 100644
--- a/server/db-schema-test.js
+++ b/server/db-schema-test.js
@@ -116,6 +116,17 @@ const MIGRATIONS_SQL = {
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
+ CREATE TABLE IF NOT EXISTS birthdays (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ birth_date TEXT NOT NULL,
+ notes TEXT,
+ photo_data TEXT,
+ calendar_event_id INTEGER REFERENCES calendar_events(id) ON DELETE SET NULL,
+ created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
CREATE TABLE IF NOT EXISTS budget_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
@@ -182,6 +193,9 @@ const MIGRATIONS_SQL = {
CREATE TRIGGER IF NOT EXISTS trg_contacts_updated_at
AFTER UPDATE ON contacts FOR EACH ROW
BEGIN UPDATE contacts SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
+ CREATE TRIGGER IF NOT EXISTS trg_birthdays_updated_at
+ AFTER UPDATE ON birthdays FOR EACH ROW
+ BEGIN UPDATE birthdays SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
CREATE TRIGGER IF NOT EXISTS trg_budget_entries_updated_at
AFTER UPDATE ON budget_entries FOR EACH ROW
BEGIN UPDATE budget_entries SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
@@ -196,6 +210,10 @@ const MIGRATIONS_SQL = {
CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned);
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
+ CREATE INDEX IF NOT EXISTS idx_birthdays_name ON birthdays(name);
+ CREATE INDEX IF NOT EXISTS idx_birthdays_birth_date ON birthdays(birth_date);
+ CREATE INDEX IF NOT EXISTS idx_birthdays_created_by ON birthdays(created_by);
+ CREATE INDEX IF NOT EXISTS idx_birthdays_calendar_ref ON birthdays(calendar_event_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
`,
diff --git a/server/db.js b/server/db.js
index 57253e1..17a0295 100644
--- a/server/db.js
+++ b/server/db.js
@@ -691,6 +691,32 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
`,
},
+ {
+ version: 18,
+ description: 'Birthdays with calendar integration',
+ up: `
+ CREATE TABLE IF NOT EXISTS birthdays (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ birth_date TEXT NOT NULL,
+ notes TEXT,
+ photo_data TEXT,
+ calendar_event_id INTEGER REFERENCES calendar_events(id) ON DELETE SET NULL,
+ created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
+
+ CREATE TRIGGER IF NOT EXISTS trg_birthdays_updated_at
+ AFTER UPDATE ON birthdays FOR EACH ROW
+ BEGIN UPDATE birthdays SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
+
+ CREATE INDEX IF NOT EXISTS idx_birthdays_name ON birthdays(name);
+ CREATE INDEX IF NOT EXISTS idx_birthdays_birth_date ON birthdays(birth_date);
+ CREATE INDEX IF NOT EXISTS idx_birthdays_created_by ON birthdays(created_by);
+ CREATE INDEX IF NOT EXISTS idx_birthdays_calendar_ref ON birthdays(calendar_event_id);
+ `,
+ },
];
/**
diff --git a/server/index.js b/server/index.js
index d09af0e..be4759d 100644
--- a/server/index.js
+++ b/server/index.js
@@ -25,6 +25,7 @@ import recipesRouter from './routes/recipes.js';
import calendarRouter from './routes/calendar.js';
import notesRouter from './routes/notes.js';
import contactsRouter from './routes/contacts.js';
+import birthdaysRouter from './routes/birthdays.js';
import budgetRouter from './routes/budget.js';
import weatherRouter from './routes/weather.js';
import preferencesRouter from './routes/preferences.js';
@@ -38,6 +39,7 @@ const logOikos = createLogger('Oikos');
const { version: APP_VERSION } = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf-8')
);
+const DEFAULT_APP_NAME = 'Oikos';
const app = express();
const PORT = process.env.PORT || 3000;
@@ -164,7 +166,14 @@ app.use('/api/v1/auth', authRouter);
// Versionsinformation - keine Authentifizierung erforderlich (Login-Seite benötigt diese)
app.get('/api/v1/version', (req, res) => {
- res.json({ version: APP_VERSION });
+ let appName = DEFAULT_APP_NAME;
+ try {
+ const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get('app_name');
+ if (row?.value) appName = row.value;
+ } catch {
+ // fall back to default
+ }
+ res.json({ version: APP_VERSION, app_name: appName });
});
function sendOpenApi(req, res) {
@@ -188,6 +197,7 @@ app.use('/api/v1/recipes', recipesRouter);
app.use('/api/v1/calendar', calendarRouter);
app.use('/api/v1/notes', notesRouter);
app.use('/api/v1/contacts', contactsRouter);
+app.use('/api/v1/birthdays', birthdaysRouter);
app.use('/api/v1/budget', budgetRouter);
app.use('/api/v1/weather', weatherRouter);
app.use('/api/v1/preferences', preferencesRouter);
diff --git a/server/openapi.js b/server/openapi.js
index b9d8039..6d51f94 100644
--- a/server/openapi.js
+++ b/server/openapi.js
@@ -370,6 +370,20 @@ function buildPaths() {
delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) },
+ '/api/v1/birthdays': {
+ get: op({ summary: 'List birthdays', tag: 'Birthdays' }),
+ post: op({ summary: 'Create birthday', tag: 'Birthdays', stateChanging: true, requestBody: jsonBody(null) }),
+ },
+ '/api/v1/birthdays/upcoming': {
+ get: op({ summary: 'List upcoming birthdays', tag: 'Birthdays' }),
+ },
+ '/api/v1/birthdays/meta/options': {
+ get: op({ summary: 'Get birthday upload options', tag: 'Birthdays' }),
+ },
+ '/api/v1/birthdays/{id}': {
+ put: op({ summary: 'Update birthday', tag: 'Birthdays', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
+ delete: op({ summary: 'Delete birthday', tag: 'Birthdays', params: [idParam()], stateChanging: true }),
+ },
'/api/v1/budget/summary': { get: op({ summary: 'Get budget summary', tag: 'Budget' }) },
'/api/v1/budget/export': { get: op({ summary: 'Export budget entries as CSV', tag: 'Budget' }) },
'/api/v1/budget/meta': { get: op({ summary: 'Get budget categories and subcategories', tag: 'Budget' }) },
@@ -437,6 +451,7 @@ function buildOpenApiSpec(req, appVersion) {
{ name: 'Calendar' },
{ name: 'Notes' },
{ name: 'Contacts' },
+ { name: 'Birthdays' },
{ name: 'Budget' },
{ name: 'Weather' },
{ name: 'Preferences' },
diff --git a/server/routes/birthdays.js b/server/routes/birthdays.js
new file mode 100644
index 0000000..91141da
--- /dev/null
+++ b/server/routes/birthdays.js
@@ -0,0 +1,159 @@
+import express from 'express';
+import { createLogger } from '../logger.js';
+import * as db from '../db.js';
+import { collectErrors, date as validateDate, str, MAX_SHORT, MAX_TEXT, MAX_TITLE } from '../middleware/validate.js';
+import { deleteBirthdayArtifacts, hydrateBirthday, syncBirthdayArtifacts, syncAllBirthdayReminders } from '../services/birthdays.js';
+
+const log = createLogger('Birthdays');
+const router = express.Router();
+const MAX_PHOTO_LENGTH = 900_000;
+const PHOTO_RE = /^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
+
+function validatePhotoData(val) {
+ if (val === undefined) return { value: undefined, error: null };
+ if (val === null || val === '') return { value: null, error: null };
+ const s = String(val).trim();
+ if (s.length > MAX_PHOTO_LENGTH) return { value: null, error: 'Profile picture is too large.' };
+ if (!PHOTO_RE.test(s)) return { value: null, error: 'Profile picture must be a valid image data URL.' };
+ return { value: s, error: null };
+}
+
+function loadBirthday(id) {
+ return db.get().prepare('SELECT * FROM birthdays WHERE id = ?').get(id);
+}
+
+function loadBirthdayForUser(id, userId) {
+ return db.get().prepare('SELECT * FROM birthdays WHERE id = ? AND created_by = ?').get(id, userId);
+}
+
+function sortHydrated(rows) {
+ return rows
+ .map((row) => hydrateBirthday(row))
+ .sort((a, b) => a.days_until - b.days_until || a.name.localeCompare(b.name));
+}
+
+router.get('/', (req, res) => {
+ try {
+ const userId = req.authUserId || req.session.userId;
+ syncAllBirthdayReminders(db.get(), userId);
+
+ let sql = 'SELECT * FROM birthdays WHERE created_by = ?';
+ const params = [userId];
+
+ if (req.query.q) {
+ sql += ' AND name LIKE ?';
+ params.push(`%${String(req.query.q).trim()}%`);
+ }
+
+ sql += ' ORDER BY name COLLATE NOCASE ASC';
+
+ const rows = db.get().prepare(sql).all(...params);
+ res.json({ data: sortHydrated(rows) });
+ } catch (err) {
+ log.error('GET / error:', err);
+ res.status(500).json({ error: 'Internal error.', code: 500 });
+ }
+});
+
+router.get('/upcoming', (req, res) => {
+ try {
+ const userId = req.authUserId || req.session.userId;
+ syncAllBirthdayReminders(db.get(), userId);
+ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 5, 1), 50);
+ const rows = db.get().prepare('SELECT * FROM birthdays WHERE created_by = ? ORDER BY name COLLATE NOCASE ASC').all(userId);
+ res.json({ data: sortHydrated(rows).slice(0, limit) });
+ } catch (err) {
+ log.error('GET /upcoming error:', err);
+ res.status(500).json({ error: 'Internal error.', code: 500 });
+ }
+});
+
+router.post('/', (req, res) => {
+ try {
+ const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
+ const vBirthDate = validateDate(req.body.birth_date, 'Birth date', true);
+ const vNotes = str(req.body.notes, 'Notes', { max: MAX_TEXT, required: false });
+ const vPhoto = validatePhotoData(req.body.photo_data);
+ const errors = collectErrors([vName, vBirthDate, vNotes, vPhoto]);
+ if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
+
+ const result = db.get().prepare(`
+ INSERT INTO birthdays (name, birth_date, notes, photo_data, created_by)
+ VALUES (?, ?, ?, ?, ?)
+ `).run(vName.value, vBirthDate.value, vNotes.value, vPhoto.value ?? null, req.authUserId || req.session.userId);
+
+ const birthday = loadBirthday(result.lastInsertRowid);
+ const synced = db.transaction(() => syncBirthdayArtifacts(db.get(), birthday));
+ res.status(201).json({ data: hydrateBirthday(loadBirthday(synced.id)) });
+ } catch (err) {
+ log.error('POST / error:', err);
+ res.status(500).json({ error: 'Internal error.', code: 500 });
+ }
+});
+
+router.put('/:id', (req, res) => {
+ try {
+ const userId = req.authUserId || req.session.userId;
+ const id = parseInt(req.params.id, 10);
+ const existing = loadBirthdayForUser(id, userId);
+ if (!existing) return res.status(404).json({ error: 'Birthday not found.', code: 404 });
+
+ const checks = [];
+ if (req.body.name !== undefined) checks.push(str(req.body.name, 'Name', { max: MAX_TITLE, required: false }));
+ if (req.body.birth_date !== undefined) checks.push(validateDate(req.body.birth_date, 'Birth date'));
+ if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notes', { max: MAX_TEXT, required: false }));
+ if (req.body.photo_data !== undefined) checks.push(validatePhotoData(req.body.photo_data));
+ const errors = collectErrors(checks);
+ if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
+
+ const vPhoto = req.body.photo_data !== undefined ? validatePhotoData(req.body.photo_data) : { value: undefined };
+
+ db.get().prepare(`
+ UPDATE birthdays
+ SET name = COALESCE(?, name),
+ birth_date = COALESCE(?, birth_date),
+ notes = ?,
+ photo_data = ?,
+ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
+ WHERE id = ?
+ `).run(
+ req.body.name?.trim() ?? null,
+ req.body.birth_date ?? null,
+ req.body.notes !== undefined ? (req.body.notes?.trim() || null) : existing.notes,
+ req.body.photo_data !== undefined ? (vPhoto.value ?? null) : existing.photo_data,
+ id,
+ );
+
+ const updated = loadBirthday(id);
+ db.transaction(() => syncBirthdayArtifacts(db.get(), updated));
+ res.json({ data: hydrateBirthday(loadBirthday(id)) });
+ } catch (err) {
+ log.error('PUT /:id error:', err);
+ res.status(500).json({ error: 'Internal error.', code: 500 });
+ }
+});
+
+router.delete('/:id', (req, res) => {
+ try {
+ const userId = req.authUserId || req.session.userId;
+ const id = parseInt(req.params.id, 10);
+ const existing = loadBirthdayForUser(id, userId);
+ if (!existing) return res.status(404).json({ error: 'Birthday not found.', code: 404 });
+
+ db.transaction(() => {
+ deleteBirthdayArtifacts(db.get(), existing);
+ db.get().prepare('DELETE FROM birthdays WHERE id = ?').run(id);
+ });
+
+ res.status(204).end();
+ } catch (err) {
+ log.error('DELETE /:id error:', err);
+ res.status(500).json({ error: 'Internal error.', code: 500 });
+ }
+});
+
+router.get('/meta/options', (_req, res) => {
+ res.json({ data: { photoMaxBytes: MAX_PHOTO_LENGTH, acceptedImageTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] } });
+});
+
+export default router;
diff --git a/server/routes/calendar.js b/server/routes/calendar.js
index 77a13bc..4b7e186 100644
--- a/server/routes/calendar.js
+++ b/server/routes/calendar.js
@@ -22,6 +22,19 @@ const router = express.Router();
const VALID_SOURCES = ['local', 'google', 'apple', 'ics'];
const ICS_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
+function getUserId(req) {
+ const candidates = [req.authUserId, req.user?.id, req.session?.userId];
+ for (const value of candidates) {
+ const parsed = Number(value);
+ if (Number.isInteger(parsed) && parsed > 0) return parsed;
+ }
+ return null;
+}
+
+function isAdminUser(req) {
+ return req.authRole === 'admin' || req.session?.isAdmin === true || req.session?.role === 'admin';
+}
+
// --------------------------------------------------------
// RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events
// innerhalb [from, to] generieren (inklusive beider Grenzen).
@@ -146,7 +159,7 @@ router.get('/', (req, res) => {
)
)
`;
- const params = [to, from, to, req.session.userId];
+ const params = [to, from, to, getUserId(req)];
if (req.query.assigned_to) {
sql += ' AND e.assigned_to = ?';
@@ -203,7 +216,7 @@ router.get('/upcoming', (req, res) => {
)
)
ORDER BY e.start_datetime ASC
- `).all(nowDate, future, future, req.session.userId);
+ `).all(nowDate, future, future, getUserId(req));
const expanded = expandRecurringEvents(rawEvents, nowDate, future)
.filter((e) => e.start_datetime >= new Date().toISOString())
@@ -396,7 +409,7 @@ router.delete('/apple/disconnect', requireAdmin, (req, res) => {
router.get('/subscriptions', (req, res) => {
try {
- const subs = icsSubscription.getAll(req.session.userId);
+ const subs = icsSubscription.getAll(getUserId(req));
res.json({ data: subs });
} catch (err) {
log.error('', err);
@@ -416,7 +429,7 @@ router.post('/subscriptions', async (req, res) => {
if (!colorVal || !ICS_COLOR_RE.test(colorVal))
return res.status(400).json({ error: 'color: Pflichtfeld, muss #RRGGBB sein.', code: 400 });
- const { sub, syncError } = await icsSubscription.create(req.session.userId, {
+ const { sub, syncError } = await icsSubscription.create(getUserId(req), {
name: name.trim(), url, color: colorVal, shared: shared ? 1 : 0,
});
res.status(201).json({ data: sub, syncError: syncError || null });
@@ -432,7 +445,7 @@ router.patch('/subscriptions/:id', (req, res) => {
try {
const subId = parseInt(req.params.id, 10);
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
- const isAdmin = req.session.isAdmin;
+ const isAdmin = isAdminUser(req);
const fields = {};
if (req.body.name !== undefined) {
if (typeof req.body.name !== 'string' || req.body.name.trim().length === 0 || req.body.name.length > 100)
@@ -446,7 +459,7 @@ router.patch('/subscriptions/:id', (req, res) => {
}
if (req.body.shared !== undefined) fields.shared = req.body.shared;
- const updated = icsSubscription.update(req.session.userId, subId, fields, isAdmin);
+ const updated = icsSubscription.update(getUserId(req), subId, fields, isAdmin);
if (!updated) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
res.json({ data: updated });
} catch (err) {
@@ -460,8 +473,8 @@ router.delete('/subscriptions/:id', (req, res) => {
try {
const subId = parseInt(req.params.id, 10);
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
- const isAdmin = req.session.isAdmin;
- const ok = icsSubscription.remove(req.session.userId, subId, isAdmin);
+ const isAdmin = isAdminUser(req);
+ const ok = icsSubscription.remove(getUserId(req), subId, isAdmin);
if (!ok) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
res.status(204).end();
} catch (err) {
@@ -475,10 +488,10 @@ router.post('/subscriptions/:id/sync', async (req, res) => {
try {
const subId = parseInt(req.params.id, 10);
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
- const isAdmin = req.session.isAdmin;
+ const isAdmin = isAdminUser(req);
const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
if (!sub) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
- if (!isAdmin && sub.created_by !== req.session.userId)
+ if (!isAdmin && sub.created_by !== getUserId(req))
return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
await icsSubscription.sync(subId);
const updated = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
@@ -526,6 +539,17 @@ router.get('/:id', (req, res) => {
// --------------------------------------------------------
router.post('/', (req, res) => {
try {
+ const userId = getUserId(req);
+ if (!userId) {
+ log.warn('Rejecting calendar create without resolved authenticated user id', {
+ authMethod: req.authMethod || null,
+ authUserId: req.authUserId || null,
+ reqUserId: req.user?.id || null,
+ sessionUserId: req.session?.userId || null,
+ });
+ return res.status(401).json({ error: 'Not authenticated.', code: 401 });
+ }
+
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
const vDesc = str(req.body.description, 'Beschreibung', { max: MAX_TEXT, required: false });
const vStart = datetime(req.body.start_datetime, 'Startdatum', true);
@@ -553,7 +577,7 @@ router.post('/', (req, res) => {
vStart.value, vEnd.value,
all_day ? 1 : 0, vLoc.value,
vColor.value, assigned_to || null,
- req.session.userId, vRrule.value
+ userId, vRrule.value
);
const event = db.get().prepare(`
@@ -669,8 +693,8 @@ router.post('/:id/reset', (req, res) => {
if (event.external_source !== 'ics')
return res.status(400).json({ error: 'Nur ICS-Events können zurückgesetzt werden.', code: 400 });
- const userId = req.session.userId;
- const isAdmin = req.session.isAdmin;
+ const userId = getUserId(req);
+ const isAdmin = isAdminUser(req);
if (!isAdmin && event.created_by !== userId && event.sub_created_by !== userId)
return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js
index 9bb2f32..4e85d39 100644
--- a/server/routes/dashboard.js
+++ b/server/routes/dashboard.js
@@ -7,6 +7,7 @@
import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
+import { hydrateBirthday } from '../services/birthdays.js';
const log = createLogger('Dashboard');
@@ -30,10 +31,12 @@ router.get('/', (req, res) => {
try {
const d = db.get();
const result = {};
+ const userId = req.authUserId || req.session.userId;
// Heute und +48h als ISO-Strings
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
+ const currentMonth = todayStr.slice(0, 7);
const deadline48h = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString();
// Anstehende Termine (nächste 5, ab jetzt)
@@ -170,6 +173,63 @@ router.get('/', (req, res) => {
result.users = [];
}
+ try {
+ const rows = d.prepare('SELECT * FROM birthdays WHERE created_by = ? ORDER BY name COLLATE NOCASE ASC').all(userId);
+ result.birthdays = rows
+ .map((row) => hydrateBirthday(row))
+ .sort((a, b) => a.days_until - b.days_until || a.name.localeCompare(b.name))
+ .slice(0, 3);
+ result.birthdayCount = rows.length;
+ } catch (err) {
+ log.error('birthdays error:', err.message);
+ result.birthdays = [];
+ result.birthdayCount = 0;
+ }
+
+ try {
+ const from = `${currentMonth}-01`;
+ const to = `${currentMonth}-31`;
+ const totals = d.prepare(`
+ SELECT
+ SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
+ SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
+ SUM(amount) AS balance,
+ COUNT(*) AS entry_count
+ FROM budget_entries
+ WHERE date BETWEEN ? AND ?
+ `).get(from, to);
+
+ const topExpense = d.prepare(`
+ SELECT category, SUM(amount) AS amount
+ FROM budget_entries
+ WHERE amount < 0 AND date BETWEEN ? AND ?
+ GROUP BY category
+ ORDER BY ABS(SUM(amount)) DESC
+ LIMIT 1
+ `).get(from, to);
+
+ result.budget = {
+ month: currentMonth,
+ income: totals?.income || 0,
+ expenses: Math.abs(totals?.expenses || 0),
+ balance: totals?.balance || 0,
+ entryCount: totals?.entry_count || 0,
+ topExpenseCategory: topExpense?.category || null,
+ topExpenseAmount: Math.abs(topExpense?.amount || 0),
+ };
+ } catch (err) {
+ log.error('budget error:', err.message);
+ result.budget = {
+ month: currentMonth,
+ income: 0,
+ expenses: 0,
+ balance: 0,
+ entryCount: 0,
+ topExpenseCategory: null,
+ topExpenseAmount: 0,
+ };
+ }
+
res.json(result);
} catch (err) {
log.error('Critical error:', err.message);
diff --git a/server/routes/preferences.js b/server/routes/preferences.js
index 0cd9c71..d02888d 100644
--- a/server/routes/preferences.js
+++ b/server/routes/preferences.js
@@ -7,6 +7,7 @@
import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
+import { str, MAX_SHORT } from '../middleware/validate.js';
const log = createLogger('Preferences');
@@ -17,8 +18,12 @@ const DEFAULT_MEAL_TYPES = VALID_MEAL_TYPES.join(',');
const VALID_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
const DEFAULT_CURRENCY = 'EUR';
+const DEFAULT_APP_NAME = 'Oikos';
-const VALID_WIDGET_IDS = ['weather', 'tasks', 'calendar', 'shopping', 'meals', 'notes'];
+const VALID_DATE_FORMATS = ['mdy', 'dmy', 'ymd'];
+const DEFAULT_DATE_FORMAT = 'mdy';
+
+const VALID_WIDGET_IDS = ['tasks', 'calendar', 'birthdays', 'budget', 'family', 'weather', 'shopping', 'meals', 'notes'];
const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id) => ({ id, visible: true })));
// --------------------------------------------------------
@@ -39,6 +44,10 @@ function cfgSet(key, value) {
`).run(key, value);
}
+function cfgDelete(key) {
+ db.get().prepare('DELETE FROM sync_config WHERE key = ?').run(key);
+}
+
// --------------------------------------------------------
// Widget-Hilfsfunktionen
// --------------------------------------------------------
@@ -78,12 +87,16 @@ router.get('/', (req, res) => {
const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
const currency = cfgGet('currency') ?? DEFAULT_CURRENCY;
+ const dateFormat = VALID_DATE_FORMATS.includes(cfgGet('date_format')) ? cfgGet('date_format') : DEFAULT_DATE_FORMAT;
+ const appName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
res.json({
data: {
visible_meal_types: visibleMealTypes,
currency,
+ date_format: dateFormat,
+ app_name: appName,
dashboard_widgets: dashboardWidgets,
},
});
@@ -102,7 +115,7 @@ router.get('/', (req, res) => {
router.put('/', (req, res) => {
try {
- const { visible_meal_types, currency, dashboard_widgets } = req.body;
+ const { visible_meal_types, currency, date_format, app_name, dashboard_widgets } = req.body;
if (visible_meal_types !== undefined) {
if (!Array.isArray(visible_meal_types)) {
@@ -122,6 +135,20 @@ router.put('/', (req, res) => {
cfgSet('currency', currency);
}
+ if (date_format !== undefined) {
+ if (!VALID_DATE_FORMATS.includes(date_format)) {
+ return res.status(400).json({ error: `Ungültiges Datumsformat. Erlaubt: ${VALID_DATE_FORMATS.join(', ')}`, code: 400 });
+ }
+ cfgSet('date_format', date_format);
+ }
+
+ if (app_name !== undefined) {
+ const vAppName = str(app_name, 'Application name', { max: MAX_SHORT, required: false });
+ if (vAppName.error) return res.status(400).json({ error: vAppName.error, code: 400 });
+ if (vAppName.value) cfgSet('app_name', vAppName.value);
+ else cfgDelete('app_name');
+ }
+
if (dashboard_widgets !== undefined) {
if (!Array.isArray(dashboard_widgets)) {
return res.status(400).json({ error: 'dashboard_widgets muss ein Array sein', code: 400 });
@@ -133,12 +160,16 @@ router.put('/', (req, res) => {
const rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY;
+ const savedDateFormat = VALID_DATE_FORMATS.includes(cfgGet('date_format')) ? cfgGet('date_format') : DEFAULT_DATE_FORMAT;
+ const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
res.json({
data: {
visible_meal_types: savedMealTypes,
currency: savedCurrency,
+ date_format: savedDateFormat,
+ app_name: savedAppName,
dashboard_widgets: savedWidgets,
},
});
diff --git a/server/routes/reminders.js b/server/routes/reminders.js
index e27c894..6462971 100644
--- a/server/routes/reminders.js
+++ b/server/routes/reminders.js
@@ -8,6 +8,7 @@ import { createLogger } from '../logger.js';
import express from 'express';
import * as db from '../db.js';
import * as v from '../middleware/validate.js';
+import { syncAllBirthdayReminders } from '../services/birthdays.js';
const log = createLogger('Reminders');
const router = express.Router();
@@ -22,8 +23,9 @@ const VALID_ENTITY_TYPES = ['task', 'event'];
// --------------------------------------------------------
router.get('/pending', (req, res) => {
try {
- const userId = req.session.userId;
+ const userId = req.authUserId || req.session.userId;
const now = new Date().toISOString();
+ syncAllBirthdayReminders(db.get(), userId, new Date());
const rows = db.get().prepare(`
SELECT
@@ -53,7 +55,7 @@ router.get('/pending', (req, res) => {
// --------------------------------------------------------
router.get('/', (req, res) => {
try {
- const userId = req.session.userId;
+ const userId = req.authUserId || req.session.userId;
const entityType = req.query.entity_type;
const entityId = parseInt(req.query.entity_id, 10);
@@ -82,7 +84,7 @@ router.get('/', (req, res) => {
// --------------------------------------------------------
router.post('/', (req, res) => {
try {
- const userId = req.session.userId;
+ const userId = req.authUserId || req.session.userId;
const { entity_type, entity_id, remind_at } = req.body;
const errors = v.collectErrors([
@@ -127,7 +129,7 @@ router.post('/', (req, res) => {
// --------------------------------------------------------
router.patch('/:id/dismiss', (req, res) => {
try {
- const userId = req.session.userId;
+ const userId = req.authUserId || req.session.userId;
const reminderId = parseInt(req.params.id, 10);
if (!reminderId) {
@@ -157,7 +159,7 @@ router.patch('/:id/dismiss', (req, res) => {
// --------------------------------------------------------
router.delete('/:id', (req, res) => {
try {
- const userId = req.session.userId;
+ const userId = req.authUserId || req.session.userId;
const reminderId = parseInt(req.params.id, 10);
if (!reminderId) {
@@ -187,7 +189,7 @@ router.delete('/:id', (req, res) => {
// --------------------------------------------------------
router.delete('/', (req, res) => {
try {
- const userId = req.session.userId;
+ const userId = req.authUserId || req.session.userId;
const entityType = req.query.entity_type;
const entityId = parseInt(req.query.entity_id, 10);
diff --git a/server/services/birthdays.js b/server/services/birthdays.js
new file mode 100644
index 0000000..b716da5
--- /dev/null
+++ b/server/services/birthdays.js
@@ -0,0 +1,201 @@
+const BIRTHDAY_COLOR = '#E11D48';
+const BIRTHDAY_RRULE = 'FREQ=YEARLY;INTERVAL=1';
+
+function pad2(n) {
+ return String(n).padStart(2, '0');
+}
+
+function leapYear(year) {
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
+}
+
+function normalizedMonthDay(birthDate, year) {
+ const [, monthStr, dayStr] = String(birthDate).split('-');
+ const month = parseInt(monthStr, 10);
+ let day = parseInt(dayStr, 10);
+ if (month === 2 && day === 29 && !leapYear(year)) day = 28;
+ return `${year}-${pad2(month)}-${pad2(day)}`;
+}
+
+function nextBirthdayDate(birthDate, from = new Date()) {
+ const now = from instanceof Date ? from : new Date(from);
+ const thisYear = normalizedMonthDay(birthDate, now.getFullYear());
+ const today = now.toISOString().slice(0, 10);
+ return thisYear >= today
+ ? thisYear
+ : normalizedMonthDay(birthDate, now.getFullYear() + 1);
+}
+
+function nextBirthdayAge(birthDate, from = new Date()) {
+ const next = nextBirthdayDate(birthDate, from);
+ return parseInt(next.slice(0, 4), 10) - parseInt(String(birthDate).slice(0, 4), 10);
+}
+
+function daysUntilBirthday(birthDate, from = new Date()) {
+ const now = from instanceof Date ? from : new Date(from);
+ const next = nextBirthdayDate(birthDate, now);
+ const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
+ const nextUtc = Date.UTC(
+ parseInt(next.slice(0, 4), 10),
+ parseInt(next.slice(5, 7), 10) - 1,
+ parseInt(next.slice(8, 10), 10),
+ );
+ return Math.round((nextUtc - todayUtc) / 86400000);
+}
+
+function birthdayReminderAt(birthDate, from = new Date()) {
+ const next = nextBirthdayDate(birthDate, from);
+ return `${next}T12:00:00Z`;
+}
+
+function eventTitle(name) {
+ return `Birthday: ${name}`;
+}
+
+function eventDescription(name, birthDate) {
+ return `Birthday reminder for ${name} (${birthDate}).`;
+}
+
+function syncBirthdayCalendarEvent(database, birthday) {
+ const payload = {
+ title: eventTitle(birthday.name),
+ description: eventDescription(birthday.name, birthday.birth_date),
+ start_datetime: birthday.birth_date,
+ end_datetime: null,
+ all_day: 1,
+ location: null,
+ color: BIRTHDAY_COLOR,
+ assigned_to: null,
+ recurrence_rule: BIRTHDAY_RRULE,
+ created_by: birthday.created_by,
+ };
+
+ if (birthday.calendar_event_id) {
+ const existing = database.prepare('SELECT id FROM calendar_events WHERE id = ?').get(birthday.calendar_event_id);
+ if (existing) {
+ database.prepare(`
+ UPDATE calendar_events
+ SET title = ?, description = ?, start_datetime = ?, end_datetime = ?, all_day = ?,
+ location = ?, color = ?, assigned_to = ?, recurrence_rule = ?, created_by = ?,
+ external_source = 'local'
+ WHERE id = ?
+ `).run(
+ payload.title,
+ payload.description,
+ payload.start_datetime,
+ payload.end_datetime,
+ payload.all_day,
+ payload.location,
+ payload.color,
+ payload.assigned_to,
+ payload.recurrence_rule,
+ payload.created_by,
+ birthday.calendar_event_id,
+ );
+ return birthday.calendar_event_id;
+ }
+ }
+
+ const result = database.prepare(`
+ INSERT INTO calendar_events
+ (title, description, start_datetime, end_datetime, all_day, location, color,
+ assigned_to, created_by, recurrence_rule, external_source)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local')
+ `).run(
+ payload.title,
+ payload.description,
+ payload.start_datetime,
+ payload.end_datetime,
+ payload.all_day,
+ payload.location,
+ payload.color,
+ payload.assigned_to,
+ payload.created_by,
+ payload.recurrence_rule,
+ );
+
+ database.prepare('UPDATE birthdays SET calendar_event_id = ? WHERE id = ?')
+ .run(result.lastInsertRowid, birthday.id);
+ return result.lastInsertRowid;
+}
+
+function syncBirthdayReminder(database, birthday, from = new Date()) {
+ if (!birthday.calendar_event_id) return null;
+
+ const desired = birthdayReminderAt(birthday.birth_date, from);
+ const existing = database.prepare(`
+ SELECT * FROM reminders
+ WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
+ ORDER BY created_at DESC
+ `).all(birthday.calendar_event_id, birthday.created_by);
+
+ const active = existing.find((row) => row.dismissed === 0);
+ if (active && active.remind_at === desired) return active.id;
+
+ database.prepare(`
+ DELETE FROM reminders
+ WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
+ `).run(birthday.calendar_event_id, birthday.created_by);
+
+ const result = database.prepare(`
+ INSERT INTO reminders (entity_type, entity_id, remind_at, created_by)
+ VALUES ('event', ?, ?, ?)
+ `).run(birthday.calendar_event_id, desired, birthday.created_by);
+
+ return result.lastInsertRowid;
+}
+
+function syncBirthdayArtifacts(database, birthday, from = new Date()) {
+ const calendarEventId = syncBirthdayCalendarEvent(database, birthday);
+ const refreshed = { ...birthday, calendar_event_id: calendarEventId };
+ syncBirthdayReminder(database, refreshed, from);
+ return refreshed;
+}
+
+function deleteBirthdayArtifacts(database, birthday) {
+ if (birthday.calendar_event_id) {
+ database.prepare(`
+ DELETE FROM reminders
+ WHERE entity_type = 'event' AND entity_id = ? AND created_by = ?
+ `).run(birthday.calendar_event_id, birthday.created_by);
+ database.prepare('DELETE FROM calendar_events WHERE id = ?').run(birthday.calendar_event_id);
+ }
+}
+
+function hydrateBirthday(row, from = new Date()) {
+ const next_birthday = nextBirthdayDate(row.birth_date, from);
+ return {
+ ...row,
+ next_birthday,
+ next_age: nextBirthdayAge(row.birth_date, from),
+ days_until: daysUntilBirthday(row.birth_date, from),
+ };
+}
+
+function syncAllBirthdayReminders(database, userId, from = new Date()) {
+ const birthdays = database.prepare(`
+ SELECT * FROM birthdays WHERE created_by = ? ORDER BY birth_date ASC
+ `).all(userId);
+ birthdays.forEach((birthday) => {
+ const refreshed = birthday.calendar_event_id ? birthday : {
+ ...birthday,
+ calendar_event_id: syncBirthdayCalendarEvent(database, birthday),
+ };
+ syncBirthdayReminder(database, refreshed, from);
+ });
+}
+
+export {
+ BIRTHDAY_COLOR,
+ BIRTHDAY_RRULE,
+ birthdayReminderAt,
+ daysUntilBirthday,
+ deleteBirthdayArtifacts,
+ eventDescription,
+ eventTitle,
+ hydrateBirthday,
+ nextBirthdayAge,
+ nextBirthdayDate,
+ syncAllBirthdayReminders,
+ syncBirthdayArtifacts,
+};
diff --git a/test-dashboard.js b/test-dashboard.js
index f028191..b7b2480 100644
--- a/test-dashboard.js
+++ b/test-dashboard.js
@@ -6,6 +6,7 @@
import { DatabaseSync } from 'node:sqlite';
import { MIGRATIONS_SQL } from './server/db-schema-test.js';
+import { hydrateBirthday } from './server/services/birthdays.js';
let passed = 0;
let failed = 0;
@@ -49,6 +50,7 @@ const uid2 = u2.lastInsertRowid;
const today = new Date().toISOString().slice(0, 10);
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
+const currentMonth = today.slice(0, 7);
const inOneHour = new Date(Date.now() + 3600000).toISOString();
const in30h = new Date(Date.now() + 30 * 3600000).toISOString().slice(0, 10);
const in72h = new Date(Date.now() + 72 * 3600000).toISOString().slice(0, 10);
@@ -83,6 +85,22 @@ db.prepare(`INSERT INTO notes (content, title, pinned, color, created_by)
db.prepare(`INSERT INTO notes (content, pinned, color, created_by)
VALUES ('Nicht angepinnt', 0, '#E3F2FF', ?)`).run(uid1);
+// Geburtstage
+db.prepare(`INSERT INTO birthdays (name, birth_date, created_by)
+ VALUES ('Heute Geburtstag', ?, ?)`).run(`2012-${today.slice(5)}`, uid1);
+db.prepare(`INSERT INTO birthdays (name, birth_date, created_by)
+ VALUES ('Morgen Geburtstag', ?, ?)`).run(`2010-${tomorrow.slice(5)}`, uid1);
+db.prepare(`INSERT INTO birthdays (name, birth_date, created_by)
+ VALUES ('Anderer Nutzer', ?, ?)`).run(`2011-${today.slice(5)}`, uid2);
+
+// Budget
+db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
+ VALUES ('Salary', 3000, 'Erwerbseinkommen', '', ?, ?)`).run(`${currentMonth}-05`, uid1);
+db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
+ VALUES ('Rent', -1200, 'housing', 'rent_mortgage', ?, ?)`).run(`${currentMonth}-06`, uid1);
+db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
+ VALUES ('Groceries', -450, 'food', 'supermarket', ?, ?)`).run(`${currentMonth}-07`, uid1);
+
console.log('\n[Dashboard-Test] API-Abfragen\n');
// --------------------------------------------------------
@@ -191,6 +209,53 @@ test('Angepinnte Notizen: nicht angepinnte werden ausgeschlossen', () => {
assert(!unpinned, 'Nicht angepinnte Notiz sollte gefiltert sein');
});
+// --------------------------------------------------------
+// Tests: Geburtstage
+// --------------------------------------------------------
+test('Geburtstage: nur aktueller Nutzer, sortiert nach nächstem Geburtstag', () => {
+ const rows = db.prepare('SELECT * FROM birthdays WHERE created_by = ? ORDER BY name COLLATE NOCASE ASC').all(uid1);
+ const birthdays = rows
+ .map((row) => hydrateBirthday(row, new Date(`${today}T12:00:00Z`)))
+ .sort((a, b) => a.days_until - b.days_until || a.name.localeCompare(b.name))
+ .slice(0, 3);
+
+ assert(rows.length === 2, `Erwartet 2 Geburtstage, erhalten ${rows.length}`);
+ assert(birthdays[0].name === 'Heute Geburtstag', 'Heutiger Geburtstag zuerst');
+ assert(birthdays[0].days_until === 0, 'Heutiger Geburtstag hat 0 Tage Rest');
+});
+
+// --------------------------------------------------------
+// Tests: Budget
+// --------------------------------------------------------
+test('Budget: Monatswerte für Einnahmen, Ausgaben, Saldo und Top-Ausgabe', () => {
+ const from = `${currentMonth}-01`;
+ const to = `${currentMonth}-31`;
+ const totals = db.prepare(`
+ SELECT
+ SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
+ SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
+ SUM(amount) AS balance,
+ COUNT(*) AS entry_count
+ FROM budget_entries
+ WHERE date BETWEEN ? AND ?
+ `).get(from, to);
+
+ const topExpense = db.prepare(`
+ SELECT category, SUM(amount) AS amount
+ FROM budget_entries
+ WHERE amount < 0 AND date BETWEEN ? AND ?
+ GROUP BY category
+ ORDER BY ABS(SUM(amount)) DESC
+ LIMIT 1
+ `).get(from, to);
+
+ assert(totals.income === 3000, `Einnahmen sollten 3000 sein, erhalten ${totals.income}`);
+ assert(Math.abs(totals.expenses) === 1650, `Ausgaben sollten 1650 sein, erhalten ${totals.expenses}`);
+ assert(totals.balance === 1350, `Saldo sollte 1350 sein, erhalten ${totals.balance}`);
+ assert(totals.entry_count === 3, `Erwartet 3 Einträge, erhalten ${totals.entry_count}`);
+ assert(topExpense.category === 'housing', 'Wohnen sollte Top-Ausgabenkategorie sein');
+});
+
// --------------------------------------------------------
// Ergebnis
// --------------------------------------------------------
diff --git a/test-db.js b/test-db.js
index 0ab8a51..6548729 100644
--- a/test-db.js
+++ b/test-db.js
@@ -73,7 +73,7 @@ test('Migration v1 ausführen (alle Tabellen und Triggers)', () => {
const EXPECTED_TABLES = [
'users', 'tasks', 'shopping_lists', 'shopping_items',
'meals', 'meal_ingredients', 'calendar_events',
- 'notes', 'contacts', 'budget_entries',
+ 'notes', 'contacts', 'birthdays', 'budget_entries',
'budget_categories', 'budget_subcategories', 'api_tokens',
];
@@ -99,6 +99,7 @@ const EXPECTED_TRIGGERS = [
'calendar_events',
'notes',
'contacts',
+ 'birthdays',
'budget_entries',
].map((t) => `trg_${t}_updated_at`);
@@ -200,6 +201,19 @@ test('API-Token anlegen und lesen', () => {
assert(token.revoked_at === null, 'Token sollte nicht widerrufen sein');
});
+test('Geburtstag mit Kalender-Referenz anlegen', () => {
+ const event = db.prepare(`
+ INSERT INTO calendar_events (title, start_datetime, all_day, created_by, recurrence_rule)
+ VALUES ('Birthday: Alex', '2014-05-10', 1, 1, 'FREQ=YEARLY;INTERVAL=1')
+ `).run();
+ const birthday = db.prepare(`
+ INSERT INTO birthdays (name, birth_date, calendar_event_id, created_by)
+ VALUES ('Alex', '2014-05-10', ?, 1)
+ `).run(event.lastInsertRowid);
+ const row = db.prepare('SELECT * FROM birthdays WHERE id = ?').get(birthday.lastInsertRowid);
+ assert(row.calendar_event_id === event.lastInsertRowid, 'Kalender-Referenz stimmt nicht');
+});
+
// --------------------------------------------------------
// Ergebnis
// --------------------------------------------------------