Adding Birthday tracking feature - to compete with FamilyWall
This commit is contained in:
@@ -30,6 +30,8 @@ data/
|
|||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
.codex
|
||||||
|
|
||||||
|
|
||||||
# Claude Code — share skills/agents/rules/hooks/settings; keep local permissions and worktrees out
|
# Claude Code — share skills/agents/rules/hooks/settings; keep local permissions and worktrees out
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
services:
|
services:
|
||||||
oikos:
|
oikos:
|
||||||
image: ghcr.io/ulsklyc/oikos:latest
|
# image: ghcr.io/ulsklyc/oikos:latest
|
||||||
build: . # optional: use --build to build locally instead
|
build: . # optional: use --build to build locally instead
|
||||||
container_name: oikos
|
container_name: oikos
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "0.0.0.0:3000:3000"
|
- "0.0.0.0:3100:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- oikos_data:/data
|
- oikos_data:/data
|
||||||
env_file:
|
env_file:
|
||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
# Direct HTTP access (no reverse proxy):
|
# Direct HTTP access (no reverse proxy):
|
||||||
- SESSION_SECURE=false
|
- SESSION_SECURE=false
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "التسوق",
|
"shopping": "التسوق",
|
||||||
"notes": "الملاحظات",
|
"notes": "الملاحظات",
|
||||||
"contacts": "جهات الاتصال",
|
"contacts": "جهات الاتصال",
|
||||||
|
"birthdays": "أعياد الميلاد",
|
||||||
"budget": "الميزانية",
|
"budget": "الميزانية",
|
||||||
"settings": "الإعدادات",
|
"settings": "الإعدادات",
|
||||||
"main": "القائمة الرئيسية",
|
"main": "القائمة الرئيسية",
|
||||||
@@ -757,6 +758,34 @@
|
|||||||
"placeholder": "بحث…",
|
"placeholder": "بحث…",
|
||||||
"noResults": "لم يتم العثور على نتائج."
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "تذكير",
|
"sectionTitle": "تذكير",
|
||||||
"enableLabel": "تعيين تذكير",
|
"enableLabel": "تعيين تذكير",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "Einkauf",
|
"shopping": "Einkauf",
|
||||||
"notes": "Pinnwand",
|
"notes": "Pinnwand",
|
||||||
"contacts": "Kontakte",
|
"contacts": "Kontakte",
|
||||||
|
"birthdays": "Geburtstage",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"main": "Hauptnavigation",
|
"main": "Hauptnavigation",
|
||||||
@@ -754,6 +755,35 @@
|
|||||||
"pendingBadgeTitle": "{{count}} fällige Erinnerung",
|
"pendingBadgeTitle": "{{count}} fällige Erinnerung",
|
||||||
"pendingBadgeTitlePlural": "{{count}} fällige Erinnerungen"
|
"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": {
|
"recipes": {
|
||||||
"title": "Rezepte",
|
"title": "Rezepte",
|
||||||
"addRecipe": "Rezept hinzufügen",
|
"addRecipe": "Rezept hinzufügen",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "Αγορές",
|
"shopping": "Αγορές",
|
||||||
"notes": "Σημειώσεις",
|
"notes": "Σημειώσεις",
|
||||||
"contacts": "Επαφές",
|
"contacts": "Επαφές",
|
||||||
|
"birthdays": "Γενέθλια",
|
||||||
"budget": "Προϋπολογισμός",
|
"budget": "Προϋπολογισμός",
|
||||||
"settings": "Ρυθμίσεις",
|
"settings": "Ρυθμίσεις",
|
||||||
"main": "Κύρια πλοήγηση",
|
"main": "Κύρια πλοήγηση",
|
||||||
@@ -757,6 +758,34 @@
|
|||||||
"placeholder": "Αναζήτηση…",
|
"placeholder": "Αναζήτηση…",
|
||||||
"noResults": "Δεν βρέθηκαν αποτελέσματα."
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "Υπενθύμιση",
|
"sectionTitle": "Υπενθύμιση",
|
||||||
"enableLabel": "Ορισμός υπενθύμισης",
|
"enableLabel": "Ορισμός υπενθύμισης",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "Shopping",
|
"shopping": "Shopping",
|
||||||
"notes": "Board",
|
"notes": "Board",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
|
"birthdays": "Birthdays",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"main": "Main navigation",
|
"main": "Main navigation",
|
||||||
@@ -748,6 +749,35 @@
|
|||||||
"pendingBadgeTitle": "{{count}} reminder due",
|
"pendingBadgeTitle": "{{count}} reminder due",
|
||||||
"pendingBadgeTitlePlural": "{{count}} reminders 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": {
|
"recipes": {
|
||||||
"title": "Recipes",
|
"title": "Recipes",
|
||||||
"addRecipe": "Add recipe",
|
"addRecipe": "Add recipe",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "Compras",
|
"shopping": "Compras",
|
||||||
"notes": "Notas",
|
"notes": "Notas",
|
||||||
"contacts": "Contactos",
|
"contacts": "Contactos",
|
||||||
|
"birthdays": "Cumpleaños",
|
||||||
"budget": "Presupuesto",
|
"budget": "Presupuesto",
|
||||||
"settings": "Ajustes",
|
"settings": "Ajustes",
|
||||||
"main": "Navegación principal",
|
"main": "Navegación principal",
|
||||||
@@ -757,6 +758,34 @@
|
|||||||
"placeholder": "Buscar…",
|
"placeholder": "Buscar…",
|
||||||
"noResults": "No se encontraron resultados."
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "Recordatorio",
|
"sectionTitle": "Recordatorio",
|
||||||
"enableLabel": "Establecer recordatorio",
|
"enableLabel": "Establecer recordatorio",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "Courses",
|
"shopping": "Courses",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
|
"birthdays": "Anniversaires",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"main": "Navigation principale",
|
"main": "Navigation principale",
|
||||||
@@ -757,6 +758,34 @@
|
|||||||
"placeholder": "Rechercher…",
|
"placeholder": "Rechercher…",
|
||||||
"noResults": "Aucun résultat trouvé."
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "Rappel",
|
"sectionTitle": "Rappel",
|
||||||
"enableLabel": "Définir un rappel",
|
"enableLabel": "Définir un rappel",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "खरीदारी",
|
"shopping": "खरीदारी",
|
||||||
"notes": "नोट्स",
|
"notes": "नोट्स",
|
||||||
"contacts": "संपर्क",
|
"contacts": "संपर्क",
|
||||||
|
"birthdays": "जन्मदिन",
|
||||||
"budget": "बजट",
|
"budget": "बजट",
|
||||||
"settings": "सेटिंग्स",
|
"settings": "सेटिंग्स",
|
||||||
"main": "मुख्य नेविगेशन",
|
"main": "मुख्य नेविगेशन",
|
||||||
@@ -757,6 +758,34 @@
|
|||||||
"placeholder": "खोजें…",
|
"placeholder": "खोजें…",
|
||||||
"noResults": "कोई परिणाम नहीं मिला।"
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "अनुस्मारक",
|
"sectionTitle": "अनुस्मारक",
|
||||||
"enableLabel": "अनुस्मारक सेट करें",
|
"enableLabel": "अनुस्मारक सेट करें",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "Spesa",
|
"shopping": "Spesa",
|
||||||
"notes": "Bacheca",
|
"notes": "Bacheca",
|
||||||
"contacts": "Contatti",
|
"contacts": "Contatti",
|
||||||
|
"birthdays": "Compleanni",
|
||||||
"budget": "Bilancio",
|
"budget": "Bilancio",
|
||||||
"settings": "Impostazioni",
|
"settings": "Impostazioni",
|
||||||
"main": "Navigazione principale",
|
"main": "Navigazione principale",
|
||||||
@@ -757,6 +758,34 @@
|
|||||||
"placeholder": "Cerca…",
|
"placeholder": "Cerca…",
|
||||||
"noResults": "Nessun risultato trovato."
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "Promemoria",
|
"sectionTitle": "Promemoria",
|
||||||
"enableLabel": "Imposta promemoria",
|
"enableLabel": "Imposta promemoria",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "買い物",
|
"shopping": "買い物",
|
||||||
"notes": "メモ",
|
"notes": "メモ",
|
||||||
"contacts": "連絡先",
|
"contacts": "連絡先",
|
||||||
|
"birthdays": "誕生日",
|
||||||
"budget": "家計",
|
"budget": "家計",
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"main": "メインナビゲーション",
|
"main": "メインナビゲーション",
|
||||||
@@ -757,6 +758,34 @@
|
|||||||
"placeholder": "検索…",
|
"placeholder": "検索…",
|
||||||
"noResults": "結果が見つかりませんでした。"
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "リマインダー",
|
"sectionTitle": "リマインダー",
|
||||||
"enableLabel": "リマインダーを設定",
|
"enableLabel": "リマインダーを設定",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "Compras",
|
"shopping": "Compras",
|
||||||
"notes": "Notas",
|
"notes": "Notas",
|
||||||
"contacts": "Contatos",
|
"contacts": "Contatos",
|
||||||
|
"birthdays": "Aniversários",
|
||||||
"budget": "Orçamento",
|
"budget": "Orçamento",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
"main": "Navegação principal",
|
"main": "Navegação principal",
|
||||||
@@ -757,6 +758,35 @@
|
|||||||
"placeholder": "Pesquisar…",
|
"placeholder": "Pesquisar…",
|
||||||
"noResults": "Nenhum resultado encontrado."
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "Lembrete",
|
"sectionTitle": "Lembrete",
|
||||||
"enableLabel": "Definir lembrete",
|
"enableLabel": "Definir lembrete",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "Покупки",
|
"shopping": "Покупки",
|
||||||
"notes": "Заметки",
|
"notes": "Заметки",
|
||||||
"contacts": "Контакты",
|
"contacts": "Контакты",
|
||||||
|
"birthdays": "Дни рождения",
|
||||||
"budget": "Бюджет",
|
"budget": "Бюджет",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"main": "Главная навигация",
|
"main": "Главная навигация",
|
||||||
@@ -757,6 +758,34 @@
|
|||||||
"placeholder": "Поиск…",
|
"placeholder": "Поиск…",
|
||||||
"noResults": "Результаты не найдены."
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "Напоминание",
|
"sectionTitle": "Напоминание",
|
||||||
"enableLabel": "Установить напоминание",
|
"enableLabel": "Установить напоминание",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "Shopping",
|
"shopping": "Shopping",
|
||||||
"notes": "Anteckningar",
|
"notes": "Anteckningar",
|
||||||
"contacts": "Kontakter",
|
"contacts": "Kontakter",
|
||||||
|
"birthdays": "Födelsedagar",
|
||||||
"budget": "Budget",
|
"budget": "Budget",
|
||||||
"settings": "Inställningar",
|
"settings": "Inställningar",
|
||||||
"main": "Huvudnavigering",
|
"main": "Huvudnavigering",
|
||||||
@@ -757,6 +758,34 @@
|
|||||||
"placeholder": "Sök…",
|
"placeholder": "Sök…",
|
||||||
"noResults": "Inga resultat hittades."
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "Påminnelse",
|
"sectionTitle": "Påminnelse",
|
||||||
"enableLabel": "Ange påminnelse",
|
"enableLabel": "Ange påminnelse",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "Alışveriş",
|
"shopping": "Alışveriş",
|
||||||
"notes": "Notlar",
|
"notes": "Notlar",
|
||||||
"contacts": "Kişiler",
|
"contacts": "Kişiler",
|
||||||
|
"birthdays": "Doğum Günleri",
|
||||||
"budget": "Bütçe",
|
"budget": "Bütçe",
|
||||||
"settings": "Ayarlar",
|
"settings": "Ayarlar",
|
||||||
"main": "Ana gezinme",
|
"main": "Ana gezinme",
|
||||||
@@ -757,6 +758,34 @@
|
|||||||
"placeholder": "Ara…",
|
"placeholder": "Ara…",
|
||||||
"noResults": "Sonuç bulunamadı."
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "Hatırlatıcı",
|
"sectionTitle": "Hatırlatıcı",
|
||||||
"enableLabel": "Hatırlatıcı ayarla",
|
"enableLabel": "Hatırlatıcı ayarla",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "Покупки",
|
"shopping": "Покупки",
|
||||||
"notes": "Нотатки",
|
"notes": "Нотатки",
|
||||||
"contacts": "Контакти",
|
"contacts": "Контакти",
|
||||||
|
"birthdays": "Дні народження",
|
||||||
"budget": "Бюджет",
|
"budget": "Бюджет",
|
||||||
"settings": "Налаштування",
|
"settings": "Налаштування",
|
||||||
"main": "Головна навігація",
|
"main": "Головна навігація",
|
||||||
@@ -777,5 +778,33 @@
|
|||||||
"open": "Відкрити пошук",
|
"open": "Відкрити пошук",
|
||||||
"placeholder": "Пошук…",
|
"placeholder": "Пошук…",
|
||||||
"noResults": "Результатів не знайдено."
|
"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}}."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"shopping": "购物",
|
"shopping": "购物",
|
||||||
"notes": "便签",
|
"notes": "便签",
|
||||||
"contacts": "联系人",
|
"contacts": "联系人",
|
||||||
|
"birthdays": "生日",
|
||||||
"budget": "预算",
|
"budget": "预算",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"main": "主导航",
|
"main": "主导航",
|
||||||
@@ -757,6 +758,34 @@
|
|||||||
"placeholder": "搜索…",
|
"placeholder": "搜索…",
|
||||||
"noResults": "未找到结果。"
|
"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": {
|
"reminders": {
|
||||||
"sectionTitle": "提醒",
|
"sectionTitle": "提醒",
|
||||||
"enableLabel": "设置提醒",
|
"enableLabel": "设置提醒",
|
||||||
|
|||||||
@@ -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 `<img class="birthday-avatar ${extraClass}" src="${birthday.photo_data}" alt="${esc(birthday.name)}">`;
|
||||||
|
}
|
||||||
|
return `<span class="birthday-avatar birthday-avatar--fallback ${extraClass}">${esc(initials(birthday.name))}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredBirthdays() {
|
||||||
|
const q = state.query.trim().toLowerCase();
|
||||||
|
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) => `
|
||||||
|
<button class="birthday-suggestion" type="button" data-index="${idx}" data-name="${esc(birthday.name)}">
|
||||||
|
${photoAvatar(birthday, 'birthday-avatar--xs')}
|
||||||
|
<span>
|
||||||
|
<strong>${esc(birthday.name)}</strong>
|
||||||
|
<small>${esc(ageNote(birthday))}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUpcoming() {
|
||||||
|
const host = _container.querySelector('#birthdays-upcoming');
|
||||||
|
if (!host) return;
|
||||||
|
if (!state.upcoming.length) {
|
||||||
|
host.innerHTML = `<div class="empty-state empty-state--compact">
|
||||||
|
<div class="empty-state__title">${t('birthdays.emptyTitle')}</div>
|
||||||
|
<div class="empty-state__description">${t('birthdays.emptyDescription')}</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.innerHTML = state.upcoming.map((birthday) => `
|
||||||
|
<article class="birthday-card">
|
||||||
|
<div class="birthday-card__media">${photoAvatar(birthday)}</div>
|
||||||
|
<div class="birthday-card__body">
|
||||||
|
<div class="birthday-card__name">${esc(birthday.name)}</div>
|
||||||
|
<div class="birthday-card__date">${esc(formatDate(birthday.next_birthday))}</div>
|
||||||
|
<div class="birthday-card__note">${esc(ageNote(birthday))}</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const host = _container.querySelector('#birthdays-list');
|
||||||
|
if (!host) return;
|
||||||
|
const list = filteredBirthdays();
|
||||||
|
if (!list.length) {
|
||||||
|
host.innerHTML = `<div class="empty-state">
|
||||||
|
<div class="empty-state__title">${t('birthdays.emptyTitle')}</div>
|
||||||
|
<div class="empty-state__description">${t('birthdays.emptyDescription')}</div>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
host.innerHTML = list.map((birthday) => `
|
||||||
|
<article class="birthday-item" data-id="${birthday.id}">
|
||||||
|
<div class="birthday-item__media">${photoAvatar(birthday)}</div>
|
||||||
|
<div class="birthday-item__body">
|
||||||
|
<div class="birthday-item__row">
|
||||||
|
<strong class="birthday-item__name">${esc(birthday.name)}</strong>
|
||||||
|
<span class="birthday-item__next">${esc(formatDate(birthday.next_birthday))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="birthday-item__meta">${esc(formatDate(birthday.birth_date))}</div>
|
||||||
|
<div class="birthday-item__note">${esc(ageNote(birthday))}</div>
|
||||||
|
${birthday.notes ? `<div class="birthday-item__notes">${esc(birthday.notes)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="birthday-item__actions">
|
||||||
|
<button class="contact-action-btn" type="button" data-action="edit" data-id="${birthday.id}" aria-label="${t('common.edit')}">
|
||||||
|
<i data-lucide="pencil" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<button class="contact-action-btn" type="button" data-action="delete" data-id="${birthday.id}" aria-label="${t('common.delete')}">
|
||||||
|
<i data-lucide="trash-2" style="width:16px;height:16px;" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
stagger(host.querySelectorAll('.birthday-item'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
_container.innerHTML = `
|
||||||
|
<div class="birthdays-page">
|
||||||
|
<h1 class="sr-only">${t('birthdays.title')}</h1>
|
||||||
|
<div class="birthdays-toolbar">
|
||||||
|
<div class="birthdays-toolbar__search">
|
||||||
|
<i data-lucide="search" class="birthdays-toolbar__search-icon" aria-hidden="true"></i>
|
||||||
|
<input type="search" class="birthdays-toolbar__search-input" id="birthdays-search"
|
||||||
|
placeholder="${t('birthdays.searchPlaceholder')}" autocomplete="off" value="${esc(state.query)}">
|
||||||
|
<div class="autocomplete-dropdown birthdays-autocomplete" id="birthdays-autocomplete" hidden></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn--primary" id="birthdays-add-btn">
|
||||||
|
<i data-lucide="plus" style="width:16px;height:16px;margin-right:4px;" aria-hidden="true"></i>
|
||||||
|
${t('birthdays.addButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="birthdays-section birthdays-section--hero">
|
||||||
|
<div class="birthdays-section__header">
|
||||||
|
<h2>${t('birthdays.upcomingTitle')}</h2>
|
||||||
|
<p>${t('birthdays.upcomingHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="birthday-cards" id="birthdays-upcoming"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="birthdays-section birthdays-section--list">
|
||||||
|
<div class="birthdays-section__header">
|
||||||
|
<h2>${t('birthdays.peopleTitle')}</h2>
|
||||||
|
<p>${t('birthdays.peopleHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="birthdays-list" id="birthdays-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button class="page-fab" id="fab-new-birthday" aria-label="${t('birthdays.addButton')}">
|
||||||
|
<i data-lucide="plus" style="width:24px;height:24px" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
renderUpcoming();
|
||||||
|
renderList();
|
||||||
|
renderSuggestions();
|
||||||
|
if (window.lucide) window.lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
const openCreate = () => openBirthdayModal({ mode: 'create' });
|
||||||
|
_container.querySelector('#birthdays-add-btn').addEventListener('click', openCreate);
|
||||||
|
_container.querySelector('#fab-new-birthday').addEventListener('click', openCreate);
|
||||||
|
|
||||||
|
const search = _container.querySelector('#birthdays-search');
|
||||||
|
search.addEventListener('input', (e) => {
|
||||||
|
state.query = e.target.value;
|
||||||
|
renderSuggestions();
|
||||||
|
renderList();
|
||||||
|
});
|
||||||
|
search.addEventListener('focus', renderSuggestions);
|
||||||
|
search.addEventListener('blur', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const dropdown = _container.querySelector('#birthdays-autocomplete');
|
||||||
|
if (dropdown) dropdown.hidden = true;
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
_container.querySelector('#birthdays-autocomplete').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.birthday-suggestion');
|
||||||
|
if (!btn) return;
|
||||||
|
state.query = btn.dataset.name;
|
||||||
|
search.value = state.query;
|
||||||
|
renderList();
|
||||||
|
renderSuggestions();
|
||||||
|
});
|
||||||
|
|
||||||
|
_container.querySelector('#birthdays-list').addEventListener('click', async (e) => {
|
||||||
|
const action = e.target.closest('[data-action]');
|
||||||
|
if (!action) return;
|
||||||
|
const id = Number(action.dataset.id);
|
||||||
|
const birthday = state.birthdays.find((item) => item.id === id);
|
||||||
|
if (!birthday) return;
|
||||||
|
if (action.dataset.action === 'edit') {
|
||||||
|
openBirthdayModal({ mode: 'edit', birthday });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.dataset.action === 'delete') {
|
||||||
|
await deleteBirthday(id, birthday.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsDataUrl(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result || ''));
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read image.'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function birthdayPreviewHtml(name, photoData) {
|
||||||
|
if (photoData) return `<img class="birthday-preview__image" src="${photoData}" alt="${esc(name || '')}">`;
|
||||||
|
return `<span class="birthday-preview__fallback">${esc(initials(name))}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBirthdayModal({ mode, birthday = null }) {
|
||||||
|
const isEdit = mode === 'edit';
|
||||||
|
let photoData = birthday?.photo_data || null;
|
||||||
|
|
||||||
|
openSharedModal({
|
||||||
|
title: isEdit ? t('birthdays.editTitle') : t('birthdays.newTitle'),
|
||||||
|
content: `
|
||||||
|
<div class="birthday-modal">
|
||||||
|
<div class="birthday-preview" id="birthday-preview">${birthdayPreviewHtml(birthday?.name || '', photoData)}</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="bd-name">${t('birthdays.nameLabel')}</label>
|
||||||
|
<input class="form-input" id="bd-name" type="text" value="${esc(birthday?.name || '')}" autocomplete="name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="bd-birth-date">${t('birthdays.birthDateLabel')}</label>
|
||||||
|
<input class="form-input" id="bd-birth-date" type="date" value="${esc(birthday?.birth_date || '')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="bd-photo">${t('birthdays.photoLabel')}</label>
|
||||||
|
<input class="form-input" id="bd-photo" type="file" accept="image/png,image/jpeg,image/webp,image/gif">
|
||||||
|
<div class="form-help">${t('birthdays.photoOptional')}</div>
|
||||||
|
<div class="birthday-modal__photo-actions">
|
||||||
|
<button type="button" class="btn btn--secondary" id="bd-remove-photo">${t('birthdays.removePhoto')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="bd-notes">${t('birthdays.notesLabel')}</label>
|
||||||
|
<textarea class="form-input" id="bd-notes" rows="3" placeholder="${t('birthdays.notesPlaceholder')}">${esc(birthday?.notes || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="birthday-modal__hint">${t('birthdays.calendarHint')}</div>
|
||||||
|
<div class="modal-panel__footer" style="border:none;padding:0;margin-top:var(--space-4)">
|
||||||
|
${isEdit ? `<button class="btn btn--danger" id="bd-delete">${t('common.delete')}</button>` : '<div></div>'}
|
||||||
|
<div style="display:flex;gap:var(--space-3);">
|
||||||
|
<button class="btn btn--secondary" type="button" id="bd-cancel">${t('common.cancel')}</button>
|
||||||
|
<button class="btn btn--primary" type="button" id="bd-save">${isEdit ? t('common.save') : t('common.create')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
size: 'md',
|
||||||
|
onSave(panel) {
|
||||||
|
const nameInput = panel.querySelector('#bd-name');
|
||||||
|
const preview = panel.querySelector('#birthday-preview');
|
||||||
|
const renderPreview = () => {
|
||||||
|
preview.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();
|
||||||
|
}
|
||||||
+3
-1
@@ -22,6 +22,7 @@ const ROUTES = [
|
|||||||
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' },
|
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' },
|
||||||
{ path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' },
|
{ path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' },
|
||||||
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
|
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' },
|
||||||
|
{ path: '/birthdays', page: '/pages/birthdays.js', requiresAuth: true, module: 'birthdays' },
|
||||||
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' },
|
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' },
|
||||||
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' },
|
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' },
|
||||||
];
|
];
|
||||||
@@ -125,7 +126,7 @@ let _pendingLoginRedirect = false;
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|
||||||
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/recipes', '/shopping',
|
const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/recipes', '/shopping',
|
||||||
'/notes', '/contacts', '/budget', '/settings'];
|
'/notes', '/contacts', '/birthdays', '/budget', '/settings'];
|
||||||
|
|
||||||
const PRIMARY_NAV = 4;
|
const PRIMARY_NAV = 4;
|
||||||
|
|
||||||
@@ -651,6 +652,7 @@ function navItems() {
|
|||||||
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
|
{ path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' },
|
||||||
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
|
{ path: '/notes', label: t('nav.notes'), icon: 'sticky-note' },
|
||||||
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
|
{ path: '/contacts', label: t('nav.contacts'), icon: 'book-user' },
|
||||||
|
{ path: '/birthdays', label: t('nav.birthdays'), icon: 'cake' },
|
||||||
{ path: '/budget', label: t('nav.budget'), icon: 'wallet' },
|
{ path: '/budget', label: t('nav.budget'), icon: 'wallet' },
|
||||||
{ path: '/settings', label: t('nav.settings'), icon: 'settings' },
|
{ path: '/settings', label: t('nav.settings'), icon: 'settings' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -168,6 +168,8 @@
|
|||||||
--module-notes: var(--_module-notes); /* Amber-700 - Notizen (6.3:1 auf weiß — WCAG AA) */
|
--module-notes: var(--_module-notes); /* Amber-700 - Notizen (6.3:1 auf weiß — WCAG AA) */
|
||||||
--_module-contacts: #0969DA;
|
--_module-contacts: #0969DA;
|
||||||
--module-contacts: var(--_module-contacts); /* Kräftiges Blau - Kontakte */
|
--module-contacts: var(--_module-contacts); /* Kräftiges Blau - Kontakte */
|
||||||
|
--_module-birthdays: #E11D48;
|
||||||
|
--module-birthdays: var(--_module-birthdays); /* Rose - Geburtstage */
|
||||||
--_module-budget: #0F766E;
|
--_module-budget: #0F766E;
|
||||||
--module-budget: var(--_module-budget); /* Teal-700 - Finanzen, Stabilität */
|
--module-budget: var(--_module-budget); /* Teal-700 - Finanzen, Stabilität */
|
||||||
--_module-settings: #6E7781;
|
--_module-settings: #6E7781;
|
||||||
@@ -520,6 +522,7 @@
|
|||||||
--_module-shopping: #F472B6;
|
--_module-shopping: #F472B6;
|
||||||
--_module-notes: #FCD34D;
|
--_module-notes: #FCD34D;
|
||||||
--_module-contacts: #60A5FA;
|
--_module-contacts: #60A5FA;
|
||||||
|
--_module-birthdays: #FB7185;
|
||||||
--_module-budget: #2DD4BF;
|
--_module-budget: #2DD4BF;
|
||||||
--_module-settings: #94A3B8;
|
--_module-settings: #94A3B8;
|
||||||
|
|
||||||
@@ -621,6 +624,7 @@
|
|||||||
--_module-shopping: #F472B6; /* Pink-400 */
|
--_module-shopping: #F472B6; /* Pink-400 */
|
||||||
--_module-notes: #FCD34D;
|
--_module-notes: #FCD34D;
|
||||||
--_module-contacts: #60A5FA;
|
--_module-contacts: #60A5FA;
|
||||||
|
--_module-birthdays: #FB7185;
|
||||||
--_module-budget: #2DD4BF; /* Teal-400 */
|
--_module-budget: #2DD4BF; /* Teal-400 */
|
||||||
--_module-settings: #94A3B8;
|
--_module-settings: #94A3B8;
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -13,9 +13,9 @@
|
|||||||
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
|
* → bypassCacheUntil (in-memory + Cache API für SW-Restart-Robustheit)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SHELL_CACHE = 'oikos-shell-v52';
|
const SHELL_CACHE = 'oikos-shell-v53';
|
||||||
const PAGES_CACHE = 'oikos-pages-v47';
|
const PAGES_CACHE = 'oikos-pages-v48';
|
||||||
const ASSETS_CACHE = 'oikos-assets-v47';
|
const ASSETS_CACHE = 'oikos-assets-v48';
|
||||||
const BYPASS_CACHE = 'oikos-bypass-flag';
|
const BYPASS_CACHE = 'oikos-bypass-flag';
|
||||||
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
const ALL_CACHES = [SHELL_CACHE, PAGES_CACHE, ASSETS_CACHE];
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ const APP_SHELL = [
|
|||||||
'/styles/calendar.css',
|
'/styles/calendar.css',
|
||||||
'/styles/notes.css',
|
'/styles/notes.css',
|
||||||
'/styles/contacts.css',
|
'/styles/contacts.css',
|
||||||
|
'/styles/birthdays.css',
|
||||||
'/styles/budget.css',
|
'/styles/budget.css',
|
||||||
'/styles/settings.css',
|
'/styles/settings.css',
|
||||||
'/styles/recipes.css',
|
'/styles/recipes.css',
|
||||||
@@ -74,6 +75,7 @@ const PAGE_MODULES = [
|
|||||||
'/pages/calendar.js',
|
'/pages/calendar.js',
|
||||||
'/pages/notes.js',
|
'/pages/notes.js',
|
||||||
'/pages/contacts.js',
|
'/pages/contacts.js',
|
||||||
|
'/pages/birthdays.js',
|
||||||
'/pages/budget.js',
|
'/pages/budget.js',
|
||||||
'/pages/settings.js',
|
'/pages/settings.js',
|
||||||
'/pages/login.js',
|
'/pages/login.js',
|
||||||
|
|||||||
@@ -116,6 +116,17 @@ const MIGRATIONS_SQL = {
|
|||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
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'))
|
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 (
|
CREATE TABLE IF NOT EXISTS budget_entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
@@ -182,6 +193,9 @@ const MIGRATIONS_SQL = {
|
|||||||
CREATE TRIGGER IF NOT EXISTS trg_contacts_updated_at
|
CREATE TRIGGER IF NOT EXISTS trg_contacts_updated_at
|
||||||
AFTER UPDATE ON contacts FOR EACH ROW
|
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;
|
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
|
CREATE TRIGGER IF NOT EXISTS trg_budget_entries_updated_at
|
||||||
AFTER UPDATE ON budget_entries FOR EACH ROW
|
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;
|
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_notes_pinned ON notes(pinned);
|
||||||
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
|
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_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_hash ON api_tokens(token_hash);
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -691,6 +691,32 @@ const MIGRATIONS = [
|
|||||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
|
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);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import recipesRouter from './routes/recipes.js';
|
|||||||
import calendarRouter from './routes/calendar.js';
|
import calendarRouter from './routes/calendar.js';
|
||||||
import notesRouter from './routes/notes.js';
|
import notesRouter from './routes/notes.js';
|
||||||
import contactsRouter from './routes/contacts.js';
|
import contactsRouter from './routes/contacts.js';
|
||||||
|
import birthdaysRouter from './routes/birthdays.js';
|
||||||
import budgetRouter from './routes/budget.js';
|
import budgetRouter from './routes/budget.js';
|
||||||
import weatherRouter from './routes/weather.js';
|
import weatherRouter from './routes/weather.js';
|
||||||
import preferencesRouter from './routes/preferences.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/calendar', calendarRouter);
|
||||||
app.use('/api/v1/notes', notesRouter);
|
app.use('/api/v1/notes', notesRouter);
|
||||||
app.use('/api/v1/contacts', contactsRouter);
|
app.use('/api/v1/contacts', contactsRouter);
|
||||||
|
app.use('/api/v1/birthdays', birthdaysRouter);
|
||||||
app.use('/api/v1/budget', budgetRouter);
|
app.use('/api/v1/budget', budgetRouter);
|
||||||
app.use('/api/v1/weather', weatherRouter);
|
app.use('/api/v1/weather', weatherRouter);
|
||||||
app.use('/api/v1/preferences', preferencesRouter);
|
app.use('/api/v1/preferences', preferencesRouter);
|
||||||
|
|||||||
@@ -370,6 +370,20 @@ function buildPaths() {
|
|||||||
delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
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/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/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/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' }) },
|
'/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: 'Calendar' },
|
||||||
{ name: 'Notes' },
|
{ name: 'Notes' },
|
||||||
{ name: 'Contacts' },
|
{ name: 'Contacts' },
|
||||||
|
{ name: 'Birthdays' },
|
||||||
{ name: 'Budget' },
|
{ name: 'Budget' },
|
||||||
{ name: 'Weather' },
|
{ name: 'Weather' },
|
||||||
{ name: 'Preferences' },
|
{ name: 'Preferences' },
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -8,6 +8,7 @@ import { createLogger } from '../logger.js';
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import * as db from '../db.js';
|
import * as db from '../db.js';
|
||||||
import * as v from '../middleware/validate.js';
|
import * as v from '../middleware/validate.js';
|
||||||
|
import { syncAllBirthdayReminders } from '../services/birthdays.js';
|
||||||
|
|
||||||
const log = createLogger('Reminders');
|
const log = createLogger('Reminders');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -22,8 +23,9 @@ const VALID_ENTITY_TYPES = ['task', 'event'];
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
router.get('/pending', (req, res) => {
|
router.get('/pending', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.session.userId;
|
const userId = req.authUserId || req.session.userId;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
syncAllBirthdayReminders(db.get(), userId, new Date());
|
||||||
|
|
||||||
const rows = db.get().prepare(`
|
const rows = db.get().prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -53,7 +55,7 @@ router.get('/pending', (req, res) => {
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.session.userId;
|
const userId = req.authUserId || req.session.userId;
|
||||||
const entityType = req.query.entity_type;
|
const entityType = req.query.entity_type;
|
||||||
const entityId = parseInt(req.query.entity_id, 10);
|
const entityId = parseInt(req.query.entity_id, 10);
|
||||||
|
|
||||||
@@ -82,7 +84,7 @@ router.get('/', (req, res) => {
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.session.userId;
|
const userId = req.authUserId || req.session.userId;
|
||||||
const { entity_type, entity_id, remind_at } = req.body;
|
const { entity_type, entity_id, remind_at } = req.body;
|
||||||
|
|
||||||
const errors = v.collectErrors([
|
const errors = v.collectErrors([
|
||||||
@@ -127,7 +129,7 @@ router.post('/', (req, res) => {
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
router.patch('/:id/dismiss', (req, res) => {
|
router.patch('/:id/dismiss', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.session.userId;
|
const userId = req.authUserId || req.session.userId;
|
||||||
const reminderId = parseInt(req.params.id, 10);
|
const reminderId = parseInt(req.params.id, 10);
|
||||||
|
|
||||||
if (!reminderId) {
|
if (!reminderId) {
|
||||||
@@ -157,7 +159,7 @@ router.patch('/:id/dismiss', (req, res) => {
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.session.userId;
|
const userId = req.authUserId || req.session.userId;
|
||||||
const reminderId = parseInt(req.params.id, 10);
|
const reminderId = parseInt(req.params.id, 10);
|
||||||
|
|
||||||
if (!reminderId) {
|
if (!reminderId) {
|
||||||
@@ -187,7 +189,7 @@ router.delete('/:id', (req, res) => {
|
|||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
router.delete('/', (req, res) => {
|
router.delete('/', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.session.userId;
|
const userId = req.authUserId || req.session.userId;
|
||||||
const entityType = req.query.entity_type;
|
const entityType = req.query.entity_type;
|
||||||
const entityId = parseInt(req.query.entity_id, 10);
|
const entityId = parseInt(req.query.entity_id, 10);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+15
-1
@@ -73,7 +73,7 @@ test('Migration v1 ausführen (alle Tabellen und Triggers)', () => {
|
|||||||
const EXPECTED_TABLES = [
|
const EXPECTED_TABLES = [
|
||||||
'users', 'tasks', 'shopping_lists', 'shopping_items',
|
'users', 'tasks', 'shopping_lists', 'shopping_items',
|
||||||
'meals', 'meal_ingredients', 'calendar_events',
|
'meals', 'meal_ingredients', 'calendar_events',
|
||||||
'notes', 'contacts', 'budget_entries',
|
'notes', 'contacts', 'birthdays', 'budget_entries',
|
||||||
'budget_categories', 'budget_subcategories', 'api_tokens',
|
'budget_categories', 'budget_subcategories', 'api_tokens',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -99,6 +99,7 @@ const EXPECTED_TRIGGERS = [
|
|||||||
'calendar_events',
|
'calendar_events',
|
||||||
'notes',
|
'notes',
|
||||||
'contacts',
|
'contacts',
|
||||||
|
'birthdays',
|
||||||
'budget_entries',
|
'budget_entries',
|
||||||
].map((t) => `trg_${t}_updated_at`);
|
].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');
|
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
|
// Ergebnis
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user