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:
2026-09-01 23:28:21 -07:00
parent d300107be0
commit c9ab203b76
8 changed files with 400 additions and 4 deletions
+18
View File
@@ -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
+17 -1
View File
@@ -224,9 +224,25 @@ const sharedCards: Obj[] = [
{ id: "sc2", addressBookIds: { ab9: true }, name: { full: "Dorothy Vaughan" }, emails: { e1: { address: "[email protected]", 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[] = [
+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;
}
+70 -1
View File
@@ -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;
+7
View File
@@ -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: [],
+25 -1
View File
@@ -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() {
<span>{t("My calendars")}</span>
<button className="icon-btn" title={t("New calendar")} onClick={() => setEditCal({})}><Plus size={16} /></button>
</div>
{/* 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 && (
<div
className={`cal-list-item ${cal.hidden[BIRTHDAY_CALENDAR_ID] ? "hidden-cal" : ""}`}
onClick={() => cal.toggleHidden(BIRTHDAY_CALENDAR_ID)}
title={t("From the birthdays on your contacts. Nothing is stored.")}
>
<span className="cal-color" style={{ background: "#e0a33e", borderColor: "#e0a33e" }} />
<span className="cal-name">{t("Birthdays")}</span>
</div>
)}
{calendars.map((c) => (
<div key={c.id} className={`cal-list-item ${cal.hidden[c.id] ? "hidden-cal" : ""}`} onClick={() => cal.toggleHidden(c.id)} onContextMenu={(e) => { e.preventDefault(); setMenuCal(c); menu.openAt(e.clientX, e.clientY); }}>
<span className="cal-color" style={{ background: c.color ?? "var(--accent)", borderColor: c.color ?? "var(--accent)" }} />
+9 -1
View File
@@ -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() {
))}
<button className="btn mb-16" onClick={async () => { const n = await promptDialog({ title: "New category", placeholder: "Name" }); if (n?.trim() && !s.eventCategories.some((c) => c.name.toLowerCase() === n.trim().toLowerCase())) update({ eventCategories: [...s.eventCategories, { name: n.trim(), color: CALENDAR_COLORS[s.eventCategories.length % CALENDAR_COLORS.length]! }] }); }}><Plus size={16} /> {t("New category")}</button>
<h2>{t("Birthdays")}</h2>
<Switch
checked={s.birthdayCalendar}
onChange={(v) => 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.")}
/>
<h2>{t("Working hours")}</h2>
<div className="field-row">
<div className="field">