From d144a6e85ccb143f7cd0f87462d676468d83e79a Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 04:43:40 +0200
Subject: [PATCH] feat: let plugins add sections to event details and the event
form, and label calendar days
---
.../calendar-plugin-surfaces.test.tsx | 186 ++++++++++++++++++
components/calendar/calendar-day-view.tsx | 2 +
components/calendar/calendar-month-view.tsx | 2 +
components/calendar/calendar-week-view.tsx | 2 +
components/calendar/event-detail-popover.tsx | 5 +
components/calendar/event-modal.tsx | 57 +++++-
components/calendar/mini-calendar.tsx | 2 +
components/calendar/plugin-day-label.tsx | 63 ++++++
.../plugins/__tests__/fake-slot-offer.tsx | 37 ++++
9 files changed, 355 insertions(+), 1 deletion(-)
create mode 100644 components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
create mode 100644 components/calendar/plugin-day-label.tsx
create mode 100644 components/plugins/__tests__/fake-slot-offer.tsx
diff --git a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
new file mode 100644
index 000000000..06e07cf7d
--- /dev/null
+++ b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
@@ -0,0 +1,186 @@
+import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { EventModal } from '../event-modal';
+import { EventDetailPopover } from '../event-detail-popover';
+import { CalendarWeekView } from '../calendar-week-view';
+import { CalendarDayView } from '../calendar-day-view';
+import { CalendarMonthView } from '../calendar-month-view';
+import { MiniCalendar } from '../mini-calendar';
+import { resetDayLabels, setDayLabels } from '@/lib/plugin-sandbox/day-labels';
+import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
+import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
+
+// Plugin surfaces in the calendar: a section in an event's details and in
+// its form, and the labels and dots plugins put on days.
+
+vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+
+const calendars = [
+ { id: 'cal-1', name: 'Work', color: '#123456', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
+] as unknown as Calendar[];
+
+const existing = {
+ id: 'e1',
+ uid: 'uid-1',
+ title: 'Planning',
+ start: '2026-10-12T10:00:00',
+ duration: 'PT1H',
+ timeZone: null,
+ showWithoutTime: false,
+ calendarIds: { 'cal-1': true },
+} as unknown as CalendarEvent;
+
+afterEach(() => {
+ cleanup();
+ clearSlotOffers();
+ resetDayLabels();
+});
+
+describe('calendar-event-detail', () => {
+ it('shows the event in the detail popover', () => {
+ offerSlots('p1', ['calendar-event-detail']);
+ render(
+ ,
+ );
+
+ const frame = screen.getByTestId('plugin-frame-calendar-event-detail');
+ expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' });
+ });
+
+ it('shows the event in the modal view, and not in the form', () => {
+ offerSlots('p1', ['calendar-event-detail']);
+ render();
+
+ expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-detail')).event).toMatchObject({ id: 'e1', title: 'Planning' });
+
+ fireEvent.click(screen.getByText('events.edit'));
+ expect(screen.queryByTestId('plugin-frame-calendar-event-detail')).toBeNull();
+ });
+
+ it('renders nothing when no plugin offers it', () => {
+ render();
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ });
+});
+
+describe('calendar-event-form', () => {
+ it('hands the plugin the form state of an edited event and follows the fields', async () => {
+ offerSlots('p1', ['calendar-event-form']);
+ render();
+ expect(screen.queryByTestId('plugin-frame-calendar-event-form')).toBeNull();
+
+ fireEvent.click(screen.getByText('events.edit'));
+ const frame = screen.getByTestId('plugin-frame-calendar-event-form');
+ expect(framePropsOf(frame).event).toMatchObject({
+ id: 'e1', uid: 'uid-1', calendarId: 'cal-1', title: 'Planning', start: '2026-10-12T10:00:00', duration: 'PT1H', isAllDay: false,
+ });
+
+ fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Planning v2' } });
+ await waitFor(() => {
+ expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ title: 'Planning v2' });
+ });
+ });
+
+ it('gives a new event an empty id', () => {
+ offerSlots('p1', ['calendar-event-form']);
+ render(
+ ,
+ );
+
+ expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({
+ id: '', uid: '', calendarId: 'cal-1', duration: 'PT1H30M', participants: [],
+ });
+ });
+
+ it('renders nothing when no plugin offers it', () => {
+ render();
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ });
+});
+
+describe('calendar-day-header and mini-calendar-marks', () => {
+ const week = {
+ focus: { date: new Date(2026, 8, 9), nonce: 0 },
+ windowKey: 'week:2026-09-07',
+ rangeStart: new Date(2026, 8, 7),
+ rangeEnd: new Date(2026, 8, 13),
+ selectedDate: new Date(2026, 8, 9),
+ events: [],
+ calendars,
+ onSelectDate: vi.fn(),
+ onSelectEvent: vi.fn(),
+ onCreateAtTime: vi.fn(),
+ };
+
+ function headerOf(dayKey: string): HTMLElement {
+ const header = document.querySelector(`[role="columnheader"][data-day="${dayKey}"], [data-day="${dayKey}"] [role="columnheader"]`);
+ if (!header) throw new Error(`no header for ${dayKey}`);
+ return header;
+ }
+
+ it('puts a label under the day name in the week view', () => {
+ render();
+ expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
+
+ act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day', title: 'A feast' }]));
+
+ const label = headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]');
+ expect(label).toHaveTextContent('Feast day');
+ expect(label?.title).toBe('A feast (Holidays)');
+ expect(headerOf('2026-09-10').querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
+ });
+
+ it('puts a label under the day name in the day view', () => {
+ act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
+ render();
+
+ expect(headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]')).toHaveTextContent('Feast day');
+ });
+
+ it('shows the label in the month cell header on desktop only', () => {
+ act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
+ const month = { ...week, windowKey: 'month:2026-09-09', rangeStart: new Date(2026, 7, 31), rangeEnd: new Date(2026, 9, 4) };
+ const { unmount } = render();
+
+ const labels = document.querySelectorAll('[data-plugin-surface="calendar-day-header"]');
+ expect(labels).toHaveLength(1);
+ expect(labels[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
+ unmount();
+
+ render();
+ expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
+ });
+
+ it('marks days with a dot in the small month calendar', () => {
+ render(
+ ,
+ );
+ expect(document.querySelector('[data-plugin-surface="mini-calendar-marks"]')).toBeNull();
+
+ act(() => setDayLabels('hol', 'Holidays', [
+ { date: '2026-09-09', dot: true, title: 'A feast' },
+ { date: '2026-09-10', text: 'Text only' },
+ ]));
+
+ const dots = document.querySelectorAll('[data-plugin-surface="mini-calendar-marks"]');
+ expect(dots).toHaveLength(1);
+ expect(dots[0].closest('button')).toHaveTextContent('9');
+ expect(dots[0].closest('button')).toHaveTextContent('A feast');
+ });
+});
diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx
index a111549e5..da1a8e942 100644
--- a/components/calendar/calendar-day-view.tsx
+++ b/components/calendar/calendar-day-view.tsx
@@ -20,6 +20,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
import { readableTextOn } from "@/lib/color-transform";
+import { PluginDayLabel } from "./plugin-day-label";
interface CalendarDayViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -334,6 +335,7 @@ export function CalendarDayView({
>
{format(day, "d")}
+
{hasAllDayArea && (
diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx
index 31ba69a59..1164876b5 100644
--- a/components/calendar/calendar-month-view.tsx
+++ b/components/calendar/calendar-month-view.tsx
@@ -18,6 +18,7 @@ import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { useScrollWindow } from "@/hooks/use-scroll-window";
import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
+import { PluginDayLabel } from "./plugin-day-label";
interface CalendarMonthViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -379,6 +380,7 @@ export function CalendarMonthView({
>
{formatDayNumber(day)}
+ {!isMobile && }
{isMobile && !showChips ? (
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx
index 2a342a034..d7f63c9d4 100644
--- a/components/calendar/calendar-week-view.tsx
+++ b/components/calendar/calendar-week-view.tsx
@@ -23,6 +23,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
import { readableTextOn } from "@/lib/color-transform";
+import { PluginDayLabel } from "./plugin-day-label";
interface CalendarWeekViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -436,6 +437,7 @@ export function CalendarWeekView({
>
{format(day, "d")}
+
);
})}
diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx
index e7c573d4c..7a54d8f66 100644
--- a/components/calendar/event-detail-popover.tsx
+++ b/components/calendar/event-detail-popover.tsx
@@ -25,6 +25,8 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { eventToView } from "@/lib/plugin-projection";
interface EventDetailPopoverProps {
event: CalendarEvent;
@@ -145,6 +147,7 @@ export function EventDetailPopover({
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; surface it as the meeting link anyway.
const meeting = useMemo(() => findMeetingLink(event), [event]);
+ const pluginProps = useMemo(() => ({ event: eventToView(event) }), [event]);
const virtualLocation = meeting?.uri ?? null;
// A URL opens as a link, a location that only names the service joins the
@@ -580,6 +583,8 @@ export function EventDetailPopover({
)}
+
+
{/* Quick Note */}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 14b2d26aa..6a0b5b4f3 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -39,7 +39,7 @@ import {
calendarHooks,
type CalendarEventFormSavePatch,
} from "@/lib/plugin-hooks";
-import type { ConflictWarning } from "@/lib/plugin-types";
+import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types";
import { eventToView } from "@/lib/plugin-projection";
export interface PendingEventPreview {
@@ -446,6 +446,55 @@ export function EventModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
+ // calendar-event-detail: the event as plugins see it, in the read views.
+ const eventDetailProps = useMemo(() => (event ? { event: eventToView(event) } : null), [event]);
+
+ // calendar-event-form: the form's current state in the same shape. Typing
+ // would hand the plugin iframe new props on every key, so the view settles
+ // for half a second first.
+ const formEventView = useMemo(() => {
+ const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime || "00:00"}:00`;
+ const start = new Date(startStr);
+ let duration = event?.duration ?? "PT1H";
+ if (!isNaN(start.getTime())) {
+ if (allDay) {
+ const inclusiveEnd = new Date(`${endDate}T00:00:00`);
+ if (!isNaN(inclusiveEnd.getTime())) duration = buildAllDayDuration(start, inclusiveEnd < start ? start : inclusiveEnd);
+ } else {
+ const end = new Date(`${endDate}T${endTime || "00:00"}:00`);
+ if (!isNaN(end.getTime())) duration = buildDuration(start, end);
+ }
+ }
+ const base = event ? eventToView(event) : null;
+ return {
+ id: event?.id ?? "",
+ uid: event?.uid ?? "",
+ calendarId,
+ title,
+ description,
+ start: startStr,
+ duration,
+ timeZone: getEffectiveTimeZone(),
+ utcStart: null,
+ utcEnd: null,
+ isAllDay: allDay,
+ location,
+ status: base?.status ?? "confirmed",
+ participants: attendees.map((a) => {
+ const known = base?.participants.find((p) => p.email.toLowerCase() === a.email.toLowerCase());
+ return known ?? { name: a.name, email: a.email, role: "attendee", status: "needs-action" };
+ }),
+ isRecurring: recurrence !== "none",
+ keywords: base?.keywords ?? [],
+ };
+ }, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]);
+ const [settledFormEvent, setSettledFormEvent] = useState(formEventView);
+ useEffect(() => {
+ const timer = setTimeout(() => setSettledFormEvent(formEventView), 500);
+ return () => clearTimeout(timer);
+ }, [formEventView]);
+ const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]);
+
// Report live preview to parent for grid outline
useEffect(() => {
if (!onPreviewChange || isEdit) return;
@@ -823,6 +872,8 @@ export function EventModal({
)}
+
+ {eventDetailProps && }
@@ -1000,6 +1051,8 @@ export function EventModal({
)}
+
+ {eventDetailProps && }
@@ -1347,6 +1400,8 @@ export function EventModal({
)}
+
+
diff --git a/components/calendar/mini-calendar.tsx b/components/calendar/mini-calendar.tsx
index 03bc55dc8..cd7a9e8bc 100644
--- a/components/calendar/mini-calendar.tsx
+++ b/components/calendar/mini-calendar.tsx
@@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils";
import { displayNow } from "@/lib/timezone";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
+import { PluginDayLabel } from "./plugin-day-label";
type PickerView = "days" | "months" | "years";
@@ -199,6 +200,7 @@ export function MiniCalendar({
{hasEvent && !selected && !today && (
)}
+
);
diff --git a/components/calendar/plugin-day-label.tsx b/components/calendar/plugin-day-label.tsx
new file mode 100644
index 000000000..63ff9aa0f
--- /dev/null
+++ b/components/calendar/plugin-day-label.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { cn } from "@/lib/utils";
+import { dayLabelsByDate, subscribeDayLabels, type RegisteredDayLabel } from "@/lib/plugin-sandbox/day-labels";
+
+const NONE: RegisteredDayLabel[] = [];
+const EMPTY_MAP = new Map();
+
+/** The labels plugins put on one day (api.ui.setDayLabels). */
+export function useDayLabels(date: string): RegisteredDayLabel[] {
+ const byDate = useSyncExternalStore(subscribeDayLabels, dayLabelsByDate, () => EMPTY_MAP);
+ return byDate.get(date) ?? NONE;
+}
+
+function tooltip(labels: RegisteredDayLabel[]): string {
+ return labels.map((l) => `${l.title ?? l.text ?? ""} (${l.pluginName})`.trim()).join("\n");
+}
+
+interface PluginDayLabelProps {
+ /**
+ * calendar-day-header: the label text under a day's name (day, week and
+ * month views). mini-calendar-marks: a dot on the day in the small month
+ * calendar; the parent must be positioned.
+ */
+ surface: "calendar-day-header" | "mini-calendar-marks";
+ /** YYYY-MM-DD */
+ date: string;
+ className?: string;
+}
+
+/** What plugins put on a calendar day. Renders nothing without labels. */
+export function PluginDayLabel({ surface, date, className }: PluginDayLabelProps) {
+ const labels = useDayLabels(date);
+
+ if (surface === "mini-calendar-marks") {
+ const marks = labels.filter((l) => l.dot);
+ if (marks.length === 0) return null;
+ return (
+ <>
+
+ {marks.map((l) => l.title ?? l.text ?? l.pluginName).join(", ")}
+ >
+ );
+ }
+
+ const withText = labels.filter((l) => l.text);
+ if (withText.length === 0) return null;
+ return (
+
+ {withText.map((l) => l.text).join(" ยท ")}
+
+ );
+}
diff --git a/components/plugins/__tests__/fake-slot-offer.tsx b/components/plugins/__tests__/fake-slot-offer.tsx
new file mode 100644
index 000000000..2f4c68e33
--- /dev/null
+++ b/components/plugins/__tests__/fake-slot-offer.tsx
@@ -0,0 +1,37 @@
+// Test helpers for mount-site tests: a plugin that offers a slot, and a
+// stand-in for the sandboxed iframe that shows which props it was handed.
+//
+// Use with:
+// vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
+import type { SlotName } from '@/lib/plugin-types';
+
+const registered = new Set();
+
+/** Registers an active plugin that offers these slots. */
+export function offerSlots(pluginId: string, slots: SlotName[]): void {
+ register({
+ plugin: { id: pluginId, name: pluginId },
+ code: '',
+ tier: 'untrusted',
+ background: {},
+ slotOffers: slots.map((name) => ({ name, order: 100, hasShouldShow: false })),
+ hookDisposables: [],
+ } as unknown as ActivePlugin);
+ registered.add(pluginId);
+}
+
+export function clearSlotOffers(): void {
+ for (const id of registered) deregister(id);
+ registered.clear();
+}
+
+/** Drop-in for PluginIframeSlot: no iframe, the props as JSON. */
+export function PluginIframeSlot({ pluginId, slot, extraProps }: { pluginId: string; slot: string; extraProps?: Record }) {
+ return ;
+}
+
+/** The props the fake iframe for this slot received. */
+export function framePropsOf(el: HTMLElement): Record {
+ return JSON.parse(el.getAttribute('data-props') || '{}') as Record;
+}