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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user