Merge pull request #206 from Coffey-Labs/feat/birthday-calendar
Show birthdays from the address book as a calendar
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user