feat: add birthday calendar feature with settings and localization

This commit is contained in:
Linus Rath
2026-04-03 22:45:07 +02:00
parent 14ecae61dc
commit 4aac65d7d2
14 changed files with 303 additions and 21 deletions
+42 -11
View File
@@ -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() {
<CalendarMonthView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
@@ -815,7 +842,7 @@ export default function CalendarPage() {
<CalendarWeekView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
@@ -834,7 +861,7 @@ export default function CalendarPage() {
<CalendarDayView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
@@ -851,7 +878,7 @@ export default function CalendarPage() {
<CalendarAgendaView
selectedDate={selectedDate}
events={visibleEvents}
calendars={calendars}
calendars={allCalendars}
onSelectEvent={handleSelectEvent}
onHoverEvent={handleHoverEvent}
onHoverLeave={handleHoverLeave}
@@ -942,10 +969,14 @@ export default function CalendarPage() {
showWeekNumbers={showWeekNumbers}
/>
<CalendarSidebarPanel
calendars={calendars}
calendars={allCalendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
onColorChange={client ? (calendarId, color) => {
if (calendarId === BIRTHDAY_CALENDAR_ID) {
updateSetting('birthdayCalendarColor', color);
return;
}
updateCalendar(client, calendarId, { color });
} : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
@@ -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 && (
<Cake className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
</button>
{/* Subscription context menu on right-click */}
+11
View File
@@ -19,6 +19,7 @@ export function CalendarSettings() {
showWeekNumbers,
enableCalendarTasks,
showTasksOnCalendar,
showBirthdayCalendar,
calendarHoverPreview,
updateSetting,
} = useSettingsStore();
@@ -98,6 +99,16 @@ export function CalendarSettings() {
/>
</SettingItem>
<SettingItem
label={t('show_birthday_calendar')}
description={t('show_birthday_calendar_desc')}
>
<ToggleSwitch
checked={showBirthdayCalendar}
onChange={(checked) => updateSetting('showBirthdayCalendar', checked)}
/>
</SettingItem>
{isFeatureEnabled('calendarTasksEnabled') && (
<>
<SettingItem
+199
View File
@@ -0,0 +1,199 @@
import type { ContactCard, CalendarEvent, Calendar, PartialDate, Timestamp } from '@/lib/jmap/types';
import { getContactDisplayName } from '@/stores/contact-store';
import { format, eachYearOfInterval, parseISO } from 'date-fns';
export const BIRTHDAY_CALENDAR_ID = '__birthday-calendar__';
export const BIRTHDAY_CALENDAR_COLOR = '#eab308'; // Yellow
/**
* Virtual calendar object for the contact birthday calendar.
*/
export function createBirthdayCalendar(name?: string, color?: string): Calendar {
return {
id: BIRTHDAY_CALENDAR_ID,
name: name || 'Birthdays',
description: null,
color: color || BIRTHDAY_CALENDAR_COLOR,
sortOrder: 999,
isSubscribed: true,
isVisible: true,
isDefault: false,
includeInAvailability: 'none',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: false,
mayWriteOwn: false,
mayUpdatePrivate: false,
mayRSVP: false,
mayAdmin: false,
mayDelete: false,
},
};
}
/**
* Extract month and day from an AnniversaryDate.
* Returns null if the date cannot be parsed into month/day.
*/
function parseBirthdayDate(date: string | PartialDate | Timestamp): { month: number; day: number; year?: number } | null {
if (typeof date === 'string') {
// Could be ISO date string like "1990-05-15" or partial "--05-15"
if (date.startsWith('--')) {
// Partial date: --MM-DD
const match = date.match(/^--(\d{2})-(\d{2})$/);
if (match) {
return { month: parseInt(match[1], 10), day: parseInt(match[2], 10) };
}
return null;
}
try {
const parsed = parseISO(date);
if (!isNaN(parsed.getTime())) {
return {
month: parsed.getMonth() + 1,
day: parsed.getDate(),
year: parsed.getFullYear(),
};
}
} catch {
return null;
}
return null;
}
if ('utc' in date && date['@type'] === 'Timestamp') {
// Timestamp type
try {
const parsed = parseISO(date.utc);
if (!isNaN(parsed.getTime())) {
return {
month: parsed.getMonth() + 1,
day: parsed.getDate(),
year: parsed.getFullYear(),
};
}
} catch {
return null;
}
return null;
}
// PartialDate type
const partial = date as PartialDate;
if (partial.month && partial.day) {
return {
month: partial.month,
day: partial.day,
year: partial.year || undefined,
};
}
return null;
}
/**
* Generate virtual CalendarEvent objects from contacts that have birthday anniversaries.
* Events are generated for each year in the given date range.
*/
export function generateBirthdayEvents(
contacts: ContactCard[],
rangeStart: string,
rangeEnd: string,
): CalendarEvent[] {
const events: CalendarEvent[] = [];
const start = parseISO(rangeStart);
const end = parseISO(rangeEnd);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return events;
}
const years = eachYearOfInterval({ start, end });
// Also include the end date's year if not already covered
const endYear = end.getFullYear();
if (!years.some(y => 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;
}
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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ì",
+4 -1
View File
@@ -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": "月曜日",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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": "Понедельник",
+10
View File
@@ -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<SettingsState>()(
calendarInvitationParsingEnabled: state.calendarInvitationParsingEnabled,
enableCalendarTasks: state.enableCalendarTasks,
showTasksOnCalendar: state.showTasksOnCalendar,
showBirthdayCalendar: state.showBirthdayCalendar,
birthdayCalendarColor: state.birthdayCalendarColor,
expandedFilterView: state.expandedFilterView,
showTimeInMonthView: state.showTimeInMonthView,
showWeekNumbers: state.showWeekNumbers,