@remit/web-client 0.0.230 → 0.0.231

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.stories.tsx +25 -0
  3. package/src/components/calendar/CalendarComposePane.tsx +34 -0
  4. package/src/components/calendar/CalendarEventPane.stories.tsx +81 -0
  5. package/src/components/calendar/CalendarEventPane.tsx +67 -0
  6. package/src/components/calendar/CalendarShell.tsx +83 -0
  7. package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
  8. package/src/components/calendar/CalendarWorkspace.stories.tsx +197 -0
  9. package/src/components/calendar/CalendarWorkspace.tsx +162 -0
  10. package/src/components/calendar/OpenCalendarEvent.tsx +39 -0
  11. package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
  12. package/src/components/mail/MailSidebarAdapter.tsx +17 -0
  13. package/src/hooks/useCalendarData.ts +72 -0
  14. package/src/lib/calendar-density.ts +34 -0
  15. package/src/lib/calendar-fixtures.ts +207 -0
  16. package/src/lib/calendar-format.ts +29 -0
  17. package/src/lib/calendar-route.test.ts +210 -0
  18. package/src/lib/calendar-route.ts +183 -0
  19. package/src/lib/search-params.test.ts +77 -0
  20. package/src/lib/search-params.ts +59 -0
  21. package/src/routeTree.gen.ts +199 -0
  22. package/src/router.tsx +5 -0
  23. package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
  24. package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
  25. package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
  26. package/src/routes/calendar/$view.$date/index.tsx +25 -0
  27. package/src/routes/calendar/$view.$date/new.tsx +20 -0
  28. package/src/routes/calendar/$view.$date.tsx +100 -0
  29. package/src/routes/calendar/-calendar-tree.test.ts +267 -0
  30. package/src/routes/calendar/index.tsx +27 -0
  31. package/src/routes/calendar.tsx +98 -0
  32. package/src/routing/calendar-fragment.test.ts +66 -0
  33. package/src/routing/calendar.ts +139 -0
  34. package/src/routing/fragment.ts +19 -0
  35. package/src/routing/index.ts +10 -0
  36. package/src/routing/nav-selection.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.230",
3
+ "version": "0.0.231",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -0,0 +1,25 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { CalendarComposePane } from "./CalendarComposePane";
3
+
4
+ /**
5
+ * `/calendar/{view}/{date}/new`. The address is real and the editor is not
6
+ * written yet, so the pane says which it is and offers the way back — the one
7
+ * thing worse than an unfinished surface is one that looks finished and does
8
+ * nothing.
9
+ */
10
+ const meta: Meta<typeof CalendarComposePane> = {
11
+ title: "App/Calendar/Compose pane",
12
+ component: CalendarComposePane,
13
+ parameters: { layout: "fullscreen" },
14
+ args: { onClose: () => {} },
15
+ render: (args) => (
16
+ <div className="h-dvh max-w-xl border-l border-line bg-canvas">
17
+ <CalendarComposePane {...args} />
18
+ </div>
19
+ ),
20
+ };
21
+ export default meta;
22
+
23
+ type Story = StoryObj<typeof CalendarComposePane>;
24
+
25
+ export const NotBuiltYet: Story = {};
@@ -0,0 +1,34 @@
1
+ import { CalendarPlus } from "lucide-react";
2
+
3
+ /**
4
+ * Writing a new event.
5
+ *
6
+ * A sibling of the open event rather than a child, so navigating here unmatches
7
+ * whatever the calendar had open in the same transition and a composer over an
8
+ * open event cannot be addressed — the move `compose` makes over a thread.
9
+ *
10
+ * The editor itself needs somewhere to save to, so it arrives with the calendar
11
+ * API. The route says that rather than rendering an empty pane.
12
+ */
13
+ export interface CalendarComposePaneProps {
14
+ onClose: () => void;
15
+ }
16
+
17
+ export function CalendarComposePane({ onClose }: CalendarComposePaneProps) {
18
+ return (
19
+ <div className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center">
20
+ <CalendarPlus className="size-8 text-fg-subtle" aria-hidden="true" />
21
+ <p className="text-sm font-medium text-fg">Not built yet</p>
22
+ <p className="max-w-xs text-sm text-fg-muted">
23
+ Writing an event arrives with the calendar API.
24
+ </p>
25
+ <button
26
+ type="button"
27
+ onClick={onClose}
28
+ className="rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg outline-none hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring"
29
+ >
30
+ Back to the calendar
31
+ </button>
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,81 @@
1
+ import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { CalendarEventPane } from "./CalendarEventPane";
4
+
5
+ /**
6
+ * The calendar's reading pane, at the three states the routes below the view
7
+ * can put it in: the event, one occurrence of a repeating one, and an address
8
+ * naming an event the calendar does not have on screen.
9
+ */
10
+ const calendar: CalendarDescriptor = {
11
+ id: "cal_work",
12
+ accountId: "acct_work",
13
+ accountLabel: "Work",
14
+ name: "Northwind",
15
+ color: "cal-1",
16
+ };
17
+
18
+ const event: CalendarEventData = {
19
+ id: "evt_roadmap",
20
+ calendarId: calendar.id,
21
+ title: "Roadmap review",
22
+ start: "2026-06-10T10:00:00+02:00",
23
+ end: "2026-06-10T11:30:00+02:00",
24
+ allDay: false,
25
+ location: "Room Zuid",
26
+ notes: "Bring the staffing numbers.",
27
+ attendees: [
28
+ {
29
+ name: "Priya Natarajan",
30
+ email: "priya@northwind.example",
31
+ rsvp: "accepted",
32
+ role: "organizer",
33
+ },
34
+ ],
35
+ myRsvp: "accepted",
36
+ threadId: "",
37
+ threadSubject: "",
38
+ timeZone: "Europe/Amsterdam",
39
+ zoneCertainty: "explicit",
40
+ recurrenceRule: "",
41
+ seriesId: "",
42
+ seriesException: false,
43
+ status: "confirmed",
44
+ };
45
+
46
+ const meta: Meta<typeof CalendarEventPane> = {
47
+ title: "App/Calendar/Event pane",
48
+ component: CalendarEventPane,
49
+ parameters: { layout: "fullscreen" },
50
+ args: { event, calendar, isOccurrence: false, onClose: () => {} },
51
+ render: (args) => (
52
+ <div className="h-dvh max-w-xl border-l border-line bg-canvas">
53
+ <CalendarEventPane {...args} />
54
+ </div>
55
+ ),
56
+ };
57
+ export default meta;
58
+
59
+ type Story = StoryObj<typeof CalendarEventPane>;
60
+
61
+ export const Event: Story = {};
62
+
63
+ /** The series is what the address names; the occurrence is the segment below. */
64
+ export const Occurrence: Story = {
65
+ args: {
66
+ event: {
67
+ ...event,
68
+ title: "Standup",
69
+ start: "2026-06-11T09:15:00+02:00",
70
+ end: "2026-06-11T09:30:00+02:00",
71
+ recurrenceRule: "Every weekday, 09:15",
72
+ seriesId: "ser_standup",
73
+ },
74
+ isOccurrence: true,
75
+ },
76
+ };
77
+
78
+ /** An old link, or a week the event is not in. Never a blank pane. */
79
+ export const NotOnThisWeek: Story = {
80
+ args: { event: undefined, calendar: undefined },
81
+ };
@@ -0,0 +1,67 @@
1
+ import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
+ import { EventDetail } from "@remit/ui";
3
+ import { CalendarDays } from "lucide-react";
4
+ import { formatEventWhen } from "@/lib/calendar-format";
5
+
6
+ /**
7
+ * The event the address has open.
8
+ *
9
+ * Presentational, like the workspace beside it: the route resolves the event
10
+ * and turns closing into a navigation, so this renders what it is handed and
11
+ * nothing else. Editing and deleting arrive with the calendar API, and until
12
+ * then the header offers neither — a control that leads nowhere is worse than
13
+ * no control.
14
+ */
15
+ export interface CalendarEventPaneProps {
16
+ /** Absent when the address names an event the calendar does not have. */
17
+ event: CalendarEventData | undefined;
18
+ calendar: CalendarDescriptor | undefined;
19
+ /** Whether the address names one occurrence rather than the series. */
20
+ isOccurrence: boolean;
21
+ onClose: () => void;
22
+ }
23
+
24
+ export function CalendarEventPane({
25
+ event,
26
+ calendar,
27
+ isOccurrence,
28
+ onClose,
29
+ }: CalendarEventPaneProps) {
30
+ if (!event || !calendar) {
31
+ return (
32
+ <div className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center">
33
+ <CalendarDays className="size-8 text-fg-subtle" aria-hidden="true" />
34
+ <p className="text-sm font-medium text-fg">
35
+ That event isn't on this week
36
+ </p>
37
+ <p className="max-w-xs text-sm text-fg-muted">
38
+ The address names an event the calendar doesn't have here.
39
+ </p>
40
+ <button
41
+ type="button"
42
+ onClick={onClose}
43
+ className="rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg outline-none hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring"
44
+ >
45
+ Back to the calendar
46
+ </button>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <div className="flex h-full min-h-0 flex-col bg-surface">
53
+ {isOccurrence && (
54
+ <p className="shrink-0 border-b border-line bg-surface-sunken px-row-inset py-1.5 text-2xs uppercase tracking-wider text-fg-subtle">
55
+ One occurrence of a repeating event
56
+ </p>
57
+ )}
58
+ <EventDetail
59
+ event={event}
60
+ calendar={calendar}
61
+ whenText={formatEventWhen(event)}
62
+ onClose={onClose}
63
+ className="min-h-0 flex-1"
64
+ />
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,83 @@
1
+ import { AppShellSlotted } from "@remit/ui";
2
+ import { createContext, type ReactNode, useContext } from "react";
3
+ import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
4
+
5
+ /**
6
+ * The chrome the calendar shares with mail, published by the `/calendar` layout
7
+ * and consumed by the view route that mounts the shell.
8
+ *
9
+ * It lives in a module reached only through the `@/` alias for the same reason
10
+ * `lib/mail-context.ts` does: the generated route tree imports route files
11
+ * relatively, so a context declared inside one would resolve to a second module
12
+ * instance and hand the consumer an empty default.
13
+ */
14
+ export interface CalendarShellChrome {
15
+ /** Below the reading boundary the shell shows one pane, as mail's does. */
16
+ isSinglePane: boolean;
17
+ isLoading: boolean;
18
+ nav: ReactNode;
19
+ navOpen: boolean;
20
+ onOpenNav: () => void;
21
+ onCloseNav: () => void;
22
+ }
23
+
24
+ const CalendarShellCtx = createContext<CalendarShellChrome | null>(null);
25
+
26
+ export const CalendarShellProvider = ({
27
+ chrome,
28
+ children,
29
+ }: {
30
+ chrome: CalendarShellChrome;
31
+ children: ReactNode;
32
+ }) => (
33
+ <CalendarShellCtx.Provider value={chrome}>
34
+ {children}
35
+ </CalendarShellCtx.Provider>
36
+ );
37
+
38
+ export interface CalendarShellProps {
39
+ /** The grid, or whatever the current zoom draws. */
40
+ workspace: ReactNode;
41
+ /** The open event, from the route below this one. */
42
+ reading: ReactNode;
43
+ /** Whether the address has an event or the composer open. */
44
+ hasOpenEvent: boolean;
45
+ }
46
+
47
+ /**
48
+ * The shell the calendar mounts around its own panes.
49
+ *
50
+ * `listBias="list"` because the grid is the work rather than an index into it:
51
+ * a week of columns costs width it cannot give back when a detail opens, which
52
+ * is the opposite of the split a message list wants.
53
+ *
54
+ * Below the reading boundary there is one pane, so the open event replaces the
55
+ * grid in it — the same swap mail makes, read off the address rather than
56
+ * decided here.
57
+ */
58
+ export function CalendarShell({
59
+ workspace,
60
+ reading,
61
+ hasOpenEvent,
62
+ }: CalendarShellProps) {
63
+ const chrome = useContext(CalendarShellCtx);
64
+ if (!chrome) return <AppShellSkeleton />;
65
+
66
+ const shared = {
67
+ nav: chrome.nav,
68
+ skeleton: <AppShellSkeleton />,
69
+ isLoading: chrome.isLoading,
70
+ navOpen: chrome.navOpen,
71
+ onOpenNav: chrome.onOpenNav,
72
+ onCloseNav: chrome.onCloseNav,
73
+ listBias: "list" as const,
74
+ };
75
+
76
+ if (chrome.isSinglePane) {
77
+ return (
78
+ <AppShellSlotted {...shared} list={hasOpenEvent ? reading : workspace} />
79
+ );
80
+ }
81
+
82
+ return <AppShellSlotted {...shared} list={workspace} reading={reading} />;
83
+ }
@@ -0,0 +1,37 @@
1
+ import type { CalendarViewId } from "@remit/ui";
2
+ import { CalendarRange } from "lucide-react";
3
+
4
+ /**
5
+ * A zoom level that is named but not drawn yet.
6
+ *
7
+ * The ladder is five steps and three of them arrive later, so the route stays
8
+ * addressable and says what it is waiting on. A view that silently rendered
9
+ * nothing would be indistinguishable from a week with nothing in it.
10
+ */
11
+ const WAITING_ON: Record<string, string> = {
12
+ year: "The year grid arrives with the rest of the zoom ladder.",
13
+ month: "The month grid arrives with the rest of the zoom ladder.",
14
+ agenda: "The agenda arrives with the scrolling day strip.",
15
+ };
16
+
17
+ export interface CalendarViewPlaceholderProps {
18
+ view: CalendarViewId;
19
+ }
20
+
21
+ export function CalendarViewPlaceholder({
22
+ view,
23
+ }: CalendarViewPlaceholderProps) {
24
+ return (
25
+ <div
26
+ className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center"
27
+ data-testid={`calendar-placeholder-${view}`}
28
+ >
29
+ <CalendarRange className="size-8 text-fg-subtle" aria-hidden="true" />
30
+ <p className="text-sm font-medium text-fg">Not built yet</p>
31
+ <p className="max-w-xs text-sm text-fg-muted">
32
+ {WAITING_ON[view] ?? "This view arrives in a later stage."} Week and Day
33
+ work now.
34
+ </p>
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,197 @@
1
+ import {
2
+ type CalendarColorId,
3
+ type CalendarEventData,
4
+ type CalendarViewId,
5
+ type Density,
6
+ NavSidebar,
7
+ } from "@remit/ui";
8
+ import type { Meta, StoryObj } from "@storybook/react-vite";
9
+ import { useState } from "react";
10
+ import { CalendarWorkspace } from "./CalendarWorkspace";
11
+
12
+ /**
13
+ * The calendar pane the `/calendar/{view}/{date}` route mounts, at every state
14
+ * the shipping shell can be in.
15
+ *
16
+ * It is presentational: the toolbar's moves leave through callbacks and the
17
+ * route turns each one into an address, so a story can drive them with local
18
+ * state and get the same surface the app renders. What the stories are for is
19
+ * what the markup cannot answer — how a week reads once a day is full, and
20
+ * whether a zoom that is not drawn yet reads as unfinished rather than empty.
21
+ */
22
+ const TIME_ZONE = "Europe/Amsterdam";
23
+ const DATE = "2026-06-10";
24
+ const NOW = `${DATE}T09:30:00+02:00`;
25
+
26
+ const WORK = "cal_work";
27
+ const TEAM = "cal_team";
28
+ const HOME = "cal_home";
29
+
30
+ const colorByCalendarId: Record<string, CalendarColorId> = {
31
+ [WORK]: "cal-1",
32
+ [TEAM]: "cal-6",
33
+ [HOME]: "cal-4",
34
+ };
35
+
36
+ const template: CalendarEventData = {
37
+ id: "",
38
+ calendarId: WORK,
39
+ title: "",
40
+ start: "",
41
+ end: "",
42
+ allDay: false,
43
+ location: "",
44
+ notes: "",
45
+ attendees: [],
46
+ myRsvp: "accepted",
47
+ threadId: "",
48
+ threadSubject: "",
49
+ timeZone: TIME_ZONE,
50
+ zoneCertainty: "explicit",
51
+ recurrenceRule: "",
52
+ seriesId: "",
53
+ seriesException: false,
54
+ status: "confirmed",
55
+ };
56
+
57
+ const at = (
58
+ id: string,
59
+ title: string,
60
+ day: string,
61
+ from: string,
62
+ to: string,
63
+ over: Partial<CalendarEventData> = {},
64
+ ): CalendarEventData => ({
65
+ ...template,
66
+ ...over,
67
+ id,
68
+ title,
69
+ start: `2026-06-${day}T${from}:00+02:00`,
70
+ end: `2026-06-${day}T${to}:00+02:00`,
71
+ });
72
+
73
+ const week: CalendarEventData[] = [
74
+ at("standup-mon", "Standup", "08", "09:15", "09:30", {
75
+ recurrenceRule: "Every weekday, 09:15",
76
+ seriesId: "ser_standup",
77
+ }),
78
+ at("roadmap", "Roadmap review", "08", "11:00", "12:00", {
79
+ calendarId: TEAM,
80
+ }),
81
+ at("standup-tue", "Standup", "09", "09:15", "09:30", {
82
+ recurrenceRule: "Every weekday, 09:15",
83
+ seriesId: "ser_standup",
84
+ }),
85
+ at("supplier", "Supplier call", "09", "14:00", "15:00", {
86
+ calendarId: TEAM,
87
+ zoneCertainty: "ambiguous",
88
+ }),
89
+ at("standup-wed", "Standup", "10", "09:15", "09:30", {
90
+ recurrenceRule: "Every weekday, 09:15",
91
+ seriesId: "ser_standup",
92
+ }),
93
+ at("design", "Design review", "10", "10:00", "12:00", {
94
+ calendarId: TEAM,
95
+ }),
96
+ at("dentist", "Dentist", "10", "11:00", "12:00", {
97
+ calendarId: HOME,
98
+ location: "Kerkstraat 4",
99
+ }),
100
+ at("one-to-one", "One to one", "11", "15:00", "16:00"),
101
+ at("retro", "Retro", "12", "16:00", "17:00", { calendarId: TEAM }),
102
+ at("climbing", "Climbing", "13", "10:00", "12:00", { calendarId: HOME }),
103
+ ];
104
+
105
+ /** The shell drives view and date through the router; a story drives its own. */
106
+ function Workspace({
107
+ initialView,
108
+ events,
109
+ }: {
110
+ initialView: CalendarViewId;
111
+ events: CalendarEventData[];
112
+ }) {
113
+ const [view, setView] = useState<CalendarViewId>(initialView);
114
+ const [density, setDensity] = useState<Density>("comfortable");
115
+ const [selected, setSelected] = useState("");
116
+ return (
117
+ <CalendarWorkspace
118
+ view={view}
119
+ date={DATE}
120
+ events={events}
121
+ colorByCalendarId={colorByCalendarId}
122
+ density={density}
123
+ selectedEventId={selected}
124
+ timeZone={TIME_ZONE}
125
+ now={NOW}
126
+ onChangeView={setView}
127
+ onToday={() => undefined}
128
+ onStep={() => undefined}
129
+ onChangeDensity={setDensity}
130
+ onSelectEvent={setSelected}
131
+ onPickSlot={() => undefined}
132
+ />
133
+ );
134
+ }
135
+
136
+ const meta: Meta<typeof Workspace> = {
137
+ title: "App/Calendar/Workspace",
138
+ component: Workspace,
139
+ parameters: { layout: "fullscreen" },
140
+ args: { initialView: "week", events: week },
141
+ render: (args) => (
142
+ <div className="h-dvh bg-canvas">
143
+ <Workspace {...args} />
144
+ </div>
145
+ ),
146
+ };
147
+ export default meta;
148
+
149
+ type Story = StoryObj<typeof Workspace>;
150
+
151
+ export const Week: Story = {};
152
+
153
+ export const Day: Story = { args: { initialView: "day" } };
154
+
155
+ /** Nothing scheduled is still a grid, never a "nothing here" surface. */
156
+ export const EmptyWeek: Story = { args: { events: [] } };
157
+
158
+ /**
159
+ * The three zooms that are named but not drawn yet. Each says what it is
160
+ * waiting on, so the route is addressable without pretending to be finished.
161
+ */
162
+ export const YearNotBuiltYet: Story = { args: { initialView: "year" } };
163
+
164
+ export const MonthNotBuiltYet: Story = { args: { initialView: "month" } };
165
+
166
+ export const AgendaNotBuiltYet: Story = { args: { initialView: "agenda" } };
167
+
168
+ /**
169
+ * The calendar beside the nav it is reached from. The entry sits under the
170
+ * daily brief, which is where the shipping sidebar puts it.
171
+ */
172
+ export const WithTheNav: Story = {
173
+ render: (args) => (
174
+ <div className="flex h-dvh bg-canvas">
175
+ <div className="w-64 shrink-0">
176
+ <NavSidebar
177
+ accounts={[
178
+ {
179
+ id: "acct_work",
180
+ label: "Work",
181
+ email: "work@example.invalid",
182
+ mailboxes: [
183
+ { id: "mb_inbox", name: "Inbox", role: "inbox", unseen: 3 },
184
+ { id: "mb_sent", name: "Sent", role: "sent" },
185
+ ],
186
+ },
187
+ ]}
188
+ selectedNavId="calendar"
189
+ calendarNav="shown"
190
+ />
191
+ </div>
192
+ <div className="min-w-0 flex-1">
193
+ <Workspace {...args} />
194
+ </div>
195
+ </div>
196
+ ),
197
+ };
@@ -0,0 +1,162 @@
1
+ import {
2
+ type CalendarColorId,
3
+ CalendarDateNav,
4
+ type CalendarEventData,
5
+ CalendarGrid,
6
+ type CalendarSlotPick,
7
+ type CalendarViewId,
8
+ CalendarViewSwitch,
9
+ type Density,
10
+ segmentClassName,
11
+ } from "@remit/ui";
12
+ import { useId, useState } from "react";
13
+ import { CalendarViewPlaceholder } from "@/components/calendar/CalendarViewPlaceholder";
14
+ import { NavMenuButton } from "@/components/mail/NavMenuButton";
15
+ import { calendarViewMountsGrid } from "@/lib/calendar-route";
16
+
17
+ /**
18
+ * The calendar's own pane: the toolbar that moves it and whatever the current
19
+ * zoom draws.
20
+ *
21
+ * Presentational, like everything in `@remit/ui` it is built from. It holds no
22
+ * events, reads no clock and knows no router: prev, next, today and the zoom
23
+ * switch all leave through callbacks, and the route turns each one into an
24
+ * address. That is what keeps "which view" and "which day" facts of the URL
25
+ * rather than of this component's state.
26
+ */
27
+
28
+ const DENSITY_OPTIONS: { value: Density; label: string }[] = [
29
+ { value: "comfortable", label: "Detail" },
30
+ { value: "compact", label: "Glance" },
31
+ ];
32
+
33
+ export interface CalendarWorkspaceProps {
34
+ view: CalendarViewId;
35
+ /** The day the view is centred on, `YYYY-MM-DD`. */
36
+ date: string;
37
+ events: CalendarEventData[];
38
+ colorByCalendarId: Record<string, CalendarColorId>;
39
+ density: Density;
40
+ /** The event the reading pane has open, empty for none. */
41
+ selectedEventId: string;
42
+ timeZone: string;
43
+ /** The instant the grid calls now. */
44
+ now: string;
45
+ onChangeView: (view: CalendarViewId) => void;
46
+ onToday: () => void;
47
+ onChangeDensity: (density: Density) => void;
48
+ onSelectEvent: (eventId: string) => void;
49
+ onPickSlot: (pick: CalendarSlotPick) => void;
50
+ onStep: (direction: -1 | 1) => void;
51
+ }
52
+
53
+ /**
54
+ * The range the toolbar names before the grid has measured one, and the whole
55
+ * answer at a zoom that draws no grid.
56
+ */
57
+ function fallbackRangeTitle(view: CalendarViewId, date: string): string {
58
+ const instant = new Date(`${date}T00:00:00`);
59
+ if (view === "year")
60
+ return new Intl.DateTimeFormat(undefined, { year: "numeric" }).format(
61
+ instant,
62
+ );
63
+ if (view === "day")
64
+ return new Intl.DateTimeFormat(undefined, {
65
+ weekday: "short",
66
+ day: "numeric",
67
+ month: "long",
68
+ year: "numeric",
69
+ }).format(instant);
70
+ return new Intl.DateTimeFormat(undefined, {
71
+ month: "long",
72
+ year: "numeric",
73
+ }).format(instant);
74
+ }
75
+
76
+ export function CalendarWorkspace({
77
+ view,
78
+ date,
79
+ events,
80
+ colorByCalendarId,
81
+ density,
82
+ selectedEventId,
83
+ timeZone,
84
+ now,
85
+ onChangeView,
86
+ onToday,
87
+ onChangeDensity,
88
+ onSelectEvent,
89
+ onPickSlot,
90
+ onStep,
91
+ }: CalendarWorkspaceProps) {
92
+ const densityGroup = useId();
93
+ // The grid computes the range it drew, which is the only honest title for a
94
+ // week — it knows where the week starts. Held against the address it was
95
+ // measured for, so leaving a view does not carry its title into the next one.
96
+ const [measured, setMeasured] = useState({ key: "", title: "" });
97
+ const addressKey = `${view}/${date}`;
98
+ const title =
99
+ measured.key === addressKey
100
+ ? measured.title
101
+ : fallbackRangeTitle(view, date);
102
+
103
+ return (
104
+ <div className="flex h-full min-h-0 flex-col bg-surface">
105
+ <div className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-2">
106
+ {/* Below the nav pane the sidebar is a slide-over with nothing to open
107
+ it: the calendar is a whole surface of its own, so without this
108
+ the reader has no way back to their mail. */}
109
+ <NavMenuButton />
110
+ <CalendarDateNav
111
+ title={title}
112
+ onPrev={() => onStep(-1)}
113
+ onNext={() => onStep(1)}
114
+ onToday={onToday}
115
+ >
116
+ <fieldset className="inline-flex items-center gap-0.5">
117
+ <legend className="sr-only">Calendar density</legend>
118
+ {DENSITY_OPTIONS.map((option) => (
119
+ <label
120
+ key={option.value}
121
+ className={`${segmentClassName(density === option.value)} h-7 text-xs`}
122
+ >
123
+ <input
124
+ type="radio"
125
+ name={densityGroup}
126
+ value={option.value}
127
+ checked={density === option.value}
128
+ onChange={() => onChangeDensity(option.value)}
129
+ className="sr-only"
130
+ />
131
+ {option.label}
132
+ </label>
133
+ ))}
134
+ </fieldset>
135
+ <CalendarViewSwitch value={view} onChange={onChangeView} />
136
+ </CalendarDateNav>
137
+ </div>
138
+
139
+ <div className="min-h-0 flex-1">
140
+ {calendarViewMountsGrid(view) ? (
141
+ <CalendarGrid
142
+ view={view}
143
+ date={date}
144
+ events={events}
145
+ colorByCalendarId={colorByCalendarId}
146
+ density={density}
147
+ selectedEventId={selectedEventId}
148
+ timeZone={timeZone}
149
+ now={now}
150
+ onSelectEvent={onSelectEvent}
151
+ onPickSlot={onPickSlot}
152
+ onRangeChange={(measuredTitle) =>
153
+ setMeasured({ key: addressKey, title: measuredTitle })
154
+ }
155
+ />
156
+ ) : (
157
+ <CalendarViewPlaceholder view={view} />
158
+ )}
159
+ </div>
160
+ </div>
161
+ );
162
+ }