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 // --------------------------------------------------------