From 4aac65d7d25360e5496af5a2e51e68cab3e63420 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 3 Apr 2026 22:45:07 +0200
Subject: [PATCH] feat: add birthday calendar feature with settings and
localization
---
app/[locale]/calendar/page.tsx | 53 ++++-
.../calendar/calendar-sidebar-panel.tsx | 6 +-
components/settings/calendar-settings.tsx | 11 +
lib/birthday-calendar.ts | 199 ++++++++++++++++++
locales/de/common.json | 5 +-
locales/en/common.json | 5 +-
locales/es/common.json | 5 +-
locales/fr/common.json | 5 +-
locales/it/common.json | 5 +-
locales/ja/common.json | 5 +-
locales/nl/common.json | 5 +-
locales/pt/common.json | 5 +-
locales/ru/common.json | 5 +-
stores/settings-store.ts | 10 +
14 files changed, 303 insertions(+), 21 deletions(-)
create mode 100644 lib/birthday-calendar.ts
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,