From 394b4ea84e62892312d8a98f9e2c3825827f45ee Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Sun, 26 Apr 2026 07:36:53 -0300 Subject: [PATCH 1/4] Adding Birthday tracking feature - to compete with FamilyWall --- .gitignore | 2 + docker-compose.yml | 6 +- public/locales/ar.json | 29 +++ public/locales/de.json | 30 +++ public/locales/el.json | 29 +++ public/locales/en.json | 30 +++ public/locales/es.json | 29 +++ public/locales/fr.json | 29 +++ public/locales/hi.json | 29 +++ public/locales/it.json | 29 +++ public/locales/ja.json | 29 +++ public/locales/pt.json | 30 +++ public/locales/ru.json | 29 +++ public/locales/sv.json | 29 +++ public/locales/tr.json | 29 +++ public/locales/uk.json | 29 +++ public/locales/zh.json | 29 +++ public/pages/birthdays.js | 372 +++++++++++++++++++++++++++++++++++ public/router.js | 4 +- public/styles/birthdays.css | 247 +++++++++++++++++++++++ public/styles/tokens.css | 4 + public/sw.js | 8 +- server/db-schema-test.js | 18 ++ server/db.js | 26 +++ server/index.js | 2 + server/openapi.js | 15 ++ server/routes/birthdays.js | 159 +++++++++++++++ server/routes/reminders.js | 14 +- server/services/birthdays.js | 201 +++++++++++++++++++ test-db.js | 16 +- 30 files changed, 1518 insertions(+), 14 deletions(-) create mode 100644 public/pages/birthdays.js create mode 100644 public/styles/birthdays.css create mode 100644 server/routes/birthdays.js create mode 100644 server/services/birthdays.js diff --git a/.gitignore b/.gitignore index 7e74964..3c03c07 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ data/ .idea/ *.swp *.swo +.codex + # Claude Code — share skills/agents/rules/hooks/settings; keep local permissions and worktrees out .claude/settings.local.json diff --git a/docker-compose.yml b/docker-compose.yml index cd71eb6..31eab39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,11 @@ services: oikos: - image: ghcr.io/ulsklyc/oikos:latest +# image: ghcr.io/ulsklyc/oikos:latest build: . # optional: use --build to build locally instead container_name: oikos restart: unless-stopped ports: - - "0.0.0.0:3000:3000" + - "0.0.0.0:3100:3000" volumes: - oikos_data:/data env_file: @@ -19,7 +19,7 @@ services: # Direct HTTP access (no reverse proxy): - SESSION_SECURE=false healthcheck: - test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"] + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3100/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"] interval: 30s timeout: 10s retries: 3 diff --git a/public/locales/ar.json b/public/locales/ar.json index b2144a0..38fa584 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -38,6 +38,7 @@ "shopping": "التسوق", "notes": "الملاحظات", "contacts": "جهات الاتصال", + "birthdays": "أعياد الميلاد", "budget": "الميزانية", "settings": "الإعدادات", "main": "القائمة الرئيسية", @@ -757,6 +758,34 @@ "placeholder": "بحث…", "noResults": "لم يتم العثور على نتائج." }, + "birthdays": { + "title": "أعياد الميلاد", + "addButton": "إضافة عيد ميلاد", + "searchPlaceholder": "ابحث عن أعياد الميلاد…", + "upcomingTitle": "أعياد الميلاد القادمة", + "upcomingHint": "الاحتفالات القادمة، وهي متزامنة بالفعل مع التقويم.", + "peopleTitle": "الأشخاص", + "peopleHint": "ابحث وراجع وعدّل جميع أعياد الميلاد المحفوظة.", + "emptyTitle": "لا توجد أعياد ميلاد بعد", + "emptyDescription": "أضف عيد ميلاد ليبقى ظاهرًا في التقويم والتذكيرات.", + "newTitle": "عيد ميلاد جديد", + "editTitle": "تعديل عيد الميلاد", + "nameLabel": "الاسم", + "birthDateLabel": "تاريخ الميلاد", + "photoLabel": "الصورة الشخصية", + "removePhoto": "إزالة الصورة", + "notesLabel": "ملاحظات", + "notesPlaceholder": "أفكار هدايا، الكعكة المفضلة، ملاحظات عائلية…", + "calendarHint": "يتم إضافة كل عيد ميلاد تلقائيًا إلى التقويم ونظام التذكيرات.", + "requiredFields": "الاسم وتاريخ الميلاد مطلوبان.", + "createdToast": "تم حفظ عيد الميلاد.", + "updatedToast": "تم تحديث عيد الميلاد.", + "deletedToast": "تم حذف عيد الميلاد.", + "deleteConfirm": "هل تريد حذف عيد ميلاد \"{{name}}\"؟", + "ageNoteToday": "سيكمل {{age}} عامًا اليوم.", + "ageNoteTomorrow": "سيكمل {{age}} عامًا غدًا.", + "ageNoteDays": "سيكمل {{age}} عامًا بعد {{days}} يومًا." + }, "reminders": { "sectionTitle": "تذكير", "enableLabel": "تعيين تذكير", diff --git a/public/locales/de.json b/public/locales/de.json index 804c6d3..3dcfe7f 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -38,6 +38,7 @@ "shopping": "Einkauf", "notes": "Pinnwand", "contacts": "Kontakte", + "birthdays": "Geburtstage", "budget": "Budget", "settings": "Einstellungen", "main": "Hauptnavigation", @@ -754,6 +755,35 @@ "pendingBadgeTitle": "{{count}} fällige Erinnerung", "pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen" }, + "birthdays": { + "title": "Geburtstage", + "addButton": "Geburtstag hinzufügen", + "searchPlaceholder": "Geburtstage suchen…", + "upcomingTitle": "Nächste Geburtstage", + "upcomingHint": "Die nächsten Feiern, bereits mit Kalender und Erinnerungen verknüpft.", + "peopleTitle": "Personen", + "peopleHint": "Alle gespeicherten Geburtstage durchsuchen, prüfen und bearbeiten.", + "emptyTitle": "Noch keine Geburtstage", + "emptyDescription": "Füge einen Geburtstag hinzu, damit er im Kalender und bei Erinnerungen erscheint.", + "newTitle": "Neuer Geburtstag", + "editTitle": "Geburtstag bearbeiten", + "nameLabel": "Name", + "birthDateLabel": "Geburtsdatum", + "photoLabel": "Profilbild", + "photoOptional": "Optional: Du kannst auch ohne Profilbild speichern.", + "removePhoto": "Bild entfernen", + "notesLabel": "Notizen", + "notesPlaceholder": "Geschenkideen, Lieblingskuchen, Familiennotizen…", + "calendarHint": "Jeder Geburtstag wird automatisch zum Kalender und Erinnerungssystem hinzugefügt.", + "requiredFields": "Name und Geburtsdatum sind erforderlich.", + "createdToast": "Geburtstag gespeichert.", + "updatedToast": "Geburtstag aktualisiert.", + "deletedToast": "Geburtstag gelöscht.", + "deleteConfirm": "Geburtstag von \"{{name}}\" löschen?", + "ageNoteToday": "Wird heute {{age}} Jahre alt.", + "ageNoteTomorrow": "Wird morgen {{age}} Jahre alt.", + "ageNoteDays": "Wird in {{days}} Tagen {{age}} Jahre alt." + }, "recipes": { "title": "Rezepte", "addRecipe": "Rezept hinzufügen", diff --git a/public/locales/el.json b/public/locales/el.json index 2ed0b72..9b9c1d7 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -38,6 +38,7 @@ "shopping": "Αγορές", "notes": "Σημειώσεις", "contacts": "Επαφές", + "birthdays": "Γενέθλια", "budget": "Προϋπολογισμός", "settings": "Ρυθμίσεις", "main": "Κύρια πλοήγηση", @@ -757,6 +758,34 @@ "placeholder": "Αναζήτηση…", "noResults": "Δεν βρέθηκαν αποτελέσματα." }, + "birthdays": { + "title": "Γενέθλια", + "addButton": "Προσθήκη γενεθλίων", + "searchPlaceholder": "Αναζήτηση γενεθλίων…", + "upcomingTitle": "Επόμενα γενέθλια", + "upcomingHint": "Οι επόμενοι εορτασμοί, ήδη συγχρονισμένοι με το ημερολόγιο.", + "peopleTitle": "Άτομα", + "peopleHint": "Αναζητήστε, ελέγξτε και επεξεργαστείτε όλα τα αποθηκευμένα γενέθλια.", + "emptyTitle": "Δεν υπάρχουν γενέθλια ακόμη", + "emptyDescription": "Προσθέστε ένα γενέθλιο ώστε να εμφανίζεται στο ημερολόγιο και στις υπενθυμίσεις.", + "newTitle": "Νέα γενέθλια", + "editTitle": "Επεξεργασία γενεθλίων", + "nameLabel": "Όνομα", + "birthDateLabel": "Ημερομηνία γέννησης", + "photoLabel": "Φωτογραφία προφίλ", + "removePhoto": "Αφαίρεση φωτογραφίας", + "notesLabel": "Σημειώσεις", + "notesPlaceholder": "Ιδέες δώρων, αγαπημένη τούρτα, οικογενειακές σημειώσεις…", + "calendarHint": "Κάθε γενέθλιο προστίθεται αυτόματα στο ημερολόγιο και στο σύστημα υπενθυμίσεων.", + "requiredFields": "Το όνομα και η ημερομηνία γέννησης είναι υποχρεωτικά.", + "createdToast": "Τα γενέθλια αποθηκεύτηκαν.", + "updatedToast": "Τα γενέθλια ενημερώθηκαν.", + "deletedToast": "Τα γενέθλια διαγράφηκαν.", + "deleteConfirm": "Διαγραφή γενεθλίων για τον/την \"{{name}}\";", + "ageNoteToday": "Γίνεται {{age}} ετών σήμερα.", + "ageNoteTomorrow": "Γίνεται {{age}} ετών αύριο.", + "ageNoteDays": "Γίνεται {{age}} ετών σε {{days}} ημέρες." + }, "reminders": { "sectionTitle": "Υπενθύμιση", "enableLabel": "Ορισμός υπενθύμισης", diff --git a/public/locales/en.json b/public/locales/en.json index 76505d6..2e85007 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -38,6 +38,7 @@ "shopping": "Shopping", "notes": "Board", "contacts": "Contacts", + "birthdays": "Birthdays", "budget": "Budget", "settings": "Settings", "main": "Main navigation", @@ -748,6 +749,35 @@ "pendingBadgeTitle": "{{count}} reminder due", "pendingBadgeTitlePlural": "{{count}} reminders due" }, + "birthdays": { + "title": "Birthdays", + "addButton": "Add birthday", + "searchPlaceholder": "Search birthdays…", + "upcomingTitle": "Next birthdays", + "upcomingHint": "The next people to celebrate, already synced to the calendar.", + "peopleTitle": "People", + "peopleHint": "Search, review and edit every saved birthday.", + "emptyTitle": "No birthdays yet", + "emptyDescription": "Add a birthday to keep it visible in the calendar and reminders.", + "newTitle": "New birthday", + "editTitle": "Edit birthday", + "nameLabel": "Name", + "birthDateLabel": "Birth date", + "photoLabel": "Profile picture", + "photoOptional": "Optional: you can save without a profile picture.", + "removePhoto": "Remove picture", + "notesLabel": "Notes", + "notesPlaceholder": "Gift ideas, favorite cake, family notes…", + "calendarHint": "Each birthday is automatically added to the calendar and reminder system.", + "requiredFields": "Name and birth date are required.", + "createdToast": "Birthday saved.", + "updatedToast": "Birthday updated.", + "deletedToast": "Birthday deleted.", + "deleteConfirm": "Delete birthday for \"{{name}}\"?", + "ageNoteToday": "Turns {{age}} today.", + "ageNoteTomorrow": "Turns {{age}} tomorrow.", + "ageNoteDays": "Turns {{age}} in {{days}} days." + }, "recipes": { "title": "Recipes", "addRecipe": "Add recipe", diff --git a/public/locales/es.json b/public/locales/es.json index e774628..ba60756 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -38,6 +38,7 @@ "shopping": "Compras", "notes": "Notas", "contacts": "Contactos", + "birthdays": "Cumpleaños", "budget": "Presupuesto", "settings": "Ajustes", "main": "Navegación principal", @@ -757,6 +758,34 @@ "placeholder": "Buscar…", "noResults": "No se encontraron resultados." }, + "birthdays": { + "title": "Cumpleaños", + "addButton": "Añadir cumpleaños", + "searchPlaceholder": "Buscar cumpleaños…", + "upcomingTitle": "Próximos cumpleaños", + "upcomingHint": "Las próximas celebraciones, ya sincronizadas con el calendario.", + "peopleTitle": "Personas", + "peopleHint": "Busca, revisa y edita todos los cumpleaños guardados.", + "emptyTitle": "Todavía no hay cumpleaños", + "emptyDescription": "Añade un cumpleaños para mantenerlo visible en el calendario y en los recordatorios.", + "newTitle": "Nuevo cumpleaños", + "editTitle": "Editar cumpleaños", + "nameLabel": "Nombre", + "birthDateLabel": "Fecha de nacimiento", + "photoLabel": "Foto de perfil", + "removePhoto": "Eliminar foto", + "notesLabel": "Notas", + "notesPlaceholder": "Ideas de regalo, tarta favorita, notas familiares…", + "calendarHint": "Cada cumpleaños se añade automáticamente al calendario y al sistema de recordatorios.", + "requiredFields": "El nombre y la fecha de nacimiento son obligatorios.", + "createdToast": "Cumpleaños guardado.", + "updatedToast": "Cumpleaños actualizado.", + "deletedToast": "Cumpleaños eliminado.", + "deleteConfirm": "¿Eliminar el cumpleaños de \"{{name}}\"?", + "ageNoteToday": "Cumple {{age}} años hoy.", + "ageNoteTomorrow": "Cumple {{age}} años mañana.", + "ageNoteDays": "Cumplirá {{age}} años en {{days}} días." + }, "reminders": { "sectionTitle": "Recordatorio", "enableLabel": "Establecer recordatorio", diff --git a/public/locales/fr.json b/public/locales/fr.json index d30fb93..bf82d53 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -38,6 +38,7 @@ "shopping": "Courses", "notes": "Notes", "contacts": "Contacts", + "birthdays": "Anniversaires", "budget": "Budget", "settings": "Paramètres", "main": "Navigation principale", @@ -757,6 +758,34 @@ "placeholder": "Rechercher…", "noResults": "Aucun résultat trouvé." }, + "birthdays": { + "title": "Anniversaires", + "addButton": "Ajouter un anniversaire", + "searchPlaceholder": "Rechercher des anniversaires…", + "upcomingTitle": "Prochains anniversaires", + "upcomingHint": "Les prochaines célébrations, déjà synchronisées avec le calendrier.", + "peopleTitle": "Personnes", + "peopleHint": "Recherchez, vérifiez et modifiez tous les anniversaires enregistrés.", + "emptyTitle": "Aucun anniversaire pour le moment", + "emptyDescription": "Ajoutez un anniversaire pour le garder visible dans le calendrier et les rappels.", + "newTitle": "Nouvel anniversaire", + "editTitle": "Modifier l'anniversaire", + "nameLabel": "Nom", + "birthDateLabel": "Date de naissance", + "photoLabel": "Photo de profil", + "removePhoto": "Supprimer la photo", + "notesLabel": "Notes", + "notesPlaceholder": "Idées de cadeaux, gâteau préféré, notes familiales…", + "calendarHint": "Chaque anniversaire est automatiquement ajouté au calendrier et au système de rappels.", + "requiredFields": "Le nom et la date de naissance sont obligatoires.", + "createdToast": "Anniversaire enregistré.", + "updatedToast": "Anniversaire mis à jour.", + "deletedToast": "Anniversaire supprimé.", + "deleteConfirm": "Supprimer l'anniversaire de \"{{name}}\" ?", + "ageNoteToday": "Fête ses {{age}} ans aujourd'hui.", + "ageNoteTomorrow": "Fêtera ses {{age}} ans demain.", + "ageNoteDays": "Fêtera ses {{age}} ans dans {{days}} jours." + }, "reminders": { "sectionTitle": "Rappel", "enableLabel": "Définir un rappel", diff --git a/public/locales/hi.json b/public/locales/hi.json index b42c0e8..ec68ba9 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -38,6 +38,7 @@ "shopping": "खरीदारी", "notes": "नोट्स", "contacts": "संपर्क", + "birthdays": "जन्मदिन", "budget": "बजट", "settings": "सेटिंग्स", "main": "मुख्य नेविगेशन", @@ -757,6 +758,34 @@ "placeholder": "खोजें…", "noResults": "कोई परिणाम नहीं मिला।" }, + "birthdays": { + "title": "जन्मदिन", + "addButton": "जन्मदिन जोड़ें", + "searchPlaceholder": "जन्मदिन खोजें…", + "upcomingTitle": "आने वाले जन्मदिन", + "upcomingHint": "आने वाले समारोह, जो पहले से कैलेंडर से सिंक हैं।", + "peopleTitle": "लोग", + "peopleHint": "सहेजे गए सभी जन्मदिन खोजें, देखें और संपादित करें।", + "emptyTitle": "अभी तक कोई जन्मदिन नहीं", + "emptyDescription": "जन्मदिन जोड़ें ताकि वह कैलेंडर और रिमाइंडर में दिखाई दे।", + "newTitle": "नया जन्मदिन", + "editTitle": "जन्मदिन संपादित करें", + "nameLabel": "नाम", + "birthDateLabel": "जन्म तिथि", + "photoLabel": "प्रोफ़ाइल तस्वीर", + "removePhoto": "तस्वीर हटाएँ", + "notesLabel": "नोट्स", + "notesPlaceholder": "उपहार के विचार, पसंदीदा केक, परिवार के नोट्स…", + "calendarHint": "हर जन्मदिन अपने आप कैलेंडर और रिमाइंडर सिस्टम में जोड़ दिया जाता है।", + "requiredFields": "नाम और जन्म तिथि आवश्यक हैं।", + "createdToast": "जन्मदिन सहेज लिया गया।", + "updatedToast": "जन्मदिन अपडेट किया गया।", + "deletedToast": "जन्मदिन हटाया गया।", + "deleteConfirm": "\"{{name}}\" का जन्मदिन हटाएँ?", + "ageNoteToday": "आज {{age}} वर्ष का/की होगा/होगी।", + "ageNoteTomorrow": "कल {{age}} वर्ष का/की होगा/होगी।", + "ageNoteDays": "{{days}} दिनों में {{age}} वर्ष का/की होगा/होगी।" + }, "reminders": { "sectionTitle": "अनुस्मारक", "enableLabel": "अनुस्मारक सेट करें", diff --git a/public/locales/it.json b/public/locales/it.json index c6c13db..1d1a2be 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -38,6 +38,7 @@ "shopping": "Spesa", "notes": "Bacheca", "contacts": "Contatti", + "birthdays": "Compleanni", "budget": "Bilancio", "settings": "Impostazioni", "main": "Navigazione principale", @@ -757,6 +758,34 @@ "placeholder": "Cerca…", "noResults": "Nessun risultato trovato." }, + "birthdays": { + "title": "Compleanni", + "addButton": "Aggiungi compleanno", + "searchPlaceholder": "Cerca compleanni…", + "upcomingTitle": "Prossimi compleanni", + "upcomingHint": "Le prossime ricorrenze, già sincronizzate con il calendario.", + "peopleTitle": "Persone", + "peopleHint": "Cerca, controlla e modifica tutti i compleanni salvati.", + "emptyTitle": "Nessun compleanno ancora", + "emptyDescription": "Aggiungi un compleanno per mantenerlo visibile nel calendario e nei promemoria.", + "newTitle": "Nuovo compleanno", + "editTitle": "Modifica compleanno", + "nameLabel": "Nome", + "birthDateLabel": "Data di nascita", + "photoLabel": "Foto profilo", + "removePhoto": "Rimuovi foto", + "notesLabel": "Note", + "notesPlaceholder": "Idee regalo, torta preferita, note di famiglia…", + "calendarHint": "Ogni compleanno viene aggiunto automaticamente al calendario e al sistema di promemoria.", + "requiredFields": "Nome e data di nascita sono obbligatori.", + "createdToast": "Compleanno salvato.", + "updatedToast": "Compleanno aggiornato.", + "deletedToast": "Compleanno eliminato.", + "deleteConfirm": "Eliminare il compleanno di \"{{name}}\"?", + "ageNoteToday": "Compie {{age}} anni oggi.", + "ageNoteTomorrow": "Compirà {{age}} anni domani.", + "ageNoteDays": "Compirà {{age}} anni tra {{days}} giorni." + }, "reminders": { "sectionTitle": "Promemoria", "enableLabel": "Imposta promemoria", diff --git a/public/locales/ja.json b/public/locales/ja.json index 31d97f9..448e42d 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -38,6 +38,7 @@ "shopping": "買い物", "notes": "メモ", "contacts": "連絡先", + "birthdays": "誕生日", "budget": "家計", "settings": "設定", "main": "メインナビゲーション", @@ -757,6 +758,34 @@ "placeholder": "検索…", "noResults": "結果が見つかりませんでした。" }, + "birthdays": { + "title": "誕生日", + "addButton": "誕生日を追加", + "searchPlaceholder": "誕生日を検索…", + "upcomingTitle": "次の誕生日", + "upcomingHint": "次に祝う誕生日。すでにカレンダーに同期されています。", + "peopleTitle": "人物", + "peopleHint": "保存されたすべての誕生日を検索、確認、編集できます。", + "emptyTitle": "まだ誕生日はありません", + "emptyDescription": "誕生日を追加すると、カレンダーとリマインダーに表示されます。", + "newTitle": "新しい誕生日", + "editTitle": "誕生日を編集", + "nameLabel": "名前", + "birthDateLabel": "生年月日", + "photoLabel": "プロフィール画像", + "removePhoto": "画像を削除", + "notesLabel": "メモ", + "notesPlaceholder": "プレゼント案、好きなケーキ、家族メモ…", + "calendarHint": "各誕生日は自動的にカレンダーとリマインダーシステムに追加されます。", + "requiredFields": "名前と生年月日は必須です。", + "createdToast": "誕生日を保存しました。", + "updatedToast": "誕生日を更新しました。", + "deletedToast": "誕生日を削除しました。", + "deleteConfirm": "「{{name}}」の誕生日を削除しますか?", + "ageNoteToday": "今日で{{age}}歳になります。", + "ageNoteTomorrow": "明日で{{age}}歳になります。", + "ageNoteDays": "{{days}}日後に{{age}}歳になります。" + }, "reminders": { "sectionTitle": "リマインダー", "enableLabel": "リマインダーを設定", diff --git a/public/locales/pt.json b/public/locales/pt.json index ff5808c..161932c 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -38,6 +38,7 @@ "shopping": "Compras", "notes": "Notas", "contacts": "Contatos", + "birthdays": "Aniversários", "budget": "Orçamento", "settings": "Configurações", "main": "Navegação principal", @@ -757,6 +758,35 @@ "placeholder": "Pesquisar…", "noResults": "Nenhum resultado encontrado." }, + "birthdays": { + "title": "Aniversários", + "addButton": "Adicionar aniversário", + "searchPlaceholder": "Buscar aniversários…", + "upcomingTitle": "Próximos aniversários", + "upcomingHint": "As próximas comemorações, já sincronizadas com o calendário.", + "peopleTitle": "Pessoas", + "peopleHint": "Pesquise, revise e edite todos os aniversários salvos.", + "emptyTitle": "Nenhum aniversário ainda", + "emptyDescription": "Adicione um aniversário para mantê-lo visível no calendário e nos lembretes.", + "newTitle": "Novo aniversário", + "editTitle": "Editar aniversário", + "nameLabel": "Nome", + "birthDateLabel": "Data de nascimento", + "photoLabel": "Foto de perfil", + "photoOptional": "Opcional: você também pode salvar sem foto de perfil.", + "removePhoto": "Remover foto", + "notesLabel": "Notas", + "notesPlaceholder": "Ideias de presente, bolo favorito, notas da família…", + "calendarHint": "Cada aniversário é adicionado automaticamente ao calendário e ao sistema de lembretes.", + "requiredFields": "Nome e data de nascimento são obrigatórios.", + "createdToast": "Aniversário salvo.", + "updatedToast": "Aniversário atualizado.", + "deletedToast": "Aniversário excluído.", + "deleteConfirm": "Excluir o aniversário de \"{{name}}\"?", + "ageNoteToday": "Completa {{age}} anos hoje.", + "ageNoteTomorrow": "Completa {{age}} anos amanhã.", + "ageNoteDays": "Completa {{age}} anos em {{days}} dias." + }, "reminders": { "sectionTitle": "Lembrete", "enableLabel": "Definir lembrete", diff --git a/public/locales/ru.json b/public/locales/ru.json index 9e8b56e..9b93928 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -38,6 +38,7 @@ "shopping": "Покупки", "notes": "Заметки", "contacts": "Контакты", + "birthdays": "Дни рождения", "budget": "Бюджет", "settings": "Настройки", "main": "Главная навигация", @@ -757,6 +758,34 @@ "placeholder": "Поиск…", "noResults": "Результаты не найдены." }, + "birthdays": { + "title": "Дни рождения", + "addButton": "Добавить день рождения", + "searchPlaceholder": "Поиск дней рождения…", + "upcomingTitle": "Ближайшие дни рождения", + "upcomingHint": "Ближайшие праздники, уже синхронизированные с календарём.", + "peopleTitle": "Люди", + "peopleHint": "Ищите, просматривайте и редактируйте все сохранённые дни рождения.", + "emptyTitle": "Дней рождения пока нет", + "emptyDescription": "Добавьте день рождения, чтобы он отображался в календаре и напоминаниях.", + "newTitle": "Новый день рождения", + "editTitle": "Редактировать день рождения", + "nameLabel": "Имя", + "birthDateLabel": "Дата рождения", + "photoLabel": "Фото профиля", + "removePhoto": "Удалить фото", + "notesLabel": "Заметки", + "notesPlaceholder": "Идеи подарков, любимый торт, семейные заметки…", + "calendarHint": "Каждый день рождения автоматически добавляется в календарь и систему напоминаний.", + "requiredFields": "Имя и дата рождения обязательны.", + "createdToast": "День рождения сохранён.", + "updatedToast": "День рождения обновлён.", + "deletedToast": "День рождения удалён.", + "deleteConfirm": "Удалить день рождения \"{{name}}\"?", + "ageNoteToday": "Исполняется {{age}} сегодня.", + "ageNoteTomorrow": "Исполнится {{age}} завтра.", + "ageNoteDays": "Исполнится {{age}} через {{days}} дн." + }, "reminders": { "sectionTitle": "Напоминание", "enableLabel": "Установить напоминание", diff --git a/public/locales/sv.json b/public/locales/sv.json index fdde958..3914485 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -38,6 +38,7 @@ "shopping": "Shopping", "notes": "Anteckningar", "contacts": "Kontakter", + "birthdays": "Födelsedagar", "budget": "Budget", "settings": "Inställningar", "main": "Huvudnavigering", @@ -757,6 +758,34 @@ "placeholder": "Sök…", "noResults": "Inga resultat hittades." }, + "birthdays": { + "title": "Födelsedagar", + "addButton": "Lägg till födelsedag", + "searchPlaceholder": "Sök födelsedagar…", + "upcomingTitle": "Kommande födelsedagar", + "upcomingHint": "Nästa firanden, redan synkade med kalendern.", + "peopleTitle": "Personer", + "peopleHint": "Sök, granska och redigera alla sparade födelsedagar.", + "emptyTitle": "Inga födelsedagar ännu", + "emptyDescription": "Lägg till en födelsedag så att den syns i kalendern och påminnelserna.", + "newTitle": "Ny födelsedag", + "editTitle": "Redigera födelsedag", + "nameLabel": "Namn", + "birthDateLabel": "Födelsedatum", + "photoLabel": "Profilbild", + "removePhoto": "Ta bort bild", + "notesLabel": "Anteckningar", + "notesPlaceholder": "Presentidéer, favoritårta, familjeanteckningar…", + "calendarHint": "Varje födelsedag läggs automatiskt till i kalendern och påminnelsesystemet.", + "requiredFields": "Namn och födelsedatum krävs.", + "createdToast": "Födelsedag sparad.", + "updatedToast": "Födelsedag uppdaterad.", + "deletedToast": "Födelsedag borttagen.", + "deleteConfirm": "Ta bort födelsedagen för \"{{name}}\"?", + "ageNoteToday": "Fyller {{age}} år idag.", + "ageNoteTomorrow": "Fyller {{age}} år i morgon.", + "ageNoteDays": "Fyller {{age}} år om {{days}} dagar." + }, "reminders": { "sectionTitle": "Påminnelse", "enableLabel": "Ange påminnelse", diff --git a/public/locales/tr.json b/public/locales/tr.json index 63da054..bc78fb8 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -38,6 +38,7 @@ "shopping": "Alışveriş", "notes": "Notlar", "contacts": "Kişiler", + "birthdays": "Doğum Günleri", "budget": "Bütçe", "settings": "Ayarlar", "main": "Ana gezinme", @@ -757,6 +758,34 @@ "placeholder": "Ara…", "noResults": "Sonuç bulunamadı." }, + "birthdays": { + "title": "Doğum Günleri", + "addButton": "Doğum günü ekle", + "searchPlaceholder": "Doğum günlerinde ara…", + "upcomingTitle": "Yaklaşan doğum günleri", + "upcomingHint": "Takvimle zaten senkronize edilmiş sıradaki kutlamalar.", + "peopleTitle": "Kişiler", + "peopleHint": "Kaydedilen tüm doğum günlerini arayın, inceleyin ve düzenleyin.", + "emptyTitle": "Henüz doğum günü yok", + "emptyDescription": "Takvimde ve hatırlatıcılarda görünür kalması için bir doğum günü ekleyin.", + "newTitle": "Yeni doğum günü", + "editTitle": "Doğum gününü düzenle", + "nameLabel": "Ad", + "birthDateLabel": "Doğum tarihi", + "photoLabel": "Profil resmi", + "removePhoto": "Resmi kaldır", + "notesLabel": "Notlar", + "notesPlaceholder": "Hediye fikirleri, favori pasta, aile notları…", + "calendarHint": "Her doğum günü otomatik olarak takvime ve hatırlatma sistemine eklenir.", + "requiredFields": "Ad ve doğum tarihi gereklidir.", + "createdToast": "Doğum günü kaydedildi.", + "updatedToast": "Doğum günü güncellendi.", + "deletedToast": "Doğum günü silindi.", + "deleteConfirm": "\"{{name}}\" için doğum günü silinsin mi?", + "ageNoteToday": "Bugün {{age}} yaşına giriyor.", + "ageNoteTomorrow": "Yarın {{age}} yaşına giriyor.", + "ageNoteDays": "{{days}} gün içinde {{age}} yaşına girecek." + }, "reminders": { "sectionTitle": "Hatırlatıcı", "enableLabel": "Hatırlatıcı ayarla", diff --git a/public/locales/uk.json b/public/locales/uk.json index edc73b9..1c6bbe2 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -38,6 +38,7 @@ "shopping": "Покупки", "notes": "Нотатки", "contacts": "Контакти", + "birthdays": "Дні народження", "budget": "Бюджет", "settings": "Налаштування", "main": "Головна навігація", @@ -777,5 +778,33 @@ "open": "Відкрити пошук", "placeholder": "Пошук…", "noResults": "Результатів не знайдено." + }, + "birthdays": { + "title": "Дні народження", + "addButton": "Додати день народження", + "searchPlaceholder": "Шукати дні народження…", + "upcomingTitle": "Найближчі дні народження", + "upcomingHint": "Найближчі святкування, уже синхронізовані з календарем.", + "peopleTitle": "Люди", + "peopleHint": "Шукайте, переглядайте й редагуйте всі збережені дні народження.", + "emptyTitle": "Поки що немає днів народження", + "emptyDescription": "Додайте день народження, щоб він відображався в календарі та нагадуваннях.", + "newTitle": "Новий день народження", + "editTitle": "Редагувати день народження", + "nameLabel": "Ім'я", + "birthDateLabel": "Дата народження", + "photoLabel": "Фото профілю", + "removePhoto": "Видалити фото", + "notesLabel": "Нотатки", + "notesPlaceholder": "Ідеї подарунків, улюблений торт, сімейні нотатки…", + "calendarHint": "Кожен день народження автоматично додається до календаря та системи нагадувань.", + "requiredFields": "Ім'я та дата народження є обов'язковими.", + "createdToast": "День народження збережено.", + "updatedToast": "День народження оновлено.", + "deletedToast": "День народження видалено.", + "deleteConfirm": "Видалити день народження для \"{{name}}\"?", + "ageNoteToday": "Сьогодні виповнюється {{age}}.", + "ageNoteTomorrow": "Завтра виповниться {{age}}.", + "ageNoteDays": "За {{days}} дн. виповниться {{age}}." } } diff --git a/public/locales/zh.json b/public/locales/zh.json index cf18b5b..4de2055 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -38,6 +38,7 @@ "shopping": "购物", "notes": "便签", "contacts": "联系人", + "birthdays": "生日", "budget": "预算", "settings": "设置", "main": "主导航", @@ -757,6 +758,34 @@ "placeholder": "搜索…", "noResults": "未找到结果。" }, + "birthdays": { + "title": "生日", + "addButton": "添加生日", + "searchPlaceholder": "搜索生日…", + "upcomingTitle": "即将到来的生日", + "upcomingHint": "接下来的生日庆祝,已同步到日历。", + "peopleTitle": "人物", + "peopleHint": "搜索、查看并编辑所有已保存的生日。", + "emptyTitle": "还没有生日", + "emptyDescription": "添加一个生日,让它显示在日历和提醒中。", + "newTitle": "新建生日", + "editTitle": "编辑生日", + "nameLabel": "姓名", + "birthDateLabel": "出生日期", + "photoLabel": "头像", + "removePhoto": "删除照片", + "notesLabel": "备注", + "notesPlaceholder": "礼物想法、最喜欢的蛋糕、家庭备注…", + "calendarHint": "每个生日都会自动添加到日历和提醒系统中。", + "requiredFields": "姓名和出生日期为必填项。", + "createdToast": "生日已保存。", + "updatedToast": "生日已更新。", + "deletedToast": "生日已删除。", + "deleteConfirm": "删除“{{name}}”的生日?", + "ageNoteToday": "今天满 {{age}} 岁。", + "ageNoteTomorrow": "明天满 {{age}} 岁。", + "ageNoteDays": "{{days}} 天后满 {{age}} 岁。" + }, "reminders": { "sectionTitle": "提醒", "enableLabel": "设置提醒", diff --git a/public/pages/birthdays.js b/public/pages/birthdays.js new file mode 100644 index 0000000..15f31f3 --- /dev/null +++ b/public/pages/birthdays.js @@ -0,0 +1,372 @@ +import { api } from '/api.js'; +import { openModal as openSharedModal, closeModal, confirmModal } from '/components/modal.js'; +import { stagger } from '/utils/ux.js'; +import { t, formatDate } from '/i18n.js'; +import { esc } from '/utils/html.js'; + +let state = { + birthdays: [], + upcoming: [], + query: '', +}; +let _container = null; + +function initials(name) { + return String(name || '') + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() || '') + .join('') || '?'; +} + +function ageNote(birthday) { + if (birthday.days_until === 0) return t('birthdays.ageNoteToday', { age: birthday.next_age }); + if (birthday.days_until === 1) return t('birthdays.ageNoteTomorrow', { age: birthday.next_age }); + return t('birthdays.ageNoteDays', { age: birthday.next_age, days: birthday.days_until }); +} + +function photoAvatar(birthday, extraClass = '') { + if (birthday.photo_data) { + return `${esc(birthday.name)}`; + } + return `${esc(initials(birthday.name))}`; +} + +function filteredBirthdays() { + const q = state.query.trim().toLowerCase(); + if (!q) return state.birthdays; + return state.birthdays.filter((birthday) => + birthday.name.toLowerCase().includes(q) || + (birthday.notes || '').toLowerCase().includes(q) + ); +} + +function suggestions() { + const q = state.query.trim().toLowerCase(); + if (!q) return []; + return state.birthdays + .filter((birthday) => birthday.name.toLowerCase().includes(q)) + .slice(0, 6); +} + +async function loadData() { + const [allRes, upcomingRes] = await Promise.all([ + api.get('/birthdays'), + api.get('/birthdays/upcoming?limit=4'), + ]); + state.birthdays = allRes.data ?? []; + state.upcoming = upcomingRes.data ?? []; +} + +function renderSuggestions() { + const dropdown = _container.querySelector('#birthdays-autocomplete'); + if (!dropdown) return; + const items = suggestions(); + if (!items.length) { + dropdown.hidden = true; + dropdown.innerHTML = ''; + return; + } + dropdown.hidden = false; + dropdown.innerHTML = items.map((birthday, idx) => ` + + `).join(''); +} + +function renderUpcoming() { + const host = _container.querySelector('#birthdays-upcoming'); + if (!host) return; + if (!state.upcoming.length) { + host.innerHTML = `
+
${t('birthdays.emptyTitle')}
+
${t('birthdays.emptyDescription')}
+
`; + return; + } + host.innerHTML = state.upcoming.map((birthday) => ` +
+
${photoAvatar(birthday)}
+
+
${esc(birthday.name)}
+
${esc(formatDate(birthday.next_birthday))}
+
${esc(ageNote(birthday))}
+
+
+ `).join(''); +} + +function renderList() { + const host = _container.querySelector('#birthdays-list'); + if (!host) return; + const list = filteredBirthdays(); + if (!list.length) { + host.innerHTML = `
+
${t('birthdays.emptyTitle')}
+
${t('birthdays.emptyDescription')}
+
`; + return; + } + + host.innerHTML = list.map((birthday) => ` +
+
${photoAvatar(birthday)}
+
+
+ ${esc(birthday.name)} + ${esc(formatDate(birthday.next_birthday))} +
+
${esc(formatDate(birthday.birth_date))}
+
${esc(ageNote(birthday))}
+ ${birthday.notes ? `
${esc(birthday.notes)}
` : ''} +
+
+ + +
+
+ `).join(''); + + if (window.lucide) window.lucide.createIcons(); + stagger(host.querySelectorAll('.birthday-item')); +} + +function renderPage() { + _container.innerHTML = ` +
+

${t('birthdays.title')}

+
+ + +
+ +
+
+

${t('birthdays.upcomingTitle')}

+

${t('birthdays.upcomingHint')}

+
+
+
+ +
+
+

${t('birthdays.peopleTitle')}

+

${t('birthdays.peopleHint')}

+
+
+
+ + +
+ `; + + renderUpcoming(); + renderList(); + renderSuggestions(); + if (window.lucide) window.lucide.createIcons(); +} + +function bindEvents() { + const openCreate = () => openBirthdayModal({ mode: 'create' }); + _container.querySelector('#birthdays-add-btn').addEventListener('click', openCreate); + _container.querySelector('#fab-new-birthday').addEventListener('click', openCreate); + + const search = _container.querySelector('#birthdays-search'); + search.addEventListener('input', (e) => { + state.query = e.target.value; + renderSuggestions(); + renderList(); + }); + search.addEventListener('focus', renderSuggestions); + search.addEventListener('blur', () => { + setTimeout(() => { + const dropdown = _container.querySelector('#birthdays-autocomplete'); + if (dropdown) dropdown.hidden = true; + }, 100); + }); + + _container.querySelector('#birthdays-autocomplete').addEventListener('click', (e) => { + const btn = e.target.closest('.birthday-suggestion'); + if (!btn) return; + state.query = btn.dataset.name; + search.value = state.query; + renderList(); + renderSuggestions(); + }); + + _container.querySelector('#birthdays-list').addEventListener('click', async (e) => { + const action = e.target.closest('[data-action]'); + if (!action) return; + const id = Number(action.dataset.id); + const birthday = state.birthdays.find((item) => item.id === id); + if (!birthday) return; + if (action.dataset.action === 'edit') { + openBirthdayModal({ mode: 'edit', birthday }); + return; + } + if (action.dataset.action === 'delete') { + await deleteBirthday(id, birthday.name); + } + }); +} + +function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || '')); + reader.onerror = () => reject(new Error('Failed to read image.')); + reader.readAsDataURL(file); + }); +} + +function birthdayPreviewHtml(name, photoData) { + if (photoData) return `${esc(name || '')}`; + return `${esc(initials(name))}`; +} + +function openBirthdayModal({ mode, birthday = null }) { + const isEdit = mode === 'edit'; + let photoData = birthday?.photo_data || null; + + openSharedModal({ + title: isEdit ? t('birthdays.editTitle') : t('birthdays.newTitle'), + content: ` +
+
${birthdayPreviewHtml(birthday?.name || '', photoData)}
+
+ + +
+
+ + +
+
+ + +
${t('birthdays.photoOptional')}
+
+ +
+
+
+ + +
+
${t('birthdays.calendarHint')}
+ +
+ `, + size: 'md', + onSave(panel) { + const nameInput = panel.querySelector('#bd-name'); + const preview = panel.querySelector('#birthday-preview'); + const renderPreview = () => { + preview.innerHTML = birthdayPreviewHtml(nameInput.value.trim(), photoData); + }; + nameInput.addEventListener('input', renderPreview); + panel.querySelector('#bd-photo').addEventListener('change', async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + photoData = await readFileAsDataUrl(file); + renderPreview(); + } catch (err) { + window.oikos?.showToast(err.message, 'danger'); + } + }); + panel.querySelector('#bd-remove-photo').addEventListener('click', () => { + photoData = null; + panel.querySelector('#bd-photo').value = ''; + renderPreview(); + }); + panel.querySelector('#bd-cancel').addEventListener('click', closeModal); + panel.querySelector('#bd-delete')?.addEventListener('click', async () => { + closeModal(); + await deleteBirthday(birthday.id, birthday.name); + }); + panel.querySelector('#bd-save').addEventListener('click', async () => { + const saveBtn = panel.querySelector('#bd-save'); + const body = { + name: panel.querySelector('#bd-name').value.trim(), + birth_date: panel.querySelector('#bd-birth-date').value, + notes: panel.querySelector('#bd-notes').value.trim(), + photo_data: photoData, + }; + + if (!body.name || !body.birth_date) { + window.oikos?.showToast(t('birthdays.requiredFields'), 'warning'); + return; + } + + saveBtn.disabled = true; + try { + if (isEdit) { + const res = await api.put(`/birthdays/${birthday.id}`, body); + const idx = state.birthdays.findIndex((item) => item.id === birthday.id); + if (idx !== -1) state.birthdays[idx] = res.data; + window.oikos?.showToast(t('birthdays.updatedToast'), 'success'); + } else { + const res = await api.post('/birthdays', body); + state.birthdays.push(res.data); + window.oikos?.showToast(t('birthdays.createdToast'), 'success'); + } + state.birthdays.sort((a, b) => a.days_until - b.days_until || a.name.localeCompare(b.name)); + const upcomingRes = await api.get('/birthdays/upcoming?limit=4'); + state.upcoming = upcomingRes.data ?? []; + renderUpcoming(); + renderSuggestions(); + renderList(); + closeModal(); + } catch (err) { + window.oikos?.showToast(err.message, 'danger'); + saveBtn.disabled = false; + } + }); + }, + }); +} + +async function deleteBirthday(id, name) { + if (!await confirmModal(t('birthdays.deleteConfirm', { name }), { danger: true, confirmLabel: t('common.delete') })) return; + await api.delete(`/birthdays/${id}`); + state.birthdays = state.birthdays.filter((birthday) => birthday.id !== id); + state.upcoming = state.upcoming.filter((birthday) => birthday.id !== id); + renderUpcoming(); + renderSuggestions(); + renderList(); + window.oikos?.showToast(t('birthdays.deletedToast'), 'success'); +} + +export async function render(container) { + _container = container; + await loadData(); + renderPage(); + bindEvents(); +} diff --git a/public/router.js b/public/router.js index 865b5b7..61b5dab 100644 --- a/public/router.js +++ b/public/router.js @@ -22,6 +22,7 @@ const ROUTES = [ { path: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' }, { path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' }, { path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' }, + { path: '/birthdays', page: '/pages/birthdays.js', requiresAuth: true, module: 'birthdays' }, { path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' }, { path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' }, ]; @@ -125,7 +126,7 @@ let _pendingLoginRedirect = false; // -------------------------------------------------------- const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/recipes', '/shopping', - '/notes', '/contacts', '/budget', '/settings']; + '/notes', '/contacts', '/birthdays', '/budget', '/settings']; const PRIMARY_NAV = 4; @@ -651,6 +652,7 @@ function navItems() { { path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' }, { path: '/notes', label: t('nav.notes'), icon: 'sticky-note' }, { 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: '/settings', label: t('nav.settings'), icon: 'settings' }, ]; diff --git a/public/styles/birthdays.css b/public/styles/birthdays.css new file mode 100644 index 0000000..31f41a8 --- /dev/null +++ b/public/styles/birthdays.css @@ -0,0 +1,247 @@ +.birthdays-page { --module-accent: var(--module-birthdays); } + +.birthdays-page { + display: flex; + flex-direction: column; + gap: var(--space-4); + max-width: var(--content-max-width); + margin: 0 auto; + padding-bottom: calc(var(--nav-bottom-height) + var(--space-6)); +} + +.birthdays-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); + border-top: 3px solid var(--module-accent); + background: var(--color-surface); +} + +.birthdays-toolbar__search { + flex: 1; + position: relative; +} + +.birthdays-toolbar__search-icon { + position: absolute; + left: var(--space-3); + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: var(--color-text-disabled); + pointer-events: none; +} + +.birthdays-toolbar__search-input { + width: 100%; + min-height: 40px; + padding: var(--space-2) var(--space-3) var(--space-2) 36px; + border-radius: var(--radius-glass-button); + border: 1.5px solid var(--glass-border-subtle); + background: var(--color-surface-2); +} + +.birthdays-section { + padding: 0 var(--space-4); +} + +.birthdays-section__header h2 { + margin: 0; + font-size: var(--text-lg); +} + +.birthdays-section__header p { + margin: var(--space-1) 0 0; + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.birthday-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: var(--space-3); + margin-top: var(--space-3); +} + +.birthday-card, +.birthday-item { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-left: 3px solid var(--module-accent); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); +} + +.birthday-card { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4); +} + +.birthday-card__body, +.birthday-item__body { + min-width: 0; + flex: 1; +} + +.birthday-card__name, +.birthday-item__name { + font-size: var(--text-base); + font-weight: var(--font-weight-semibold); +} + +.birthday-card__date, +.birthday-item__meta, +.birthday-item__next { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.birthday-card__note, +.birthday-item__note, +.birthday-item__notes { + margin-top: var(--space-1); + font-size: var(--text-sm); +} + +.birthday-item__notes { + color: var(--color-text-secondary); +} + +.birthdays-list { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-top: var(--space-3); +} + +.birthday-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4); +} + +.birthday-item__row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); +} + +.birthday-item__actions { + display: flex; + gap: var(--space-1); +} + +.birthday-avatar { + width: 56px; + height: 56px; + border-radius: var(--radius-full); + object-fit: cover; + flex-shrink: 0; +} + +.birthday-avatar--fallback { + display: inline-flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--module-accent) 16%, white); + color: var(--module-accent); + font-weight: var(--font-weight-bold); +} + +.birthday-avatar--xs { + width: 34px; + height: 34px; + font-size: var(--text-sm); +} + +.birthdays-autocomplete { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: var(--z-dropdown); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-surface-elevated); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.birthday-suggestion { + width: 100%; + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border: none; + background: transparent; + text-align: left; + cursor: pointer; +} + +.birthday-suggestion:hover { + background: var(--color-surface-hover); +} + +.birthday-suggestion span { + display: flex; + min-width: 0; + flex-direction: column; +} + +.birthday-suggestion small { + color: var(--color-text-secondary); +} + +.birthday-preview { + width: 84px; + height: 84px; + margin: 0 auto var(--space-3); + border-radius: var(--radius-full); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--module-accent) 16%, white); +} + +.birthday-preview__image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.birthday-preview__fallback { + color: var(--module-accent); + font-size: var(--text-xl); + font-weight: var(--font-weight-bold); +} + +.birthday-modal__photo-actions { + margin-top: var(--space-2); +} + +.birthday-modal__hint { + color: var(--color-text-secondary); + font-size: var(--text-sm); +} + +.contact-action-btn { + width: 36px; + height: 36px; + border-radius: var(--radius-full); + border: none; + background: var(--color-surface-2); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-text-secondary); +} diff --git a/public/styles/tokens.css b/public/styles/tokens.css index e49104c..928026e 100644 --- a/public/styles/tokens.css +++ b/public/styles/tokens.css @@ -168,6 +168,8 @@ --module-notes: var(--_module-notes); /* Amber-700 - Notizen (6.3:1 auf weiß — WCAG AA) */ --_module-contacts: #0969DA; --module-contacts: var(--_module-contacts); /* Kräftiges Blau - Kontakte */ + --_module-birthdays: #E11D48; + --module-birthdays: var(--_module-birthdays); /* Rose - Geburtstage */ --_module-budget: #0F766E; --module-budget: var(--_module-budget); /* Teal-700 - Finanzen, Stabilität */ --_module-settings: #6E7781; @@ -520,6 +522,7 @@ --_module-shopping: #F472B6; --_module-notes: #FCD34D; --_module-contacts: #60A5FA; + --_module-birthdays: #FB7185; --_module-budget: #2DD4BF; --_module-settings: #94A3B8; @@ -621,6 +624,7 @@ --_module-shopping: #F472B6; /* Pink-400 */ --_module-notes: #FCD34D; --_module-contacts: #60A5FA; + --_module-birthdays: #FB7185; --_module-budget: #2DD4BF; /* Teal-400 */ --_module-settings: #94A3B8; diff --git a/public/sw.js b/public/sw.js index 96ab73d..a9342cc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -13,9 +13,9 @@ * → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit) */ -const SHELL_CACHE = 'oikos-shell-v52'; -const PAGES_CACHE = 'oikos-pages-v47'; -const ASSETS_CACHE = 'oikos-assets-v47'; +const SHELL_CACHE = 'oikos-shell-v53'; +const PAGES_CACHE = 'oikos-pages-v48'; +const ASSETS_CACHE = 'oikos-assets-v48'; const BYPASS_CACHE = 'oikos-bypass-flag'; const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE]; @@ -50,6 +50,7 @@ const APP_SHELL = [ '/styles/calendar.css', '/styles/notes.css', '/styles/contacts.css', + '/styles/birthdays.css', '/styles/budget.css', '/styles/settings.css', '/styles/recipes.css', @@ -74,6 +75,7 @@ const PAGE_MODULES = [ '/pages/calendar.js', '/pages/notes.js', '/pages/contacts.js', + '/pages/birthdays.js', '/pages/budget.js', '/pages/settings.js', '/pages/login.js', diff --git a/server/db-schema-test.js b/server/db-schema-test.js index 981dde1..ec9386e 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -116,6 +116,17 @@ const MIGRATIONS_SQL = { created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); + CREATE TABLE IF NOT EXISTS birthdays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + birth_date TEXT NOT NULL, + notes TEXT, + photo_data TEXT, + calendar_event_id INTEGER REFERENCES calendar_events(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); CREATE TABLE IF NOT EXISTS budget_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, @@ -182,6 +193,9 @@ const MIGRATIONS_SQL = { CREATE TRIGGER IF NOT EXISTS trg_contacts_updated_at AFTER UPDATE ON contacts FOR EACH ROW BEGIN UPDATE contacts SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + CREATE TRIGGER IF NOT EXISTS trg_birthdays_updated_at + AFTER UPDATE ON birthdays FOR EACH ROW + BEGIN UPDATE birthdays SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; CREATE TRIGGER IF NOT EXISTS trg_budget_entries_updated_at AFTER UPDATE ON budget_entries FOR EACH ROW BEGIN UPDATE budget_entries SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; @@ -196,6 +210,10 @@ const MIGRATIONS_SQL = { CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned); CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date); CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by); + CREATE INDEX IF NOT EXISTS idx_birthdays_name ON birthdays(name); + CREATE INDEX IF NOT EXISTS idx_birthdays_birth_date ON birthdays(birth_date); + CREATE INDEX IF NOT EXISTS idx_birthdays_created_by ON birthdays(created_by); + CREATE INDEX IF NOT EXISTS idx_birthdays_calendar_ref ON birthdays(calendar_event_id); CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by); `, diff --git a/server/db.js b/server/db.js index 57253e1..17a0295 100644 --- a/server/db.js +++ b/server/db.js @@ -691,6 +691,32 @@ const MIGRATIONS = [ CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by); `, }, + { + version: 18, + description: 'Birthdays with calendar integration', + up: ` + CREATE TABLE IF NOT EXISTS birthdays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + birth_date TEXT NOT NULL, + notes TEXT, + photo_data TEXT, + calendar_event_id INTEGER REFERENCES calendar_events(id) ON DELETE SET NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TRIGGER IF NOT EXISTS trg_birthdays_updated_at + AFTER UPDATE ON birthdays FOR EACH ROW + BEGIN UPDATE birthdays SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; + + CREATE INDEX IF NOT EXISTS idx_birthdays_name ON birthdays(name); + CREATE INDEX IF NOT EXISTS idx_birthdays_birth_date ON birthdays(birth_date); + CREATE INDEX IF NOT EXISTS idx_birthdays_created_by ON birthdays(created_by); + CREATE INDEX IF NOT EXISTS idx_birthdays_calendar_ref ON birthdays(calendar_event_id); + `, + }, ]; /** diff --git a/server/index.js b/server/index.js index 77307a8..2d33941 100644 --- a/server/index.js +++ b/server/index.js @@ -25,6 +25,7 @@ import recipesRouter from './routes/recipes.js'; import calendarRouter from './routes/calendar.js'; import notesRouter from './routes/notes.js'; import contactsRouter from './routes/contacts.js'; +import birthdaysRouter from './routes/birthdays.js'; import budgetRouter from './routes/budget.js'; import weatherRouter from './routes/weather.js'; import preferencesRouter from './routes/preferences.js'; @@ -191,6 +192,7 @@ app.use('/api/v1/recipes', recipesRouter); app.use('/api/v1/calendar', calendarRouter); app.use('/api/v1/notes', notesRouter); app.use('/api/v1/contacts', contactsRouter); +app.use('/api/v1/birthdays', birthdaysRouter); app.use('/api/v1/budget', budgetRouter); app.use('/api/v1/weather', weatherRouter); app.use('/api/v1/preferences', preferencesRouter); diff --git a/server/openapi.js b/server/openapi.js index b9d8039..6d51f94 100644 --- a/server/openapi.js +++ b/server/openapi.js @@ -370,6 +370,20 @@ function buildPaths() { delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }), }, '/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) }, + '/api/v1/birthdays': { + get: op({ summary: 'List birthdays', tag: 'Birthdays' }), + post: op({ summary: 'Create birthday', tag: 'Birthdays', stateChanging: true, requestBody: jsonBody(null) }), + }, + '/api/v1/birthdays/upcoming': { + get: op({ summary: 'List upcoming birthdays', tag: 'Birthdays' }), + }, + '/api/v1/birthdays/meta/options': { + get: op({ summary: 'Get birthday upload options', tag: 'Birthdays' }), + }, + '/api/v1/birthdays/{id}': { + put: op({ summary: 'Update birthday', tag: 'Birthdays', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }), + delete: op({ summary: 'Delete birthday', tag: 'Birthdays', params: [idParam()], stateChanging: true }), + }, '/api/v1/budget/summary': { get: op({ summary: 'Get budget summary', tag: 'Budget' }) }, '/api/v1/budget/export': { get: op({ summary: 'Export budget entries as CSV', tag: 'Budget' }) }, '/api/v1/budget/meta': { get: op({ summary: 'Get budget categories and subcategories', tag: 'Budget' }) }, @@ -437,6 +451,7 @@ function buildOpenApiSpec(req, appVersion) { { name: 'Calendar' }, { name: 'Notes' }, { name: 'Contacts' }, + { name: 'Birthdays' }, { name: 'Budget' }, { name: 'Weather' }, { name: 'Preferences' }, diff --git a/server/routes/birthdays.js b/server/routes/birthdays.js new file mode 100644 index 0000000..91141da --- /dev/null +++ b/server/routes/birthdays.js @@ -0,0 +1,159 @@ +import express from 'express'; +import { createLogger } from '../logger.js'; +import * as db from '../db.js'; +import { collectErrors, date as validateDate, str, MAX_SHORT, MAX_TEXT, MAX_TITLE } from '../middleware/validate.js'; +import { deleteBirthdayArtifacts, hydrateBirthday, syncBirthdayArtifacts, syncAllBirthdayReminders } from '../services/birthdays.js'; + +const log = createLogger('Birthdays'); +const router = express.Router(); +const MAX_PHOTO_LENGTH = 900_000; +const PHOTO_RE = /^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/; + +function validatePhotoData(val) { + if (val === undefined) return { value: undefined, error: null }; + if (val === null || val === '') return { value: null, error: null }; + const s = String(val).trim(); + if (s.length > MAX_PHOTO_LENGTH) return { value: null, error: 'Profile picture is too large.' }; + if (!PHOTO_RE.test(s)) return { value: null, error: 'Profile picture must be a valid image data URL.' }; + return { value: s, error: null }; +} + +function loadBirthday(id) { + return db.get().prepare('SELECT * FROM birthdays WHERE id = ?').get(id); +} + +function loadBirthdayForUser(id, userId) { + return db.get().prepare('SELECT * FROM birthdays WHERE id = ? AND created_by = ?').get(id, userId); +} + +function sortHydrated(rows) { + return rows + .map((row) => hydrateBirthday(row)) + .sort((a, b) => a.days_until - b.days_until || a.name.localeCompare(b.name)); +} + +router.get('/', (req, res) => { + try { + const userId = req.authUserId || req.session.userId; + syncAllBirthdayReminders(db.get(), userId); + + let sql = 'SELECT * FROM birthdays WHERE created_by = ?'; + const params = [userId]; + + if (req.query.q) { + sql += ' AND name LIKE ?'; + params.push(`%${String(req.query.q).trim()}%`); + } + + sql += ' ORDER BY name COLLATE NOCASE ASC'; + + const rows = db.get().prepare(sql).all(...params); + res.json({ data: sortHydrated(rows) }); + } catch (err) { + log.error('GET / error:', err); + res.status(500).json({ error: 'Internal error.', code: 500 }); + } +}); + +router.get('/upcoming', (req, res) => { + try { + const userId = req.authUserId || req.session.userId; + syncAllBirthdayReminders(db.get(), userId); + const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 5, 1), 50); + const rows = db.get().prepare('SELECT * FROM birthdays WHERE created_by = ? ORDER BY name COLLATE NOCASE ASC').all(userId); + res.json({ data: sortHydrated(rows).slice(0, limit) }); + } catch (err) { + log.error('GET /upcoming error:', err); + res.status(500).json({ error: 'Internal error.', code: 500 }); + } +}); + +router.post('/', (req, res) => { + try { + const vName = str(req.body.name, 'Name', { max: MAX_TITLE }); + const vBirthDate = validateDate(req.body.birth_date, 'Birth date', true); + const vNotes = str(req.body.notes, 'Notes', { max: MAX_TEXT, required: false }); + const vPhoto = validatePhotoData(req.body.photo_data); + const errors = collectErrors([vName, vBirthDate, vNotes, vPhoto]); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const result = db.get().prepare(` + INSERT INTO birthdays (name, birth_date, notes, photo_data, created_by) + VALUES (?, ?, ?, ?, ?) + `).run(vName.value, vBirthDate.value, vNotes.value, vPhoto.value ?? null, req.authUserId || req.session.userId); + + const birthday = loadBirthday(result.lastInsertRowid); + const synced = db.transaction(() => syncBirthdayArtifacts(db.get(), birthday)); + res.status(201).json({ data: hydrateBirthday(loadBirthday(synced.id)) }); + } catch (err) { + log.error('POST / error:', err); + res.status(500).json({ error: 'Internal error.', code: 500 }); + } +}); + +router.put('/:id', (req, res) => { + try { + const userId = req.authUserId || req.session.userId; + const id = parseInt(req.params.id, 10); + const existing = loadBirthdayForUser(id, userId); + if (!existing) return res.status(404).json({ error: 'Birthday not found.', code: 404 }); + + const checks = []; + if (req.body.name !== undefined) checks.push(str(req.body.name, 'Name', { max: MAX_TITLE, required: false })); + if (req.body.birth_date !== undefined) checks.push(validateDate(req.body.birth_date, 'Birth date')); + if (req.body.notes !== undefined) checks.push(str(req.body.notes, 'Notes', { max: MAX_TEXT, required: false })); + if (req.body.photo_data !== undefined) checks.push(validatePhotoData(req.body.photo_data)); + const errors = collectErrors(checks); + if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); + + const vPhoto = req.body.photo_data !== undefined ? validatePhotoData(req.body.photo_data) : { value: undefined }; + + db.get().prepare(` + UPDATE birthdays + SET name = COALESCE(?, name), + birth_date = COALESCE(?, birth_date), + notes = ?, + photo_data = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = ? + `).run( + req.body.name?.trim() ?? null, + req.body.birth_date ?? null, + req.body.notes !== undefined ? (req.body.notes?.trim() || null) : existing.notes, + req.body.photo_data !== undefined ? (vPhoto.value ?? null) : existing.photo_data, + id, + ); + + const updated = loadBirthday(id); + db.transaction(() => syncBirthdayArtifacts(db.get(), updated)); + res.json({ data: hydrateBirthday(loadBirthday(id)) }); + } catch (err) { + log.error('PUT /:id error:', err); + res.status(500).json({ error: 'Internal error.', code: 500 }); + } +}); + +router.delete('/:id', (req, res) => { + try { + const userId = req.authUserId || req.session.userId; + const id = parseInt(req.params.id, 10); + const existing = loadBirthdayForUser(id, userId); + if (!existing) return res.status(404).json({ error: 'Birthday not found.', code: 404 }); + + db.transaction(() => { + deleteBirthdayArtifacts(db.get(), existing); + db.get().prepare('DELETE FROM birthdays WHERE id = ?').run(id); + }); + + res.status(204).end(); + } catch (err) { + log.error('DELETE /:id error:', err); + res.status(500).json({ error: 'Internal error.', code: 500 }); + } +}); + +router.get('/meta/options', (_req, res) => { + res.json({ data: { photoMaxBytes: MAX_PHOTO_LENGTH, acceptedImageTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] } }); +}); + +export default router; diff --git a/server/routes/reminders.js b/server/routes/reminders.js index e27c894..6462971 100644 --- a/server/routes/reminders.js +++ b/server/routes/reminders.js @@ -8,6 +8,7 @@ import { createLogger } from '../logger.js'; import express from 'express'; import * as db from '../db.js'; import * as v from '../middleware/validate.js'; +import { syncAllBirthdayReminders } from '../services/birthdays.js'; const log = createLogger('Reminders'); const router = express.Router(); @@ -22,8 +23,9 @@ const VALID_ENTITY_TYPES = ['task', 'event']; // -------------------------------------------------------- router.get('/pending', (req, res) => { try { - const userId = req.session.userId; + const userId = req.authUserId || req.session.userId; const now = new Date().toISOString(); + syncAllBirthdayReminders(db.get(), userId, new Date()); const rows = db.get().prepare(` SELECT @@ -53,7 +55,7 @@ router.get('/pending', (req, res) => { // -------------------------------------------------------- router.get('/', (req, res) => { try { - const userId = req.session.userId; + const userId = req.authUserId || req.session.userId; const entityType = req.query.entity_type; const entityId = parseInt(req.query.entity_id, 10); @@ -82,7 +84,7 @@ router.get('/', (req, res) => { // -------------------------------------------------------- router.post('/', (req, res) => { try { - const userId = req.session.userId; + const userId = req.authUserId || req.session.userId; const { entity_type, entity_id, remind_at } = req.body; const errors = v.collectErrors([ @@ -127,7 +129,7 @@ router.post('/', (req, res) => { // -------------------------------------------------------- router.patch('/:id/dismiss', (req, res) => { try { - const userId = req.session.userId; + const userId = req.authUserId || req.session.userId; const reminderId = parseInt(req.params.id, 10); if (!reminderId) { @@ -157,7 +159,7 @@ router.patch('/:id/dismiss', (req, res) => { // -------------------------------------------------------- router.delete('/:id', (req, res) => { try { - const userId = req.session.userId; + const userId = req.authUserId || req.session.userId; const reminderId = parseInt(req.params.id, 10); if (!reminderId) { @@ -187,7 +189,7 @@ router.delete('/:id', (req, res) => { // -------------------------------------------------------- router.delete('/', (req, res) => { try { - const userId = req.session.userId; + const userId = req.authUserId || req.session.userId; const entityType = req.query.entity_type; const entityId = parseInt(req.query.entity_id, 10); diff --git a/server/services/birthdays.js b/server/services/birthdays.js new file mode 100644 index 0000000..b716da5 --- /dev/null +++ b/server/services/birthdays.js @@ -0,0 +1,201 @@ +const BIRTHDAY_COLOR = '#E11D48'; +const BIRTHDAY_RRULE = 'FREQ=YEARLY;INTERVAL=1'; + +function pad2(n) { + return String(n).padStart(2, '0'); +} + +function leapYear(year) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function normalizedMonthDay(birthDate, year) { + const [, monthStr, dayStr] = String(birthDate).split('-'); + const month = parseInt(monthStr, 10); + let day = parseInt(dayStr, 10); + if (month === 2 && day === 29 && !leapYear(year)) day = 28; + return `${year}-${pad2(month)}-${pad2(day)}`; +} + +function nextBirthdayDate(birthDate, from = new Date()) { + const now = from instanceof Date ? from : new Date(from); + const thisYear = normalizedMonthDay(birthDate, now.getFullYear()); + const today = now.toISOString().slice(0, 10); + return thisYear >= today + ? thisYear + : normalizedMonthDay(birthDate, now.getFullYear() + 1); +} + +function nextBirthdayAge(birthDate, from = new Date()) { + const next = nextBirthdayDate(birthDate, from); + return parseInt(next.slice(0, 4), 10) - parseInt(String(birthDate).slice(0, 4), 10); +} + +function daysUntilBirthday(birthDate, from = new Date()) { + const now = from instanceof Date ? from : new Date(from); + const next = nextBirthdayDate(birthDate, now); + const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const nextUtc = Date.UTC( + parseInt(next.slice(0, 4), 10), + parseInt(next.slice(5, 7), 10) - 1, + parseInt(next.slice(8, 10), 10), + ); + return Math.round((nextUtc - todayUtc) / 86400000); +} + +function birthdayReminderAt(birthDate, from = new Date()) { + const next = nextBirthdayDate(birthDate, from); + return `${next}T12:00:00Z`; +} + +function eventTitle(name) { + return `Birthday: ${name}`; +} + +function eventDescription(name, birthDate) { + return `Birthday reminder for ${name} (${birthDate}).`; +} + +function syncBirthdayCalendarEvent(database, birthday) { + const payload = { + title: eventTitle(birthday.name), + description: eventDescription(birthday.name, birthday.birth_date), + start_datetime: birthday.birth_date, + end_datetime: null, + all_day: 1, + location: null, + color: BIRTHDAY_COLOR, + assigned_to: null, + recurrence_rule: BIRTHDAY_RRULE, + created_by: birthday.created_by, + }; + + if (birthday.calendar_event_id) { + const existing = database.prepare('SELECT id FROM calendar_events WHERE id = ?').get(birthday.calendar_event_id); + if (existing) { + database.prepare(` + UPDATE calendar_events + SET title = ?, description = ?, start_datetime = ?, end_datetime = ?, all_day = ?, + location = ?, color = ?, assigned_to = ?, recurrence_rule = ?, created_by = ?, + external_source = 'local' + WHERE id = ? + `).run( + payload.title, + payload.description, + payload.start_datetime, + payload.end_datetime, + payload.all_day, + payload.location, + payload.color, + payload.assigned_to, + payload.recurrence_rule, + payload.created_by, + birthday.calendar_event_id, + ); + return birthday.calendar_event_id; + } + } + + const result = database.prepare(` + INSERT INTO calendar_events + (title, description, start_datetime, end_datetime, all_day, location, color, + assigned_to, created_by, recurrence_rule, external_source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local') + `).run( + payload.title, + payload.description, + payload.start_datetime, + payload.end_datetime, + payload.all_day, + payload.location, + payload.color, + payload.assigned_to, + payload.created_by, + payload.recurrence_rule, + ); + + database.prepare('UPDATE birthdays SET calendar_event_id = ? WHERE id = ?') + .run(result.lastInsertRowid, birthday.id); + return result.lastInsertRowid; +} + +function syncBirthdayReminder(database, birthday, from = new Date()) { + if (!birthday.calendar_event_id) return null; + + const desired = birthdayReminderAt(birthday.birth_date, from); + const existing = database.prepare(` + SELECT * FROM reminders + WHERE entity_type = 'event' AND entity_id = ? AND created_by = ? + ORDER BY created_at DESC + `).all(birthday.calendar_event_id, birthday.created_by); + + const active = existing.find((row) => row.dismissed === 0); + if (active && active.remind_at === desired) return active.id; + + database.prepare(` + DELETE FROM reminders + WHERE entity_type = 'event' AND entity_id = ? AND created_by = ? + `).run(birthday.calendar_event_id, birthday.created_by); + + const result = database.prepare(` + INSERT INTO reminders (entity_type, entity_id, remind_at, created_by) + VALUES ('event', ?, ?, ?) + `).run(birthday.calendar_event_id, desired, birthday.created_by); + + return result.lastInsertRowid; +} + +function syncBirthdayArtifacts(database, birthday, from = new Date()) { + const calendarEventId = syncBirthdayCalendarEvent(database, birthday); + const refreshed = { ...birthday, calendar_event_id: calendarEventId }; + syncBirthdayReminder(database, refreshed, from); + return refreshed; +} + +function deleteBirthdayArtifacts(database, birthday) { + if (birthday.calendar_event_id) { + database.prepare(` + DELETE FROM reminders + WHERE entity_type = 'event' AND entity_id = ? AND created_by = ? + `).run(birthday.calendar_event_id, birthday.created_by); + database.prepare('DELETE FROM calendar_events WHERE id = ?').run(birthday.calendar_event_id); + } +} + +function hydrateBirthday(row, from = new Date()) { + const next_birthday = nextBirthdayDate(row.birth_date, from); + return { + ...row, + next_birthday, + next_age: nextBirthdayAge(row.birth_date, from), + days_until: daysUntilBirthday(row.birth_date, from), + }; +} + +function syncAllBirthdayReminders(database, userId, from = new Date()) { + const birthdays = database.prepare(` + SELECT * FROM birthdays WHERE created_by = ? ORDER BY birth_date ASC + `).all(userId); + birthdays.forEach((birthday) => { + const refreshed = birthday.calendar_event_id ? birthday : { + ...birthday, + calendar_event_id: syncBirthdayCalendarEvent(database, birthday), + }; + syncBirthdayReminder(database, refreshed, from); + }); +} + +export { + BIRTHDAY_COLOR, + BIRTHDAY_RRULE, + birthdayReminderAt, + daysUntilBirthday, + deleteBirthdayArtifacts, + eventDescription, + eventTitle, + hydrateBirthday, + nextBirthdayAge, + nextBirthdayDate, + syncAllBirthdayReminders, + syncBirthdayArtifacts, +}; diff --git a/test-db.js b/test-db.js index 0ab8a51..6548729 100644 --- a/test-db.js +++ b/test-db.js @@ -73,7 +73,7 @@ test('Migration v1 ausführen (alle Tabellen und Triggers)', () => { const EXPECTED_TABLES = [ 'users', 'tasks', 'shopping_lists', 'shopping_items', 'meals', 'meal_ingredients', 'calendar_events', - 'notes', 'contacts', 'budget_entries', + 'notes', 'contacts', 'birthdays', 'budget_entries', 'budget_categories', 'budget_subcategories', 'api_tokens', ]; @@ -99,6 +99,7 @@ const EXPECTED_TRIGGERS = [ 'calendar_events', 'notes', 'contacts', + 'birthdays', 'budget_entries', ].map((t) => `trg_${t}_updated_at`); @@ -200,6 +201,19 @@ test('API-Token anlegen und lesen', () => { assert(token.revoked_at === null, 'Token sollte nicht widerrufen sein'); }); +test('Geburtstag mit Kalender-Referenz anlegen', () => { + const event = db.prepare(` + INSERT INTO calendar_events (title, start_datetime, all_day, created_by, recurrence_rule) + VALUES ('Birthday: Alex', '2014-05-10', 1, 1, 'FREQ=YEARLY;INTERVAL=1') + `).run(); + const birthday = db.prepare(` + INSERT INTO birthdays (name, birth_date, calendar_event_id, created_by) + VALUES ('Alex', '2014-05-10', ?, 1) + `).run(event.lastInsertRowid); + const row = db.prepare('SELECT * FROM birthdays WHERE id = ?').get(birthday.lastInsertRowid); + assert(row.calendar_event_id === event.lastInsertRowid, 'Kalender-Referenz stimmt nicht'); +}); + // -------------------------------------------------------- // Ergebnis // -------------------------------------------------------- From 3c5a8c7eb3a946290941845df33dfa6063d91dc4 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Sun, 26 Apr 2026 19:32:19 -0300 Subject: [PATCH 2/4] Adding option for allowing users to define the Application visible name --- public/pages/settings.js | 105 ++++++++++++++++++++++++++++++++++++++- server/index.js | 10 +++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/public/pages/settings.js b/public/pages/settings.js index 8a3d45d..7d1f6bd 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -12,6 +12,8 @@ import '/components/oikos-locale-picker.js'; const SUPPORTED_CURRENCIES = ['AED', 'AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'HUF', 'INR', 'JPY', 'NOK', 'PLN', 'RUB', 'SAR', 'SEK', 'TRY', 'UAH', 'USD']; const SETTINGS_TAB_KEY = 'oikos:settings:tab'; +const APP_NAME_STORAGE_KEY = 'oikos-app-name'; +const DEFAULT_APP_NAME = 'Oikos'; const CATEGORY_I18N = { 'Obst & Gemüse': 'shopping.catFruitVeg', @@ -56,7 +58,7 @@ export async function render(container, { user }) { let users = []; let googleStatus = { configured: false, connected: false, lastSync: null }; let appleStatus = { configured: false, lastSync: null }; - let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR' }; + let prefs = { visible_meal_types: ['breakfast', 'lunch', 'dinner', 'snack'], currency: 'EUR', date_format: 'mdy', app_name: DEFAULT_APP_NAME }; let categories = []; let icsSubscriptions = []; let apiTokens = []; @@ -80,6 +82,13 @@ export async function render(container, { user }) { if (apiTokensRes.status === 'fulfilled') apiTokens = apiTokensRes.value.data ?? []; } catch (_) { /* non-critical */ } + if (prefs.date_format) { + try { localStorage.setItem('oikos-date-format', prefs.date_format); } catch (_) {} + } + if (prefs.app_name) { + try { localStorage.setItem(APP_NAME_STORAGE_KEY, prefs.app_name); } catch (_) {} + } + const googleStatusText = googleStatus.connected ? (googleStatus.lastSync ? t('settings.connectedLastSync', { date: formatDateTime(googleStatus.lastSync) }) : t('settings.connected')) : googleStatus.configured ? t('settings.notConnected') : t('settings.notConfigured'); @@ -139,6 +148,48 @@ export async function render(container, { user }) { + ${user?.role === 'admin' ? ` +
+

${t('settings.sectionAppName')}

+
+

${t('settings.appNameTitle')}

+

${t('settings.appNameHint')}

+
+
+ + +
+ +
+ + +
+
+
+
+ ` : ''} + +
+

${t('settings.sectionDate')}

+
+

${t('settings.dateFormatTitle')}

+

${t('settings.dateFormatHint')}

+ + +
+
+

${t('settings.languageTitle')}

@@ -514,6 +565,58 @@ function bindEvents(container, user, categories, icsSubscriptions, apiTokens) { }); } + const dateFormatSelect = container.querySelector('#date-format-select'); + if (dateFormatSelect) { + dateFormatSelect.addEventListener('change', async () => { + try { + await api.put('/preferences', { date_format: dateFormatSelect.value }); + try { localStorage.setItem('oikos-date-format', dateFormatSelect.value); } catch (_) {} + window.dispatchEvent(new CustomEvent('date-format-changed', { detail: { dateFormat: dateFormatSelect.value } })); + window.oikos?.showToast(t('settings.dateFormatSavedToast'), 'success'); + } catch (err) { + window.oikos?.showToast(err.message ?? t('common.errorGeneric'), 'danger'); + } + }); + } + + const appNameForm = container.querySelector('#app-name-form'); + if (appNameForm) { + appNameForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = container.querySelector('#app-name-error'); + const input = container.querySelector('#app-name-input'); + errorEl.hidden = true; + const value = input.value.trim(); + try { + await api.put('/preferences', { app_name: value }); + try { + if (value) localStorage.setItem(APP_NAME_STORAGE_KEY, value); + else localStorage.removeItem(APP_NAME_STORAGE_KEY); + } catch (_) {} + input.value = value || DEFAULT_APP_NAME; + window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: value || DEFAULT_APP_NAME } })); + window.oikos?.showToast(t('settings.appNameSavedToast'), 'success'); + } catch (err) { + showError(errorEl, err.message ?? t('common.errorGeneric')); + } + }); + + container.querySelector('#app-name-reset-btn')?.addEventListener('click', async () => { + const errorEl = container.querySelector('#app-name-error'); + const input = container.querySelector('#app-name-input'); + errorEl.hidden = true; + input.value = DEFAULT_APP_NAME; + try { + await api.put('/preferences', { app_name: '' }); + try { localStorage.removeItem(APP_NAME_STORAGE_KEY); } catch (_) {} + window.dispatchEvent(new CustomEvent('app-name-changed', { detail: { appName: DEFAULT_APP_NAME } })); + window.oikos?.showToast(t('settings.appNameSavedToast'), 'success'); + } catch (err) { + showError(errorEl, err.message ?? t('common.errorGeneric')); + } + }); + } + // Passwort ändern const passwordForm = container.querySelector('#password-form'); if (passwordForm) { diff --git a/server/index.js b/server/index.js index 75dceb3..be4759d 100644 --- a/server/index.js +++ b/server/index.js @@ -39,6 +39,7 @@ const logOikos = createLogger('Oikos'); const { version: APP_VERSION } = JSON.parse( readFileSync(new URL('../package.json', import.meta.url), 'utf-8') ); +const DEFAULT_APP_NAME = 'Oikos'; const app = express(); const PORT = process.env.PORT || 3000; @@ -165,7 +166,14 @@ app.use('/api/v1/auth', authRouter); // Versionsinformation - keine Authentifizierung erforderlich (Login-Seite benötigt diese) app.get('/api/v1/version', (req, res) => { - res.json({ version: APP_VERSION }); + let appName = DEFAULT_APP_NAME; + try { + const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get('app_name'); + if (row?.value) appName = row.value; + } catch { + // fall back to default + } + res.json({ version: APP_VERSION, app_name: appName }); }); function sendOpenApi(req, res) { From 08199495b6a1bfbb03a1ff1a007d244cf10b6504 Mon Sep 17 00:00:00 2001 From: Rafael Foster Date: Sun, 26 Apr 2026 21:18:59 -0300 Subject: [PATCH 3/4] A lot of change in this commit. Changing the dashboard to get more data and the new features added --- public/i18n.js | 35 +- public/locales/ar.json | 24 + public/locales/de.json | 24 + public/locales/el.json | 24 + public/locales/en.json | 24 + public/locales/es.json | 24 + public/locales/fr.json | 24 + public/locales/hi.json | 24 + public/locales/it.json | 24 + public/locales/ja.json | 24 + public/locales/pt.json | 24 + public/locales/ru.json | 24 + public/locales/sv.json | 24 + public/locales/tr.json | 24 + public/locales/uk.json | 24 + public/locales/zh.json | 24 + public/pages/birthdays.js | 70 +- public/pages/dashboard.js | 481 +++++++++++--- public/pages/login.js | 28 +- public/router.js | 88 ++- public/styles/birthdays.css | 94 ++- public/styles/dashboard.css | 1168 ++++++++++++++++++++++++++++++++++ public/sw-register.js | 19 +- public/sw.js | 56 +- server/routes/calendar.js | 50 +- server/routes/dashboard.js | 60 ++ server/routes/preferences.js | 35 +- test-dashboard.js | 65 ++ 28 files changed, 2428 insertions(+), 181 deletions(-) diff --git a/public/i18n.js b/public/i18n.js index c14da31..64b88d9 100644 --- a/public/i18n.js +++ b/public/i18n.js @@ -8,6 +8,8 @@ const SUPPORTED_LOCALES = ['de', 'en', 'es', 'fr', 'it', 'sv', 'el', 'ru', 'tr', 'zh', 'ja', 'ar', 'hi', 'pt', 'uk']; const DEFAULT_LOCALE = 'de'; const STORAGE_KEY = 'oikos-locale'; +const DATE_FORMAT_KEY = 'oikos-date-format'; +const DEFAULT_DATE_FORMAT = 'mdy'; let currentLocale = DEFAULT_LOCALE; let translations = {}; @@ -78,6 +80,28 @@ export function t(key, params = {}) { return str; } +function isDateOnlyString(value) { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value); +} + +function getDateFormatPreference() { + const stored = localStorage.getItem(DATE_FORMAT_KEY); + return ['mdy', 'dmy', 'ymd'].includes(stored) ? stored : DEFAULT_DATE_FORMAT; +} + +function formatDateParts(date, useUtc = false) { + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ''; + const year = useUtc ? d.getUTCFullYear() : d.getFullYear(); + const month = String((useUtc ? d.getUTCMonth() : d.getMonth()) + 1).padStart(2, '0'); + const day = String(useUtc ? d.getUTCDate() : d.getDate()).padStart(2, '0'); + switch (getDateFormatPreference()) { + case 'dmy': return `${day}/${month}/${year}`; + case 'ymd': return `${year}-${month}-${day}`; + default: return `${month}/${day}/${year}`; + } +} + /** Aktuelle Locale abfragen */ export function getLocale() { return currentLocale; @@ -91,13 +115,10 @@ export function getSupportedLocales() { /** Datum locale-aware formatieren */ export function formatDate(date) { if (date == null) return ''; - const d = date instanceof Date ? date : new Date(date); - if (isNaN(d.getTime())) return ''; - return new Intl.DateTimeFormat(currentLocale, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }).format(d); + if (isDateOnlyString(date)) { + return formatDateParts(new Date(`${date}T00:00:00Z`), true); + } + return formatDateParts(date); } /** Uhrzeit locale-aware formatieren */ diff --git a/public/locales/ar.json b/public/locales/ar.json index 38fa584..be87365 100644 --- a/public/locales/ar.json +++ b/public/locales/ar.json @@ -83,6 +83,19 @@ "allDay": "طوال اليوم", "shoppingMore": "+{{count}} أخرى", "weather": "الطقس", + "familyMembers": "أفراد العائلة", + "participantsAdded": "مشاركون مضافون", + "upcomingBirthdays": "أعياد الميلاد القادمة", + "noBirthdays": "لا توجد أعياد ميلاد بعد", + "daysLeft": "{{count}} أيام", + "budgetOverview": "نظرة عامة على الميزانية", + "monthlyIncome": "الدخل", + "monthlyExpenses": "المصروفات", + "monthlyBalance": "الرصيد", + "savingsRate": "معدل الادخار", + "topExpense": "أكبر مصروف", + "budgetEntries": "القيود", + "noBudgetData": "لا توجد بيانات ميزانية لهذا الشهر.", "customize": "تخصيص", "customizeTitle": "تخصيص الأدوات", "customizeReset": "الافتراضي", @@ -537,6 +550,7 @@ "tabAccount": "الحساب", "tabsAriaLabel": "أقسام الإعدادات", "sectionDesign": "التصميم", + "sectionAppName": "اسم التطبيق", "sectionShopping": "التسوق", "shoppingCategoriesLabel": "فئات التسوق", "shoppingCategoriesHint": "إضافة الفئات أو إعادة تسميتها أو حذفها أو ترتيبها.", @@ -554,6 +568,16 @@ "sectionCalendarSync": "مزامنة التقويم", "sectionFamily": "أفراد العائلة", "cardAppearance": "المظهر", + "appNameTitle": "اسم التطبيق", + "appNameLabel": "اسم التطبيق", + "appNameHint": "يظهر هذا الاسم في الشريط الجانبي وعنوان المتصفح وشاشة تسجيل الدخول.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "تم حفظ اسم التطبيق.", + "sectionDate": "التاريخ", + "dateFormatTitle": "تنسيق التاريخ", + "dateFormatLabel": "تنسيق التاريخ المفضل", + "dateFormatHint": "اختر كيف تظهر التواريخ في التطبيق.", + "dateFormatSavedToast": "تم حفظ تنسيق التاريخ.", "themeSystem": "النظام", "themeSysLabel": "استخدام إعداد النظام", "themeLight": "فاتح", diff --git a/public/locales/de.json b/public/locales/de.json index 9c36484..291975f 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -89,6 +89,19 @@ "allDay": "Ganztägig", "shoppingMore": "+{{count}} weitere", "weather": "Wetter", + "familyMembers": "Familienmitglieder", + "participantsAdded": "Teilnehmer hinzugefügt", + "upcomingBirthdays": "Nächste Geburtstage", + "noBirthdays": "Noch keine Geburtstage", + "daysLeft": "{{count}} Tage", + "budgetOverview": "Budgetübersicht", + "monthlyIncome": "Einnahmen", + "monthlyExpenses": "Ausgaben", + "monthlyBalance": "Saldo", + "savingsRate": "Sparquote", + "topExpense": "Größte Ausgabe", + "budgetEntries": "Einträge", + "noBudgetData": "Keine Budgetdaten in diesem Monat.", "customize": "Anpassen", "customizeTitle": "Widgets anpassen", "customizeReset": "Standard", @@ -543,6 +556,7 @@ "tabAccount": "Konto", "tabsAriaLabel": "Einstellungsbereiche", "sectionDesign": "Design", + "sectionAppName": "Anwendungsname", "sectionShopping": "Einkauf", "shoppingCategoriesLabel": "Einkaufskategorien", "shoppingCategoriesHint": "Kategorien hinzufügen, umbenennen, löschen oder sortieren.", @@ -560,6 +574,16 @@ "sectionCalendarSync": "Kalender-Synchronisation", "sectionFamily": "Familienmitglieder", "cardAppearance": "Darstellung", + "appNameTitle": "App-Name", + "appNameLabel": "Anwendungsname", + "appNameHint": "Dieser Name erscheint in der Seitenleiste, im Browser-Titel und auf dem Login-Bildschirm.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "Anwendungsname gespeichert.", + "sectionDate": "Datum", + "dateFormatTitle": "Datumsformat", + "dateFormatLabel": "Bevorzugtes Datumsformat", + "dateFormatHint": "Wähle, wie Daten in der App angezeigt werden.", + "dateFormatSavedToast": "Datumsformat gespeichert.", "themeSystem": "System", "themeSysLabel": "System-Einstellung verwenden", "themeLight": "Hell", diff --git a/public/locales/el.json b/public/locales/el.json index 9b9c1d7..b882d69 100644 --- a/public/locales/el.json +++ b/public/locales/el.json @@ -83,6 +83,19 @@ "allDay": "Όλη μέρα", "shoppingMore": "+{{count}} ακόμα", "weather": "Καιρός", + "familyMembers": "Μέλη οικογένειας", + "participantsAdded": "συμμετέχοντες προστέθηκαν", + "upcomingBirthdays": "Επόμενα γενέθλια", + "noBirthdays": "Δεν υπάρχουν γενέθλια ακόμα", + "daysLeft": "{{count}} ημέρες", + "budgetOverview": "Επισκόπηση προϋπολογισμού", + "monthlyIncome": "Έσοδα", + "monthlyExpenses": "Έξοδα", + "monthlyBalance": "Υπόλοιπο", + "savingsRate": "Ποσοστό αποταμίευσης", + "topExpense": "Μεγαλύτερο έξοδο", + "budgetEntries": "Καταχωρήσεις", + "noBudgetData": "Δεν υπάρχουν δεδομένα προϋπολογισμού αυτόν τον μήνα.", "customize": "Προσαρμογή", "customizeTitle": "Προσαρμογή widgets", "customizeReset": "Επαναφορά", @@ -537,6 +550,7 @@ "tabAccount": "Λογαριασμός", "tabsAriaLabel": "Τμήματα ρυθμίσεων", "sectionDesign": "Εμφάνιση", + "sectionAppName": "Όνομα εφαρμογής", "sectionShopping": "Αγορές", "shoppingCategoriesLabel": "Κατηγορίες αγορών", "shoppingCategoriesHint": "Προσθέστε, μετονομάστε, διαγράψτε ή ταξινομήστε κατηγορίες.", @@ -554,6 +568,16 @@ "sectionCalendarSync": "Συγχρονισμός ημερολογίου", "sectionFamily": "Μέλη οικογένειας", "cardAppearance": "Εμφάνιση", + "appNameTitle": "Όνομα εφαρμογής", + "appNameLabel": "Όνομα εφαρμογής", + "appNameHint": "Αυτό το όνομα εμφανίζεται στην πλαϊνή μπάρα, στον τίτλο του προγράμματος περιήγησης και στην οθόνη σύνδεσης.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "Το όνομα εφαρμογής αποθηκεύτηκε.", + "sectionDate": "Ημερομηνία", + "dateFormatTitle": "Μορφή ημερομηνίας", + "dateFormatLabel": "Προτιμώμενη μορφή ημερομηνίας", + "dateFormatHint": "Επιλέξτε πώς εμφανίζονται οι ημερομηνίες στην εφαρμογή.", + "dateFormatSavedToast": "Η μορφή ημερομηνίας αποθηκεύτηκε.", "themeSystem": "Σύστημα", "themeSysLabel": "Χρήση ρύθμισης συστήματος", "themeLight": "Ανοιχτό", diff --git a/public/locales/en.json b/public/locales/en.json index 2e85007..68aaba8 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -83,6 +83,19 @@ "allDay": "All day", "shoppingMore": "+{{count}} more", "weather": "Weather", + "familyMembers": "Family members", + "participantsAdded": "participants added", + "upcomingBirthdays": "Upcoming birthdays", + "noBirthdays": "No birthdays yet", + "daysLeft": "{{count}} days", + "budgetOverview": "Budget overview", + "monthlyIncome": "Income", + "monthlyExpenses": "Expenses", + "monthlyBalance": "Balance", + "savingsRate": "Savings rate", + "topExpense": "Top expense", + "budgetEntries": "Entries", + "noBudgetData": "No budget data this month.", "customize": "Customize", "customizeTitle": "Customize widgets", "customizeReset": "Reset", @@ -537,6 +550,7 @@ "tabAccount": "Account", "tabsAriaLabel": "Settings sections", "sectionDesign": "Appearance", + "sectionAppName": "Application name", "sectionShopping": "Shopping", "shoppingCategoriesLabel": "Shopping Categories", "shoppingCategoriesHint": "Add, rename, delete or reorder categories.", @@ -554,6 +568,16 @@ "sectionCalendarSync": "Calendar Sync", "sectionFamily": "Family Members", "cardAppearance": "Display", + "appNameTitle": "App name", + "appNameLabel": "Application name", + "appNameHint": "This name appears in the sidebar, browser title and login screen.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "Application name saved.", + "sectionDate": "Date", + "dateFormatTitle": "Date format", + "dateFormatLabel": "Preferred date format", + "dateFormatHint": "Choose how dates are displayed throughout the app.", + "dateFormatSavedToast": "Date format saved.", "themeSystem": "System", "themeSysLabel": "Use system setting", "themeLight": "Light", diff --git a/public/locales/es.json b/public/locales/es.json index ba60756..fba9471 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -83,6 +83,19 @@ "allDay": "Todo el día", "shoppingMore": "+{{count}} más", "weather": "Clima", + "familyMembers": "Miembros de la familia", + "participantsAdded": "participantes añadidos", + "upcomingBirthdays": "Próximos cumpleaños", + "noBirthdays": "Aún no hay cumpleaños", + "daysLeft": "{{count}} días", + "budgetOverview": "Resumen del presupuesto", + "monthlyIncome": "Ingresos", + "monthlyExpenses": "Gastos", + "monthlyBalance": "Saldo", + "savingsRate": "Tasa de ahorro", + "topExpense": "Mayor gasto", + "budgetEntries": "Movimientos", + "noBudgetData": "No hay datos de presupuesto este mes.", "customize": "Personalizar", "customizeTitle": "Personalizar widgets", "customizeReset": "Restablecer", @@ -537,6 +550,7 @@ "tabAccount": "Cuenta", "tabsAriaLabel": "Secciones de configuración", "sectionDesign": "Diseño", + "sectionAppName": "Nombre de la aplicación", "sectionShopping": "Compras", "shoppingCategoriesLabel": "Categorías de compra", "shoppingCategoriesHint": "Añade, renombra, elimina u ordena las categorías.", @@ -554,6 +568,16 @@ "sectionCalendarSync": "Sincronización de calendario", "sectionFamily": "Miembros de la familia", "cardAppearance": "Apariencia", + "appNameTitle": "Nombre de la app", + "appNameLabel": "Nombre de la aplicación", + "appNameHint": "Este nombre aparece en la barra lateral, el título del navegador y la pantalla de inicio de sesión.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "Nombre de la aplicación guardado.", + "sectionDate": "Fecha", + "dateFormatTitle": "Formato de fecha", + "dateFormatLabel": "Formato de fecha preferido", + "dateFormatHint": "Elige cómo se muestran las fechas en toda la app.", + "dateFormatSavedToast": "Formato de fecha guardado.", "themeSystem": "Sistema", "themeSysLabel": "Usar configuración del sistema", "themeLight": "Claro", diff --git a/public/locales/fr.json b/public/locales/fr.json index bf82d53..c0e0b3f 100644 --- a/public/locales/fr.json +++ b/public/locales/fr.json @@ -83,6 +83,19 @@ "allDay": "Toute la journée", "shoppingMore": "+{{count}} de plus", "weather": "Météo", + "familyMembers": "Membres de la famille", + "participantsAdded": "participants ajoutés", + "upcomingBirthdays": "Prochains anniversaires", + "noBirthdays": "Aucun anniversaire pour l'instant", + "daysLeft": "{{count}} jours", + "budgetOverview": "Aperçu du budget", + "monthlyIncome": "Revenus", + "monthlyExpenses": "Dépenses", + "monthlyBalance": "Solde", + "savingsRate": "Taux d'épargne", + "topExpense": "Plus grosse dépense", + "budgetEntries": "Écritures", + "noBudgetData": "Aucune donnée de budget ce mois-ci.", "customize": "Personnaliser", "customizeTitle": "Personnaliser les widgets", "customizeReset": "Réinitialiser", @@ -537,6 +550,7 @@ "tabAccount": "Compte", "tabsAriaLabel": "Sections des paramètres", "sectionDesign": "Apparence", + "sectionAppName": "Nom de l'application", "sectionShopping": "Courses", "shoppingCategoriesLabel": "Catégories de courses", "shoppingCategoriesHint": "Ajoutez, renommez, supprimez ou réorganisez les catégories.", @@ -554,6 +568,16 @@ "sectionCalendarSync": "Synchronisation du calendrier", "sectionFamily": "Membres de la famille", "cardAppearance": "Affichage", + "appNameTitle": "Nom de l'application", + "appNameLabel": "Nom de l'application", + "appNameHint": "Ce nom apparaît dans la barre latérale, le titre du navigateur et l'écran de connexion.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "Nom de l'application enregistré.", + "sectionDate": "Date", + "dateFormatTitle": "Format de date", + "dateFormatLabel": "Format de date préféré", + "dateFormatHint": "Choisissez comment les dates sont affichées dans l'application.", + "dateFormatSavedToast": "Format de date enregistré.", "themeSystem": "Système", "themeSysLabel": "Utiliser le paramètre système", "themeLight": "Clair", diff --git a/public/locales/hi.json b/public/locales/hi.json index ec68ba9..252117a 100644 --- a/public/locales/hi.json +++ b/public/locales/hi.json @@ -83,6 +83,19 @@ "allDay": "पूरे दिन", "shoppingMore": "+{{count}} और", "weather": "मौसम", + "familyMembers": "परिवार के सदस्य", + "participantsAdded": "प्रतिभागी जोड़े गए", + "upcomingBirthdays": "आने वाले जन्मदिन", + "noBirthdays": "अभी कोई जन्मदिन नहीं", + "daysLeft": "{{count}} दिन", + "budgetOverview": "बजट अवलोकन", + "monthlyIncome": "आय", + "monthlyExpenses": "खर्च", + "monthlyBalance": "शेष", + "savingsRate": "बचत दर", + "topExpense": "सबसे बड़ा खर्च", + "budgetEntries": "प्रविष्टियां", + "noBudgetData": "इस महीने बजट डेटा नहीं है।", "customize": "अनुकूलित करें", "customizeTitle": "विजेट अनुकूलित करें", "customizeReset": "डिफ़ॉल्ट", @@ -537,6 +550,7 @@ "tabAccount": "खाता", "tabsAriaLabel": "सेटिंग्स अनुभाग", "sectionDesign": "डिज़ाइन", + "sectionAppName": "ऐप का नाम", "sectionShopping": "खरीदारी", "shoppingCategoriesLabel": "खरीदारी श्रेणियां", "shoppingCategoriesHint": "श्रेणियां जोड़ें, नाम बदलें, हटाएं या क्रम बदलें।", @@ -554,6 +568,16 @@ "sectionCalendarSync": "कैलेंडर सिंक", "sectionFamily": "परिवार के सदस्य", "cardAppearance": "दिखावट", + "appNameTitle": "ऐप का नाम", + "appNameLabel": "ऐप का नाम", + "appNameHint": "यह नाम साइडबार, ब्राउज़र शीर्षक और लॉगिन स्क्रीन में दिखाई देगा।", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "ऐप का नाम सहेजा गया।", + "sectionDate": "तारीख", + "dateFormatTitle": "तारीख प्रारूप", + "dateFormatLabel": "पसंदीदा तारीख प्रारूप", + "dateFormatHint": "चुनें कि ऐप में तारीखें कैसे दिखाई दें।", + "dateFormatSavedToast": "तारीख प्रारूप सहेजा गया।", "themeSystem": "सिस्टम", "themeSysLabel": "सिस्टम सेटिंग का उपयोग करें", "themeLight": "हल्का", diff --git a/public/locales/it.json b/public/locales/it.json index 1d1a2be..a790dfb 100644 --- a/public/locales/it.json +++ b/public/locales/it.json @@ -83,6 +83,19 @@ "allDay": "Tutto il giorno", "shoppingMore": "+{{count}} altri", "weather": "Meteo", + "familyMembers": "Membri della famiglia", + "participantsAdded": "partecipanti aggiunti", + "upcomingBirthdays": "Prossimi compleanni", + "noBirthdays": "Ancora nessun compleanno", + "daysLeft": "{{count}} giorni", + "budgetOverview": "Panoramica budget", + "monthlyIncome": "Entrate", + "monthlyExpenses": "Uscite", + "monthlyBalance": "Saldo", + "savingsRate": "Tasso di risparmio", + "topExpense": "Spesa principale", + "budgetEntries": "Movimenti", + "noBudgetData": "Nessun dato di budget questo mese.", "customize": "Personalizza", "customizeTitle": "Personalizza widget", "customizeReset": "Ripristina", @@ -537,6 +550,7 @@ "tabAccount": "Account", "tabsAriaLabel": "Sezioni impostazioni", "sectionDesign": "Aspetto", + "sectionAppName": "Nome dell'applicazione", "sectionShopping": "Spesa", "shoppingCategoriesLabel": "Categorie spesa", "shoppingCategoriesHint": "Aggiungi, rinomina, elimina o riordina le categorie.", @@ -554,6 +568,16 @@ "sectionCalendarSync": "Sincronizzazione calendario", "sectionFamily": "Membri della famiglia", "cardAppearance": "Visualizzazione", + "appNameTitle": "Nome dell'app", + "appNameLabel": "Nome dell'applicazione", + "appNameHint": "Questo nome appare nella barra laterale, nel titolo del browser e nella schermata di accesso.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "Nome dell'applicazione salvato.", + "sectionDate": "Data", + "dateFormatTitle": "Formato data", + "dateFormatLabel": "Formato data preferito", + "dateFormatHint": "Scegli come vengono mostrate le date nell'app.", + "dateFormatSavedToast": "Formato data salvato.", "themeSystem": "Sistema", "themeSysLabel": "Usa impostazione di sistema", "themeLight": "Chiaro", diff --git a/public/locales/ja.json b/public/locales/ja.json index 448e42d..68b4694 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -83,6 +83,19 @@ "allDay": "終日", "shoppingMore": "+{{count}} 件", "weather": "天気", + "familyMembers": "家族メンバー", + "participantsAdded": "人が追加済み", + "upcomingBirthdays": "今後の誕生日", + "noBirthdays": "誕生日はまだありません", + "daysLeft": "{{count}}日", + "budgetOverview": "予算の概要", + "monthlyIncome": "収入", + "monthlyExpenses": "支出", + "monthlyBalance": "残高", + "savingsRate": "貯蓄率", + "topExpense": "最大の支出", + "budgetEntries": "記録", + "noBudgetData": "今月の予算データはありません。", "customize": "カスタマイズ", "customizeTitle": "ウィジェットのカスタマイズ", "customizeReset": "デフォルト", @@ -537,6 +550,7 @@ "tabAccount": "アカウント", "tabsAriaLabel": "設定カテゴリー", "sectionDesign": "デザイン", + "sectionAppName": "アプリ名", "sectionShopping": "買い物", "shoppingCategoriesLabel": "買い物カテゴリー", "shoppingCategoriesHint": "カテゴリーの追加、名前変更、削除、並び替えができます。", @@ -554,6 +568,16 @@ "sectionCalendarSync": "カレンダー同期", "sectionFamily": "家族メンバー", "cardAppearance": "外観", + "appNameTitle": "アプリ名", + "appNameLabel": "アプリ名", + "appNameHint": "この名前はサイドバー、ブラウザのタイトル、ログイン画面に表示されます。", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "アプリ名を保存しました。", + "sectionDate": "日付", + "dateFormatTitle": "日付形式", + "dateFormatLabel": "希望する日付形式", + "dateFormatHint": "アプリ内で日付をどう表示するかを選択します。", + "dateFormatSavedToast": "日付形式を保存しました。", "themeSystem": "システム設定", "themeSysLabel": "システム設定を使用", "themeLight": "ライト", diff --git a/public/locales/pt.json b/public/locales/pt.json index 161932c..9ee9b9f 100644 --- a/public/locales/pt.json +++ b/public/locales/pt.json @@ -83,6 +83,19 @@ "allDay": "Dia inteiro", "shoppingMore": "+{{count}} mais", "weather": "Clima", + "familyMembers": "Membros da família", + "participantsAdded": "participantes adicionados", + "upcomingBirthdays": "Próximos aniversários", + "noBirthdays": "Nenhum aniversário ainda", + "daysLeft": "{{count}} dias", + "budgetOverview": "Visão do orçamento", + "monthlyIncome": "Receitas", + "monthlyExpenses": "Despesas", + "monthlyBalance": "Saldo", + "savingsRate": "Taxa de poupança", + "topExpense": "Maior despesa", + "budgetEntries": "Lançamentos", + "noBudgetData": "Sem dados de orçamento neste mês.", "customize": "Personalizar", "customizeTitle": "Personalizar widgets", "customizeReset": "Padrão", @@ -537,6 +550,7 @@ "tabAccount": "Conta", "tabsAriaLabel": "Seções de configurações", "sectionDesign": "Design", + "sectionAppName": "Nome da aplicação", "sectionShopping": "Compras", "shoppingCategoriesLabel": "Categorias de compras", "shoppingCategoriesHint": "Adicione, renomeie, exclua ou ordene categorias.", @@ -554,6 +568,16 @@ "sectionCalendarSync": "Sincronização de calendário", "sectionFamily": "Membros da família", "cardAppearance": "Aparência", + "appNameTitle": "Nome do app", + "appNameLabel": "Nome da aplicação", + "appNameHint": "Este nome aparece na barra lateral, no título do navegador e no ecrã de login.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "Nome da aplicação guardado.", + "sectionDate": "Data", + "dateFormatTitle": "Formato da data", + "dateFormatLabel": "Formato preferido da data", + "dateFormatHint": "Escolha como as datas aparecem em toda a aplicação.", + "dateFormatSavedToast": "Formato da data salvo.", "themeSystem": "Sistema", "themeSysLabel": "Usar configuração do sistema", "themeLight": "Claro", diff --git a/public/locales/ru.json b/public/locales/ru.json index 9b93928..6e145f1 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -83,6 +83,19 @@ "allDay": "Весь день", "shoppingMore": "+{{count}} ещё", "weather": "Погода", + "familyMembers": "Члены семьи", + "participantsAdded": "участников добавлено", + "upcomingBirthdays": "Ближайшие дни рождения", + "noBirthdays": "Дней рождения пока нет", + "daysLeft": "{{count}} дн.", + "budgetOverview": "Обзор бюджета", + "monthlyIncome": "Доходы", + "monthlyExpenses": "Расходы", + "monthlyBalance": "Баланс", + "savingsRate": "Норма сбережений", + "topExpense": "Крупнейший расход", + "budgetEntries": "Записи", + "noBudgetData": "Нет данных бюджета за этот месяц.", "customize": "Настроить", "customizeTitle": "Настроить виджеты", "customizeReset": "Сбросить", @@ -537,6 +550,7 @@ "tabAccount": "Аккаунт", "tabsAriaLabel": "Разделы настроек", "sectionDesign": "Внешний вид", + "sectionAppName": "Название приложения", "sectionShopping": "Покупки", "shoppingCategoriesLabel": "Категории покупок", "shoppingCategoriesHint": "Добавляйте, переименовывайте, удаляйте или сортируйте категории.", @@ -554,6 +568,16 @@ "sectionCalendarSync": "Синхронизация календаря", "sectionFamily": "Члены семьи", "cardAppearance": "Отображение", + "appNameTitle": "Название приложения", + "appNameLabel": "Название приложения", + "appNameHint": "Это название отображается в боковом меню, заголовке браузера и на экране входа.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "Название приложения сохранено.", + "sectionDate": "Дата", + "dateFormatTitle": "Формат даты", + "dateFormatLabel": "Предпочитаемый формат даты", + "dateFormatHint": "Выберите, как даты отображаются в приложении.", + "dateFormatSavedToast": "Формат даты сохранён.", "themeSystem": "Система", "themeSysLabel": "Использовать системную настройку", "themeLight": "Светлая", diff --git a/public/locales/sv.json b/public/locales/sv.json index 3914485..f546000 100644 --- a/public/locales/sv.json +++ b/public/locales/sv.json @@ -83,6 +83,19 @@ "allDay": "Hela dagen", "shoppingMore": "+{{count}} till", "weather": "Väder", + "familyMembers": "Familjemedlemmar", + "participantsAdded": "deltagare tillagda", + "upcomingBirthdays": "Kommande födelsedagar", + "noBirthdays": "Inga födelsedagar ännu", + "daysLeft": "{{count}} dagar", + "budgetOverview": "Budgetöversikt", + "monthlyIncome": "Inkomster", + "monthlyExpenses": "Utgifter", + "monthlyBalance": "Saldo", + "savingsRate": "Sparandegrad", + "topExpense": "Största utgift", + "budgetEntries": "Poster", + "noBudgetData": "Ingen budgetdata denna månad.", "customize": "Anpassa", "customizeTitle": "Anpassa widgets", "customizeReset": "Återställ", @@ -537,6 +550,7 @@ "tabAccount": "Konto", "tabsAriaLabel": "Inställningsavsnitt", "sectionDesign": "Utseende", + "sectionAppName": "Appnamn", "sectionShopping": "Inköp", "shoppingCategoriesLabel": "Inköpskategorier", "shoppingCategoriesHint": "Lägg till, byt namn, ta bort eller sortera om kategorier.", @@ -554,6 +568,16 @@ "sectionCalendarSync": "Kalendersynkronisering", "sectionFamily": "Familjemedlemmar", "cardAppearance": "Visa", + "appNameTitle": "Appnamn", + "appNameLabel": "Appnamn", + "appNameHint": "Det här namnet visas i sidomenyn, webbläsarens titel och inloggningsskärmen.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "Appnamn sparat.", + "sectionDate": "Datum", + "dateFormatTitle": "Datumformat", + "dateFormatLabel": "Önskat datumformat", + "dateFormatHint": "Välj hur datum visas i appen.", + "dateFormatSavedToast": "Datumformat sparat.", "themeSystem": "System", "themeSysLabel": "Använd systeminställning", "themeLight": "Ljus", diff --git a/public/locales/tr.json b/public/locales/tr.json index bc78fb8..faadd2d 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -83,6 +83,19 @@ "allDay": "Tüm gün", "shoppingMore": "+{{count}} daha", "weather": "Hava", + "familyMembers": "Aile üyeleri", + "participantsAdded": "katılımcı eklendi", + "upcomingBirthdays": "Yaklaşan doğum günleri", + "noBirthdays": "Henüz doğum günü yok", + "daysLeft": "{{count}} gün", + "budgetOverview": "Bütçe özeti", + "monthlyIncome": "Gelir", + "monthlyExpenses": "Giderler", + "monthlyBalance": "Bakiye", + "savingsRate": "Tasarruf oranı", + "topExpense": "En büyük gider", + "budgetEntries": "Kayıtlar", + "noBudgetData": "Bu ay bütçe verisi yok.", "customize": "Özelleştir", "customizeTitle": "Widget'ları özelleştir", "customizeReset": "Sıfırla", @@ -537,6 +550,7 @@ "tabAccount": "Hesap", "tabsAriaLabel": "Ayar bölümleri", "sectionDesign": "Görünüm", + "sectionAppName": "Uygulama adı", "sectionShopping": "Alışveriş", "shoppingCategoriesLabel": "Alışveriş Kategorileri", "shoppingCategoriesHint": "Kategorileri ekleyin, yeniden adlandırın, silin veya sıralayın.", @@ -554,6 +568,16 @@ "sectionCalendarSync": "Takvim Senkronizasyonu", "sectionFamily": "Aile Üyeleri", "cardAppearance": "Görünüm", + "appNameTitle": "Uygulama adı", + "appNameLabel": "Uygulama adı", + "appNameHint": "Bu ad kenar çubuğunda, tarayıcı başlığında ve giriş ekranında görünür.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "Uygulama adı kaydedildi.", + "sectionDate": "Tarih", + "dateFormatTitle": "Tarih biçimi", + "dateFormatLabel": "Tercih edilen tarih biçimi", + "dateFormatHint": "Tarihlerin uygulamada nasıl görüneceğini seçin.", + "dateFormatSavedToast": "Tarih biçimi kaydedildi.", "themeSystem": "Sistem", "themeSysLabel": "Sistem ayarını kullan", "themeLight": "Açık", diff --git a/public/locales/uk.json b/public/locales/uk.json index 1c6bbe2..c035241 100644 --- a/public/locales/uk.json +++ b/public/locales/uk.json @@ -83,6 +83,19 @@ "allDay": "Весь день", "shoppingMore": "+{{count}} ще", "weather": "Погода", + "familyMembers": "Члени родини", + "participantsAdded": "учасників додано", + "upcomingBirthdays": "Найближчі дні народження", + "noBirthdays": "Днів народження ще немає", + "daysLeft": "{{count}} дн.", + "budgetOverview": "Огляд бюджету", + "monthlyIncome": "Доходи", + "monthlyExpenses": "Витрати", + "monthlyBalance": "Баланс", + "savingsRate": "Рівень заощаджень", + "topExpense": "Найбільша витрата", + "budgetEntries": "Записи", + "noBudgetData": "Немає бюджетних даних за цей місяць.", "customize": "Налаштувати", "customizeTitle": "Налаштувати віджети", "customizeReset": "Скинути", @@ -537,6 +550,7 @@ "tabAccount": "Обліковий запис", "tabsAriaLabel": "Розділи налаштувань", "sectionDesign": "Зовнішній вигляд", + "sectionAppName": "Назва застосунку", "sectionShopping": "Покупки", "shoppingCategoriesLabel": "Категорії покупок", "shoppingCategoriesHint": "Додавайте, перейменовуйте, видаляйте або змінюйте порядок категорій.", @@ -554,6 +568,16 @@ "sectionCalendarSync": "Синхронізація календаря", "sectionFamily": "Члени родини", "cardAppearance": "Відображення", + "appNameTitle": "Назва застосунку", + "appNameLabel": "Назва застосунку", + "appNameHint": "Ця назва відображається в бічному меню, заголовку браузера та на екрані входу.", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "Назву застосунку збережено.", + "sectionDate": "Дата", + "dateFormatTitle": "Формат дати", + "dateFormatLabel": "Бажаний формат дати", + "dateFormatHint": "Виберіть, як дати відображаються в застосунку.", + "dateFormatSavedToast": "Формат дати збережено.", "themeSystem": "Системна", "themeSysLabel": "Використовувати системні налаштування", "themeLight": "Світла", diff --git a/public/locales/zh.json b/public/locales/zh.json index 4de2055..b483620 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -83,6 +83,19 @@ "allDay": "全天", "shoppingMore": "+{{count}} 更多", "weather": "天气", + "familyMembers": "家庭成员", + "participantsAdded": "位参与者已添加", + "upcomingBirthdays": "即将到来的生日", + "noBirthdays": "还没有生日", + "daysLeft": "{{count}} 天", + "budgetOverview": "预算概览", + "monthlyIncome": "收入", + "monthlyExpenses": "支出", + "monthlyBalance": "余额", + "savingsRate": "储蓄率", + "topExpense": "最大支出", + "budgetEntries": "记录", + "noBudgetData": "本月没有预算数据。", "customize": "自定义", "customizeTitle": "自定义小组件", "customizeReset": "重置", @@ -537,6 +550,7 @@ "tabAccount": "账户", "tabsAriaLabel": "设置类别", "sectionDesign": "外观", + "sectionAppName": "应用名称", "sectionShopping": "购物", "shoppingCategoriesLabel": "购物分类", "shoppingCategoriesHint": "添加、重命名、删除或排序分类。", @@ -554,6 +568,16 @@ "sectionCalendarSync": "日历同步", "sectionFamily": "家庭成员", "cardAppearance": "外观", + "appNameTitle": "应用名称", + "appNameLabel": "应用名称", + "appNameHint": "此名称会显示在侧边栏、浏览器标题和登录界面中。", + "appNamePlaceholder": "Oikos", + "appNameSavedToast": "应用名称已保存。", + "sectionDate": "日期", + "dateFormatTitle": "日期格式", + "dateFormatLabel": "首选日期格式", + "dateFormatHint": "选择日期在应用中的显示方式。", + "dateFormatSavedToast": "日期格式已保存。", "themeSystem": "跟随系统", "themeSysLabel": "使用系统设置", "themeLight": "浅色", diff --git a/public/pages/birthdays.js b/public/pages/birthdays.js index 15f31f3..8ec7284 100644 --- a/public/pages/birthdays.js +++ b/public/pages/birthdays.js @@ -35,11 +35,11 @@ function photoAvatar(birthday, extraClass = '') { function filteredBirthdays() { const q = state.query.trim().toLowerCase(); - if (!q) return state.birthdays; - return state.birthdays.filter((birthday) => + const list = !q ? state.birthdays : state.birthdays.filter((birthday) => birthday.name.toLowerCase().includes(q) || (birthday.notes || '').toLowerCase().includes(q) ); + return [...list].sort((a, b) => a.name.localeCompare(b.name)); } function suggestions() { @@ -94,8 +94,15 @@ function renderUpcoming() {
${photoAvatar(birthday)}
-
${esc(birthday.name)}
-
${esc(formatDate(birthday.next_birthday))}
+
+
+
${esc(birthday.name)}
+
${esc(formatDate(birthday.next_birthday))}
+
+
+ ${birthday.days_until === 0 ? esc(t('common.today')) : birthday.days_until === 1 ? esc(t('common.tomorrow')) : esc(`${birthday.days_until}d`)} +
+
${esc(ageNote(birthday))}
@@ -146,33 +153,42 @@ function renderPage() {

${t('birthdays.title')}

- +

${t('birthdays.calendarHint')}

-
-
-

${t('birthdays.upcomingTitle')}

-

${t('birthdays.upcomingHint')}

-
-
-
+
+ -
-
-

${t('birthdays.peopleTitle')}

-

${t('birthdays.peopleHint')}

-
-
-
+
+
+ +
+
+

${t('birthdays.peopleTitle')}

+

${t('birthdays.peopleHint')}

+
+
+
+
-
-
- `; -} - function renderUrgentTasks(tasks) { if (!tasks.length) { return `
@@ -288,6 +275,43 @@ function renderUpcomingEvents(events) {
`; } +function renderUpcomingBirthdays(birthdays) { + if (!birthdays.length) { + return `
+ ${widgetHeader('cake', t('nav.birthdays'), 0, '/birthdays')} +
+ +
${t('dashboard.noBirthdays')}
+
+
`; + } + + const items = birthdays.map((b) => { + const daysLabel = b.days_until === 0 + ? t('common.today') + : b.days_until === 1 + ? t('common.tomorrow') + : t('dashboard.daysLeft', { count: b.days_until }); + return ` +
+
+ ${b.photo_data ? `` : `${esc(initials(b.name))}`} +
+
+
${esc(b.name)}
+
${formatDate(b.next_birthday)} · ${daysLabel}
+
+
${esc(String(b.next_age ?? ''))}
+
+ `; + }).join(''); + + return `
+ ${widgetHeader('cake', t('nav.birthdays'), birthdays.length, '/birthdays')} +
${items}
+
`; +} + function renderTodayMeals(meals) { const MEAL_ORDER = ['breakfast', 'lunch', 'dinner', 'snack']; @@ -334,6 +358,283 @@ function renderPinnedNotes(notes) {
`; } +function renderFamilyWidget(users) { + const visible = users.slice(0, 6); + const avatars = visible.map((u) => ` + + ${esc(initials(u.display_name))} + + `).join(''); + + return `
+ ${widgetHeader('users', t('dashboard.familyMembers'), users.length, '/settings')} +
+
${users.length}
+
${t('dashboard.participantsAdded')}
+
${avatars}
+
+
`; +} + +function renderBudgetWidget(budget, currency) { + const income = budget?.income || 0; + const expenses = budget?.expenses || 0; + const balance = budget?.balance || 0; + const savingsRate = income > 0 ? Math.round((balance / income) * 100) : 0; + const balanceTone = balance >= 0 ? 'positive' : 'negative'; + const hasData = (budget?.entryCount || 0) > 0; + + return `
+ ${widgetHeader('wallet', t('dashboard.budgetOverview'), null, '/budget')} +
+
+ ${t('dashboard.monthlyBalance')} + ${formatCurrency(balance, currency)} +
+
+
+ ${t('dashboard.monthlyIncome')} + ${formatCurrency(income, currency)} +
+
+ ${t('dashboard.monthlyExpenses')} + ${formatCurrency(expenses, currency)} +
+
+ ${t('dashboard.savingsRate')} + ${income > 0 ? `${savingsRate}%` : '-'} +
+
+ ${t('dashboard.budgetEntries')} + ${budget?.entryCount || 0} +
+
+ +
+
`; +} + +function renderQuickAction({ route, label, icon, tone = '' }) { + return ` + + `; +} + +function renderKpiTile({ title, value, meta, icon, route, tone = '' }) { + return ` + + `; +} + +function renderDashboardOverview(user, stats = null, weather = null) { + const dateLabel = formatDate(new Date()); + const weatherLabel = weather + ? `${esc(weather.city)} · ${esc(weather.current?.temp)}${weather.units === 'imperial' ? '°F' : weather.units === 'standard' ? 'K' : '°C'}` + : t('dashboard.weather'); + + const actions = [ + { route: '/tasks', label: t('nav.tasks'), icon: 'check-square', tone: 'blue' }, + { route: '/calendar', label: t('nav.calendar'), icon: 'calendar', tone: 'violet' }, + { route: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart', tone: 'green' }, + { route: '/notes', label: t('nav.notes'), icon: 'sticky-note', tone: 'amber' }, + ].map(renderQuickAction).join(''); + + const kpis = stats ? [ + renderKpiTile({ + title: t('tasks.title'), + value: String(stats.overdueCount ?? 0), + meta: t('dashboard.overdue'), + icon: 'alert-circle', + route: '/tasks', + tone: 'danger', + }), + renderKpiTile({ + title: t('nav.calendar'), + value: String(stats.todayEventCount ?? 0), + meta: t('common.today'), + icon: 'calendar-days', + route: '/calendar', + tone: 'calendar', + }), + renderKpiTile({ + title: t('nav.meals'), + value: stats.todayMealTitle ? esc(stats.todayMealTitle) : '-', + meta: t('dashboard.todayMeals'), + icon: 'utensils', + route: '/meals', + tone: 'meals', + }), + renderKpiTile({ + title: t('dashboard.weather'), + value: weatherLabel, + meta: t('dashboard.weatherRefreshTitle'), + icon: 'cloud-sun', + route: '/', + tone: 'weather', + }), + renderKpiTile({ + title: t('nav.birthdays'), + value: String(stats.birthdayCount ?? 0), + meta: t('dashboard.upcomingBirthdays'), + icon: 'cake', + route: '/birthdays', + tone: 'birthdays', + }), + renderKpiTile({ + title: t('dashboard.familyMembers'), + value: String(stats.familyCount ?? 0), + meta: t('dashboard.participantsAdded'), + icon: 'users', + route: '/settings', + tone: 'family', + }), + ].join('') : ` +
+
+
+
+
+
+ `; + + return ` +
+
+
+ ${dateLabel} +

${greeting(user.display_name)}

+
+
+
${actions}
+ +
+
+
+ ${kpis} +
+
+ `; +} + +function widgetRegion(id) { + return ['budget', 'family', 'weather', 'shopping', 'meals'].includes(id) ? 'side' : 'main'; +} + +function widgetTileClass(id) { + const map = { + tasks: 'dashboard-tile--wide', + calendar: 'dashboard-tile--compact', + birthdays: 'dashboard-tile--compact', + budget: 'dashboard-tile--wide', + family: 'dashboard-tile--compact', + meals: 'dashboard-tile--compact', + notes: 'dashboard-tile--wide', + shopping: 'dashboard-tile--compact', + weather: 'dashboard-tile--wide', + }; + return map[id] || 'dashboard-tile--compact'; +} + +function renderDashboardTile(id, html) { + if (!html) return ''; + return `
${html}
`; +} + +function renderDashboardLayout(cfg, data, weather, currency) { + const widgetById = { + tasks: () => renderUrgentTasks(data.urgentTasks ?? []), + calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []), + birthdays: () => renderUpcomingBirthdays(data.birthdays ?? []), + budget: () => renderBudgetWidget(data.budget ?? {}, currency), + family: () => renderFamilyWidget(data.users ?? []), + meals: () => renderTodayMeals(data.todayMeals ?? []), + notes: () => renderPinnedNotes(data.pinnedNotes ?? []), + shopping: () => renderShoppingLists(data.shoppingLists ?? []), + weather: () => (weather ? renderWeatherWidget(weather) : ''), + }; + + const visible = cfg.filter((w) => w.visible && widgetById[w.id]); + const mainTiles = visible + .filter((w) => widgetRegion(w.id) === 'main') + .map((w) => renderDashboardTile(w.id, widgetById[w.id]())) + .join(''); + + const sideTiles = visible + .filter((w) => widgetRegion(w.id) === 'side') + .map((w) => renderDashboardTile(w.id, widgetById[w.id]())) + .join(''); + + return ` +
+
+
+ ${mainTiles} +
+
+ +
+ `; +} + +function renderDashboardSkeleton() { + return ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${skeletonWidget(3)} + ${skeletonWidget(3)} + ${skeletonWidget(2)} + ${skeletonWidget(3)} +
+
+ +
+ `; +} + // -------------------------------------------------------- // Shopping-Widget // -------------------------------------------------------- @@ -515,25 +816,6 @@ function initFab(container, signal) { document.addEventListener('click', () => { if (open) toggleFab(false); }, { signal }); } -// -------------------------------------------------------- -// Widget-Rendering nach Konfiguration -// -------------------------------------------------------- - -function renderWidgets(cfg, data, weather) { - const renderers = { - tasks: () => renderUrgentTasks(data.urgentTasks ?? []), - calendar: () => renderUpcomingEvents(data.upcomingEvents ?? []), - shopping: () => renderShoppingLists(data.shoppingLists ?? []), - meals: () => renderTodayMeals(data.todayMeals ?? []), - notes: () => renderPinnedNotes(data.pinnedNotes ?? []), - weather: () => (weather ? renderWeatherWidget(weather) : ''), - }; - return cfg - .filter((w) => w.visible) - .map((w) => (renderers[w.id] ? renderers[w.id]() : '')) - .join(''); -} - // -------------------------------------------------------- // Customize-Modal // -------------------------------------------------------- @@ -728,20 +1010,17 @@ export async function render(container, { user }) { container.innerHTML = `

${t('dashboard.title')}

-
- ${renderGreeting(user, {})} - ${skeletonWidget(3)} - ${skeletonWidget(3)} - ${skeletonWidget(2)} - ${skeletonWidget(3)} +
+ ${renderDashboardSkeleton()}
${renderFab()} `; - let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [] }; + let data = { upcomingEvents: [], urgentTasks: [], todayMeals: [], pinnedNotes: [], shoppingLists: [], birthdays: [], users: [], budget: {} }; let weather = null; let widgetConfig = DEFAULT_WIDGET_CONFIG; + let currency = 'EUR'; try { const [dashRes, weatherRes, prefsRes] = await Promise.all([ api.get('/dashboard'), @@ -751,6 +1030,7 @@ export async function render(container, { user }) { data = dashRes; weather = weatherRes.data ?? null; widgetConfig = prefsRes.data?.dashboard_widgets ?? DEFAULT_WIDGET_CONFIG; + currency = prefsRes.data?.currency ?? 'EUR'; } catch (err) { console.error('[Dashboard] Ladefehler:', err.message, 'Status:', err.status ?? 'network'); window.oikos?.showToast(t('dashboard.loadError'), 'warning'); @@ -772,52 +1052,45 @@ export async function render(container, { user }) { todayMealTitle: (data.todayMeals ?? []).find((m) => m.meal_type === 'lunch')?.title ?? (data.todayMeals ?? [])[0]?.title ?? null, + birthdayCount: data.birthdayCount ?? (data.birthdays ?? []).length, + familyCount: (data.users ?? []).length, }; const rerender = () => render(container, { user }); - function rebuildGrid(cfg) { - const grid = container.querySelector('.dashboard__grid'); - if (!grid) return; - const greeting = grid.querySelector('.widget-greeting'); - grid.replaceChildren(...(greeting ? [greeting] : [])); - grid.insertAdjacentHTML('beforeend', renderWidgets(cfg, data, weather)); + function rebuildDashboard(cfg) { + const shell = container.querySelector('#dashboard-shell'); + if (!shell) return; + shell.innerHTML = ` + ${renderDashboardOverview(user, stats, weather)} + ${renderDashboardLayout(cfg, data, weather, currency)} + `; wireLinks(container, rerender); if (window.lucide) window.lucide.createIcons(); - wireWeatherRefresh(container); + wireWeatherRefresh(container, (updatedWeather) => { + weather = updatedWeather; + rebuildDashboard(cfg); + }); + container.querySelector('#dashboard-customize-btn')?.addEventListener('click', () => { + openCustomizeModal(widgetConfig, (newConfig) => { + widgetConfig = newConfig; + rebuildDashboard(widgetConfig); + }); + }, { signal: _fabController.signal }); } - // Greeting in-place aktualisieren (Stats-Chips hinzufügen), kein Gesamt-Reset - const greetingEl = container.querySelector('.widget-greeting'); - if (greetingEl) greetingEl.outerHTML = renderGreeting(user, stats); - - // Skeletons durch echte Widgets ersetzen - rebuildGrid(widgetConfig); + rebuildDashboard(widgetConfig); initFab(container, _fabController.signal); - container.querySelector('#dashboard-customize-btn')?.addEventListener( - 'click', - () => openCustomizeModal(widgetConfig, (newConfig) => { - widgetConfig = newConfig; - rebuildGrid(widgetConfig); - }), - { signal: _fabController.signal }, - ); - // 30-Minuten Auto-Refresh für Wetter const refreshBtn = container.querySelector('#weather-refresh-btn'); if (refreshBtn) { const doAutoRefresh = async () => { try { const res = await api.get('/weather').catch(() => ({ data: null })); - const wWidget = container.querySelector('#weather-widget'); - if (wWidget) { - wWidget.outerHTML = renderWeatherWidget(res.data ?? null); - const newWidget = container.querySelector('#weather-widget'); - if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget }); - wireWeatherRefresh(container); - } + weather = res.data ?? null; + rebuildDashboard(widgetConfig); } catch { /* silently ignore */ } }; const timerId = setInterval(doAutoRefresh, 30 * 60 * 1000); @@ -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'); if (!refreshBtn) return; const doWeatherRefresh = async () => { @@ -838,7 +1111,7 @@ function wireWeatherRefresh(container) { wWidget.outerHTML = renderWeatherWidget(res.data ?? null); const newWidget = container.querySelector('#weather-widget'); if (newWidget && window.lucide) window.lucide.createIcons({ el: newWidget }); - wireWeatherRefresh(container); + onUpdated?.(res.data ?? null); window.oikos?.showToast(t('dashboard.weatherUpdated'), 'success', 1500); } } catch { /* silently ignore */ } diff --git a/public/pages/login.js b/public/pages/login.js index 464317e..5e1d479 100644 --- a/public/pages/login.js +++ b/public/pages/login.js @@ -8,16 +8,30 @@ import { auth } from '/api.js'; import { t } from '/i18n.js'; const VERSION_URL = '/api/v1/version'; +const DEFAULT_APP_NAME = 'Oikos'; +const APP_NAME_STORAGE_KEY = 'oikos-app-name'; + +function getStoredAppName() { + return localStorage.getItem(APP_NAME_STORAGE_KEY) || DEFAULT_APP_NAME; +} + +function setAppBranding(appName) { + const name = String(appName || '').trim() || DEFAULT_APP_NAME; + document.title = name; + const titleEl = document.querySelector('.login-hero__title'); + if (titleEl) titleEl.textContent = name; +} /** * Rendert die Login-Seite in den gegebenen Container. * @param {HTMLElement} container */ export async function render(container) { + const storedAppName = getStoredAppName(); container.innerHTML = `