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
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import { birthdaysInRange, isBirthdayEvent, BIRTHDAY_ID_PREFIX } from "@/lib/birthdays";
import type { ContactCard } from "@/jmap/types";
const card = (id: string, full: string, date: { year?: number; month?: number; day?: number; utc?: string } | null, kind = "birth"): ContactCard =>
({
id,
uid: id,
addressBookIds: { b1: true },
name: { full },
...(date ? { anniversaries: { a1: { kind, date } } } : {}),
}) as ContactCard;
const range = (from: string, to: string) => [new Date(from), new Date(to)] as const;
const names = (b: ReturnType<typeof birthdaysInRange>) => b.map((x) => `${x.name} ${x.date.toISOString().slice(0, 10)}${x.age === null ? "" : ` (${x.age})`}`);
describe("birthdaysInRange", () => {
it("puts a birthday in the year the range covers, with the age", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
expect(names(birthdaysInRange([card("c1", "Ada Lovelace", { year: 1990, month: 6, day: 15 })], s, e))).toEqual(["Ada Lovelace 2026-06-15 (36)"]);
});
it("gives no age when the card recorded only a day and month", () => {
// Very common, and a real answer rather than a broken one.
const [s, e] = range("2026-01-01", "2027-01-01");
const out = birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e);
expect(out[0]!.age).toBeNull();
expect(out[0]!.date.getMonth()).toBe(5);
});
it("emits one occurrence per year across a range that spans years", () => {
const [s, e] = range("2025-06-01", "2027-06-01");
expect(names(birthdaysInRange([card("c1", "Ada", { year: 2000, month: 12, day: 25 })], s, e))).toEqual([
"Ada 2025-12-25 (25)",
"Ada 2026-12-25 (26)",
]);
});
it("leaves out a birthday outside the range", () => {
const [s, e] = range("2026-07-01", "2026-08-01");
expect(birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e)).toEqual([]);
});
it("puts 29 February on the 28th in a year that has no 29th", () => {
// The month is the fact; moving it to 1 March is the arithmetic winning.
const [s, e] = range("2026-01-01", "2027-01-01");
const out = birthdaysInRange([card("c1", "Ada", { year: 2000, month: 2, day: 29 })], s, e);
expect(out[0]!.date.getMonth()).toBe(1);
expect(out[0]!.date.getDate()).toBe(28);
});
it("keeps 29 February on the 29th in a leap year", () => {
const [s, e] = range("2028-01-01", "2029-01-01");
const out = birthdaysInRange([card("c1", "Ada", { year: 2000, month: 2, day: 29 })], s, e);
expect(out[0]!.date.getDate()).toBe(29);
});
it("reads a timestamp date as well as a partial one", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
const out = birthdaysInRange([card("c1", "Ada", { utc: "1990-06-15T00:00:00Z" })], s, e);
expect(out[0]!.date.getMonth()).toBe(5);
expect(out[0]!.age).toBe(36);
});
it("ignores anniversaries that are not birthdays", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
expect(birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 }, "wedding")], s, e)).toEqual([]);
});
it("ignores a card with no anniversary and one with no usable name", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
expect(birthdaysInRange([card("c1", "Ada", null)], s, e)).toEqual([]);
expect(birthdaysInRange([card("c2", "", { month: 6, day: 15 })], s, e)).toEqual([]);
});
it("falls back to a name built from components, then to the organisation", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
const parts = {
id: "c1",
uid: "c1",
addressBookIds: {},
name: { components: [{ kind: "given", value: "Grace" }, { kind: "surname", value: "Hopper" }] },
anniversaries: { a1: { kind: "birth", date: { month: 12, day: 9 } } },
} as unknown as ContactCard;
expect(birthdaysInRange([parts], s, e)[0]!.name).toBe("Grace Hopper");
const org = {
id: "c2",
uid: "c2",
addressBookIds: {},
organizations: { o1: { name: "Acme Ltd" } },
anniversaries: { a1: { kind: "birth", date: { month: 3, day: 1 } } },
} as unknown as ContactCard;
expect(birthdaysInRange([org], s, e)[0]!.name).toBe("Acme Ltd");
});
it("never reports a negative age from a birth year in the future", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
expect(birthdaysInRange([card("c1", "Ada", { year: 2040, month: 6, day: 15 })], s, e)[0]!.age).toBeNull();
});
it("ignores an impossible date rather than inventing one", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
expect(birthdaysInRange([card("c1", "Ada", { month: 13, day: 40 })], s, e)).toEqual([]);
expect(birthdaysInRange([card("c1", "Ada", { month: 4, day: 31 })], s, e)).toEqual([]);
});
it("returns them in date order, whatever order the contacts were in", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
const out = birthdaysInRange(
[card("c1", "Zoe", { month: 11, day: 2 }), card("c2", "Amy", { month: 2, day: 3 })],
s,
e,
);
expect(out.map((b) => b.name)).toEqual(["Amy", "Zoe"]);
});
it("gives each occurrence a stable, unique id that marks it as synthesised", () => {
const [s, e] = range("2025-01-01", "2027-01-01");
const out = birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e);
expect(new Set(out.map((b) => b.id)).size).toBe(out.length);
expect(out.every((b) => isBirthdayEvent(b.id))).toBe(true);
expect(out[0]!.id.startsWith(BIRTHDAY_ID_PREFIX)).toBe(true);
// Nothing that came off the server should ever look like one.
expect(isBirthdayEvent("abc123")).toBe(false);
expect(isBirthdayEvent(null)).toBe(false);
});
it("declines a range that is empty, backwards, or absurdly wide", () => {
const cards = [card("c1", "Ada", { month: 6, day: 15 })];
expect(birthdaysInRange(cards, new Date("2026-01-01"), new Date("2026-01-01"))).toEqual([]);
expect(birthdaysInRange(cards, new Date("2027-01-01"), new Date("2026-01-01"))).toEqual([]);
expect(birthdaysInRange(cards, new Date("2000-01-01"), new Date("2100-01-01"))).toEqual([]);
});
});
+119
View File
@@ -0,0 +1,119 @@
/**
* Birthdays, read off the contacts rather than stored as events.
*
* Nothing is written anywhere. The dates already live on the cards, and
* copying them into real calendar events would mean two records of the same
* fact that drift the first time somebody corrects one — and ihasmail keeping
* a calendar of its own is exactly what it does not do. So the events are
* derived when a view asks for a range, and vanish when the contact does.
*/
import type { ContactCard } from "@/jmap/types";
export interface Birthday {
/** Stable across renders and unique per occurrence, so React can key on it. */
id: string;
contactId: string;
name: string;
/** Local date of the occurrence, at midnight. */
date: Date;
/**
* How old they turn, where the card gave a year. Many cards record only a
* day and month, which is a real answer rather than a broken one.
*/
age: number | null;
}
/** The prefix marking a synthesised event, so nothing tries to save one. */
export const BIRTHDAY_ID_PREFIX = "ihm-birthday:";
/** The virtual calendar's id. Not a JMAP id, and deliberately unlike one. */
export const BIRTHDAY_CALENDAR_ID = "ihm-birthdays";
export function isBirthdayEvent(id: string | null | undefined): boolean {
return Boolean(id?.startsWith(BIRTHDAY_ID_PREFIX));
}
/** Month and day of a card's birth anniversary, and the year where it gave one. */
function birthDate(card: ContactCard): { month: number; day: number; year: number | null } | null {
for (const a of Object.values(card.anniversaries ?? {})) {
if (a?.kind !== "birth") continue;
const d = a.date;
if (!d) continue;
// A PartialDate carries the parts directly; a Timestamp carries an instant.
if (typeof d.month === "number" && typeof d.day === "number") {
return { month: d.month, day: d.day, year: typeof d.year === "number" ? d.year : null };
}
if (d.utc) {
const t = new Date(d.utc);
if (!Number.isNaN(t.getTime())) return { month: t.getMonth() + 1, day: t.getDate(), year: t.getFullYear() };
}
}
return null;
}
/**
* Where 29 February falls in a year that has no 29 February.
*
* The 28th, not 1 March. Somebody born in February has a birthday in February,
* and moving it into another month to satisfy the calendar is the arithmetic
* winning over the fact. Every choice here is a convention; this is the one
* that keeps the month right.
*/
function occurrence(year: number, month: number, day: number): Date | null {
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
const d = new Date(year, month - 1, day);
// Rolled into the next month: this day does not exist in this year.
if (d.getMonth() !== month - 1) {
if (month === 2 && day === 29) return new Date(year, 1, 28);
return null;
}
return d;
}
const displayName = (c: ContactCard): string =>
(c.name?.full ?? "").trim() ||
[c.name?.components?.find((p) => p.kind === "given")?.value, c.name?.components?.find((p) => p.kind === "surname")?.value]
.filter(Boolean)
.join(" ")
.trim() ||
Object.values(c.organizations ?? {})[0]?.name?.trim() ||
"";
/**
* Every birthday falling between `start` and `end`, one per contact per year.
*
* The range is walked by year rather than by day, so a month view costs one
* pass over the contacts and a year view costs two.
*/
export function birthdaysInRange(cards: Iterable<ContactCard>, start: Date, end: Date): Birthday[] {
if (!(start instanceof Date) || !(end instanceof Date) || end <= start) return [];
const out: Birthday[] = [];
const firstYear = start.getFullYear();
const lastYear = end.getFullYear();
// A range spanning more years than a calendar view ever shows is a caller
// mistake, not something to spend a minute of CPU on.
if (lastYear - firstYear > 5) return [];
for (const card of cards) {
const born = birthDate(card);
if (!born) continue;
const name = displayName(card);
if (!name) continue;
for (let year = firstYear; year <= lastYear; year++) {
const date = occurrence(year, born.month, born.day);
if (!date) continue;
if (date < start || date >= end) continue;
out.push({
id: `${BIRTHDAY_ID_PREFIX}${card.id}:${year}`,
contactId: card.id,
name,
date,
// Only where the card gave a year, and never negative: a birth year in
// the future is bad data, and "turns -3" helps nobody.
age: born.year !== null && year - born.year >= 0 ? year - born.year : null,
});
}
}
out.sort((a, b) => a.date.getTime() - b.date.getTime());
return out;
}
+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: [],
+25 -1
View File
@@ -1,9 +1,11 @@
import { useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState, useEffect } from "react";
import { useLocation } from "wouter";
import { ChevronLeft, ChevronRight, MoreVertical, Pencil, Plus, Share2, Trash2, Eye, EyeOff, Star, Upload, UserMinus, X } from "lucide-react";
import { useCalendar } from "@/store/calendar";
import { dateTimeKey, useSettings } from "@/store/settings";
import { addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates";
import { BIRTHDAY_CALENDAR_ID } from "@/lib/birthdays";
import { useContacts } from "@/store/contacts";
import { formatMonthYear } from "@/lib/format";
import { formatWeekday } from "@/lib/datetime";
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
@@ -62,6 +64,16 @@ export function CalendarSidebar() {
const dow = useMemo(() => grid.slice(0, 7).map((d) => formatWeekday(d, "narrow")), [grid, locale]);
if (!cal.available) return null;
const birthdaysOn = useSettings((st) => st.settings.birthdayCalendar);
/*
* The cards have to be loaded for there to be any birthdays to derive, and
* the calendar is a view somebody can land on directly without ever opening
* Contacts.
*/
useEffect(() => {
if (birthdaysOn && !useContacts.getState().loaded && !useContacts.getState().loading) void useContacts.getState().loadAll();
}, [birthdaysOn]);
const calendars = Object.values(cal.calendars).sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name));
return (
@@ -85,6 +97,18 @@ export function CalendarSidebar() {
<span>{t("My calendars")}</span>
<button className="icon-btn" title={t("New calendar")} onClick={() => setEditCal({})}><Plus size={16} /></button>
</div>
{/* Derived, so no context menu and nothing to share or make default --
it is a switch, and Settings is where it is turned off entirely. */}
{birthdaysOn && (
<div
className={`cal-list-item ${cal.hidden[BIRTHDAY_CALENDAR_ID] ? "hidden-cal" : ""}`}
onClick={() => cal.toggleHidden(BIRTHDAY_CALENDAR_ID)}
title={t("From the birthdays on your contacts. Nothing is stored.")}
>
<span className="cal-color" style={{ background: "#e0a33e", borderColor: "#e0a33e" }} />
<span className="cal-name">{t("Birthdays")}</span>
</div>
)}
{calendars.map((c) => (
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
+9 -1
View File
@@ -1,5 +1,5 @@
import { useSettings } from "@/store/settings";
import { ColorSwatches, CALENDAR_COLORS } from "@/ui/misc";
import { ColorSwatches, CALENDAR_COLORS, Switch } from "@/ui/misc";
import { promptDialog } from "@/ui/dialog";
import { Plus, Trash2 } from "lucide-react";
import { t } from "@/lib/i18n";
@@ -61,6 +61,14 @@ export function CalendarSettings() {
))}
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> {t("New category")}</button>
<h2>{t("Birthdays")}</h2>
<Switch
checked={s.birthdayCalendar}
onChange={(v) => update({ birthdayCalendar: v })}
label={t("Show birthdays from your contacts")}
hint={t("A calendar of its own, derived from the birthdays already on your contact cards. Nothing is written anywhere — the dates stay on the cards, and an event disappears when the contact does or the birthday is cleared. It can be hidden from the calendar\u2019s own sidebar without turning it off here.")}
/>
<h2>{t("Working hours")}</h2>
<div className="field-row">
<div className="field">