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 `${esc(birthday.name)}`; + } + return `${esc(initials(birthday.name))}`; +} + +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.title')} +
+ +
+

${t('birthdays.calendarHint')}

+ +
+ + +
+
+ +
+
+

${t('birthdays.peopleTitle')}

+

${t('birthdays.peopleHint')}

+
+
+
+
+ + +
+ `); + + 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 `${esc(name || '')}`; + 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.photoOptional')}
+
+ +
+
+
+ + +
+
${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 ` -
-
-
-
${formatDate(new Date())} - ${time}
- ${statChips.length ? `
${statChips.join('')}
` : ''} -
- -
-
- `; -} - function renderUrgentTasks(tasks) { if (!tasks.length) { return `
@@ -382,6 +369,43 @@ function renderUpcomingEvents(events) {
`; } +function renderUpcomingBirthdays(birthdays) { + if (!birthdays.length) { + return `
+ ${widgetHeader('cake', t('nav.birthdays'), 0, '/birthdays')} +
+ +
${t('dashboard.noBirthdays')}
+
+
`; + } + + 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 ` +
+
+ ${b.photo_data ? `` : `${esc(initials(b.name))}`} +
+
+
${esc(b.name)}
+
${formatDate(b.next_birthday)} · ${daysLabel}
+
+
${esc(String(b.next_age ?? ''))}
+
+ `; + }).join(''); + + return `
+ ${widgetHeader('cake', t('nav.birthdays'), birthdays.length, '/birthdays')} +
${items}
+
`; +} + 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 `
+ ${widgetHeader('users', t('dashboard.familyMembers'), users.length, '/settings')} +
+
${users.length}
+
${t('dashboard.participantsAdded')}
+
${avatars}
+
+
`; +} + +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 `
+ ${widgetHeader('wallet', t('dashboard.budgetOverview'), null, '/budget')} +
+
+ ${t('dashboard.monthlyBalance')} + ${formatCurrency(balance, currency)} +
+
+
+ ${t('dashboard.monthlyIncome')} + ${formatCurrency(income, currency)} +
+
+ ${t('dashboard.monthlyExpenses')} + ${formatCurrency(expenses, currency)} +
+
+ ${t('dashboard.savingsRate')} + ${income > 0 ? `${savingsRate}%` : '-'} +
+
+ ${t('dashboard.budgetEntries')} + ${budget?.entryCount || 0} +
+
+ +
+
`; +} + +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 ` +
+
+
+ ${dateLabel} +

${greeting(user.display_name)}

+
+
+
${actions}
+ +
+
+
+ ${kpis} +
+
+ `; +} + +function widgetRegion(id) { + return ['budget', 'family', 'weather', 'shopping', 'meals'].includes(id) ? 'side' : 'main'; +} + +function widgetTileClass(id) { + const map = { + tasks: 'dashboard-tile--wide', + calendar: 'dashboard-tile--compact', + birthdays: 'dashboard-tile--compact', + budget: 'dashboard-tile--wide', + family: 'dashboard-tile--compact', + meals: 'dashboard-tile--compact', + notes: 'dashboard-tile--wide', + shopping: 'dashboard-tile--compact', + weather: 'dashboard-tile--wide', + }; + return map[id] || 'dashboard-tile--compact'; +} + +function renderDashboardTile(id, html) { + if (!html) return ''; + return `
${html}
`; +} + +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 ` +
+
+
+ ${mainTiles} +
+
+ +
+ `; +} + +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 = `
+ ${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 // --------------------------------------------------------