diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 4f56a171..9b47ecda 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -41,9 +41,11 @@ import { ResizeHandle } from "@/components/layout/resize-handle"; import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization"; import { getEventStartDate } from "@/lib/calendar-utils"; import { useTaskStore } from "@/stores/task-store"; +import { useContactStore } from "@/stores/contact-store"; import { cn } from "@/lib/utils"; import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types"; import { getUserParticipantId } from "@/lib/calendar-participants"; +import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { debug } from "@/lib/debug"; type PendingScopeAction = @@ -69,10 +71,11 @@ export default function CalendarPage() { setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, refreshAllSubscriptions, icalSubscriptions, } = useCalendarStore(); - const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview } = useSettingsStore(); + const { firstDayOfWeek, timeFormat, showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, calendarHoverPreview, showBirthdayCalendar, birthdayCalendarColor, updateSetting } = useSettingsStore(); const taskStore = useTaskStore(); const fetchTasksFn = useTaskStore(state => state.fetchTasks); const { identities } = useIdentityStore(); + const contacts = useContactStore((s) => s.contacts); const normalizedViewMode = isCalendarViewMode(viewMode) ? viewMode : "month"; const currentUserEmails = useMemo(() => @@ -148,6 +151,13 @@ export default function CalendarPage() { return () => clearInterval(interval); }, [client, refreshAllSubscriptions]); + // Auto-add birthday calendar to selected IDs when enabled + useEffect(() => { + if (showBirthdayCalendar && !selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) { + toggleCalendarVisibility(BIRTHDAY_CALENDAR_ID); + } + }, [showBirthdayCalendar]); // eslint-disable-line react-hooks/exhaustive-deps + const dateRange = useMemo(() => { const d = selectedDate; switch (normalizedViewMode) { @@ -743,14 +753,31 @@ export default function CalendarPage() { return () => window.removeEventListener("keydown", handleKey); }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]); - const visibleEvents = useMemo(() => - events.filter((e) => { + const birthdayEvents = useMemo(() => { + if (!showBirthdayCalendar || !dateRange) return []; + return generateBirthdayEvents(contacts, dateRange.start, dateRange.end); + }, [showBirthdayCalendar, contacts, dateRange]); + + const birthdayCalendarName = (() => { + try { return t('birthday_calendar'); } catch { return 'Birthdays'; } + })(); + + const allCalendars = useMemo(() => { + if (!showBirthdayCalendar) return calendars; + return [...calendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)]; + }, [calendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]); + + const visibleEvents = useMemo(() => { + const filtered = events.filter((e) => { if (!e.start || !e.calendarIds) return false; const calIds = Object.keys(e.calendarIds); return calIds.some((id) => selectedCalendarIds.includes(id)); - }), - [events, selectedCalendarIds] - ); + }); + if (showBirthdayCalendar && selectedCalendarIds.includes(BIRTHDAY_CALENDAR_ID)) { + return [...filtered, ...birthdayEvents]; + } + return filtered; + }, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]); useEffect(() => { const hiddenEvents = events.filter((event) => { @@ -799,7 +826,7 @@ export default function CalendarPage() { { + if (calendarId === BIRTHDAY_CALENDAR_ID) { + updateSetting('birthdayCalendarColor', color); + return; + } updateCalendar(client, calendarId, { color }); } : undefined} onSubscribe={() => setShowSubscriptionModal(true)} diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 2af48b49..6355e43b 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -2,13 +2,14 @@ import { useState, useRef, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; -import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2 } from "lucide-react"; +import { Globe, ListTodo, Pencil, RefreshCw, Share2, Trash2, Cake } from "lucide-react"; import { cn, formatDateTime } from "@/lib/utils"; import type { Calendar } from "@/lib/jmap/types"; import { CalendarColorPicker } from "@/components/settings/calendar-management-settings"; import { useCalendarStore } from "@/stores/calendar-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useTaskStore } from "@/stores/task-store"; +import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { toast } from "@/stores/toast-store"; import type { IJMAPClient } from '@/lib/jmap/client-interface'; @@ -164,6 +165,9 @@ export function CalendarSidebarPanel({ )} )} + {cal.id === BIRTHDAY_CALENDAR_ID && ( + + )} {/* Subscription context menu on right-click */} diff --git a/components/settings/calendar-settings.tsx b/components/settings/calendar-settings.tsx index 479b99c1..b8e655f8 100644 --- a/components/settings/calendar-settings.tsx +++ b/components/settings/calendar-settings.tsx @@ -19,6 +19,7 @@ export function CalendarSettings() { showWeekNumbers, enableCalendarTasks, showTasksOnCalendar, + showBirthdayCalendar, calendarHoverPreview, updateSetting, } = useSettingsStore(); @@ -98,6 +99,16 @@ export function CalendarSettings() { /> + + updateSetting('showBirthdayCalendar', checked)} + /> + + {isFeatureEnabled('calendarTasksEnabled') && ( <> y.getFullYear() === endYear)) { + years.push(new Date(endYear, 0, 1)); + } + + for (const contact of contacts) { + if (!contact.anniversaries) continue; + + for (const [key, anniversary] of Object.entries(contact.anniversaries)) { + if (anniversary.kind !== 'birth') continue; + + const parsed = parseBirthdayDate(anniversary.date); + if (!parsed) continue; + + const displayName = getContactDisplayName(contact); + if (!displayName) continue; + + for (const yearDate of years) { + const year = yearDate.getFullYear(); + const monthStr = String(parsed.month).padStart(2, '0'); + const dayStr = String(parsed.day).padStart(2, '0'); + const eventStart = `${year}-${monthStr}-${dayStr}T00:00:00`; + + // Check if this birthday occurrence falls within the date range + const occurrenceDate = new Date(year, parsed.month - 1, parsed.day); + if (occurrenceDate < start || occurrenceDate > end) continue; + + const age = parsed.year ? year - parsed.year : undefined; + const ageText = age && age > 0 ? ` (${age})` : ''; + + const event: CalendarEvent = { + id: `birthday-${contact.id}-${key}-${year}`, + calendarIds: { [BIRTHDAY_CALENDAR_ID]: true }, + isDraft: false, + isOrigin: false, + utcStart: null, + utcEnd: null, + '@type': 'Event', + uid: `birthday-${contact.id}-${key}`, + title: `🎂 ${displayName}${ageText}`, + description: '', + descriptionContentType: 'text/plain', + created: null, + updated: '', + sequence: 0, + start: eventStart, + duration: 'P1D', + timeZone: null, + showWithoutTime: true, + status: 'confirmed', + freeBusyStatus: 'free', + privacy: 'public', + color: null, + keywords: null, + categories: null, + locale: null, + replyTo: null, + organizerCalendarAddress: null, + participants: null, + mayInviteSelf: false, + mayInviteOthers: false, + hideAttendees: false, + recurrenceId: null, + recurrenceIdTimeZone: null, + recurrenceRules: null, + recurrenceOverrides: null, + excludedRecurrenceRules: null, + useDefaultAlerts: false, + alerts: null, + locations: null, + virtualLocations: null, + links: null, + relatedTo: null, + }; + + events.push(event); + } + } + } + + return events; +} diff --git a/locales/de/common.json b/locales/de/common.json index be0c6734..1f5f0cad 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1873,6 +1873,7 @@ "back_to_email": "Zurück zu E-Mails", "back_to_month": "Zurück zur Monatsansicht", "my_calendars": "Meine Kalender", + "birthday_calendar": "Geburtstage", "mini_calendar_change": "Klicken, um den Monat zu wechseln", "views": { "month": "Monat", @@ -2010,7 +2011,9 @@ "hover_preview_delay_500ms": "0,5 Sekunden Verzögerung", "hover_preview_delay_1s": "1 Sekunde Verzögerung", "hover_preview_delay_2s": "2 Sekunden Verzögerung", - "hover_preview_off": "Deaktiviert" + "hover_preview_off": "Deaktiviert", + "show_birthday_calendar": "Geburtstags-Kalender", + "show_birthday_calendar_desc": "Zeigt einen virtuellen Kalender mit Geburtstagen aus deinen Kontakten" }, "days": { "monday": "Montag", diff --git a/locales/en/common.json b/locales/en/common.json index b8b8f9e1..3dc5e4cc 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1876,6 +1876,7 @@ "back_to_email": "Back to email", "back_to_month": "Back to month", "my_calendars": "Calendars", + "birthday_calendar": "Birthdays", "mini_calendar_change": "Click to change month", "views": { "month": "Month", @@ -2013,7 +2014,9 @@ "hover_preview_delay_500ms": "0.5-second delay", "hover_preview_delay_1s": "1-second delay", "hover_preview_delay_2s": "2-second delay", - "hover_preview_off": "Disabled" + "hover_preview_off": "Disabled", + "show_birthday_calendar": "Contact birthday calendar", + "show_birthday_calendar_desc": "Show a virtual calendar with birthdays from your contacts" }, "days": { "monday": "Monday", diff --git a/locales/es/common.json b/locales/es/common.json index 80bb511f..961ebeae 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1873,6 +1873,7 @@ "back_to_email": "Volver al correo", "back_to_month": "Volver al mes", "my_calendars": "Mis calendarios", + "birthday_calendar": "Cumpleaños", "mini_calendar_change": "Clic para cambiar de mes", "views": { "month": "Mes", @@ -2010,7 +2011,9 @@ "hover_preview_delay_500ms": "Retraso de 0,5 segundos", "hover_preview_delay_1s": "Retraso de 1 segundo", "hover_preview_delay_2s": "Retraso de 2 segundos", - "hover_preview_off": "Desactivado" + "hover_preview_off": "Desactivado", + "show_birthday_calendar": "Calendario de cumpleaños", + "show_birthday_calendar_desc": "Mostrar un calendario virtual con los cumpleaños de tus contactos" }, "days": { "monday": "Lunes", diff --git a/locales/fr/common.json b/locales/fr/common.json index a5a79892..ce5f2815 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1873,6 +1873,7 @@ "back_to_email": "Retour aux e-mails", "back_to_month": "Retour au mois", "my_calendars": "Mes calendriers", + "birthday_calendar": "Anniversaires", "mini_calendar_change": "Cliquer pour changer de mois", "views": { "month": "Mois", @@ -2010,7 +2011,9 @@ "hover_preview_delay_500ms": "Délai de 0,5 seconde", "hover_preview_delay_1s": "Délai de 1 seconde", "hover_preview_delay_2s": "Délai de 2 secondes", - "hover_preview_off": "Désactivé" + "hover_preview_off": "Désactivé", + "show_birthday_calendar": "Calendrier des anniversaires", + "show_birthday_calendar_desc": "Afficher un calendrier virtuel avec les anniversaires de vos contacts" }, "days": { "monday": "Lundi", diff --git a/locales/it/common.json b/locales/it/common.json index d72529d8..5bc86c5e 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1873,6 +1873,7 @@ "back_to_email": "Torna alla posta", "back_to_month": "Torna al mese", "my_calendars": "I miei calendari", + "birthday_calendar": "Compleanni", "mini_calendar_change": "Clicca per cambiare mese", "views": { "month": "Mese", @@ -2010,7 +2011,9 @@ "hover_preview_delay_500ms": "Ritardo di 0,5 secondi", "hover_preview_delay_1s": "Ritardo di 1 secondo", "hover_preview_delay_2s": "Ritardo di 2 secondi", - "hover_preview_off": "Disabilitato" + "hover_preview_off": "Disabilitato", + "show_birthday_calendar": "Calendario compleanni", + "show_birthday_calendar_desc": "Mostra un calendario virtuale con i compleanni dei tuoi contatti" }, "days": { "monday": "Lunedì", diff --git a/locales/ja/common.json b/locales/ja/common.json index a58b4b3d..ee36f247 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1873,6 +1873,7 @@ "back_to_email": "メールに戻る", "back_to_month": "月表示に戻る", "my_calendars": "マイカレンダー", + "birthday_calendar": "誕生日", "mini_calendar_change": "クリックで月を変更", "views": { "month": "月", @@ -2010,7 +2011,9 @@ "hover_preview_delay_500ms": "0.5秒遅延", "hover_preview_delay_1s": "1秒遅延", "hover_preview_delay_2s": "2秒遅延", - "hover_preview_off": "無効" + "hover_preview_off": "無効", + "show_birthday_calendar": "誕生日カレンダー", + "show_birthday_calendar_desc": "連絡先の誕生日を表示する仮想カレンダーを表示します" }, "days": { "monday": "月曜日", diff --git a/locales/nl/common.json b/locales/nl/common.json index aa0f51dd..8770f23c 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1873,6 +1873,7 @@ "back_to_email": "Terug naar e-mail", "back_to_month": "Terug naar maand", "my_calendars": "Mijn agenda's", + "birthday_calendar": "Verjaardagen", "mini_calendar_change": "Klik om van maand te wisselen", "views": { "month": "Maand", @@ -2010,7 +2011,9 @@ "hover_preview_delay_500ms": "0,5 seconde vertraging", "hover_preview_delay_1s": "1 seconde vertraging", "hover_preview_delay_2s": "2 seconden vertraging", - "hover_preview_off": "Uitgeschakeld" + "hover_preview_off": "Uitgeschakeld", + "show_birthday_calendar": "Verjaardagskalender", + "show_birthday_calendar_desc": "Toon een virtuele kalender met verjaardagen van je contacten" }, "days": { "monday": "Maandag", diff --git a/locales/pt/common.json b/locales/pt/common.json index 4c7b40ee..645c1e67 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1873,6 +1873,7 @@ "back_to_email": "Voltar ao e-mail", "back_to_month": "Voltar ao mês", "my_calendars": "Meus calendários", + "birthday_calendar": "Aniversários", "mini_calendar_change": "Clique para mudar o mês", "views": { "month": "Mês", @@ -2010,7 +2011,9 @@ "hover_preview_delay_500ms": "Atraso de 0,5 segundos", "hover_preview_delay_1s": "Atraso de 1 segundo", "hover_preview_delay_2s": "Atraso de 2 segundos", - "hover_preview_off": "Desativado" + "hover_preview_off": "Desativado", + "show_birthday_calendar": "Calendário de aniversários", + "show_birthday_calendar_desc": "Mostrar um calendário virtual com os aniversários dos seus contactos" }, "days": { "monday": "Segunda-feira", diff --git a/locales/ru/common.json b/locales/ru/common.json index bda09452..5fceb581 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1873,6 +1873,7 @@ "back_to_email": "Вернуться к почте", "back_to_month": "Вернуться к месяцу", "my_calendars": "Календари", + "birthday_calendar": "Дни рождения", "mini_calendar_change": "Нажмите для смены месяца", "views": { "month": "Месяц", @@ -2010,7 +2011,9 @@ "hover_preview_delay_500ms": "Задержка 0,5 секунды", "hover_preview_delay_1s": "Задержка 1 секунда", "hover_preview_delay_2s": "Задержка 2 секунды", - "hover_preview_off": "Отключено" + "hover_preview_off": "Отключено", + "show_birthday_calendar": "Календарь дней рождения", + "show_birthday_calendar_desc": "Показать виртуальный календарь с днями рождения из ваших контактов" }, "days": { "monday": "Понедельник", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index ed981d7c..e500a0a0 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -153,6 +153,10 @@ interface SettingsState { enableCalendarTasks: boolean; showTasksOnCalendar: boolean; + // Contact Birthday Calendar + showBirthdayCalendar: boolean; + birthdayCalendarColor: string; + // Email Notifications emailNotificationsEnabled: boolean; emailNotificationSound: boolean; @@ -278,6 +282,10 @@ const DEFAULT_SETTINGS = { enableCalendarTasks: false, showTasksOnCalendar: true, + // Contact Birthday Calendar + showBirthdayCalendar: false, + birthdayCalendarColor: '#eab308', + // Email Notifications emailNotificationsEnabled: true, emailNotificationSound: true, @@ -392,6 +400,8 @@ export const useSettingsStore = create()( calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled, enableCalendarTasks: state.enableCalendarTasks, showTasksOnCalendar: state.showTasksOnCalendar, + showBirthdayCalendar: state.showBirthdayCalendar, + birthdayCalendarColor: state.birthdayCalendarColor, expandedFilterView: state.expandedFilterView, showTimeInMonthView: state.showTimeInMonthView, showWeekNumbers: state.showWeekNumbers,