Merge pull request #206 from Coffey-Labs/feat/birthday-calendar

Show birthdays from the address book as a calendar
This commit is contained in:
Coffey Labs
2026-09-01 23:35:46 -07:00
committed by GitHub
8 changed files with 400 additions and 4 deletions
+70 -1
View File
@@ -2,6 +2,9 @@ import { create } from "zustand";
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { BusyPeriod, Calendar, CalendarEvent, EmailAddress, GetResponse, Id, JSCalendarParticipant, JSCalendarRecurrenceRule, ParticipantIdentity, QueryResponse, SetResponse } from "@/jmap/types";
import { toUTCDate, toLocalDateTime, zonedToDate, parseDuration, DAY_MS, browserTimeZone } from "@/lib/dates";
import { t } from "@/lib/i18n";
import { useContacts } from "./contacts";
import { BIRTHDAY_CALENDAR_ID, birthdaysInRange, isBirthdayEvent, type Birthday } from "@/lib/birthdays";
import { settings, useSettings } from "./settings";
import { useSession } from "./session";
@@ -501,6 +504,26 @@ export const useCalendar = create<CalendarState>((set, get) => ({
instancesIn(start, end) {
const { events, ranges, calendars, hidden, sharedEvents, sharedRanges, sharedCalendars } = get();
/*
* Birthdays are derived here rather than fetched, and they go through the
* same funnel as everything else so no view has to know they are different.
* Nothing is stored: the dates live on the contact cards, and a second copy
* of the same fact would drift the first time somebody corrected one.
*/
const birthdays: EventInstance[] = [];
if (settings().birthdayCalendar && !hidden[BIRTHDAY_CALENDAR_ID]) {
const cal = birthdayCalendar();
for (const b of birthdaysInRange(Object.values(useContacts.getState().cards), start, end)) {
birthdays.push({
key: b.id,
event: synthesiseBirthdayEvent(b),
start: b.date,
end: new Date(b.date.getTime() + DAY_MS),
allDay: true,
calendar: cal,
});
}
}
const ids = new Set<Id>();
for (const list of Object.values(ranges)) for (const id of list) ids.add(id);
const out: EventInstance[] = [];
@@ -543,7 +566,7 @@ export const useCalendar = create<CalendarState>((set, get) => ({
if (inst.end > start && inst.start < end) out.push(inst);
}
out.sort((a, b) => a.start.getTime() - b.start.getTime() || b.end.getTime() - a.end.getTime());
return out;
return [...out, ...birthdays];
},
async getEvent(id) {
@@ -566,6 +589,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
},
async updateEvent(event, patch, sendInvites, scope) {
/*
* A derived birthday has no server-side existence, so there is nothing to
* write and an id that would mean nothing if sent. The UI already keeps
* these out of reach by giving the virtual calendar no write rights; this
* is the check that makes that true of the store as well, whatever calls
* it.
*/
if (isBirthdayEvent(event.id)) return [];
const accountId = get().accountId!;
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
// An occurrence takes less than the series does, and says so about only
@@ -580,6 +611,14 @@ export const useCalendar = create<CalendarState>((set, get) => ({
},
async destroyEvent(event, sendInvites, scope) {
/*
* A derived birthday has no server-side existence, so there is nothing to
* write and an id that would mean nothing if sent. The UI already keeps
* these out of reach by giving the virtual calendar no write rights; this
* is the check that makes that true of the store as well, whatever calls
* it.
*/
if (isBirthdayEvent(event.id)) return;
const accountId = get().accountId!;
const id = scope === "occurrence" ? await currentOccurrenceId(accountId, event) : eventIdForScope(event, scope);
const res = await client.call<SetResponse>("CalendarEvent/set", { accountId, destroy: [id], sendSchedulingMessages: sendInvites });
@@ -775,6 +814,36 @@ export function isRecurring(ev: CalendarEvent): boolean {
return Boolean(ev.recurrenceRule || ev.recurrenceRules?.length || ev.excludedRecurrenceRules?.length || ev.recurrenceId);
}
/**
* The virtual calendar the birthdays hang off. Not a JMAP calendar and
* deliberately not shaped like one: it has no account, cannot be shared, and
* every write path checks the id before it does anything.
*/
function birthdayCalendar(): Calendar {
return {
id: BIRTHDAY_CALENDAR_ID,
name: t("Birthdays"),
color: "#e0a33e",
isSubscribed: true,
isVisible: true,
myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayAdmin: false, mayDelete: false },
} as unknown as Calendar;
}
/** A CalendarEvent shaped enough for the views, and for nothing else. */
function synthesiseBirthdayEvent(b: Birthday): CalendarEvent {
const local = `${b.date.getFullYear()}-${String(b.date.getMonth() + 1).padStart(2, "0")}-${String(b.date.getDate()).padStart(2, "0")}T00:00:00`;
return {
id: b.id,
calendarIds: { [BIRTHDAY_CALENDAR_ID]: true },
title: b.age === null ? t("{name}\u2019s birthday", { name: b.name }) : t("{name}\u2019s birthday ({age})", { name: b.name, age: String(b.age) }),
start: local,
duration: "P1D",
showWithoutTime: true,
freeBusyStatus: "free",
} as unknown as CalendarEvent;
}
export function toInstance(e: CalendarEvent, calendars: Record<Id, Calendar>): EventInstance | null {
const allDay = Boolean(e.showWithoutTime);
let start: Date;
+7
View File
@@ -135,6 +135,12 @@ export interface Settings {
defaultAlertMinutes: number;
timeZone: string | null; // null = browser
labelsSidebar: boolean;
/**
* Birthdays from the address book, shown as a calendar of their own.
* Off by default: it is derived data, and a calendar that fills itself with
* dates nobody put there is a surprise rather than a feature.
*/
birthdayCalendar: boolean;
fontSize: "small" | "medium" | "large";
templates: Template[];
labels: Label[];
@@ -246,6 +252,7 @@ export const DEFAULT_SETTINGS: Settings = {
defaultAlertMinutes: 10,
timeZone: null,
labelsSidebar: true,
birthdayCalendar: false,
fontSize: "medium",
templates: [],
labels: [],