Files
ihasmail-inbuxa/web/src/lib/birthdays.ts
T

120 lines
4.5 KiB
TypeScript

/**
* 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 synthesized 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;
}