A lot of change in this commit. Changing the dashboard to get more data and the new features added
This commit is contained in:
+28
-7
@@ -8,6 +8,8 @@
|
|||||||
const SUPPORTED_LOCALES = ['de', 'en', 'es', 'fr', 'it', 'sv', 'el', 'ru', 'tr', 'zh', 'ja', 'ar', 'hi', 'pt', 'uk'];
|
const SUPPORTED_LOCALES = ['de', 'en', 'es', 'fr', 'it', 'sv', 'el', 'ru', 'tr', 'zh', 'ja', 'ar', 'hi', 'pt', 'uk'];
|
||||||
const DEFAULT_LOCALE = 'de';
|
const DEFAULT_LOCALE = 'de';
|
||||||
const STORAGE_KEY = 'oikos-locale';
|
const STORAGE_KEY = 'oikos-locale';
|
||||||
|
const DATE_FORMAT_KEY = 'oikos-date-format';
|
||||||
|
const DEFAULT_DATE_FORMAT = 'mdy';
|
||||||
|
|
||||||
let currentLocale = DEFAULT_LOCALE;
|
let currentLocale = DEFAULT_LOCALE;
|
||||||
let translations = {};
|
let translations = {};
|
||||||
@@ -78,6 +80,28 @@ export function t(key, params = {}) {
|
|||||||
return str;
|
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 */
|
/** Aktuelle Locale abfragen */
|
||||||
export function getLocale() {
|
export function getLocale() {
|
||||||
return currentLocale;
|
return currentLocale;
|
||||||
@@ -91,13 +115,10 @@ export function getSupportedLocales() {
|
|||||||
/** Datum locale-aware formatieren */
|
/** Datum locale-aware formatieren */
|
||||||
export function formatDate(date) {
|
export function formatDate(date) {
|
||||||
if (date == null) return '';
|
if (date == null) return '';
|
||||||
const d = date instanceof Date ? date : new Date(date);
|
if (isDateOnlyString(date)) {
|
||||||
if (isNaN(d.getTime())) return '';
|
return formatDateParts(new Date(`${date}T00:00:00Z`), true);
|
||||||
return new Intl.DateTimeFormat(currentLocale, {
|
}
|
||||||
day: '2-digit',
|
return formatDateParts(date);
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
}).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Uhrzeit locale-aware formatieren */
|
/** Uhrzeit locale-aware formatieren */
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "طوال اليوم",
|
"allDay": "طوال اليوم",
|
||||||
"shoppingMore": "+{{count}} أخرى",
|
"shoppingMore": "+{{count}} أخرى",
|
||||||
"weather": "الطقس",
|
"weather": "الطقس",
|
||||||
|
"familyMembers": "أفراد العائلة",
|
||||||
|
"participantsAdded": "مشاركون مضافون",
|
||||||
|
"upcomingBirthdays": "أعياد الميلاد القادمة",
|
||||||
|
"noBirthdays": "لا توجد أعياد ميلاد بعد",
|
||||||
|
"daysLeft": "{{count}} أيام",
|
||||||
|
"budgetOverview": "نظرة عامة على الميزانية",
|
||||||
|
"monthlyIncome": "الدخل",
|
||||||
|
"monthlyExpenses": "المصروفات",
|
||||||
|
"monthlyBalance": "الرصيد",
|
||||||
|
"savingsRate": "معدل الادخار",
|
||||||
|
"topExpense": "أكبر مصروف",
|
||||||
|
"budgetEntries": "القيود",
|
||||||
|
"noBudgetData": "لا توجد بيانات ميزانية لهذا الشهر.",
|
||||||
"customize": "تخصيص",
|
"customize": "تخصيص",
|
||||||
"customizeTitle": "تخصيص الأدوات",
|
"customizeTitle": "تخصيص الأدوات",
|
||||||
"customizeReset": "الافتراضي",
|
"customizeReset": "الافتراضي",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "الحساب",
|
"tabAccount": "الحساب",
|
||||||
"tabsAriaLabel": "أقسام الإعدادات",
|
"tabsAriaLabel": "أقسام الإعدادات",
|
||||||
"sectionDesign": "التصميم",
|
"sectionDesign": "التصميم",
|
||||||
|
"sectionAppName": "اسم التطبيق",
|
||||||
"sectionShopping": "التسوق",
|
"sectionShopping": "التسوق",
|
||||||
"shoppingCategoriesLabel": "فئات التسوق",
|
"shoppingCategoriesLabel": "فئات التسوق",
|
||||||
"shoppingCategoriesHint": "إضافة الفئات أو إعادة تسميتها أو حذفها أو ترتيبها.",
|
"shoppingCategoriesHint": "إضافة الفئات أو إعادة تسميتها أو حذفها أو ترتيبها.",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "مزامنة التقويم",
|
"sectionCalendarSync": "مزامنة التقويم",
|
||||||
"sectionFamily": "أفراد العائلة",
|
"sectionFamily": "أفراد العائلة",
|
||||||
"cardAppearance": "المظهر",
|
"cardAppearance": "المظهر",
|
||||||
|
"appNameTitle": "اسم التطبيق",
|
||||||
|
"appNameLabel": "اسم التطبيق",
|
||||||
|
"appNameHint": "يظهر هذا الاسم في الشريط الجانبي وعنوان المتصفح وشاشة تسجيل الدخول.",
|
||||||
|
"appNamePlaceholder": "Oikos",
|
||||||
|
"appNameSavedToast": "تم حفظ اسم التطبيق.",
|
||||||
|
"sectionDate": "التاريخ",
|
||||||
|
"dateFormatTitle": "تنسيق التاريخ",
|
||||||
|
"dateFormatLabel": "تنسيق التاريخ المفضل",
|
||||||
|
"dateFormatHint": "اختر كيف تظهر التواريخ في التطبيق.",
|
||||||
|
"dateFormatSavedToast": "تم حفظ تنسيق التاريخ.",
|
||||||
"themeSystem": "النظام",
|
"themeSystem": "النظام",
|
||||||
"themeSysLabel": "استخدام إعداد النظام",
|
"themeSysLabel": "استخدام إعداد النظام",
|
||||||
"themeLight": "فاتح",
|
"themeLight": "فاتح",
|
||||||
|
|||||||
@@ -89,6 +89,19 @@
|
|||||||
"allDay": "Ganztägig",
|
"allDay": "Ganztägig",
|
||||||
"shoppingMore": "+{{count}} weitere",
|
"shoppingMore": "+{{count}} weitere",
|
||||||
"weather": "Wetter",
|
"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",
|
"customize": "Anpassen",
|
||||||
"customizeTitle": "Widgets anpassen",
|
"customizeTitle": "Widgets anpassen",
|
||||||
"customizeReset": "Standard",
|
"customizeReset": "Standard",
|
||||||
@@ -543,6 +556,7 @@
|
|||||||
"tabAccount": "Konto",
|
"tabAccount": "Konto",
|
||||||
"tabsAriaLabel": "Einstellungsbereiche",
|
"tabsAriaLabel": "Einstellungsbereiche",
|
||||||
"sectionDesign": "Design",
|
"sectionDesign": "Design",
|
||||||
|
"sectionAppName": "Anwendungsname",
|
||||||
"sectionShopping": "Einkauf",
|
"sectionShopping": "Einkauf",
|
||||||
"shoppingCategoriesLabel": "Einkaufskategorien",
|
"shoppingCategoriesLabel": "Einkaufskategorien",
|
||||||
"shoppingCategoriesHint": "Kategorien hinzufügen, umbenennen, löschen oder sortieren.",
|
"shoppingCategoriesHint": "Kategorien hinzufügen, umbenennen, löschen oder sortieren.",
|
||||||
@@ -560,6 +574,16 @@
|
|||||||
"sectionCalendarSync": "Kalender-Synchronisation",
|
"sectionCalendarSync": "Kalender-Synchronisation",
|
||||||
"sectionFamily": "Familienmitglieder",
|
"sectionFamily": "Familienmitglieder",
|
||||||
"cardAppearance": "Darstellung",
|
"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",
|
"themeSystem": "System",
|
||||||
"themeSysLabel": "System-Einstellung verwenden",
|
"themeSysLabel": "System-Einstellung verwenden",
|
||||||
"themeLight": "Hell",
|
"themeLight": "Hell",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "Όλη μέρα",
|
"allDay": "Όλη μέρα",
|
||||||
"shoppingMore": "+{{count}} ακόμα",
|
"shoppingMore": "+{{count}} ακόμα",
|
||||||
"weather": "Καιρός",
|
"weather": "Καιρός",
|
||||||
|
"familyMembers": "Μέλη οικογένειας",
|
||||||
|
"participantsAdded": "συμμετέχοντες προστέθηκαν",
|
||||||
|
"upcomingBirthdays": "Επόμενα γενέθλια",
|
||||||
|
"noBirthdays": "Δεν υπάρχουν γενέθλια ακόμα",
|
||||||
|
"daysLeft": "{{count}} ημέρες",
|
||||||
|
"budgetOverview": "Επισκόπηση προϋπολογισμού",
|
||||||
|
"monthlyIncome": "Έσοδα",
|
||||||
|
"monthlyExpenses": "Έξοδα",
|
||||||
|
"monthlyBalance": "Υπόλοιπο",
|
||||||
|
"savingsRate": "Ποσοστό αποταμίευσης",
|
||||||
|
"topExpense": "Μεγαλύτερο έξοδο",
|
||||||
|
"budgetEntries": "Καταχωρήσεις",
|
||||||
|
"noBudgetData": "Δεν υπάρχουν δεδομένα προϋπολογισμού αυτόν τον μήνα.",
|
||||||
"customize": "Προσαρμογή",
|
"customize": "Προσαρμογή",
|
||||||
"customizeTitle": "Προσαρμογή widgets",
|
"customizeTitle": "Προσαρμογή widgets",
|
||||||
"customizeReset": "Επαναφορά",
|
"customizeReset": "Επαναφορά",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "Λογαριασμός",
|
"tabAccount": "Λογαριασμός",
|
||||||
"tabsAriaLabel": "Τμήματα ρυθμίσεων",
|
"tabsAriaLabel": "Τμήματα ρυθμίσεων",
|
||||||
"sectionDesign": "Εμφάνιση",
|
"sectionDesign": "Εμφάνιση",
|
||||||
|
"sectionAppName": "Όνομα εφαρμογής",
|
||||||
"sectionShopping": "Αγορές",
|
"sectionShopping": "Αγορές",
|
||||||
"shoppingCategoriesLabel": "Κατηγορίες αγορών",
|
"shoppingCategoriesLabel": "Κατηγορίες αγορών",
|
||||||
"shoppingCategoriesHint": "Προσθέστε, μετονομάστε, διαγράψτε ή ταξινομήστε κατηγορίες.",
|
"shoppingCategoriesHint": "Προσθέστε, μετονομάστε, διαγράψτε ή ταξινομήστε κατηγορίες.",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "Συγχρονισμός ημερολογίου",
|
"sectionCalendarSync": "Συγχρονισμός ημερολογίου",
|
||||||
"sectionFamily": "Μέλη οικογένειας",
|
"sectionFamily": "Μέλη οικογένειας",
|
||||||
"cardAppearance": "Εμφάνιση",
|
"cardAppearance": "Εμφάνιση",
|
||||||
|
"appNameTitle": "Όνομα εφαρμογής",
|
||||||
|
"appNameLabel": "Όνομα εφαρμογής",
|
||||||
|
"appNameHint": "Αυτό το όνομα εμφανίζεται στην πλαϊνή μπάρα, στον τίτλο του προγράμματος περιήγησης και στην οθόνη σύνδεσης.",
|
||||||
|
"appNamePlaceholder": "Oikos",
|
||||||
|
"appNameSavedToast": "Το όνομα εφαρμογής αποθηκεύτηκε.",
|
||||||
|
"sectionDate": "Ημερομηνία",
|
||||||
|
"dateFormatTitle": "Μορφή ημερομηνίας",
|
||||||
|
"dateFormatLabel": "Προτιμώμενη μορφή ημερομηνίας",
|
||||||
|
"dateFormatHint": "Επιλέξτε πώς εμφανίζονται οι ημερομηνίες στην εφαρμογή.",
|
||||||
|
"dateFormatSavedToast": "Η μορφή ημερομηνίας αποθηκεύτηκε.",
|
||||||
"themeSystem": "Σύστημα",
|
"themeSystem": "Σύστημα",
|
||||||
"themeSysLabel": "Χρήση ρύθμισης συστήματος",
|
"themeSysLabel": "Χρήση ρύθμισης συστήματος",
|
||||||
"themeLight": "Ανοιχτό",
|
"themeLight": "Ανοιχτό",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "All day",
|
"allDay": "All day",
|
||||||
"shoppingMore": "+{{count}} more",
|
"shoppingMore": "+{{count}} more",
|
||||||
"weather": "Weather",
|
"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",
|
"customize": "Customize",
|
||||||
"customizeTitle": "Customize widgets",
|
"customizeTitle": "Customize widgets",
|
||||||
"customizeReset": "Reset",
|
"customizeReset": "Reset",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "Account",
|
"tabAccount": "Account",
|
||||||
"tabsAriaLabel": "Settings sections",
|
"tabsAriaLabel": "Settings sections",
|
||||||
"sectionDesign": "Appearance",
|
"sectionDesign": "Appearance",
|
||||||
|
"sectionAppName": "Application name",
|
||||||
"sectionShopping": "Shopping",
|
"sectionShopping": "Shopping",
|
||||||
"shoppingCategoriesLabel": "Shopping Categories",
|
"shoppingCategoriesLabel": "Shopping Categories",
|
||||||
"shoppingCategoriesHint": "Add, rename, delete or reorder categories.",
|
"shoppingCategoriesHint": "Add, rename, delete or reorder categories.",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "Calendar Sync",
|
"sectionCalendarSync": "Calendar Sync",
|
||||||
"sectionFamily": "Family Members",
|
"sectionFamily": "Family Members",
|
||||||
"cardAppearance": "Display",
|
"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",
|
"themeSystem": "System",
|
||||||
"themeSysLabel": "Use system setting",
|
"themeSysLabel": "Use system setting",
|
||||||
"themeLight": "Light",
|
"themeLight": "Light",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "Todo el día",
|
"allDay": "Todo el día",
|
||||||
"shoppingMore": "+{{count}} más",
|
"shoppingMore": "+{{count}} más",
|
||||||
"weather": "Clima",
|
"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",
|
"customize": "Personalizar",
|
||||||
"customizeTitle": "Personalizar widgets",
|
"customizeTitle": "Personalizar widgets",
|
||||||
"customizeReset": "Restablecer",
|
"customizeReset": "Restablecer",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "Cuenta",
|
"tabAccount": "Cuenta",
|
||||||
"tabsAriaLabel": "Secciones de configuración",
|
"tabsAriaLabel": "Secciones de configuración",
|
||||||
"sectionDesign": "Diseño",
|
"sectionDesign": "Diseño",
|
||||||
|
"sectionAppName": "Nombre de la aplicación",
|
||||||
"sectionShopping": "Compras",
|
"sectionShopping": "Compras",
|
||||||
"shoppingCategoriesLabel": "Categorías de compra",
|
"shoppingCategoriesLabel": "Categorías de compra",
|
||||||
"shoppingCategoriesHint": "Añade, renombra, elimina u ordena las categorías.",
|
"shoppingCategoriesHint": "Añade, renombra, elimina u ordena las categorías.",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "Sincronización de calendario",
|
"sectionCalendarSync": "Sincronización de calendario",
|
||||||
"sectionFamily": "Miembros de la familia",
|
"sectionFamily": "Miembros de la familia",
|
||||||
"cardAppearance": "Apariencia",
|
"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",
|
"themeSystem": "Sistema",
|
||||||
"themeSysLabel": "Usar configuración del sistema",
|
"themeSysLabel": "Usar configuración del sistema",
|
||||||
"themeLight": "Claro",
|
"themeLight": "Claro",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "Toute la journée",
|
"allDay": "Toute la journée",
|
||||||
"shoppingMore": "+{{count}} de plus",
|
"shoppingMore": "+{{count}} de plus",
|
||||||
"weather": "Météo",
|
"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",
|
"customize": "Personnaliser",
|
||||||
"customizeTitle": "Personnaliser les widgets",
|
"customizeTitle": "Personnaliser les widgets",
|
||||||
"customizeReset": "Réinitialiser",
|
"customizeReset": "Réinitialiser",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "Compte",
|
"tabAccount": "Compte",
|
||||||
"tabsAriaLabel": "Sections des paramètres",
|
"tabsAriaLabel": "Sections des paramètres",
|
||||||
"sectionDesign": "Apparence",
|
"sectionDesign": "Apparence",
|
||||||
|
"sectionAppName": "Nom de l'application",
|
||||||
"sectionShopping": "Courses",
|
"sectionShopping": "Courses",
|
||||||
"shoppingCategoriesLabel": "Catégories de courses",
|
"shoppingCategoriesLabel": "Catégories de courses",
|
||||||
"shoppingCategoriesHint": "Ajoutez, renommez, supprimez ou réorganisez les catégories.",
|
"shoppingCategoriesHint": "Ajoutez, renommez, supprimez ou réorganisez les catégories.",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "Synchronisation du calendrier",
|
"sectionCalendarSync": "Synchronisation du calendrier",
|
||||||
"sectionFamily": "Membres de la famille",
|
"sectionFamily": "Membres de la famille",
|
||||||
"cardAppearance": "Affichage",
|
"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",
|
"themeSystem": "Système",
|
||||||
"themeSysLabel": "Utiliser le paramètre système",
|
"themeSysLabel": "Utiliser le paramètre système",
|
||||||
"themeLight": "Clair",
|
"themeLight": "Clair",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "पूरे दिन",
|
"allDay": "पूरे दिन",
|
||||||
"shoppingMore": "+{{count}} और",
|
"shoppingMore": "+{{count}} और",
|
||||||
"weather": "मौसम",
|
"weather": "मौसम",
|
||||||
|
"familyMembers": "परिवार के सदस्य",
|
||||||
|
"participantsAdded": "प्रतिभागी जोड़े गए",
|
||||||
|
"upcomingBirthdays": "आने वाले जन्मदिन",
|
||||||
|
"noBirthdays": "अभी कोई जन्मदिन नहीं",
|
||||||
|
"daysLeft": "{{count}} दिन",
|
||||||
|
"budgetOverview": "बजट अवलोकन",
|
||||||
|
"monthlyIncome": "आय",
|
||||||
|
"monthlyExpenses": "खर्च",
|
||||||
|
"monthlyBalance": "शेष",
|
||||||
|
"savingsRate": "बचत दर",
|
||||||
|
"topExpense": "सबसे बड़ा खर्च",
|
||||||
|
"budgetEntries": "प्रविष्टियां",
|
||||||
|
"noBudgetData": "इस महीने बजट डेटा नहीं है।",
|
||||||
"customize": "अनुकूलित करें",
|
"customize": "अनुकूलित करें",
|
||||||
"customizeTitle": "विजेट अनुकूलित करें",
|
"customizeTitle": "विजेट अनुकूलित करें",
|
||||||
"customizeReset": "डिफ़ॉल्ट",
|
"customizeReset": "डिफ़ॉल्ट",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "खाता",
|
"tabAccount": "खाता",
|
||||||
"tabsAriaLabel": "सेटिंग्स अनुभाग",
|
"tabsAriaLabel": "सेटिंग्स अनुभाग",
|
||||||
"sectionDesign": "डिज़ाइन",
|
"sectionDesign": "डिज़ाइन",
|
||||||
|
"sectionAppName": "ऐप का नाम",
|
||||||
"sectionShopping": "खरीदारी",
|
"sectionShopping": "खरीदारी",
|
||||||
"shoppingCategoriesLabel": "खरीदारी श्रेणियां",
|
"shoppingCategoriesLabel": "खरीदारी श्रेणियां",
|
||||||
"shoppingCategoriesHint": "श्रेणियां जोड़ें, नाम बदलें, हटाएं या क्रम बदलें।",
|
"shoppingCategoriesHint": "श्रेणियां जोड़ें, नाम बदलें, हटाएं या क्रम बदलें।",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "कैलेंडर सिंक",
|
"sectionCalendarSync": "कैलेंडर सिंक",
|
||||||
"sectionFamily": "परिवार के सदस्य",
|
"sectionFamily": "परिवार के सदस्य",
|
||||||
"cardAppearance": "दिखावट",
|
"cardAppearance": "दिखावट",
|
||||||
|
"appNameTitle": "ऐप का नाम",
|
||||||
|
"appNameLabel": "ऐप का नाम",
|
||||||
|
"appNameHint": "यह नाम साइडबार, ब्राउज़र शीर्षक और लॉगिन स्क्रीन में दिखाई देगा।",
|
||||||
|
"appNamePlaceholder": "Oikos",
|
||||||
|
"appNameSavedToast": "ऐप का नाम सहेजा गया।",
|
||||||
|
"sectionDate": "तारीख",
|
||||||
|
"dateFormatTitle": "तारीख प्रारूप",
|
||||||
|
"dateFormatLabel": "पसंदीदा तारीख प्रारूप",
|
||||||
|
"dateFormatHint": "चुनें कि ऐप में तारीखें कैसे दिखाई दें।",
|
||||||
|
"dateFormatSavedToast": "तारीख प्रारूप सहेजा गया।",
|
||||||
"themeSystem": "सिस्टम",
|
"themeSystem": "सिस्टम",
|
||||||
"themeSysLabel": "सिस्टम सेटिंग का उपयोग करें",
|
"themeSysLabel": "सिस्टम सेटिंग का उपयोग करें",
|
||||||
"themeLight": "हल्का",
|
"themeLight": "हल्का",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "Tutto il giorno",
|
"allDay": "Tutto il giorno",
|
||||||
"shoppingMore": "+{{count}} altri",
|
"shoppingMore": "+{{count}} altri",
|
||||||
"weather": "Meteo",
|
"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",
|
"customize": "Personalizza",
|
||||||
"customizeTitle": "Personalizza widget",
|
"customizeTitle": "Personalizza widget",
|
||||||
"customizeReset": "Ripristina",
|
"customizeReset": "Ripristina",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "Account",
|
"tabAccount": "Account",
|
||||||
"tabsAriaLabel": "Sezioni impostazioni",
|
"tabsAriaLabel": "Sezioni impostazioni",
|
||||||
"sectionDesign": "Aspetto",
|
"sectionDesign": "Aspetto",
|
||||||
|
"sectionAppName": "Nome dell'applicazione",
|
||||||
"sectionShopping": "Spesa",
|
"sectionShopping": "Spesa",
|
||||||
"shoppingCategoriesLabel": "Categorie spesa",
|
"shoppingCategoriesLabel": "Categorie spesa",
|
||||||
"shoppingCategoriesHint": "Aggiungi, rinomina, elimina o riordina le categorie.",
|
"shoppingCategoriesHint": "Aggiungi, rinomina, elimina o riordina le categorie.",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "Sincronizzazione calendario",
|
"sectionCalendarSync": "Sincronizzazione calendario",
|
||||||
"sectionFamily": "Membri della famiglia",
|
"sectionFamily": "Membri della famiglia",
|
||||||
"cardAppearance": "Visualizzazione",
|
"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",
|
"themeSystem": "Sistema",
|
||||||
"themeSysLabel": "Usa impostazione di sistema",
|
"themeSysLabel": "Usa impostazione di sistema",
|
||||||
"themeLight": "Chiaro",
|
"themeLight": "Chiaro",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "終日",
|
"allDay": "終日",
|
||||||
"shoppingMore": "+{{count}} 件",
|
"shoppingMore": "+{{count}} 件",
|
||||||
"weather": "天気",
|
"weather": "天気",
|
||||||
|
"familyMembers": "家族メンバー",
|
||||||
|
"participantsAdded": "人が追加済み",
|
||||||
|
"upcomingBirthdays": "今後の誕生日",
|
||||||
|
"noBirthdays": "誕生日はまだありません",
|
||||||
|
"daysLeft": "{{count}}日",
|
||||||
|
"budgetOverview": "予算の概要",
|
||||||
|
"monthlyIncome": "収入",
|
||||||
|
"monthlyExpenses": "支出",
|
||||||
|
"monthlyBalance": "残高",
|
||||||
|
"savingsRate": "貯蓄率",
|
||||||
|
"topExpense": "最大の支出",
|
||||||
|
"budgetEntries": "記録",
|
||||||
|
"noBudgetData": "今月の予算データはありません。",
|
||||||
"customize": "カスタマイズ",
|
"customize": "カスタマイズ",
|
||||||
"customizeTitle": "ウィジェットのカスタマイズ",
|
"customizeTitle": "ウィジェットのカスタマイズ",
|
||||||
"customizeReset": "デフォルト",
|
"customizeReset": "デフォルト",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "アカウント",
|
"tabAccount": "アカウント",
|
||||||
"tabsAriaLabel": "設定カテゴリー",
|
"tabsAriaLabel": "設定カテゴリー",
|
||||||
"sectionDesign": "デザイン",
|
"sectionDesign": "デザイン",
|
||||||
|
"sectionAppName": "アプリ名",
|
||||||
"sectionShopping": "買い物",
|
"sectionShopping": "買い物",
|
||||||
"shoppingCategoriesLabel": "買い物カテゴリー",
|
"shoppingCategoriesLabel": "買い物カテゴリー",
|
||||||
"shoppingCategoriesHint": "カテゴリーの追加、名前変更、削除、並び替えができます。",
|
"shoppingCategoriesHint": "カテゴリーの追加、名前変更、削除、並び替えができます。",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "カレンダー同期",
|
"sectionCalendarSync": "カレンダー同期",
|
||||||
"sectionFamily": "家族メンバー",
|
"sectionFamily": "家族メンバー",
|
||||||
"cardAppearance": "外観",
|
"cardAppearance": "外観",
|
||||||
|
"appNameTitle": "アプリ名",
|
||||||
|
"appNameLabel": "アプリ名",
|
||||||
|
"appNameHint": "この名前はサイドバー、ブラウザのタイトル、ログイン画面に表示されます。",
|
||||||
|
"appNamePlaceholder": "Oikos",
|
||||||
|
"appNameSavedToast": "アプリ名を保存しました。",
|
||||||
|
"sectionDate": "日付",
|
||||||
|
"dateFormatTitle": "日付形式",
|
||||||
|
"dateFormatLabel": "希望する日付形式",
|
||||||
|
"dateFormatHint": "アプリ内で日付をどう表示するかを選択します。",
|
||||||
|
"dateFormatSavedToast": "日付形式を保存しました。",
|
||||||
"themeSystem": "システム設定",
|
"themeSystem": "システム設定",
|
||||||
"themeSysLabel": "システム設定を使用",
|
"themeSysLabel": "システム設定を使用",
|
||||||
"themeLight": "ライト",
|
"themeLight": "ライト",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "Dia inteiro",
|
"allDay": "Dia inteiro",
|
||||||
"shoppingMore": "+{{count}} mais",
|
"shoppingMore": "+{{count}} mais",
|
||||||
"weather": "Clima",
|
"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",
|
"customize": "Personalizar",
|
||||||
"customizeTitle": "Personalizar widgets",
|
"customizeTitle": "Personalizar widgets",
|
||||||
"customizeReset": "Padrão",
|
"customizeReset": "Padrão",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "Conta",
|
"tabAccount": "Conta",
|
||||||
"tabsAriaLabel": "Seções de configurações",
|
"tabsAriaLabel": "Seções de configurações",
|
||||||
"sectionDesign": "Design",
|
"sectionDesign": "Design",
|
||||||
|
"sectionAppName": "Nome da aplicação",
|
||||||
"sectionShopping": "Compras",
|
"sectionShopping": "Compras",
|
||||||
"shoppingCategoriesLabel": "Categorias de compras",
|
"shoppingCategoriesLabel": "Categorias de compras",
|
||||||
"shoppingCategoriesHint": "Adicione, renomeie, exclua ou ordene categorias.",
|
"shoppingCategoriesHint": "Adicione, renomeie, exclua ou ordene categorias.",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "Sincronização de calendário",
|
"sectionCalendarSync": "Sincronização de calendário",
|
||||||
"sectionFamily": "Membros da família",
|
"sectionFamily": "Membros da família",
|
||||||
"cardAppearance": "Aparência",
|
"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",
|
"themeSystem": "Sistema",
|
||||||
"themeSysLabel": "Usar configuração do sistema",
|
"themeSysLabel": "Usar configuração do sistema",
|
||||||
"themeLight": "Claro",
|
"themeLight": "Claro",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "Весь день",
|
"allDay": "Весь день",
|
||||||
"shoppingMore": "+{{count}} ещё",
|
"shoppingMore": "+{{count}} ещё",
|
||||||
"weather": "Погода",
|
"weather": "Погода",
|
||||||
|
"familyMembers": "Члены семьи",
|
||||||
|
"participantsAdded": "участников добавлено",
|
||||||
|
"upcomingBirthdays": "Ближайшие дни рождения",
|
||||||
|
"noBirthdays": "Дней рождения пока нет",
|
||||||
|
"daysLeft": "{{count}} дн.",
|
||||||
|
"budgetOverview": "Обзор бюджета",
|
||||||
|
"monthlyIncome": "Доходы",
|
||||||
|
"monthlyExpenses": "Расходы",
|
||||||
|
"monthlyBalance": "Баланс",
|
||||||
|
"savingsRate": "Норма сбережений",
|
||||||
|
"topExpense": "Крупнейший расход",
|
||||||
|
"budgetEntries": "Записи",
|
||||||
|
"noBudgetData": "Нет данных бюджета за этот месяц.",
|
||||||
"customize": "Настроить",
|
"customize": "Настроить",
|
||||||
"customizeTitle": "Настроить виджеты",
|
"customizeTitle": "Настроить виджеты",
|
||||||
"customizeReset": "Сбросить",
|
"customizeReset": "Сбросить",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "Аккаунт",
|
"tabAccount": "Аккаунт",
|
||||||
"tabsAriaLabel": "Разделы настроек",
|
"tabsAriaLabel": "Разделы настроек",
|
||||||
"sectionDesign": "Внешний вид",
|
"sectionDesign": "Внешний вид",
|
||||||
|
"sectionAppName": "Название приложения",
|
||||||
"sectionShopping": "Покупки",
|
"sectionShopping": "Покупки",
|
||||||
"shoppingCategoriesLabel": "Категории покупок",
|
"shoppingCategoriesLabel": "Категории покупок",
|
||||||
"shoppingCategoriesHint": "Добавляйте, переименовывайте, удаляйте или сортируйте категории.",
|
"shoppingCategoriesHint": "Добавляйте, переименовывайте, удаляйте или сортируйте категории.",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "Синхронизация календаря",
|
"sectionCalendarSync": "Синхронизация календаря",
|
||||||
"sectionFamily": "Члены семьи",
|
"sectionFamily": "Члены семьи",
|
||||||
"cardAppearance": "Отображение",
|
"cardAppearance": "Отображение",
|
||||||
|
"appNameTitle": "Название приложения",
|
||||||
|
"appNameLabel": "Название приложения",
|
||||||
|
"appNameHint": "Это название отображается в боковом меню, заголовке браузера и на экране входа.",
|
||||||
|
"appNamePlaceholder": "Oikos",
|
||||||
|
"appNameSavedToast": "Название приложения сохранено.",
|
||||||
|
"sectionDate": "Дата",
|
||||||
|
"dateFormatTitle": "Формат даты",
|
||||||
|
"dateFormatLabel": "Предпочитаемый формат даты",
|
||||||
|
"dateFormatHint": "Выберите, как даты отображаются в приложении.",
|
||||||
|
"dateFormatSavedToast": "Формат даты сохранён.",
|
||||||
"themeSystem": "Система",
|
"themeSystem": "Система",
|
||||||
"themeSysLabel": "Использовать системную настройку",
|
"themeSysLabel": "Использовать системную настройку",
|
||||||
"themeLight": "Светлая",
|
"themeLight": "Светлая",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "Hela dagen",
|
"allDay": "Hela dagen",
|
||||||
"shoppingMore": "+{{count}} till",
|
"shoppingMore": "+{{count}} till",
|
||||||
"weather": "Väder",
|
"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",
|
"customize": "Anpassa",
|
||||||
"customizeTitle": "Anpassa widgets",
|
"customizeTitle": "Anpassa widgets",
|
||||||
"customizeReset": "Återställ",
|
"customizeReset": "Återställ",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "Konto",
|
"tabAccount": "Konto",
|
||||||
"tabsAriaLabel": "Inställningsavsnitt",
|
"tabsAriaLabel": "Inställningsavsnitt",
|
||||||
"sectionDesign": "Utseende",
|
"sectionDesign": "Utseende",
|
||||||
|
"sectionAppName": "Appnamn",
|
||||||
"sectionShopping": "Inköp",
|
"sectionShopping": "Inköp",
|
||||||
"shoppingCategoriesLabel": "Inköpskategorier",
|
"shoppingCategoriesLabel": "Inköpskategorier",
|
||||||
"shoppingCategoriesHint": "Lägg till, byt namn, ta bort eller sortera om kategorier.",
|
"shoppingCategoriesHint": "Lägg till, byt namn, ta bort eller sortera om kategorier.",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "Kalendersynkronisering",
|
"sectionCalendarSync": "Kalendersynkronisering",
|
||||||
"sectionFamily": "Familjemedlemmar",
|
"sectionFamily": "Familjemedlemmar",
|
||||||
"cardAppearance": "Visa",
|
"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",
|
"themeSystem": "System",
|
||||||
"themeSysLabel": "Använd systeminställning",
|
"themeSysLabel": "Använd systeminställning",
|
||||||
"themeLight": "Ljus",
|
"themeLight": "Ljus",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "Tüm gün",
|
"allDay": "Tüm gün",
|
||||||
"shoppingMore": "+{{count}} daha",
|
"shoppingMore": "+{{count}} daha",
|
||||||
"weather": "Hava",
|
"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",
|
"customize": "Özelleştir",
|
||||||
"customizeTitle": "Widget'ları özelleştir",
|
"customizeTitle": "Widget'ları özelleştir",
|
||||||
"customizeReset": "Sıfırla",
|
"customizeReset": "Sıfırla",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "Hesap",
|
"tabAccount": "Hesap",
|
||||||
"tabsAriaLabel": "Ayar bölümleri",
|
"tabsAriaLabel": "Ayar bölümleri",
|
||||||
"sectionDesign": "Görünüm",
|
"sectionDesign": "Görünüm",
|
||||||
|
"sectionAppName": "Uygulama adı",
|
||||||
"sectionShopping": "Alışveriş",
|
"sectionShopping": "Alışveriş",
|
||||||
"shoppingCategoriesLabel": "Alışveriş Kategorileri",
|
"shoppingCategoriesLabel": "Alışveriş Kategorileri",
|
||||||
"shoppingCategoriesHint": "Kategorileri ekleyin, yeniden adlandırın, silin veya sıralayın.",
|
"shoppingCategoriesHint": "Kategorileri ekleyin, yeniden adlandırın, silin veya sıralayın.",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "Takvim Senkronizasyonu",
|
"sectionCalendarSync": "Takvim Senkronizasyonu",
|
||||||
"sectionFamily": "Aile Üyeleri",
|
"sectionFamily": "Aile Üyeleri",
|
||||||
"cardAppearance": "Görünüm",
|
"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",
|
"themeSystem": "Sistem",
|
||||||
"themeSysLabel": "Sistem ayarını kullan",
|
"themeSysLabel": "Sistem ayarını kullan",
|
||||||
"themeLight": "Açık",
|
"themeLight": "Açık",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "Весь день",
|
"allDay": "Весь день",
|
||||||
"shoppingMore": "+{{count}} ще",
|
"shoppingMore": "+{{count}} ще",
|
||||||
"weather": "Погода",
|
"weather": "Погода",
|
||||||
|
"familyMembers": "Члени родини",
|
||||||
|
"participantsAdded": "учасників додано",
|
||||||
|
"upcomingBirthdays": "Найближчі дні народження",
|
||||||
|
"noBirthdays": "Днів народження ще немає",
|
||||||
|
"daysLeft": "{{count}} дн.",
|
||||||
|
"budgetOverview": "Огляд бюджету",
|
||||||
|
"monthlyIncome": "Доходи",
|
||||||
|
"monthlyExpenses": "Витрати",
|
||||||
|
"monthlyBalance": "Баланс",
|
||||||
|
"savingsRate": "Рівень заощаджень",
|
||||||
|
"topExpense": "Найбільша витрата",
|
||||||
|
"budgetEntries": "Записи",
|
||||||
|
"noBudgetData": "Немає бюджетних даних за цей місяць.",
|
||||||
"customize": "Налаштувати",
|
"customize": "Налаштувати",
|
||||||
"customizeTitle": "Налаштувати віджети",
|
"customizeTitle": "Налаштувати віджети",
|
||||||
"customizeReset": "Скинути",
|
"customizeReset": "Скинути",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "Обліковий запис",
|
"tabAccount": "Обліковий запис",
|
||||||
"tabsAriaLabel": "Розділи налаштувань",
|
"tabsAriaLabel": "Розділи налаштувань",
|
||||||
"sectionDesign": "Зовнішній вигляд",
|
"sectionDesign": "Зовнішній вигляд",
|
||||||
|
"sectionAppName": "Назва застосунку",
|
||||||
"sectionShopping": "Покупки",
|
"sectionShopping": "Покупки",
|
||||||
"shoppingCategoriesLabel": "Категорії покупок",
|
"shoppingCategoriesLabel": "Категорії покупок",
|
||||||
"shoppingCategoriesHint": "Додавайте, перейменовуйте, видаляйте або змінюйте порядок категорій.",
|
"shoppingCategoriesHint": "Додавайте, перейменовуйте, видаляйте або змінюйте порядок категорій.",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "Синхронізація календаря",
|
"sectionCalendarSync": "Синхронізація календаря",
|
||||||
"sectionFamily": "Члени родини",
|
"sectionFamily": "Члени родини",
|
||||||
"cardAppearance": "Відображення",
|
"cardAppearance": "Відображення",
|
||||||
|
"appNameTitle": "Назва застосунку",
|
||||||
|
"appNameLabel": "Назва застосунку",
|
||||||
|
"appNameHint": "Ця назва відображається в бічному меню, заголовку браузера та на екрані входу.",
|
||||||
|
"appNamePlaceholder": "Oikos",
|
||||||
|
"appNameSavedToast": "Назву застосунку збережено.",
|
||||||
|
"sectionDate": "Дата",
|
||||||
|
"dateFormatTitle": "Формат дати",
|
||||||
|
"dateFormatLabel": "Бажаний формат дати",
|
||||||
|
"dateFormatHint": "Виберіть, як дати відображаються в застосунку.",
|
||||||
|
"dateFormatSavedToast": "Формат дати збережено.",
|
||||||
"themeSystem": "Системна",
|
"themeSystem": "Системна",
|
||||||
"themeSysLabel": "Використовувати системні налаштування",
|
"themeSysLabel": "Використовувати системні налаштування",
|
||||||
"themeLight": "Світла",
|
"themeLight": "Світла",
|
||||||
|
|||||||
@@ -83,6 +83,19 @@
|
|||||||
"allDay": "全天",
|
"allDay": "全天",
|
||||||
"shoppingMore": "+{{count}} 更多",
|
"shoppingMore": "+{{count}} 更多",
|
||||||
"weather": "天气",
|
"weather": "天气",
|
||||||
|
"familyMembers": "家庭成员",
|
||||||
|
"participantsAdded": "位参与者已添加",
|
||||||
|
"upcomingBirthdays": "即将到来的生日",
|
||||||
|
"noBirthdays": "还没有生日",
|
||||||
|
"daysLeft": "{{count}} 天",
|
||||||
|
"budgetOverview": "预算概览",
|
||||||
|
"monthlyIncome": "收入",
|
||||||
|
"monthlyExpenses": "支出",
|
||||||
|
"monthlyBalance": "余额",
|
||||||
|
"savingsRate": "储蓄率",
|
||||||
|
"topExpense": "最大支出",
|
||||||
|
"budgetEntries": "记录",
|
||||||
|
"noBudgetData": "本月没有预算数据。",
|
||||||
"customize": "自定义",
|
"customize": "自定义",
|
||||||
"customizeTitle": "自定义小组件",
|
"customizeTitle": "自定义小组件",
|
||||||
"customizeReset": "重置",
|
"customizeReset": "重置",
|
||||||
@@ -537,6 +550,7 @@
|
|||||||
"tabAccount": "账户",
|
"tabAccount": "账户",
|
||||||
"tabsAriaLabel": "设置类别",
|
"tabsAriaLabel": "设置类别",
|
||||||
"sectionDesign": "外观",
|
"sectionDesign": "外观",
|
||||||
|
"sectionAppName": "应用名称",
|
||||||
"sectionShopping": "购物",
|
"sectionShopping": "购物",
|
||||||
"shoppingCategoriesLabel": "购物分类",
|
"shoppingCategoriesLabel": "购物分类",
|
||||||
"shoppingCategoriesHint": "添加、重命名、删除或排序分类。",
|
"shoppingCategoriesHint": "添加、重命名、删除或排序分类。",
|
||||||
@@ -554,6 +568,16 @@
|
|||||||
"sectionCalendarSync": "日历同步",
|
"sectionCalendarSync": "日历同步",
|
||||||
"sectionFamily": "家庭成员",
|
"sectionFamily": "家庭成员",
|
||||||
"cardAppearance": "外观",
|
"cardAppearance": "外观",
|
||||||
|
"appNameTitle": "应用名称",
|
||||||
|
"appNameLabel": "应用名称",
|
||||||
|
"appNameHint": "此名称会显示在侧边栏、浏览器标题和登录界面中。",
|
||||||
|
"appNamePlaceholder": "Oikos",
|
||||||
|
"appNameSavedToast": "应用名称已保存。",
|
||||||
|
"sectionDate": "日期",
|
||||||
|
"dateFormatTitle": "日期格式",
|
||||||
|
"dateFormatLabel": "首选日期格式",
|
||||||
|
"dateFormatHint": "选择日期在应用中的显示方式。",
|
||||||
|
"dateFormatSavedToast": "日期格式已保存。",
|
||||||
"themeSystem": "跟随系统",
|
"themeSystem": "跟随系统",
|
||||||
"themeSysLabel": "使用系统设置",
|
"themeSysLabel": "使用系统设置",
|
||||||
"themeLight": "浅色",
|
"themeLight": "浅色",
|
||||||
|
|||||||
+44
-26
@@ -35,11 +35,11 @@ function photoAvatar(birthday, extraClass = '') {
|
|||||||
|
|
||||||
function filteredBirthdays() {
|
function filteredBirthdays() {
|
||||||
const q = state.query.trim().toLowerCase();
|
const q = state.query.trim().toLowerCase();
|
||||||
if (!q) return state.birthdays;
|
const list = !q ? state.birthdays : state.birthdays.filter((birthday) =>
|
||||||
return state.birthdays.filter((birthday) =>
|
|
||||||
birthday.name.toLowerCase().includes(q) ||
|
birthday.name.toLowerCase().includes(q) ||
|
||||||
(birthday.notes || '').toLowerCase().includes(q)
|
(birthday.notes || '').toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
|
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
function suggestions() {
|
function suggestions() {
|
||||||
@@ -94,8 +94,15 @@ function renderUpcoming() {
|
|||||||
<article class="birthday-card">
|
<article class="birthday-card">
|
||||||
<div class="birthday-card__media">${photoAvatar(birthday)}</div>
|
<div class="birthday-card__media">${photoAvatar(birthday)}</div>
|
||||||
<div class="birthday-card__body">
|
<div class="birthday-card__body">
|
||||||
<div class="birthday-card__name">${esc(birthday.name)}</div>
|
<div class="birthday-card__top">
|
||||||
<div class="birthday-card__date">${esc(formatDate(birthday.next_birthday))}</div>
|
<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 class="birthday-card__note">${esc(ageNote(birthday))}</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -146,33 +153,42 @@ function renderPage() {
|
|||||||
<div class="birthdays-page">
|
<div class="birthdays-page">
|
||||||
<h1 class="sr-only">${t('birthdays.title')}</h1>
|
<h1 class="sr-only">${t('birthdays.title')}</h1>
|
||||||
<div class="birthdays-toolbar">
|
<div class="birthdays-toolbar">
|
||||||
<div class="birthdays-toolbar__search">
|
<div class="birthdays-toolbar__title">
|
||||||
<i data-lucide="search" class="birthdays-toolbar__search-icon" aria-hidden="true"></i>
|
<i data-lucide="cake" class="birthdays-toolbar__title-icon" aria-hidden="true"></i>
|
||||||
<input type="search" class="birthdays-toolbar__search-input" id="birthdays-search"
|
<span>${t('birthdays.title')}</span>
|
||||||
placeholder="${t('birthdays.searchPlaceholder')}" autocomplete="off" value="${esc(state.query)}">
|
|
||||||
<div class="autocomplete-dropdown birthdays-autocomplete" id="birthdays-autocomplete" hidden></div>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--primary" id="birthdays-add-btn">
|
<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>
|
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
||||||
${t('birthdays.addButton')}
|
${t('birthdays.addButton')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="birthdays-toolbar__subtitle">${t('birthdays.calendarHint')}</p>
|
||||||
|
|
||||||
<section class="birthdays-section birthdays-section--hero">
|
<div class="birthdays-grid">
|
||||||
<div class="birthdays-section__header">
|
<aside class="birthdays-panel birthdays-panel--upcoming">
|
||||||
<h2>${t('birthdays.upcomingTitle')}</h2>
|
<div class="birthdays-section__header">
|
||||||
<p>${t('birthdays.upcomingHint')}</p>
|
<h3>${t('birthdays.upcomingTitle')}</h3>
|
||||||
</div>
|
<p>${t('birthdays.upcomingHint')}</p>
|
||||||
<div class="birthday-cards" id="birthdays-upcoming"></div>
|
</div>
|
||||||
</section>
|
<div class="birthday-cards" id="birthdays-upcoming"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<section class="birthdays-section birthdays-section--list">
|
<section class="birthdays-panel birthdays-panel--list">
|
||||||
<div class="birthdays-section__header">
|
<div class="birthdays-toolbar birthdays-toolbar--embedded">
|
||||||
<h2>${t('birthdays.peopleTitle')}</h2>
|
<div class="birthdays-toolbar__search">
|
||||||
<p>${t('birthdays.peopleHint')}</p>
|
<i data-lucide="search" class="birthdays-toolbar__search-icon" aria-hidden="true"></i>
|
||||||
</div>
|
<input type="search" class="birthdays-toolbar__search-input" id="birthdays-search"
|
||||||
<div class="birthdays-list" id="birthdays-list"></div>
|
placeholder="${t('birthdays.searchPlaceholder')}" autocomplete="off" value="${esc(state.query)}">
|
||||||
</section>
|
<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')}">
|
<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>
|
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||||||
@@ -337,7 +353,7 @@ function openBirthdayModal({ mode, birthday = null }) {
|
|||||||
state.birthdays.push(res.data);
|
state.birthdays.push(res.data);
|
||||||
window.oikos?.showToast(t('birthdays.createdToast'), 'success');
|
window.oikos?.showToast(t('birthdays.createdToast'), 'success');
|
||||||
}
|
}
|
||||||
state.birthdays.sort((a, b) => a.days_until - b.days_until || a.name.localeCompare(b.name));
|
state.birthdays.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
const upcomingRes = await api.get('/birthdays/upcoming?limit=4');
|
const upcomingRes = await api.get('/birthdays/upcoming?limit=4');
|
||||||
state.upcoming = upcomingRes.data ?? [];
|
state.upcoming = upcomingRes.data ?? [];
|
||||||
renderUpcoming();
|
renderUpcoming();
|
||||||
@@ -356,7 +372,9 @@ function openBirthdayModal({ mode, birthday = null }) {
|
|||||||
async function deleteBirthday(id, name) {
|
async function deleteBirthday(id, name) {
|
||||||
if (!await confirmModal(t('birthdays.deleteConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return;
|
if (!await confirmModal(t('birthdays.deleteConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return;
|
||||||
await api.delete(`/birthdays/${id}`);
|
await api.delete(`/birthdays/${id}`);
|
||||||
state.birthdays = state.birthdays.filter((birthday) => birthday.id !== 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);
|
state.upcoming = state.upcoming.filter((birthday) => birthday.id !== id);
|
||||||
renderUpcoming();
|
renderUpcoming();
|
||||||
renderSuggestions();
|
renderSuggestions();
|
||||||
|
|||||||
+377
-104
@@ -16,7 +16,7 @@ let _fabController = null;
|
|||||||
// Widget-Definitionen (Reihenfolge = Standard-Layout)
|
// 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 }));
|
const DEFAULT_WIDGET_CONFIG = WIDGET_IDS.map((id) => ({ id, visible: true }));
|
||||||
|
|
||||||
@@ -28,15 +28,34 @@ function widgetLabel(id) {
|
|||||||
meals: () => t('nav.meals'),
|
meals: () => t('nav.meals'),
|
||||||
notes: () => t('nav.notes'),
|
notes: () => t('nav.notes'),
|
||||||
weather: () => t('dashboard.weather'),
|
weather: () => t('dashboard.weather'),
|
||||||
|
birthdays: () => t('nav.birthdays'),
|
||||||
|
budget: () => t('nav.budget'),
|
||||||
|
family: () => t('dashboard.familyMembers'),
|
||||||
};
|
};
|
||||||
return (map[id] ?? (() => id))();
|
return (map[id] ?? (() => id))();
|
||||||
}
|
}
|
||||||
|
|
||||||
function widgetIcon(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';
|
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
|
// Hilfsfunktionen
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -131,6 +150,19 @@ function initials(name = '') {
|
|||||||
return name.split(' ').map((w) => w[0]).join('').slice(0, 2).toUpperCase();
|
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) {
|
function widgetHeader(icon, title, count, linkHref, linkLabel) {
|
||||||
linkLabel = linkLabel ?? t('dashboard.allLink');
|
linkLabel = linkLabel ?? t('dashboard.allLink');
|
||||||
const badge = count != null
|
const badge = count != null
|
||||||
@@ -170,51 +202,6 @@ function skeletonWidget(lines = 3) {
|
|||||||
// Widget-Renderer
|
// 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) {
|
function renderUrgentTasks(tasks) {
|
||||||
if (!tasks.length) {
|
if (!tasks.length) {
|
||||||
return `<div class="widget widget--tasks">
|
return `<div class="widget widget--tasks">
|
||||||
@@ -288,6 +275,43 @@ function renderUpcomingEvents(events) {
|
|||||||
</div>`;
|
</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) {
|
function renderTodayMeals(meals) {
|
||||||
const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack'];
|
const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||||||
|
|
||||||
@@ -334,6 +358,283 @@ function renderPinnedNotes(notes) {
|
|||||||
</div>`;
|
</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
|
// Shopping-Widget
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -515,25 +816,6 @@ function initFab(container, signal) {
|
|||||||
document.addEventListener('click', () => { if (open) toggleFab(false); }, { 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
|
// Customize-Modal
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -728,20 +1010,17 @@ export async function render(container, { user }) {
|
|||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="dashboard">
|
<div class="dashboard">
|
||||||
<h1 class="sr-only">${t('dashboard.title')}</h1>
|
<h1 class="sr-only">${t('dashboard.title')}</h1>
|
||||||
<div class="dashboard__grid">
|
<div class="dashboard-shell" id="dashboard-shell">
|
||||||
${renderGreeting(user, {})}
|
${renderDashboardSkeleton()}
|
||||||
${skeletonWidget(3)}
|
|
||||||
${skeletonWidget(3)}
|
|
||||||
${skeletonWidget(2)}
|
|
||||||
${skeletonWidget(3)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${renderFab()}
|
${renderFab()}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] };
|
let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [], birthdays: [], users: [], budget: {} };
|
||||||
let weather = null;
|
let weather = null;
|
||||||
let widgetConfig = DEFAULT_WIDGET_CONFIG;
|
let widgetConfig = DEFAULT_WIDGET_CONFIG;
|
||||||
|
let currency = 'EUR';
|
||||||
try {
|
try {
|
||||||
const [dashRes, weatherRes, prefsRes] = await Promise.all([
|
const [dashRes, weatherRes, prefsRes] = await Promise.all([
|
||||||
api.get('/dashboard'),
|
api.get('/dashboard'),
|
||||||
@@ -751,6 +1030,7 @@ export async function render(container, { user }) {
|
|||||||
data = dashRes;
|
data = dashRes;
|
||||||
weather = weatherRes.data ?? null;
|
weather = weatherRes.data ?? null;
|
||||||
widgetConfig = prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG;
|
widgetConfig = prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG;
|
||||||
|
currency = prefsRes.data?.currency ?? 'EUR';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Dashboard] Ladefehler:', err.message, 'Status:', err.status ?? 'network');
|
console.error('[Dashboard] Ladefehler:', err.message, 'Status:', err.status ?? 'network');
|
||||||
window.oikos?.showToast(t('dashboard.loadError'), 'warning');
|
window.oikos?.showToast(t('dashboard.loadError'), 'warning');
|
||||||
@@ -772,52 +1052,45 @@ export async function render(container, { user }) {
|
|||||||
todayMealTitle: (data.todayMeals ?? []).find((m) => m.meal_type === 'lunch')?.title
|
todayMealTitle: (data.todayMeals ?? []).find((m) => m.meal_type === 'lunch')?.title
|
||||||
?? (data.todayMeals ?? [])[0]?.title
|
?? (data.todayMeals ?? [])[0]?.title
|
||||||
?? null,
|
?? null,
|
||||||
|
birthdayCount: data.birthdayCount ?? (data.birthdays ?? []).length,
|
||||||
|
familyCount: (data.users ?? []).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
const rerender = () => render(container, { user });
|
const rerender = () => render(container, { user });
|
||||||
|
|
||||||
function rebuildGrid(cfg) {
|
function rebuildDashboard(cfg) {
|
||||||
const grid = container.querySelector('.dashboard__grid');
|
const shell = container.querySelector('#dashboard-shell');
|
||||||
if (!grid) return;
|
if (!shell) return;
|
||||||
const greeting = grid.querySelector('.widget-greeting');
|
shell.innerHTML = `
|
||||||
grid.replaceChildren(...(greeting ? [greeting] : []));
|
${renderDashboardOverview(user, stats, weather)}
|
||||||
grid.insertAdjacentHTML('beforeend', renderWidgets(cfg, data, weather));
|
${renderDashboardLayout(cfg, data, weather, currency)}
|
||||||
|
`;
|
||||||
wireLinks(container, rerender);
|
wireLinks(container, rerender);
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
wireWeatherRefresh(container);
|
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
|
rebuildDashboard(widgetConfig);
|
||||||
const greetingEl = container.querySelector('.widget-greeting');
|
|
||||||
if (greetingEl) greetingEl.outerHTML = renderGreeting(user, stats);
|
|
||||||
|
|
||||||
// Skeletons durch echte Widgets ersetzen
|
|
||||||
rebuildGrid(widgetConfig);
|
|
||||||
|
|
||||||
initFab(container, _fabController.signal);
|
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
|
// 30-Minuten Auto-Refresh für Wetter
|
||||||
const refreshBtn = container.querySelector('#weather-refresh-btn');
|
const refreshBtn = container.querySelector('#weather-refresh-btn');
|
||||||
if (refreshBtn) {
|
if (refreshBtn) {
|
||||||
const doAutoRefresh = async () => {
|
const doAutoRefresh = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/weather').catch(() => ({ data: null }));
|
const res = await api.get('/weather').catch(() => ({ data: null }));
|
||||||
const wWidget = container.querySelector('#weather-widget');
|
weather = res.data ?? null;
|
||||||
if (wWidget) {
|
rebuildDashboard(widgetConfig);
|
||||||
wWidget.outerHTML = renderWeatherWidget(res.data ?? null);
|
|
||||||
const newWidget = container.querySelector('#weather-widget');
|
|
||||||
if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
|
|
||||||
wireWeatherRefresh(container);
|
|
||||||
}
|
|
||||||
} catch { /* silently ignore */ }
|
} catch { /* silently ignore */ }
|
||||||
};
|
};
|
||||||
const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000);
|
const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000);
|
||||||
@@ -825,7 +1098,7 @@ export async function render(container, { user }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireWeatherRefresh(container) {
|
function wireWeatherRefresh(container, onUpdated = null) {
|
||||||
const refreshBtn = container.querySelector('#weather-refresh-btn');
|
const refreshBtn = container.querySelector('#weather-refresh-btn');
|
||||||
if (!refreshBtn) return;
|
if (!refreshBtn) return;
|
||||||
const doWeatherRefresh = async () => {
|
const doWeatherRefresh = async () => {
|
||||||
@@ -838,7 +1111,7 @@ function wireWeatherRefresh(container) {
|
|||||||
wWidget.outerHTML = renderWeatherWidget(res.data ?? null);
|
wWidget.outerHTML = renderWeatherWidget(res.data ?? null);
|
||||||
const newWidget = container.querySelector('#weather-widget');
|
const newWidget = container.querySelector('#weather-widget');
|
||||||
if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
|
if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget });
|
||||||
wireWeatherRefresh(container);
|
onUpdated?.(res.data ?? null);
|
||||||
window.oikos?.showToast(t('dashboard.weatherUpdated'), 'success', 1500);
|
window.oikos?.showToast(t('dashboard.weatherUpdated'), 'success', 1500);
|
||||||
}
|
}
|
||||||
} catch { /* silently ignore */ }
|
} catch { /* silently ignore */ }
|
||||||
|
|||||||
+25
-3
@@ -8,16 +8,30 @@ import { auth } from '/api.js';
|
|||||||
import { t } from '/i18n.js';
|
import { t } from '/i18n.js';
|
||||||
|
|
||||||
const VERSION_URL = '/api/v1/version';
|
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.
|
* Rendert die Login-Seite in den gegebenen Container.
|
||||||
* @param {HTMLElement} container
|
* @param {HTMLElement} container
|
||||||
*/
|
*/
|
||||||
export async function render(container) {
|
export async function render(container) {
|
||||||
|
const storedAppName = getStoredAppName();
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<main class="login-page" id="main-content">
|
<main class="login-page" id="main-content">
|
||||||
<div class="login-hero">
|
<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>
|
<p class="login-hero__tagline">${t('login.tagline')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="login-card card card--padded">
|
<div class="login-card card card--padded">
|
||||||
@@ -67,9 +81,17 @@ export async function render(container) {
|
|||||||
const submitBtn = container.querySelector('#login-btn');
|
const submitBtn = container.querySelector('#login-btn');
|
||||||
const versionEl = container.querySelector('#login-version');
|
const versionEl = container.querySelector('#login-version');
|
||||||
|
|
||||||
fetch(VERSION_URL)
|
setAppBranding(storedAppName);
|
||||||
|
|
||||||
|
fetch(VERSION_URL, { cache: 'no-store' })
|
||||||
.then((r) => r.json())
|
.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(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
|
|||||||
+83
-5
@@ -19,10 +19,10 @@ const ROUTES = [
|
|||||||
{ path: '/shopping', page: '/pages/shopping.js', requiresAuth: true, module: 'shopping' },
|
{ path: '/shopping', page: '/pages/shopping.js', requiresAuth: true, module: 'shopping' },
|
||||||
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true, module: 'meals' },
|
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true, module: 'meals' },
|
||||||
{ path: '/calendar', page: '/pages/calendar.js', requiresAuth: true, module: 'calendar' },
|
{ 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: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' },
|
||||||
{ path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' },
|
{ path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' },
|
||||||
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
|
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
|
||||||
{ path: '/birthdays', page: '/pages/birthdays.js', requiresAuth: true, module: 'birthdays' },
|
|
||||||
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' },
|
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' },
|
||||||
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' },
|
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' },
|
||||||
];
|
];
|
||||||
@@ -117,6 +117,7 @@ async function importPage(pagePath) {
|
|||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let currentPath = null;
|
let currentPath = null;
|
||||||
let isNavigating = false;
|
let isNavigating = false;
|
||||||
|
let _preferencesLoaded = false;
|
||||||
// Gesetzt wenn auth:expired waehrend einer laufenden Navigation feuert.
|
// Gesetzt wenn auth:expired waehrend einer laufenden Navigation feuert.
|
||||||
// Die Weiterleitung zu /login wird nach Abschluss der Navigation nachgeholt.
|
// Die Weiterleitung zu /login wird nach Abschluss der Navigation nachgeholt.
|
||||||
let _pendingLoginRedirect = false;
|
let _pendingLoginRedirect = false;
|
||||||
@@ -125,11 +126,14 @@ let _pendingLoginRedirect = false;
|
|||||||
// Router
|
// Router
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/recipes', '/shopping',
|
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/birthdays', '/meals', '/recipes', '/shopping',
|
||||||
'/notes', '/contacts', '/birthdays', '/budget', '/settings'];
|
'/notes', '/contacts', '/budget', '/settings'];
|
||||||
|
|
||||||
const PRIMARY_NAV = 4;
|
const PRIMARY_NAV = 4;
|
||||||
|
|
||||||
|
const DEFAULT_APP_NAME = 'Oikos';
|
||||||
|
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
|
||||||
|
|
||||||
function getDirection(fromPath, toPath) {
|
function getDirection(fromPath, toPath) {
|
||||||
const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/');
|
const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/');
|
||||||
const toIdx = ROUTE_ORDER.indexOf(toPath);
|
const toIdx = ROUTE_ORDER.indexOf(toPath);
|
||||||
@@ -137,6 +141,53 @@ function getDirection(fromPath, toPath) {
|
|||||||
return toIdx > fromIdx ? 'right' : 'left';
|
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.
|
* Navigiert zu einem Pfad und rendert die entsprechende Seite.
|
||||||
* @param {string} path
|
* @param {string} path
|
||||||
@@ -152,6 +203,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
|
|||||||
// Überlastung: navigate(path, user) nach Login vs navigate(path, false) beim Init
|
// Überlastung: navigate(path, user) nach Login vs navigate(path, false) beim Init
|
||||||
if (typeof userOrPushState === 'object' && userOrPushState !== null) {
|
if (typeof userOrPushState === 'object' && userOrPushState !== null) {
|
||||||
currentUser = userOrPushState;
|
currentUser = userOrPushState;
|
||||||
|
await syncPreferencesOnce();
|
||||||
initReminders();
|
initReminders();
|
||||||
} else {
|
} else {
|
||||||
pushState = userOrPushState;
|
pushState = userOrPushState;
|
||||||
@@ -169,6 +221,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
|
|||||||
try {
|
try {
|
||||||
const result = await auth.me();
|
const result = await auth.me();
|
||||||
currentUser = result.user;
|
currentUser = result.user;
|
||||||
|
await syncPreferencesOnce();
|
||||||
initReminders();
|
initReminders();
|
||||||
} catch {
|
} catch {
|
||||||
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
|
currentPath = null; // Reset damit navigate('/login') nicht geblockt wird
|
||||||
@@ -199,6 +252,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
|
|||||||
await renderPage(route, previousPath);
|
await renderPage(route, previousPath);
|
||||||
updateNav(basePath);
|
updateNav(basePath);
|
||||||
updateThemeColorForRoute(route);
|
updateThemeColorForRoute(route);
|
||||||
|
updateBranding(basePath);
|
||||||
} finally {
|
} finally {
|
||||||
isNavigating = false;
|
isNavigating = false;
|
||||||
// auth:expired kann waehrend einer Navigation gefeuert haben (z.B. wenn ein
|
// auth:expired kann waehrend einer Navigation gefeuert haben (z.B. wenn ein
|
||||||
@@ -211,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.
|
* Lädt und rendert eine Seite dynamisch.
|
||||||
* @param {{ path: string, page: string }} route
|
* @param {{ path: string, page: string }} route
|
||||||
@@ -345,7 +417,7 @@ function renderAppShell(container) {
|
|||||||
sidebarLogo.appendChild(logomark);
|
sidebarLogo.appendChild(logomark);
|
||||||
|
|
||||||
const sidebarLogoSpan = document.createElement('span');
|
const sidebarLogoSpan = document.createElement('span');
|
||||||
sidebarLogoSpan.textContent = 'Oikos';
|
sidebarLogoSpan.textContent = getAppName();
|
||||||
sidebarLogo.appendChild(sidebarLogoSpan);
|
sidebarLogo.appendChild(sidebarLogoSpan);
|
||||||
const sidebarItems = document.createElement('div');
|
const sidebarItems = document.createElement('div');
|
||||||
sidebarItems.className = 'nav-sidebar__items';
|
sidebarItems.className = 'nav-sidebar__items';
|
||||||
@@ -443,6 +515,7 @@ function renderAppShell(container) {
|
|||||||
toastContainer.setAttribute('aria-live', 'assertive');
|
toastContainer.setAttribute('aria-live', 'assertive');
|
||||||
|
|
||||||
container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer);
|
container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer);
|
||||||
|
updateBranding(currentPath || '/');
|
||||||
|
|
||||||
// Klick-Handler für alle Nav-Links
|
// Klick-Handler für alle Nav-Links
|
||||||
container.querySelectorAll('[data-route]').forEach((el) => {
|
container.querySelectorAll('[data-route]').forEach((el) => {
|
||||||
@@ -646,13 +719,13 @@ function navItems() {
|
|||||||
return [
|
return [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard' },
|
{ path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard' },
|
||||||
{ path: '/tasks', label: t('nav.tasks'), icon: 'check-square' },
|
{ 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: '/calendar', label: t('nav.calendar'), icon: 'calendar' },
|
||||||
{ path: '/meals', label: t('nav.meals'), icon: 'utensils' },
|
{ path: '/meals', label: t('nav.meals'), icon: 'utensils' },
|
||||||
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-text' },
|
{ path: '/recipes', label: t('nav.recipes'), icon: 'book-text' },
|
||||||
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
|
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
|
||||||
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
|
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
|
||||||
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
|
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
|
||||||
{ path: '/birthdays', label: t('nav.birthdays'), icon: 'cake' },
|
|
||||||
{ path: '/budget', label: t('nav.budget'), icon: 'wallet' },
|
{ path: '/budget', label: t('nav.budget'), icon: 'wallet' },
|
||||||
{ path: '/settings', label: t('nav.settings'), icon: 'settings' },
|
{ path: '/settings', label: t('nav.settings'), icon: 'settings' },
|
||||||
];
|
];
|
||||||
@@ -876,6 +949,11 @@ window.addEventListener('locale-changed', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateNav(currentPath);
|
updateNav(currentPath);
|
||||||
|
updateBranding(currentPath || '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('app-name-changed', () => {
|
||||||
|
updateBranding(currentPath || '/');
|
||||||
});
|
});
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|||||||
@@ -9,6 +9,36 @@
|
|||||||
padding-bottom: calc(var(--nav-bottom-height) + var(--space-6));
|
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 {
|
.birthdays-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -19,6 +49,35 @@
|
|||||||
background: var(--color-surface);
|
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 {
|
.birthdays-toolbar__search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -48,7 +107,8 @@
|
|||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.birthdays-section__header h2 {
|
.birthdays-section__header h2,
|
||||||
|
.birthdays-section__header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
}
|
}
|
||||||
@@ -61,16 +121,14 @@
|
|||||||
|
|
||||||
.birthday-cards {
|
.birthday-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
grid-template-columns: 1fr;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-top: var(--space-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.birthday-card,
|
.birthday-card,
|
||||||
.birthday-item {
|
.birthday-item {
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-left: 3px solid var(--module-accent);
|
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
@@ -80,6 +138,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-4);
|
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-card__body,
|
||||||
@@ -88,6 +147,13 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.birthday-card__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
.birthday-card__name,
|
.birthday-card__name,
|
||||||
.birthday-item__name {
|
.birthday-item__name {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
@@ -108,6 +174,16 @@
|
|||||||
font-size: var(--text-sm);
|
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 {
|
.birthday-item__notes {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
@@ -116,7 +192,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
margin-top: var(--space-3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.birthday-item {
|
.birthday-item {
|
||||||
@@ -233,6 +308,15 @@
|
|||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.birthdays-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.birthdays-panel--upcoming {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.contact-action-btn {
|
.contact-action-btn {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+16
-3
@@ -7,9 +7,11 @@
|
|||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('/sw.js').catch((err) => {
|
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
|
||||||
console.warn('[SW] Registrierung fehlgeschlagen:', err);
|
.then((registration) => registration.update())
|
||||||
});
|
.catch((err) => {
|
||||||
|
console.warn('[SW] Registrierung fehlgeschlagen:', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// SW-Update: Auf iOS-PWA fuehrt ein sofortiger Reload bei controllerchange
|
// 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.
|
// Auf iOS-Standalone verhindert das den "leere Seite"-Bug.
|
||||||
setTimeout(() => window.location.reload(), 200);
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-13
@@ -13,11 +13,12 @@
|
|||||||
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
|
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SHELL_CACHE = 'oikos-shell-v53';
|
const SHELL_CACHE = 'oikos-shell-v56';
|
||||||
const PAGES_CACHE = 'oikos-pages-v48';
|
const PAGES_CACHE = 'oikos-pages-v51';
|
||||||
const ASSETS_CACHE = 'oikos-assets-v48';
|
const LOCALES_CACHE = 'oikos-locales-v3';
|
||||||
|
const ASSETS_CACHE = 'oikos-assets-v51';
|
||||||
const BYPASS_CACHE = 'oikos-bypass-flag';
|
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
|
// App-Shell: sofort benötigt für ersten Render
|
||||||
const APP_SHELL = [
|
const APP_SHELL = [
|
||||||
@@ -27,12 +28,6 @@ const APP_SHELL = [
|
|||||||
'/router.js',
|
'/router.js',
|
||||||
'/i18n.js',
|
'/i18n.js',
|
||||||
'/rrule-ui.js',
|
'/rrule-ui.js',
|
||||||
'/locales/de.json',
|
|
||||||
'/locales/en.json',
|
|
||||||
'/locales/ja.json',
|
|
||||||
'/locales/ar.json',
|
|
||||||
'/locales/hi.json',
|
|
||||||
'/locales/pt.json',
|
|
||||||
'/reminders.js',
|
'/reminders.js',
|
||||||
'/sw-register.js',
|
'/sw-register.js',
|
||||||
'/lucide.min.js',
|
'/lucide.min.js',
|
||||||
@@ -66,6 +61,24 @@ const APP_SHELL = [
|
|||||||
'/icons/icon-maskable-512.png',
|
'/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
|
// Seiten-Module: lazy geladen, aber vorab gecacht für Offline
|
||||||
const PAGE_MODULES = [
|
const PAGE_MODULES = [
|
||||||
'/pages/dashboard.js',
|
'/pages/dashboard.js',
|
||||||
@@ -114,10 +127,12 @@ const _bypassInit = (async () => {
|
|||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
const freshShell = APP_SHELL.map((url) => new Request(url, { cache: 'reload' }));
|
const freshShell = APP_SHELL.map((url) => new Request(url, { cache: 'reload' }));
|
||||||
const freshModules = PAGE_MODULES.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(
|
event.waitUntil(
|
||||||
Promise.all([
|
Promise.all([
|
||||||
caches.open(SHELL_CACHE).then((c) => c.addAll(freshShell)),
|
caches.open(SHELL_CACHE).then((c) => c.addAll(freshShell)),
|
||||||
caches.open(PAGES_CACHE).then((c) => c.addAll(freshModules)),
|
caches.open(PAGES_CACHE).then((c) => c.addAll(freshModules)),
|
||||||
|
caches.open(LOCALES_CACHE).then((c) => c.addAll(freshLocales)),
|
||||||
]).then(() => self.skipWaiting())
|
]).then(() => self.skipWaiting())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -207,12 +222,20 @@ function dispatchFetch(request, url) {
|
|||||||
return networkFirst(request, SHELL_CACHE);
|
return networkFirst(request, SHELL_CACHE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAsset(url.pathname) && url.origin === self.location.origin) {
|
if (url.pathname.startsWith('/locales/')) {
|
||||||
return cacheFirst(request, ASSETS_CACHE);
|
return networkFirst(request, LOCALES_CACHE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith('/pages/')) {
|
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);
|
return cacheFirst(request, SHELL_CACHE);
|
||||||
@@ -270,3 +293,10 @@ async function cacheFirst(request, cacheName) {
|
|||||||
function isAsset(pathname) {
|
function isAsset(pathname) {
|
||||||
return /\.(png|jpg|jpeg|ico|svg|webp|woff2?|gif)$/i.test(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);
|
||||||
|
}
|
||||||
|
|||||||
+37
-13
@@ -22,6 +22,19 @@ const router = express.Router();
|
|||||||
const VALID_SOURCES = ['local', 'google', 'apple', 'ics'];
|
const VALID_SOURCES = ['local', 'google', 'apple', 'ics'];
|
||||||
const ICS_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
const ICS_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
|
function getUserId(req) {
|
||||||
|
const candidates = [req.authUserId, req.user?.id, req.session?.userId];
|
||||||
|
for (const value of candidates) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isInteger(parsed) && parsed > 0) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdminUser(req) {
|
||||||
|
return req.authRole === 'admin' || req.session?.isAdmin === true || req.session?.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events
|
// RRULE-Expansion: alle Vorkommen eines wiederkehrenden Events
|
||||||
// innerhalb [from, to] generieren (inklusive beider Grenzen).
|
// innerhalb [from, to] generieren (inklusive beider Grenzen).
|
||||||
@@ -146,7 +159,7 @@ router.get('/', (req, res) => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
const params = [to, from, to, req.session.userId];
|
const params = [to, from, to, getUserId(req)];
|
||||||
|
|
||||||
if (req.query.assigned_to) {
|
if (req.query.assigned_to) {
|
||||||
sql += ' AND e.assigned_to = ?';
|
sql += ' AND e.assigned_to = ?';
|
||||||
@@ -203,7 +216,7 @@ router.get('/upcoming', (req, res) => {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
ORDER BY e.start_datetime ASC
|
ORDER BY e.start_datetime ASC
|
||||||
`).all(nowDate, future, future, req.session.userId);
|
`).all(nowDate, future, future, getUserId(req));
|
||||||
|
|
||||||
const expanded = expandRecurringEvents(rawEvents, nowDate, future)
|
const expanded = expandRecurringEvents(rawEvents, nowDate, future)
|
||||||
.filter((e) => e.start_datetime >= new Date().toISOString())
|
.filter((e) => e.start_datetime >= new Date().toISOString())
|
||||||
@@ -396,7 +409,7 @@ router.delete('/apple/disconnect', requireAdmin, (req, res) => {
|
|||||||
|
|
||||||
router.get('/subscriptions', (req, res) => {
|
router.get('/subscriptions', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const subs = icsSubscription.getAll(req.session.userId);
|
const subs = icsSubscription.getAll(getUserId(req));
|
||||||
res.json({ data: subs });
|
res.json({ data: subs });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('', err);
|
log.error('', err);
|
||||||
@@ -416,7 +429,7 @@ router.post('/subscriptions', async (req, res) => {
|
|||||||
if (!colorVal || !ICS_COLOR_RE.test(colorVal))
|
if (!colorVal || !ICS_COLOR_RE.test(colorVal))
|
||||||
return res.status(400).json({ error: 'color: Pflichtfeld, muss #RRGGBB sein.', code: 400 });
|
return res.status(400).json({ error: 'color: Pflichtfeld, muss #RRGGBB sein.', code: 400 });
|
||||||
|
|
||||||
const { sub, syncError } = await icsSubscription.create(req.session.userId, {
|
const { sub, syncError } = await icsSubscription.create(getUserId(req), {
|
||||||
name: name.trim(), url, color: colorVal, shared: shared ? 1 : 0,
|
name: name.trim(), url, color: colorVal, shared: shared ? 1 : 0,
|
||||||
});
|
});
|
||||||
res.status(201).json({ data: sub, syncError: syncError || null });
|
res.status(201).json({ data: sub, syncError: syncError || null });
|
||||||
@@ -432,7 +445,7 @@ router.patch('/subscriptions/:id', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const subId = parseInt(req.params.id, 10);
|
const subId = parseInt(req.params.id, 10);
|
||||||
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
|
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
|
||||||
const isAdmin = req.session.isAdmin;
|
const isAdmin = isAdminUser(req);
|
||||||
const fields = {};
|
const fields = {};
|
||||||
if (req.body.name !== undefined) {
|
if (req.body.name !== undefined) {
|
||||||
if (typeof req.body.name !== 'string' || req.body.name.trim().length === 0 || req.body.name.length > 100)
|
if (typeof req.body.name !== 'string' || req.body.name.trim().length === 0 || req.body.name.length > 100)
|
||||||
@@ -446,7 +459,7 @@ router.patch('/subscriptions/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
if (req.body.shared !== undefined) fields.shared = req.body.shared;
|
if (req.body.shared !== undefined) fields.shared = req.body.shared;
|
||||||
|
|
||||||
const updated = icsSubscription.update(req.session.userId, subId, fields, isAdmin);
|
const updated = icsSubscription.update(getUserId(req), subId, fields, isAdmin);
|
||||||
if (!updated) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
|
if (!updated) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
|
||||||
res.json({ data: updated });
|
res.json({ data: updated });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -460,8 +473,8 @@ router.delete('/subscriptions/:id', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const subId = parseInt(req.params.id, 10);
|
const subId = parseInt(req.params.id, 10);
|
||||||
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
|
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
|
||||||
const isAdmin = req.session.isAdmin;
|
const isAdmin = isAdminUser(req);
|
||||||
const ok = icsSubscription.remove(req.session.userId, subId, isAdmin);
|
const ok = icsSubscription.remove(getUserId(req), subId, isAdmin);
|
||||||
if (!ok) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
|
if (!ok) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -475,10 +488,10 @@ router.post('/subscriptions/:id/sync', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const subId = parseInt(req.params.id, 10);
|
const subId = parseInt(req.params.id, 10);
|
||||||
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
|
if (!Number.isFinite(subId)) return res.status(400).json({ error: 'Ungültige ID.', code: 400 });
|
||||||
const isAdmin = req.session.isAdmin;
|
const isAdmin = isAdminUser(req);
|
||||||
const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
|
const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
|
||||||
if (!sub) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
|
if (!sub) return res.status(404).json({ error: 'Abonnement nicht gefunden.', code: 404 });
|
||||||
if (!isAdmin && sub.created_by !== req.session.userId)
|
if (!isAdmin && sub.created_by !== getUserId(req))
|
||||||
return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
|
return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
|
||||||
await icsSubscription.sync(subId);
|
await icsSubscription.sync(subId);
|
||||||
const updated = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
|
const updated = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId);
|
||||||
@@ -526,6 +539,17 @@ router.get('/:id', (req, res) => {
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
if (!userId) {
|
||||||
|
log.warn('Rejecting calendar create without resolved authenticated user id', {
|
||||||
|
authMethod: req.authMethod || null,
|
||||||
|
authUserId: req.authUserId || null,
|
||||||
|
reqUserId: req.user?.id || null,
|
||||||
|
sessionUserId: req.session?.userId || null,
|
||||||
|
});
|
||||||
|
return res.status(401).json({ error: 'Not authenticated.', code: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
|
const vTitle = str(req.body.title, 'Titel', { max: MAX_TITLE });
|
||||||
const vDesc = str(req.body.description, 'Beschreibung', { max: MAX_TEXT, required: false });
|
const vDesc = str(req.body.description, 'Beschreibung', { max: MAX_TEXT, required: false });
|
||||||
const vStart = datetime(req.body.start_datetime, 'Startdatum', true);
|
const vStart = datetime(req.body.start_datetime, 'Startdatum', true);
|
||||||
@@ -553,7 +577,7 @@ router.post('/', (req, res) => {
|
|||||||
vStart.value, vEnd.value,
|
vStart.value, vEnd.value,
|
||||||
all_day ? 1 : 0, vLoc.value,
|
all_day ? 1 : 0, vLoc.value,
|
||||||
vColor.value, assigned_to || null,
|
vColor.value, assigned_to || null,
|
||||||
req.session.userId, vRrule.value
|
userId, vRrule.value
|
||||||
);
|
);
|
||||||
|
|
||||||
const event = db.get().prepare(`
|
const event = db.get().prepare(`
|
||||||
@@ -669,8 +693,8 @@ router.post('/:id/reset', (req, res) => {
|
|||||||
if (event.external_source !== 'ics')
|
if (event.external_source !== 'ics')
|
||||||
return res.status(400).json({ error: 'Nur ICS-Events können zurückgesetzt werden.', code: 400 });
|
return res.status(400).json({ error: 'Nur ICS-Events können zurückgesetzt werden.', code: 400 });
|
||||||
|
|
||||||
const userId = req.session.userId;
|
const userId = getUserId(req);
|
||||||
const isAdmin = req.session.isAdmin;
|
const isAdmin = isAdminUser(req);
|
||||||
if (!isAdmin && event.created_by !== userId && event.sub_created_by !== userId)
|
if (!isAdmin && event.created_by !== userId && event.sub_created_by !== userId)
|
||||||
return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
|
return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 });
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { createLogger } from '../logger.js';
|
import { createLogger } from '../logger.js';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import * as db from '../db.js';
|
import * as db from '../db.js';
|
||||||
|
import { hydrateBirthday } from '../services/birthdays.js';
|
||||||
|
|
||||||
const log = createLogger('Dashboard');
|
const log = createLogger('Dashboard');
|
||||||
|
|
||||||
@@ -30,10 +31,12 @@ router.get('/', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const d = db.get();
|
const d = db.get();
|
||||||
const result = {};
|
const result = {};
|
||||||
|
const userId = req.authUserId || req.session.userId;
|
||||||
|
|
||||||
// Heute und +48h als ISO-Strings
|
// Heute und +48h als ISO-Strings
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const todayStr = now.toISOString().slice(0, 10);
|
const todayStr = now.toISOString().slice(0, 10);
|
||||||
|
const currentMonth = todayStr.slice(0, 7);
|
||||||
const deadline48h = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString();
|
const deadline48h = new Date(now.getTime() + 48 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
// Anstehende Termine (nächste 5, ab jetzt)
|
// Anstehende Termine (nächste 5, ab jetzt)
|
||||||
@@ -170,6 +173,63 @@ router.get('/', (req, res) => {
|
|||||||
result.users = [];
|
result.users = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = d.prepare('SELECT * FROM birthdays WHERE created_by = ? ORDER BY name COLLATE NOCASE ASC').all(userId);
|
||||||
|
result.birthdays = rows
|
||||||
|
.map((row) => hydrateBirthday(row))
|
||||||
|
.sort((a, b) => a.days_until - b.days_until || a.name.localeCompare(b.name))
|
||||||
|
.slice(0, 3);
|
||||||
|
result.birthdayCount = rows.length;
|
||||||
|
} catch (err) {
|
||||||
|
log.error('birthdays error:', err.message);
|
||||||
|
result.birthdays = [];
|
||||||
|
result.birthdayCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const from = `${currentMonth}-01`;
|
||||||
|
const to = `${currentMonth}-31`;
|
||||||
|
const totals = d.prepare(`
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
|
||||||
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
|
||||||
|
SUM(amount) AS balance,
|
||||||
|
COUNT(*) AS entry_count
|
||||||
|
FROM budget_entries
|
||||||
|
WHERE date BETWEEN ? AND ?
|
||||||
|
`).get(from, to);
|
||||||
|
|
||||||
|
const topExpense = d.prepare(`
|
||||||
|
SELECT category, SUM(amount) AS amount
|
||||||
|
FROM budget_entries
|
||||||
|
WHERE amount < 0 AND date BETWEEN ? AND ?
|
||||||
|
GROUP BY category
|
||||||
|
ORDER BY ABS(SUM(amount)) DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(from, to);
|
||||||
|
|
||||||
|
result.budget = {
|
||||||
|
month: currentMonth,
|
||||||
|
income: totals?.income || 0,
|
||||||
|
expenses: Math.abs(totals?.expenses || 0),
|
||||||
|
balance: totals?.balance || 0,
|
||||||
|
entryCount: totals?.entry_count || 0,
|
||||||
|
topExpenseCategory: topExpense?.category || null,
|
||||||
|
topExpenseAmount: Math.abs(topExpense?.amount || 0),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
log.error('budget error:', err.message);
|
||||||
|
result.budget = {
|
||||||
|
month: currentMonth,
|
||||||
|
income: 0,
|
||||||
|
expenses: 0,
|
||||||
|
balance: 0,
|
||||||
|
entryCount: 0,
|
||||||
|
topExpenseCategory: null,
|
||||||
|
topExpenseAmount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('Critical error:', err.message);
|
log.error('Critical error:', err.message);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { createLogger } from '../logger.js';
|
import { createLogger } from '../logger.js';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import * as db from '../db.js';
|
import * as db from '../db.js';
|
||||||
|
import { str, MAX_SHORT } from '../middleware/validate.js';
|
||||||
|
|
||||||
const log = createLogger('Preferences');
|
const log = createLogger('Preferences');
|
||||||
|
|
||||||
@@ -17,8 +18,12 @@ const DEFAULT_MEAL_TYPES = VALID_MEAL_TYPES.join(',');
|
|||||||
|
|
||||||
const VALID_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
|
const VALID_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD'];
|
||||||
const DEFAULT_CURRENCY = 'EUR';
|
const DEFAULT_CURRENCY = 'EUR';
|
||||||
|
const DEFAULT_APP_NAME = 'Oikos';
|
||||||
|
|
||||||
const VALID_WIDGET_IDS = ['weather', 'tasks', 'calendar', 'shopping', 'meals', 'notes'];
|
const VALID_DATE_FORMATS = ['mdy', 'dmy', 'ymd'];
|
||||||
|
const DEFAULT_DATE_FORMAT = 'mdy';
|
||||||
|
|
||||||
|
const VALID_WIDGET_IDS = ['tasks', 'calendar', 'birthdays', 'budget', 'family', 'weather', 'shopping', 'meals', 'notes'];
|
||||||
const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id) => ({ id, visible: true })));
|
const DEFAULT_WIDGET_CONFIG = JSON.stringify(VALID_WIDGET_IDS.map((id) => ({ id, visible: true })));
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -39,6 +44,10 @@ function cfgSet(key, value) {
|
|||||||
`).run(key, value);
|
`).run(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cfgDelete(key) {
|
||||||
|
db.get().prepare('DELETE FROM sync_config WHERE key = ?').run(key);
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Widget-Hilfsfunktionen
|
// Widget-Hilfsfunktionen
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -78,12 +87,16 @@ router.get('/', (req, res) => {
|
|||||||
const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
|
const raw = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
|
||||||
const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
|
const visibleMealTypes = raw.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
|
||||||
const currency = cfgGet('currency') ?? DEFAULT_CURRENCY;
|
const currency = cfgGet('currency') ?? DEFAULT_CURRENCY;
|
||||||
|
const dateFormat = VALID_DATE_FORMATS.includes(cfgGet('date_format')) ? cfgGet('date_format') : DEFAULT_DATE_FORMAT;
|
||||||
|
const appName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
|
||||||
const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
|
const dashboardWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
data: {
|
data: {
|
||||||
visible_meal_types: visibleMealTypes,
|
visible_meal_types: visibleMealTypes,
|
||||||
currency,
|
currency,
|
||||||
|
date_format: dateFormat,
|
||||||
|
app_name: appName,
|
||||||
dashboard_widgets: dashboardWidgets,
|
dashboard_widgets: dashboardWidgets,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -102,7 +115,7 @@ router.get('/', (req, res) => {
|
|||||||
|
|
||||||
router.put('/', (req, res) => {
|
router.put('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { visible_meal_types, currency, dashboard_widgets } = req.body;
|
const { visible_meal_types, currency, date_format, app_name, dashboard_widgets } = req.body;
|
||||||
|
|
||||||
if (visible_meal_types !== undefined) {
|
if (visible_meal_types !== undefined) {
|
||||||
if (!Array.isArray(visible_meal_types)) {
|
if (!Array.isArray(visible_meal_types)) {
|
||||||
@@ -122,6 +135,20 @@ router.put('/', (req, res) => {
|
|||||||
cfgSet('currency', currency);
|
cfgSet('currency', currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (date_format !== undefined) {
|
||||||
|
if (!VALID_DATE_FORMATS.includes(date_format)) {
|
||||||
|
return res.status(400).json({ error: `Ungültiges Datumsformat. Erlaubt: ${VALID_DATE_FORMATS.join(', ')}`, code: 400 });
|
||||||
|
}
|
||||||
|
cfgSet('date_format', date_format);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app_name !== undefined) {
|
||||||
|
const vAppName = str(app_name, 'Application name', { max: MAX_SHORT, required: false });
|
||||||
|
if (vAppName.error) return res.status(400).json({ error: vAppName.error, code: 400 });
|
||||||
|
if (vAppName.value) cfgSet('app_name', vAppName.value);
|
||||||
|
else cfgDelete('app_name');
|
||||||
|
}
|
||||||
|
|
||||||
if (dashboard_widgets !== undefined) {
|
if (dashboard_widgets !== undefined) {
|
||||||
if (!Array.isArray(dashboard_widgets)) {
|
if (!Array.isArray(dashboard_widgets)) {
|
||||||
return res.status(400).json({ error: 'dashboard_widgets muss ein Array sein', code: 400 });
|
return res.status(400).json({ error: 'dashboard_widgets muss ein Array sein', code: 400 });
|
||||||
@@ -133,12 +160,16 @@ router.put('/', (req, res) => {
|
|||||||
const rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
|
const rawMealTypes = cfgGet('visible_meal_types') ?? DEFAULT_MEAL_TYPES;
|
||||||
const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
|
const savedMealTypes = rawMealTypes.split(',').filter((t) => VALID_MEAL_TYPES.includes(t));
|
||||||
const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY;
|
const savedCurrency = cfgGet('currency') ?? DEFAULT_CURRENCY;
|
||||||
|
const savedDateFormat = VALID_DATE_FORMATS.includes(cfgGet('date_format')) ? cfgGet('date_format') : DEFAULT_DATE_FORMAT;
|
||||||
|
const savedAppName = cfgGet('app_name') ?? DEFAULT_APP_NAME;
|
||||||
const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
|
const savedWidgets = parseWidgetConfig(cfgGet('dashboard_widgets'));
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
data: {
|
data: {
|
||||||
visible_meal_types: savedMealTypes,
|
visible_meal_types: savedMealTypes,
|
||||||
currency: savedCurrency,
|
currency: savedCurrency,
|
||||||
|
date_format: savedDateFormat,
|
||||||
|
app_name: savedAppName,
|
||||||
dashboard_widgets: savedWidgets,
|
dashboard_widgets: savedWidgets,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import { MIGRATIONS_SQL } from './server/db-schema-test.js';
|
import { MIGRATIONS_SQL } from './server/db-schema-test.js';
|
||||||
|
import { hydrateBirthday } from './server/services/birthdays.js';
|
||||||
|
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -49,6 +50,7 @@ const uid2 = u2.lastInsertRowid;
|
|||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
|
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10);
|
||||||
|
const currentMonth = today.slice(0, 7);
|
||||||
const inOneHour = new Date(Date.now() + 3600000).toISOString();
|
const inOneHour = new Date(Date.now() + 3600000).toISOString();
|
||||||
const in30h = new Date(Date.now() + 30 * 3600000).toISOString().slice(0, 10);
|
const in30h = new Date(Date.now() + 30 * 3600000).toISOString().slice(0, 10);
|
||||||
const in72h = new Date(Date.now() + 72 * 3600000).toISOString().slice(0, 10);
|
const in72h = new Date(Date.now() + 72 * 3600000).toISOString().slice(0, 10);
|
||||||
@@ -83,6 +85,22 @@ db.prepare(`INSERT INTO notes (content, title, pinned, color, created_by)
|
|||||||
db.prepare(`INSERT INTO notes (content, pinned, color, created_by)
|
db.prepare(`INSERT INTO notes (content, pinned, color, created_by)
|
||||||
VALUES ('Nicht angepinnt', 0, '#E3F2FF', ?)`).run(uid1);
|
VALUES ('Nicht angepinnt', 0, '#E3F2FF', ?)`).run(uid1);
|
||||||
|
|
||||||
|
// Geburtstage
|
||||||
|
db.prepare(`INSERT INTO birthdays (name, birth_date, created_by)
|
||||||
|
VALUES ('Heute Geburtstag', ?, ?)`).run(`2012-${today.slice(5)}`, uid1);
|
||||||
|
db.prepare(`INSERT INTO birthdays (name, birth_date, created_by)
|
||||||
|
VALUES ('Morgen Geburtstag', ?, ?)`).run(`2010-${tomorrow.slice(5)}`, uid1);
|
||||||
|
db.prepare(`INSERT INTO birthdays (name, birth_date, created_by)
|
||||||
|
VALUES ('Anderer Nutzer', ?, ?)`).run(`2011-${today.slice(5)}`, uid2);
|
||||||
|
|
||||||
|
// Budget
|
||||||
|
db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
|
||||||
|
VALUES ('Salary', 3000, 'Erwerbseinkommen', '', ?, ?)`).run(`${currentMonth}-05`, uid1);
|
||||||
|
db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
|
||||||
|
VALUES ('Rent', -1200, 'housing', 'rent_mortgage', ?, ?)`).run(`${currentMonth}-06`, uid1);
|
||||||
|
db.prepare(`INSERT INTO budget_entries (title, amount, category, subcategory, date, created_by)
|
||||||
|
VALUES ('Groceries', -450, 'food', 'supermarket', ?, ?)`).run(`${currentMonth}-07`, uid1);
|
||||||
|
|
||||||
console.log('\n[Dashboard-Test] API-Abfragen\n');
|
console.log('\n[Dashboard-Test] API-Abfragen\n');
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -191,6 +209,53 @@ test('Angepinnte Notizen: nicht angepinnte werden ausgeschlossen', () => {
|
|||||||
assert(!unpinned, 'Nicht angepinnte Notiz sollte gefiltert sein');
|
assert(!unpinned, 'Nicht angepinnte Notiz sollte gefiltert sein');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Tests: Geburtstage
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Geburtstage: nur aktueller Nutzer, sortiert nach nächstem Geburtstag', () => {
|
||||||
|
const rows = db.prepare('SELECT * FROM birthdays WHERE created_by = ? ORDER BY name COLLATE NOCASE ASC').all(uid1);
|
||||||
|
const birthdays = rows
|
||||||
|
.map((row) => hydrateBirthday(row, new Date(`${today}T12:00:00Z`)))
|
||||||
|
.sort((a, b) => a.days_until - b.days_until || a.name.localeCompare(b.name))
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
assert(rows.length === 2, `Erwartet 2 Geburtstage, erhalten ${rows.length}`);
|
||||||
|
assert(birthdays[0].name === 'Heute Geburtstag', 'Heutiger Geburtstag zuerst');
|
||||||
|
assert(birthdays[0].days_until === 0, 'Heutiger Geburtstag hat 0 Tage Rest');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Tests: Budget
|
||||||
|
// --------------------------------------------------------
|
||||||
|
test('Budget: Monatswerte für Einnahmen, Ausgaben, Saldo und Top-Ausgabe', () => {
|
||||||
|
const from = `${currentMonth}-01`;
|
||||||
|
const to = `${currentMonth}-31`;
|
||||||
|
const totals = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income,
|
||||||
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expenses,
|
||||||
|
SUM(amount) AS balance,
|
||||||
|
COUNT(*) AS entry_count
|
||||||
|
FROM budget_entries
|
||||||
|
WHERE date BETWEEN ? AND ?
|
||||||
|
`).get(from, to);
|
||||||
|
|
||||||
|
const topExpense = db.prepare(`
|
||||||
|
SELECT category, SUM(amount) AS amount
|
||||||
|
FROM budget_entries
|
||||||
|
WHERE amount < 0 AND date BETWEEN ? AND ?
|
||||||
|
GROUP BY category
|
||||||
|
ORDER BY ABS(SUM(amount)) DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(from, to);
|
||||||
|
|
||||||
|
assert(totals.income === 3000, `Einnahmen sollten 3000 sein, erhalten ${totals.income}`);
|
||||||
|
assert(Math.abs(totals.expenses) === 1650, `Ausgaben sollten 1650 sein, erhalten ${totals.expenses}`);
|
||||||
|
assert(totals.balance === 1350, `Saldo sollte 1350 sein, erhalten ${totals.balance}`);
|
||||||
|
assert(totals.entry_count === 3, `Erwartet 3 Einträge, erhalten ${totals.entry_count}`);
|
||||||
|
assert(topExpense.category === 'housing', 'Wohnen sollte Top-Ausgabenkategorie sein');
|
||||||
|
});
|
||||||
|
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
// Ergebnis
|
// Ergebnis
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user