From c9ab203b76f4b33b845d98399682ee1c36b6f470 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 1 Sep 2026 23:28:21 -0700 Subject: [PATCH] Show birthdays from the address book as a calendar The dates were already on the contact cards and nothing ever showed them, so the one thing a birthday is for -- noticing it in time -- was the one thing the app could not do with it. Derived, not stored. The dates stay on the cards: a second copy of the same fact drifts the first time somebody corrects one, and keeping a calendar of its own is exactly what ihasmail does not do. Entries are generated when a view asks for a range and vanish when the contact does. They go through instancesIn like everything else, so no view has to know they are different. Off until switched on. It is derived data, and a calendar that fills itself with dates nobody put there is a surprise rather than a feature. It can also be hidden from the calendar's own sidebar without being turned off, which is the same distinction the shared calendars already draw. They cannot be edited or deleted, and that falls out of the design rather than being special-cased: the virtual calendar reports no write rights, so every control that already asks before offering Edit or Delete declines on its own. updateEvent and destroyEvent refuse a synthesised id as well, so the store is safe whatever calls it -- including anything added later. Two things about the dates themselves. A card that records only a day and month is the common case rather than the exceptional one, and gets a birthday with no age rather than no birthday. And 29 February falls on the 28th in a year that has no 29th: somebody born in February has a birthday in February, and moving it into March is the arithmetic winning over the fact. Both are conventions; these are the ones that keep the fact intact. The mock now carries birthdays on most of its contacts, including one with no year and one on 29 February, so both cases are visible without a real address book. --- FEATURES.md | 18 +++ server/src/mock/index.ts | 18 ++- web/src/lib/__tests__/birthdays.test.ts | 135 ++++++++++++++++++++ web/src/lib/birthdays.ts | 119 +++++++++++++++++ web/src/store/calendar.ts | 71 +++++++++- web/src/store/settings.ts | 7 + web/src/views/calendar/CalendarSidebar.tsx | 26 +++- web/src/views/settings/CalendarSettings.tsx | 10 +- 8 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 web/src/lib/__tests__/birthdays.test.ts create mode 100644 web/src/lib/birthdays.ts diff --git a/FEATURES.md b/FEATURES.md index 9902edc..9711e93 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -452,6 +452,24 @@ nothing for anybody else. - **iCal import** through `CalendarEvent/parse` (a file of any number of events), from the calendar's own menu, into that calendar. The events are filed rather than scheduled: no invitations go out to anyone named in them. +- **Birthdays**, as a calendar of its own derived from the birthdays already on + your contacts. Off until switched on in Settings › Calendar & contacts, and + hideable from the calendar's own sidebar without turning it off. + + **Nothing is written anywhere.** The dates live on the cards; a second copy + of the same fact would drift the first time somebody corrected one, and + keeping a calendar of its own is exactly what ihasmail does not do. An entry + disappears when the contact does, or when the birthday is cleared. + + They cannot be edited or deleted, and that falls out of the design rather + than being special-cased: the virtual calendar reports no write rights, so + every control that asks before offering Edit or Delete already declines. The + store refuses a synthesised id as well, whatever calls it. + + A card that records only a day and month — the common case — gets a birthday + with no age rather than no birthday. And 29 February falls on the 28th in a + year that has no 29th: somebody born in February has a birthday in February, + and moving it into March is the arithmetic winning over the fact. ## Events diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 3ccd56c..d40a16d 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -224,9 +224,25 @@ const sharedCards: Obj[] = [ { id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "dorothy@example.org", contexts: {} } }, phones: {}, organizations: {}, nicknames: {}, addresses: {}, notes: {}, updated: new Date().toISOString() }, ]; const booksFor = (accountId: unknown): Obj[] => (accountId === SHARED_ACCOUNT ? sharedAddressBooks : addressBooks); +/** One per contact, by index; a gap means that card has no birthday. */ +const BIRTHDAYS: Array<{ year?: number; month: number; day: number } | null> = [ + { year: 1815, month: 12, day: 10 }, + { month: 6, day: 9 }, // no year: the common case + { year: 1912, month: 6, day: 23 }, + null, + { year: 2000, month: 2, day: 29 }, // lands on the 28th in a non-leap year + { year: 1918, month: 8, day: 26 }, +]; + const cards: Obj[] = people.slice(0, 6).map((p, i) => { const [given, surname] = p[0]!.split(" "); - return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined }; + return { id: `cc${i}`, addressBookIds: { ab1: true }, "@type": "Card", version: "1.0", uid: `uid-cc${i}`, kind: "individual", name: { components: [{ kind: "given", value: given }, { kind: "surname", value: surname ?? "" }], isOrdered: true }, emails: { e1: { address: p[1], contexts: { work: true } } }, phones: i % 2 ? { p1: { number: `+1 555 010${i}`, features: { mobile: true } } } : undefined, organizations: i % 3 ? { o1: { name: "Example Corp" } } : undefined, + /* + * Birthdays on most but not all of them, and one with no year, because a + * card that records only a day and month is the common case rather than + * the exceptional one. + */ + anniversaries: BIRTHDAYS[i] ? { a1: { "@type": "Anniversary", kind: "birth", date: { "@type": "PartialDate", ...BIRTHDAYS[i] } } } : undefined }; }); const principals: Obj[] = people.slice(0, 5).map((p, i) => ({ id: `pr${i}`, type: "individual", name: p[0], description: null, email: p[1], timeZone: "UTC" })); const fileNodes: Obj[] = [ diff --git a/web/src/lib/__tests__/birthdays.test.ts b/web/src/lib/__tests__/birthdays.test.ts new file mode 100644 index 0000000..92ff612 --- /dev/null +++ b/web/src/lib/__tests__/birthdays.test.ts @@ -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) => 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([]); + }); +}); diff --git a/web/src/lib/birthdays.ts b/web/src/lib/birthdays.ts new file mode 100644 index 0000000..33dd06e --- /dev/null +++ b/web/src/lib/birthdays.ts @@ -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, 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; +} diff --git a/web/src/store/calendar.ts b/web/src/store/calendar.ts index b736d1e..b714374 100644 --- a/web/src/store/calendar.ts +++ b/web/src/store/calendar.ts @@ -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((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(); 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((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((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((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("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): EventInstance | null { const allDay = Boolean(e.showWithoutTime); let start: Date; diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index db20de8..0b5e1d2 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -115,6 +115,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: Array<{ keyword: string; name: string; color: string }>; @@ -226,6 +232,7 @@ export const DEFAULT_SETTINGS: Settings = { defaultAlertMinutes: 10, timeZone: null, labelsSidebar: true, + birthdayCalendar: false, fontSize: "medium", templates: [], labels: [], diff --git a/web/src/views/calendar/CalendarSidebar.tsx b/web/src/views/calendar/CalendarSidebar.tsx index 456ee10..760890f 100644 --- a/web/src/views/calendar/CalendarSidebar.tsx +++ b/web/src/views/calendar/CalendarSidebar.tsx @@ -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() { {t("My calendars")} + {/* 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 && ( +
cal.toggleHidden(BIRTHDAY_CALENDAR_ID)} + title={t("From the birthdays on your contacts. Nothing is stored.")} + > + + {t("Birthdays")} +
+ )} {calendars.map((c) => (
cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}> diff --git a/web/src/views/settings/CalendarSettings.tsx b/web/src/views/settings/CalendarSettings.tsx index 2ce9d5f..97a0007 100644 --- a/web/src/views/settings/CalendarSettings.tsx +++ b/web/src/views/settings/CalendarSettings.tsx @@ -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() { ))} +

{t("Birthdays")}

+ 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.")} + /> +

{t("Working hours")}