feat: birthday tracking, dashboard KPIs, and app name customization (#88)

- Add Birthdays module: CRUD with calendar/reminder auto-sync, photo upload, age notes
- Add DB migration 18 (birthdays table with calendar_event_id, trigger, indexes)
- Add dashboard widgets: birthdays, family participants, budget overview
- Add Settings > General: admins can set a custom app name (reflected in title/sidebar/login)
- Improve service worker: network-first caching for mutable JS/CSS assets
- Add translations for 16 locales (birthday keys)

Fixes applied during integration:
- innerHTML replaced with insertAdjacentHTML/replaceChildren throughout birthdays.js and dashboard.js
- docker-compose.yml personal dev changes reverted

Co-authored-by: Rafael Foster <rafaelgfoster@gmail.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-04-27 07:37:09 +02:00
39 changed files with 4026 additions and 156 deletions
+28 -7
View File
@@ -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 */
+53
View File
@@ -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": "تعيين تذكير",
+54
View File
@@ -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",
+53
View File
@@ -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": "Ορισμός υπενθύμισης",
+54
View File
@@ -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",
+53
View File
@@ -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",
+53
View File
@@ -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",
+53
View File
@@ -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": "अनुस्मारक सेट करें",
+53
View File
@@ -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",
+53
View File
@@ -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": "リマインダーを設定",
+54
View File
@@ -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",
+53
View File
@@ -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": "Установить напоминание",
+53
View File
@@ -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",
+53
View File
@@ -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",
+53
View File
@@ -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.",
+53
View File
@@ -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": "设置提醒",
+397
View File
@@ -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 `<img class="birthday-avatar ${extraClass}" src="${birthday.photo_data}" alt="${esc(birthday.name)}">`;
}
return `<span class="birthday-avatar birthday-avatar--fallback ${extraClass}">${esc(initials(birthday.name))}</span>`;
}
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) => `
<button class="birthday-suggestion" type="button" data-index="${idx}" data-name="${esc(birthday.name)}">
${photoAvatar(birthday, 'birthday-avatar--xs')}
<span>
<strong>${esc(birthday.name)}</strong>
<small>${esc(ageNote(birthday))}</small>
</span>
</button>
`).join(''));
}
function renderUpcoming() {
const host = _container.querySelector('#birthdays-upcoming');
if (!host) return;
if (!state.upcoming.length) {
host.replaceChildren();
host.insertAdjacentHTML('beforeend', `<div class="empty-state empty-state--compact">
<div class="empty-state__title">${t('birthdays.emptyTitle')}</div>
<div class="empty-state__description">${t('birthdays.emptyDescription')}</div>
</div>`);
return;
}
host.replaceChildren();
host.insertAdjacentHTML('beforeend', state.upcoming.map((birthday) => `
<article class="birthday-card">
<div class="birthday-card__media">${photoAvatar(birthday)}</div>
<div class="birthday-card__body">
<div class="birthday-card__top">
<div>
<div class="birthday-card__name">${esc(birthday.name)}</div>
<div class="birthday-card__date">${esc(formatDate(birthday.next_birthday))}</div>
</div>
<div class="birthday-card__pill">
${birthday.days_until === 0 ? esc(t('common.today')) : birthday.days_until === 1 ? esc(t('common.tomorrow')) : esc(`${birthday.days_until}d`)}
</div>
</div>
<div class="birthday-card__note">${esc(ageNote(birthday))}</div>
</div>
</article>
`).join(''));
}
function renderList() {
const host = _container.querySelector('#birthdays-list');
if (!host) return;
const list = filteredBirthdays();
if (!list.length) {
host.replaceChildren();
host.insertAdjacentHTML('beforeend', `<div class="empty-state">
<div class="empty-state__title">${t('birthdays.emptyTitle')}</div>
<div class="empty-state__description">${t('birthdays.emptyDescription')}</div>
</div>`);
return;
}
host.replaceChildren();
host.insertAdjacentHTML('beforeend', list.map((birthday) => `
<article class="birthday-item" data-id="${birthday.id}">
<div class="birthday-item__media">${photoAvatar(birthday)}</div>
<div class="birthday-item__body">
<div class="birthday-item__row">
<strong class="birthday-item__name">${esc(birthday.name)}</strong>
<span class="birthday-item__next">${esc(formatDate(birthday.next_birthday))}</span>
</div>
<div class="birthday-item__meta">${esc(formatDate(birthday.birth_date))}</div>
<div class="birthday-item__note">${esc(ageNote(birthday))}</div>
${birthday.notes ? `<div class="birthday-item__notes">${esc(birthday.notes)}</div>` : ''}
</div>
<div class="birthday-item__actions">
<button class="contact-action-btn" type="button" data-action="edit" data-id="${birthday.id}" aria-label="${t('common.edit')}">
<i data-lucide="pencil" style="width:16px;height:16px;" aria-hidden="true"></i>
</button>
<button class="contact-action-btn" type="button" data-action="delete" data-id="${birthday.id}" aria-label="${t('common.delete')}">
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
</button>
</div>
</article>
`).join(''));
if (window.lucide) window.lucide.createIcons();
stagger(host.querySelectorAll('.birthday-item'));
}
function renderPage() {
_container.replaceChildren();
_container.insertAdjacentHTML('beforeend', `
<div class="birthdays-page">
<h1 class="sr-only">${t('birthdays.title')}</h1>
<div class="birthdays-toolbar">
<div class="birthdays-toolbar__title">
<i data-lucide="cake" class="birthdays-toolbar__title-icon" aria-hidden="true"></i>
<span>${t('birthdays.title')}</span>
</div>
<button class="btn btn--primary birthdays-header__action" id="birthdays-add-btn">
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
${t('birthdays.addButton')}
</button>
</div>
<p class="birthdays-toolbar__subtitle">${t('birthdays.calendarHint')}</p>
<div class="birthdays-grid">
<aside class="birthdays-panel birthdays-panel--upcoming">
<div class="birthdays-section__header">
<h3>${t('birthdays.upcomingTitle')}</h3>
<p>${t('birthdays.upcomingHint')}</p>
</div>
<div class="birthday-cards" id="birthdays-upcoming"></div>
</aside>
<section class="birthdays-panel birthdays-panel--list">
<div class="birthdays-toolbar birthdays-toolbar--embedded">
<div class="birthdays-toolbar__search">
<i data-lucide="search" class="birthdays-toolbar__search-icon" aria-hidden="true"></i>
<input type="search" class="birthdays-toolbar__search-input" id="birthdays-search"
placeholder="${t('birthdays.searchPlaceholder')}" autocomplete="off" value="${esc(state.query)}">
<div class="autocomplete-dropdown birthdays-autocomplete" id="birthdays-autocomplete" hidden></div>
</div>
</div>
<div class="birthdays-section__header birthdays-section__header--spaced">
<h3>${t('birthdays.peopleTitle')}</h3>
<p>${t('birthdays.peopleHint')}</p>
</div>
<div class="birthdays-list" id="birthdays-list"></div>
</section>
</div>
<button class="page-fab" id="fab-new-birthday" aria-label="${t('birthdays.addButton')}">
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
</button>
</div>
`);
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 `<img class="birthday-preview__image" src="${photoData}" alt="${esc(name || '')}">`;
return `<span class="birthday-preview__fallback">${esc(initials(name))}</span>`;
}
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: `
<div class="birthday-modal">
<div class="birthday-preview" id="birthday-preview">${birthdayPreviewHtml(birthday?.name || '', photoData)}</div>
<div class="form-group">
<label class="form-label" for="bd-name">${t('birthdays.nameLabel')}</label>
<input class="form-input" id="bd-name" type="text" value="${esc(birthday?.name || '')}" autocomplete="name">
</div>
<div class="form-group">
<label class="form-label" for="bd-birth-date">${t('birthdays.birthDateLabel')}</label>
<input class="form-input" id="bd-birth-date" type="date" value="${esc(birthday?.birth_date || '')}">
</div>
<div class="form-group">
<label class="form-label" for="bd-photo">${t('birthdays.photoLabel')}</label>
<input class="form-input" id="bd-photo" type="file" accept="image/png,image/jpeg,image/webp,image/gif">
<div class="form-help">${t('birthdays.photoOptional')}</div>
<div class="birthday-modal__photo-actions">
<button type="button" class="btn btn--secondary" id="bd-remove-photo">${t('birthdays.removePhoto')}</button>
</div>
</div>
<div class="form-group">
<label class="form-label" for="bd-notes">${t('birthdays.notesLabel')}</label>
<textarea class="form-input" id="bd-notes" rows="3" placeholder="${t('birthdays.notesPlaceholder')}">${esc(birthday?.notes || '')}</textarea>
</div>
<div class="birthday-modal__hint">${t('birthdays.calendarHint')}</div>
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
${isEdit ? `<button class="btn btn--danger" id="bd-delete">${t('common.delete')}</button>` : '<div></div>'}
<div style="display:flex;gap:var(--space-3);">
<button class="btn btn--secondary" type="button" id="bd-cancel">${t('common.cancel')}</button>
<button class="btn btn--primary" type="button" id="bd-save">${isEdit ? t('common.save') : t('common.create')}</button>
</div>
</div>
</div>
`,
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();
}
+378 -104
View File
@@ -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(`<span class="greeting-chip greeting-chip--warn">
<i data-lucide="alert-circle" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
${overdueCount > 1 ? t('dashboard.overdueTasksChipPlural', { count: overdueCount }) : t('dashboard.overdueTasksChip', { count: overdueCount })}
</span>`);
if (dueSoonCount > 0)
statChips.push(`<span class="greeting-chip greeting-chip--due">
<i data-lucide="clock" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
${dueSoonCount > 1 ? t('dashboard.urgentTasksChipPlural', { count: dueSoonCount }) : t('dashboard.urgentTasksChip', { count: dueSoonCount })}
</span>`);
if (todayEventCount > 0)
statChips.push(`<span class="greeting-chip">
<i data-lucide="calendar" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
${todayEventCount > 1 ? t('dashboard.eventsChipPlural', { count: todayEventCount }) : t('dashboard.eventsChip', { count: todayEventCount })}
</span>`);
if (todayMealTitle)
statChips.push(`<span class="greeting-chip">
<i data-lucide="utensils" class="icon-sm" style="flex-shrink:0" aria-hidden="true"></i>
${t('dashboard.todayMealChip', { title: esc(todayMealTitle) })}
</span>`);
const time = formatTime(new Date());
const hour = new Date().getHours();
const timeVariant = hour < 11 ? 'morning' : hour < 18 ? 'day' : 'evening';
return `
<div class="widget-greeting" data-time-variant="${timeVariant}">
<div class="widget-greeting__inner">
<div class="widget-greeting__content">
<div class="widget-greeting__title">${formatDate(new Date())} - ${time}</div>
${statChips.length ? `<div class="widget-greeting__chips">${statChips.join('')}</div>` : ''}
</div>
<button class="widget-customize-btn" id="dashboard-customize-btn"
aria-label="${t('dashboard.customize')}" title="${t('dashboard.customize')}">
<i data-lucide="settings-2" class="icon-base" aria-hidden="true"></i>
</button>
</div>
</div>
`;
}
function renderUrgentTasks(tasks) {
if (!tasks.length) {
return `<div class="widget widget--tasks">
@@ -382,6 +369,43 @@ function renderUpcomingEvents(events) {
</div>`;
}
function renderUpcomingBirthdays(birthdays) {
if (!birthdays.length) {
return `<div class="widget widget--birthdays">
${widgetHeader('cake', t('nav.birthdays'), 0, '/birthdays')}
<div class="widget__empty">
<i data-lucide="cake" class="empty-state__icon" aria-hidden="true"></i>
<div>${t('dashboard.noBirthdays')}</div>
</div>
</div>`;
}
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 `
<div class="birthday-widget-item" data-route="/birthdays" role="button" tabindex="0">
<div class="birthday-widget-item__avatar">
${b.photo_data ? `<img src="${esc(b.photo_data)}" alt="" loading="lazy">` : `<span>${esc(initials(b.name))}</span>`}
</div>
<div class="birthday-widget-item__body">
<div class="birthday-widget-item__name">${esc(b.name)}</div>
<div class="birthday-widget-item__meta">${formatDate(b.next_birthday)} · ${daysLabel}</div>
</div>
<div class="birthday-widget-item__age">${esc(String(b.next_age ?? ''))}</div>
</div>
`;
}).join('');
return `<div class="widget widget--birthdays">
${widgetHeader('cake', t('nav.birthdays'), birthdays.length, '/birthdays')}
<div class="widget__body">${items}</div>
</div>`;
}
function renderTodayMeals(meals) {
const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack'];
@@ -428,6 +452,283 @@ function renderPinnedNotes(notes) {
</div>`;
}
function renderFamilyWidget(users) {
const visible = users.slice(0, 6);
const avatars = visible.map((u) => `
<span class="family-widget-avatar" style="background:${esc(u.avatar_color || '#64748b')}" title="${esc(u.display_name)}">
${esc(initials(u.display_name))}
</span>
`).join('');
return `<div class="widget widget--family">
${widgetHeader('users', t('dashboard.familyMembers'), users.length, '/settings')}
<div class="family-widget">
<div class="family-widget__count">${users.length}</div>
<div class="family-widget__meta">${t('dashboard.participantsAdded')}</div>
<div class="family-widget__avatars">${avatars}</div>
</div>
</div>`;
}
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 `<div class="widget widget--budget">
${widgetHeader('wallet', t('dashboard.budgetOverview'), null, '/budget')}
<div class="budget-widget">
<div class="budget-widget__headline">
<span>${t('dashboard.monthlyBalance')}</span>
<strong class="budget-widget__balance budget-widget__balance--${balanceTone}">${formatCurrency(balance, currency)}</strong>
</div>
<div class="budget-widget__grid">
<div class="budget-widget-metric budget-widget-metric--income">
<span>${t('dashboard.monthlyIncome')}</span>
<strong>${formatCurrency(income, currency)}</strong>
</div>
<div class="budget-widget-metric budget-widget-metric--expense">
<span>${t('dashboard.monthlyExpenses')}</span>
<strong>${formatCurrency(expenses, currency)}</strong>
</div>
<div class="budget-widget-metric">
<span>${t('dashboard.savingsRate')}</span>
<strong>${income > 0 ? `${savingsRate}%` : '-'}</strong>
</div>
<div class="budget-widget-metric">
<span>${t('dashboard.budgetEntries')}</span>
<strong>${budget?.entryCount || 0}</strong>
</div>
</div>
<div class="budget-widget__footer">
${hasData && budget?.topExpenseCategory
? `${t('dashboard.topExpense')}: <strong>${esc(budgetCategoryLabel(budget.topExpenseCategory))}</strong> · ${formatCurrency(budget.topExpenseAmount, currency)}`
: t('dashboard.noBudgetData')}
</div>
</div>
</div>`;
}
function renderQuickAction({ route, label, icon, tone = '' }) {
return `
<button type="button" class="dashboard-action ${tone ? `dashboard-action--${tone}` : ''}" data-route="${route}">
<span class="dashboard-action__icon"><i data-lucide="${icon}" aria-hidden="true"></i></span>
<span class="dashboard-action__label">${label}</span>
</button>
`;
}
function renderKpiTile({ title, value, meta, icon, route, tone = '' }) {
return `
<button type="button" class="dashboard-kpi ${tone ? `dashboard-kpi--${tone}` : ''}" data-route="${route}">
<span class="dashboard-kpi__icon"><i data-lucide="${icon}" aria-hidden="true"></i></span>
<span class="dashboard-kpi__body">
<span class="dashboard-kpi__label">${title}</span>
<span class="dashboard-kpi__value">${value}</span>
<span class="dashboard-kpi__meta">${meta}</span>
</span>
</button>
`;
}
function renderDashboardOverview(user, stats = null, weather = null) {
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('') : `
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
`;
return `
<section class="dashboard-overview">
<div class="dashboard-overview__header">
<div class="dashboard-overview__heading">
<span class="dashboard-overview__date">${dateLabel}</span>
<h1 class="dashboard-overview__title">${greeting(user.display_name)}</h1>
</div>
<div class="dashboard-overview__tools">
<div class="dashboard-overview__actions">${actions}</div>
<button class="dashboard-icon-btn" id="dashboard-customize-btn"
aria-label="${t('dashboard.customize')}" title="${t('dashboard.customize')}">
<i data-lucide="settings-2" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="dashboard-kpi-grid">
${kpis}
</div>
</section>
`;
}
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 `<section class="dashboard-tile dashboard-tile--${id} ${widgetTileClass(id)}">${html}</section>`;
}
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 `
<section class="dashboard-workspace">
<div class="dashboard-workspace__main">
<div class="dashboard-widget-grid">
${mainTiles}
</div>
</div>
<aside class="dashboard-workspace__side">
<div class="dashboard-side-stack">
${sideTiles}
</div>
</aside>
</section>
`;
}
function renderDashboardSkeleton() {
return `
<section class="dashboard-overview">
<div class="dashboard-overview__header">
<div class="dashboard-overview__heading">
<div class="skeleton skeleton-line skeleton-line--short"></div>
<div class="skeleton skeleton-line skeleton-line--medium"></div>
</div>
</div>
<div class="dashboard-kpi-grid">
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
<div class="dashboard-kpi dashboard-kpi--skeleton"></div>
</div>
</section>
<section class="dashboard-workspace">
<div class="dashboard-workspace__main">
<div class="dashboard-widget-grid">
${skeletonWidget(3)}
${skeletonWidget(3)}
${skeletonWidget(2)}
${skeletonWidget(3)}
</div>
</div>
<aside class="dashboard-workspace__side">
<div class="dashboard-side-stack">
${skeletonWidget(3)}
${skeletonWidget(3)}
${skeletonWidget(2)}
</div>
</aside>
</section>
`;
}
// --------------------------------------------------------
// 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 = `
<div class="dashboard">
<h1 class="sr-only">${t('dashboard.title')}</h1>
<div class="dashboard__grid">
${renderGreeting(user, {})}
${skeletonWidget(3)}
${skeletonWidget(3)}
${skeletonWidget(2)}
${skeletonWidget(3)}
<div class="dashboard-shell" id="dashboard-shell">
${renderDashboardSkeleton()}
</div>
</div>
${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 */ }
+25 -3
View File
@@ -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 = `
<main class="login-page" id="main-content">
<div class="login-hero">
<h1 class="login-hero__title">Oikos</h1>
<h1 class="login-hero__title">${storedAppName}</h1>
<p class="login-hero__tagline">${t('login.tagline')}</p>
</div>
<div class="login-card card card--padded">
@@ -67,9 +81,17 @@ export async function render(container) {
const submitBtn = container.querySelector('#login-btn');
const versionEl = container.querySelector('#login-version');
fetch(VERSION_URL)
setAppBranding(storedAppName);
fetch(VERSION_URL, { cache: 'no-store' })
.then((r) => r.json())
.then((d) => { versionEl.textContent = t('login.version', { version: d.version }); })
.then((d) => {
if (d?.app_name) {
try { localStorage.setItem(APP_NAME_STORAGE_KEY, d.app_name); } catch (_) {}
setAppBranding(d.app_name);
}
versionEl.textContent = t('login.version', { version: d.version });
})
.catch(() => {});
form.addEventListener('submit', async (e) => {
+104 -1
View File
@@ -12,6 +12,8 @@ import '/components/oikos-locale-picker.js';
const SUPPORTED_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
const SETTINGS_TAB_KEY = 'oikos:settings:tab';
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
const DEFAULT_APP_NAME = 'Oikos';
const CATEGORY_I18N = {
'Obst & Gemüse': 'shopping.catFruitVeg',
@@ -56,7 +58,7 @@ export async function render(container, { user }) {
let users = [];
let googleStatus = { configured: false, connected: false, lastSync: null };
let appleStatus = { configured: false, lastSync: null };
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' };
let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', app_name: DEFAULT_APP_NAME };
let categories = [];
let icsSubscriptions = [];
let apiTokens = [];
@@ -80,6 +82,13 @@ export async function render(container, { user }) {
if (apiTokensRes.status === 'fulfilled') apiTokens = apiTokensRes.value.data ?? [];
} catch (_) { /* non-critical */ }
if (prefs.date_format) {
try { localStorage.setItem('oikos-date-format', prefs.date_format); } catch (_) {}
}
if (prefs.app_name) {
try { localStorage.setItem(APP_NAME_STORAGE_KEY, prefs.app_name); } catch (_) {}
}
const googleStatusText = googleStatus.connected
? (googleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(googleStatus.lastSync) }) : t('settings.connected'))
: googleStatus.configured ? t('settings.notConnected') : t('settings.notConfigured');
@@ -139,6 +148,48 @@ export async function render(container, { user }) {
</div>
</section>
${user?.role === 'admin' ? `
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionAppName')}</h2>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.appNameTitle')}</h3>
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.appNameHint')}</p>
<form class="settings-form settings-form--compact" id="app-name-form" novalidate autocomplete="off">
<div class="form-group">
<label class="form-label" for="app-name-input">${t('settings.appNameLabel')}</label>
<input
class="form-input"
type="text"
id="app-name-input"
maxlength="60"
placeholder="${t('settings.appNamePlaceholder')}"
value="${esc(prefs.app_name || DEFAULT_APP_NAME)}"
/>
</div>
<div id="app-name-error" class="form-error" hidden></div>
<div class="settings-form-actions">
<button type="submit" class="btn btn--primary">${t('common.save')}</button>
<button type="button" class="btn btn--secondary" id="app-name-reset-btn">${t('common.reset')}</button>
</div>
</form>
</div>
</section>
` : ''}
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.sectionDate')}</h2>
<div class="settings-card">
<h3 class="settings-card__title">${t('settings.dateFormatTitle')}</h3>
<p class="form-hint" style="margin-bottom:var(--space-3)">${t('settings.dateFormatHint')}</p>
<label class="form-label" for="date-format-select">${t('settings.dateFormatLabel')}</label>
<select class="form-input" id="date-format-select">
<option value="mdy"${prefs.date_format === 'mdy' ? ' selected' : ''}>MM/DD/YYYY</option>
<option value="dmy"${prefs.date_format === 'dmy' ? ' selected' : ''}>DD/MM/YYYY</option>
<option value="ymd"${prefs.date_format === 'ymd' ? ' selected' : ''}>YYYY-MM-DD</option>
</select>
</div>
</section>
<section class="settings-section">
<h2 class="settings-section__title">${t('settings.languageTitle')}</h2>
<div class="settings-card">
@@ -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) {
+82 -2
View File
@@ -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 || '/');
});
// --------------------------------------------------------
+331
View File
@@ -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);
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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 */
+16 -3
View File
@@ -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();
});
}
+45 -13
View File
@@ -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);
}