feat: add birthday calendar feature with settings and localization
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user