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;
}