@remit/web-client 0.0.231 → 0.0.233

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 (47) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/AgendaStrip.stories.tsx +263 -0
  3. package/src/components/calendar/AgendaStrip.tsx +145 -0
  4. package/src/components/calendar/AgendaView.tsx +222 -0
  5. package/src/components/calendar/CalendarComposePane.stories.tsx +101 -11
  6. package/src/components/calendar/CalendarComposePane.tsx +83 -23
  7. package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
  8. package/src/components/calendar/CalendarEventPane.stories.tsx +30 -1
  9. package/src/components/calendar/CalendarEventPane.tsx +28 -5
  10. package/src/components/calendar/CalendarViewPlaceholder.tsx +3 -4
  11. package/src/components/calendar/CalendarWorkspace.stories.tsx +34 -1
  12. package/src/components/calendar/CalendarWorkspace.tsx +71 -23
  13. package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
  14. package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
  15. package/src/components/calendar/agenda-address.render.test.ts +389 -0
  16. package/src/components/calendar/agenda-strip.render.test.ts +241 -0
  17. package/src/components/calendar/calendar-workspace.render.test.ts +13 -6
  18. package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
  19. package/src/hooks/calendar/agenda-window.test.ts +235 -0
  20. package/src/hooks/calendar/agenda-window.ts +183 -0
  21. package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
  22. package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
  23. package/src/hooks/calendar/draft.test.ts +241 -0
  24. package/src/hooks/calendar/draft.ts +168 -0
  25. package/src/hooks/calendar/index.ts +88 -0
  26. package/src/hooks/calendar/instance.ts +136 -0
  27. package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
  28. package/src/hooks/calendar/recurrence-rule.ts +133 -0
  29. package/src/hooks/calendar/resource.test.ts +98 -0
  30. package/src/hooks/calendar/resource.ts +74 -0
  31. package/src/hooks/calendar/selection.ts +107 -0
  32. package/src/hooks/calendar/useCalendarEvent.ts +46 -0
  33. package/src/hooks/calendar/useCalendarEvents.ts +195 -0
  34. package/src/hooks/calendar/useCalendarFreeBusy.ts +80 -0
  35. package/src/hooks/calendar/useCalendarWrites.ts +174 -0
  36. package/src/hooks/calendar/useCalendars.ts +43 -0
  37. package/src/hooks/calendar/window.test.ts +148 -0
  38. package/src/hooks/calendar/window.ts +168 -0
  39. package/src/hooks/useCalendarData.ts +62 -40
  40. package/src/lib/calendar-density.ts +13 -1
  41. package/src/lib/calendar-route.test.ts +20 -1
  42. package/src/lib/calendar-route.ts +21 -7
  43. package/src/routes/calendar/$view.$date/new.tsx +2 -2
  44. package/src/routes/calendar/$view.$date.tsx +51 -15
  45. package/src/routing/calendar.ts +32 -4
  46. package/src/test-support/http.ts +8 -0
  47. package/src/lib/calendar-fixtures.ts +0 -207
@@ -1,25 +1,115 @@
1
+ import type { CalendarDescriptor, EventDraft } from "@remit/ui";
1
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { useState } from "react";
2
4
  import { CalendarComposePane } from "./CalendarComposePane";
3
5
 
4
6
  /**
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.
7
+ * `/calendar/{view}/{date}/new`, and the same form reopened over an event being
8
+ * edited.
9
+ *
10
+ * It holds no draft of its own, so a story drives it with local state and gets
11
+ * the surface the app renders. What the stories are for is what the markup
12
+ * cannot answer: whether a refusal reads as something to fix rather than as
13
+ * something broken, and whether an edit scoped to one occurrence makes clear
14
+ * that the rule is not up for changing.
9
15
  */
16
+ const calendars: CalendarDescriptor[] = [
17
+ {
18
+ id: "cal_work",
19
+ accountId: "acct_work",
20
+ accountLabel: "Work",
21
+ name: "Northwind",
22
+ color: "cal-1",
23
+ },
24
+ {
25
+ id: "cal_home",
26
+ accountId: "acct_work",
27
+ accountLabel: "Work",
28
+ name: "Personal",
29
+ color: "cal-4",
30
+ },
31
+ ];
32
+
33
+ const draft: EventDraft = {
34
+ title: "Roadmap review",
35
+ date: "2026-06-10",
36
+ startTime: "10:00",
37
+ endTime: "11:30",
38
+ allDay: false,
39
+ calendarId: "cal_work",
40
+ location: "Room Zuid",
41
+ guests: "",
42
+ notes: "",
43
+ repeat: "",
44
+ };
45
+
10
46
  const meta: Meta<typeof CalendarComposePane> = {
11
47
  title: "App/Calendar/Compose pane",
12
48
  component: CalendarComposePane,
13
49
  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
- ),
50
+ args: {
51
+ title: "New event",
52
+ subtitle: "2026-06-10",
53
+ calendars,
54
+ draft,
55
+ problem: "",
56
+ saveLabel: "Add",
57
+ isSaving: false,
58
+ onSave: () => {},
59
+ onCancel: () => {},
60
+ },
61
+ render: function Render(args) {
62
+ const [current, setCurrent] = useState(args.draft);
63
+ return (
64
+ <div className="h-dvh max-w-2xl border-l border-line bg-canvas">
65
+ <CalendarComposePane {...args} draft={current} onChange={setCurrent} />
66
+ </div>
67
+ );
68
+ },
20
69
  };
21
70
  export default meta;
22
71
 
23
72
  type Story = StoryObj<typeof CalendarComposePane>;
24
73
 
25
- export const NotBuiltYet: Story = {};
74
+ export const NewEvent: Story = {};
75
+
76
+ /** A refusal says what was wrong and what to do, where the reader is looking. */
77
+ export const Refused: Story = {
78
+ args: {
79
+ draft: { ...draft, title: "" },
80
+ problem: "Give the event a title before saving it.",
81
+ },
82
+ };
83
+
84
+ /** The write is out; the button says so rather than looking unpressed. */
85
+ export const Saving: Story = { args: { isSaving: true } };
86
+
87
+ /**
88
+ * Editing one morning of a series. The rule belongs to the series, so it reads
89
+ * back rather than offering itself for change.
90
+ */
91
+ export const EditingOneOccurrence: Story = {
92
+ args: {
93
+ title: "Edit event",
94
+ subtitle: "This occurrence only",
95
+ saveLabel: "Save",
96
+ repeatEditable: false,
97
+ draft: {
98
+ ...draft,
99
+ title: "Standup",
100
+ startTime: "09:15",
101
+ endTime: "09:30",
102
+ repeat: "Every weekday, 09:15",
103
+ },
104
+ },
105
+ };
106
+
107
+ /** The version on screen was replaced elsewhere. Nothing is overwritten. */
108
+ export const Conflicted: Story = {
109
+ args: {
110
+ title: "Edit event",
111
+ saveLabel: "Save",
112
+ problem:
113
+ "This event changed somewhere else — over CalDAV, or in another tab. Nothing was saved. Close it and open it again to see the version that's stored now.",
114
+ },
115
+ };
@@ -1,34 +1,94 @@
1
- import { CalendarPlus } from "lucide-react";
1
+ import {
2
+ type CalendarDescriptor,
3
+ type EventDraft,
4
+ EventEditor,
5
+ EventEditorPane,
6
+ } from "@remit/ui";
7
+ import { AlertCircle } from "lucide-react";
2
8
 
3
9
  /**
4
- * Writing a new event.
10
+ * Writing an event, and editing one.
5
11
  *
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.
12
+ * Presentational, like the pane it sits beside: it holds no draft of its own
13
+ * and saves nothing. The route owns the draft and the write, so this renders
14
+ * the same form whichever of the two the address named.
9
15
  *
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.
16
+ * Guests are left out because the calendar store has nowhere to put them yet,
17
+ * which is the kit's default. A field that takes names and drops them is worse
18
+ * than no field: the reader only finds out afterwards, from an event with
19
+ * nobody on it.
12
20
  */
13
21
  export interface CalendarComposePaneProps {
14
- onClose: () => void;
22
+ /** What the pane is doing: "New event", "Edit event". */
23
+ title: string;
24
+ /** The one line of context the title cannot carry — which day, which series. */
25
+ subtitle?: string;
26
+ calendars: CalendarDescriptor[];
27
+ draft: EventDraft;
28
+ onChange: (draft: EventDraft) => void;
29
+ /**
30
+ * Why the last save did not happen, in the words the reader needs: what was
31
+ * refused and what to do about it. Empty when nothing has been refused.
32
+ */
33
+ problem: string;
34
+ saveLabel: string;
35
+ isSaving: boolean;
36
+ /**
37
+ * The rule belongs to the series rather than to one morning of it, so an
38
+ * edit scoped to a single occurrence reads it back instead of offering it.
39
+ */
40
+ repeatEditable?: boolean;
41
+ /**
42
+ * Whether the event can still be sent to a different calendar. Writing one
43
+ * chooses; editing one cannot, because the collection a resource lives in is
44
+ * part of its address and the patch has no field for it.
45
+ */
46
+ calendarEditable?: boolean;
47
+ onSave: () => void;
48
+ onCancel: () => void;
15
49
  }
16
50
 
17
- export function CalendarComposePane({ onClose }: CalendarComposePaneProps) {
51
+ export function CalendarComposePane({
52
+ title,
53
+ subtitle,
54
+ calendars,
55
+ draft,
56
+ onChange,
57
+ problem,
58
+ saveLabel,
59
+ isSaving,
60
+ repeatEditable = true,
61
+ calendarEditable = true,
62
+ onSave,
63
+ onCancel,
64
+ }: CalendarComposePaneProps) {
18
65
  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>
66
+ <EventEditorPane title={title} subtitle={subtitle} onClose={onCancel}>
67
+ <EventEditor
68
+ draft={draft}
69
+ onChange={onChange}
70
+ calendars={calendars}
71
+ layout="pane"
72
+ repeatEditable={repeatEditable}
73
+ calendarEditable={calendarEditable}
74
+ saveLabel={isSaving ? "Saving…" : saveLabel}
75
+ onSave={onSave}
76
+ onCancel={onCancel}
77
+ header={
78
+ problem === "" ? undefined : (
79
+ <div
80
+ role="alert"
81
+ className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm"
82
+ >
83
+ <AlertCircle
84
+ className="mt-0.5 size-4 shrink-0 text-danger"
85
+ aria-hidden="true"
86
+ />
87
+ <p className="min-w-0 flex-1 break-words text-fg">{problem}</p>
88
+ </div>
89
+ )
90
+ }
91
+ />
92
+ </EventEditorPane>
33
93
  );
34
94
  }
@@ -0,0 +1,35 @@
1
+ import type { CalendarSlotPick } from "@remit/ui";
2
+ import { createContext, type ReactNode, useContext } from "react";
3
+
4
+ /**
5
+ * The slot a reader dragged out of the grid, handed to the composer the click
6
+ * opened.
7
+ *
8
+ * It is not in the address on purpose. What a half-written event says is not a
9
+ * fact about where the reader is, and a link to a composer pre-filled with
10
+ * somebody else's Tuesday afternoon is not an address worth being able to send.
11
+ * The composer opened without one starts on the day the calendar is showing.
12
+ *
13
+ * It lives in a module reached through the `@/` alias for the same reason
14
+ * `CalendarShell`'s context does: the generated route tree imports route files
15
+ * relatively, so a context declared inside one resolves to a second module
16
+ * instance and hands the consumer an empty default.
17
+ */
18
+ const CalendarComposeSeedCtx = createContext<CalendarSlotPick | undefined>(
19
+ undefined,
20
+ );
21
+
22
+ export const CalendarComposeSeedProvider = ({
23
+ pick,
24
+ children,
25
+ }: {
26
+ pick: CalendarSlotPick | undefined;
27
+ children?: ReactNode;
28
+ }) => (
29
+ <CalendarComposeSeedCtx.Provider value={pick}>
30
+ {children}
31
+ </CalendarComposeSeedCtx.Provider>
32
+ );
33
+
34
+ export const useCalendarComposeSeed = (): CalendarSlotPick | undefined =>
35
+ useContext(CalendarComposeSeedCtx);
@@ -47,7 +47,15 @@ const meta: Meta<typeof CalendarEventPane> = {
47
47
  title: "App/Calendar/Event pane",
48
48
  component: CalendarEventPane,
49
49
  parameters: { layout: "fullscreen" },
50
- args: { event, calendar, isOccurrence: false, onClose: () => {} },
50
+ args: {
51
+ event,
52
+ calendar,
53
+ isOccurrence: false,
54
+ problem: "",
55
+ onEdit: () => {},
56
+ onDelete: () => {},
57
+ onClose: () => {},
58
+ },
51
59
  render: (args) => (
52
60
  <div className="h-dvh max-w-xl border-l border-line bg-canvas">
53
61
  <CalendarEventPane {...args} />
@@ -79,3 +87,24 @@ export const Occurrence: Story = {
79
87
  export const NotOnThisWeek: Story = {
80
88
  args: { event: undefined, calendar: undefined },
81
89
  };
90
+
91
+ /**
92
+ * The event was replaced somewhere else — over CalDAV, or in another tab —
93
+ * between it being read and the write going out. Nothing was overwritten, and
94
+ * the pane says so where the reader is looking rather than anywhere else.
95
+ */
96
+ export const ChangedElsewhere: Story = {
97
+ args: {
98
+ problem:
99
+ "This event changed somewhere else — over CalDAV, or in another tab. Nothing was saved. Close it and open it again to see the version that's stored now.",
100
+ },
101
+ };
102
+
103
+ /**
104
+ * The resource has not been read yet, so there is no version to write against.
105
+ * The controls stay off until there is: a write with no etag is one that can
106
+ * quietly overwrite somebody.
107
+ */
108
+ export const NotWritableYet: Story = {
109
+ args: { onEdit: undefined, onDelete: undefined },
110
+ };
@@ -1,16 +1,15 @@
1
1
  import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
2
  import { EventDetail } from "@remit/ui";
3
- import { CalendarDays } from "lucide-react";
3
+ import { AlertCircle, CalendarDays } from "lucide-react";
4
4
  import { formatEventWhen } from "@/lib/calendar-format";
5
5
 
6
6
  /**
7
7
  * The event the address has open.
8
8
  *
9
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.
10
+ * and turns closing, editing and deleting into things it does, so this renders
11
+ * what it is handed and nothing else. Each control appears only when the route
12
+ * gave it somewhere to go — a control that leads nowhere is worse than none.
14
13
  */
15
14
  export interface CalendarEventPaneProps {
16
15
  /** Absent when the address names an event the calendar does not have. */
@@ -18,6 +17,13 @@ export interface CalendarEventPaneProps {
18
17
  calendar: CalendarDescriptor | undefined;
19
18
  /** Whether the address names one occurrence rather than the series. */
20
19
  isOccurrence: boolean;
20
+ /**
21
+ * A write that did not happen, stated where the reader is looking. Empty
22
+ * when the last one did.
23
+ */
24
+ problem?: string;
25
+ onEdit?: () => void;
26
+ onDelete?: () => void;
21
27
  onClose: () => void;
22
28
  }
23
29
 
@@ -25,6 +31,9 @@ export function CalendarEventPane({
25
31
  event,
26
32
  calendar,
27
33
  isOccurrence,
34
+ problem = "",
35
+ onEdit,
36
+ onDelete,
28
37
  onClose,
29
38
  }: CalendarEventPaneProps) {
30
39
  if (!event || !calendar) {
@@ -55,10 +64,24 @@ export function CalendarEventPane({
55
64
  One occurrence of a repeating event
56
65
  </p>
57
66
  )}
67
+ {problem !== "" && (
68
+ <div
69
+ role="alert"
70
+ className="flex shrink-0 items-start gap-2 border-b border-danger/40 bg-danger-soft px-row-inset py-2 text-sm"
71
+ >
72
+ <AlertCircle
73
+ className="mt-0.5 size-4 shrink-0 text-danger"
74
+ aria-hidden="true"
75
+ />
76
+ <p className="min-w-0 flex-1 break-words text-fg">{problem}</p>
77
+ </div>
78
+ )}
58
79
  <EventDetail
59
80
  event={event}
60
81
  calendar={calendar}
61
82
  whenText={formatEventWhen(event)}
83
+ onEdit={onEdit}
84
+ onDelete={onDelete}
62
85
  onClose={onClose}
63
86
  className="min-h-0 flex-1"
64
87
  />
@@ -4,14 +4,13 @@ import { CalendarRange } from "lucide-react";
4
4
  /**
5
5
  * A zoom level that is named but not drawn yet.
6
6
  *
7
- * The ladder is five steps and three of them arrive later, so the route stays
7
+ * The ladder is five steps and two of them arrive later, so the route stays
8
8
  * addressable and says what it is waiting on. A view that silently rendered
9
9
  * nothing would be indistinguishable from a week with nothing in it.
10
10
  */
11
11
  const WAITING_ON: Record<string, string> = {
12
12
  year: "The year grid arrives with the rest of the zoom ladder.",
13
13
  month: "The month grid arrives with the rest of the zoom ladder.",
14
- agenda: "The agenda arrives with the scrolling day strip.",
15
14
  };
16
15
 
17
16
  export interface CalendarViewPlaceholderProps {
@@ -29,8 +28,8 @@ export function CalendarViewPlaceholder({
29
28
  <CalendarRange className="size-8 text-fg-subtle" aria-hidden="true" />
30
29
  <p className="text-sm font-medium text-fg">Not built yet</p>
31
30
  <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.
31
+ {WAITING_ON[view] ?? "This view arrives in a later stage."} Week, Day
32
+ and Agenda work now.
34
33
  </p>
35
34
  </div>
36
35
  );
@@ -18,6 +18,9 @@ import { CalendarWorkspace } from "./CalendarWorkspace";
18
18
  * state and get the same surface the app renders. What the stories are for is
19
19
  * what the markup cannot answer — how a week reads once a day is full, and
20
20
  * whether a zoom that is not drawn yet reads as unfinished rather than empty.
21
+ *
22
+ * The strip is a slot rather than something this pane builds, so it is storied
23
+ * under App/Calendar/Agenda and stood in for here.
21
24
  */
22
25
  const TIME_ZONE = "Europe/Amsterdam";
23
26
  const DATE = "2026-06-10";
@@ -106,9 +109,13 @@ const week: CalendarEventData[] = [
106
109
  function Workspace({
107
110
  initialView,
108
111
  events,
112
+ isLoading,
113
+ error,
109
114
  }: {
110
115
  initialView: CalendarViewId;
111
116
  events: CalendarEventData[];
117
+ isLoading?: boolean;
118
+ error?: unknown;
112
119
  }) {
113
120
  const [view, setView] = useState<CalendarViewId>(initialView);
114
121
  const [density, setDensity] = useState<Density>("comfortable");
@@ -119,6 +126,16 @@ function Workspace({
119
126
  date={DATE}
120
127
  events={events}
121
128
  colorByCalendarId={colorByCalendarId}
129
+ // The strip loads its own days and is storied on its own; here it
130
+ // stands in for whatever the route mounts at that zoom.
131
+ agenda={
132
+ <div className="p-4 text-sm text-fg-muted">
133
+ The strip, which App/Calendar/Agenda has to itself.
134
+ </div>
135
+ }
136
+ isLoading={isLoading}
137
+ error={error}
138
+ onRetry={() => undefined}
122
139
  density={density}
123
140
  selectedEventId={selected}
124
141
  timeZone={TIME_ZONE}
@@ -155,6 +172,21 @@ export const Day: Story = { args: { initialView: "day" } };
155
172
  /** Nothing scheduled is still a grid, never a "nothing here" surface. */
156
173
  export const EmptyWeek: Story = { args: { events: [] } };
157
174
 
175
+ /** The first read of a week, before the server has answered. */
176
+ export const Loading: Story = { args: { events: [], isLoading: true } };
177
+
178
+ /**
179
+ * The read was refused. This is the state an empty grid must never be confused
180
+ * with: a week drawn blank because the request failed reads as a week with
181
+ * nothing in it, and the reader plans their day around it.
182
+ */
183
+ export const CouldNotLoad: Story = {
184
+ args: {
185
+ events: [],
186
+ error: new Error("The window has to be shorter than a year."),
187
+ },
188
+ };
189
+
158
190
  /**
159
191
  * The three zooms that are named but not drawn yet. Each says what it is
160
192
  * waiting on, so the route is addressable without pretending to be finished.
@@ -163,7 +195,8 @@ export const YearNotBuiltYet: Story = { args: { initialView: "year" } };
163
195
 
164
196
  export const MonthNotBuiltYet: Story = { args: { initialView: "month" } };
165
197
 
166
- export const AgendaNotBuiltYet: Story = { args: { initialView: "agenda" } };
198
+ /** The zoom that mounts the strip instead of the grid. */
199
+ export const Agenda: Story = { args: { initialView: "agenda" } };
167
200
 
168
201
  /**
169
202
  * The calendar beside the nav it is reached from. The entry sits under the
@@ -9,10 +9,15 @@ import {
9
9
  type Density,
10
10
  segmentClassName,
11
11
  } from "@remit/ui";
12
- import { useId, useState } from "react";
12
+ import { Loader2 } from "lucide-react";
13
+ import { type ReactNode, useId, useState } from "react";
13
14
  import { CalendarViewPlaceholder } from "@/components/calendar/CalendarViewPlaceholder";
14
15
  import { NavMenuButton } from "@/components/mail/NavMenuButton";
15
- import { calendarViewMountsGrid } from "@/lib/calendar-route";
16
+ import { ErrorState } from "@/components/ui/ErrorState";
17
+ import {
18
+ calendarViewMountsAgenda,
19
+ calendarViewMountsGrid,
20
+ } from "@/lib/calendar-route";
16
21
 
17
22
  /**
18
23
  * The calendar's own pane: the toolbar that moves it and whatever the current
@@ -36,6 +41,21 @@ export interface CalendarWorkspaceProps {
36
41
  date: string;
37
42
  events: CalendarEventData[];
38
43
  colorByCalendarId: Record<string, CalendarColorId>;
44
+ /**
45
+ * The strip, at the one zoom that draws it. A slot rather than something
46
+ * built here because it loads its own days over a rolling range, and this
47
+ * pane holds no events and knows no router.
48
+ */
49
+ agenda: ReactNode;
50
+ /** The first read of this window is still out; the grid has nothing yet. */
51
+ isLoading?: boolean;
52
+ /**
53
+ * A refusal the calendar states in place of the grid. A week drawn empty
54
+ * because the read failed is indistinguishable from a week with nothing in
55
+ * it, which is the one thing this must never look like.
56
+ */
57
+ error?: unknown;
58
+ onRetry?: () => void;
39
59
  density: Density;
40
60
  /** The event the reading pane has open, empty for none. */
41
61
  selectedEventId: string;
@@ -78,6 +98,10 @@ export function CalendarWorkspace({
78
98
  date,
79
99
  events,
80
100
  colorByCalendarId,
101
+ agenda,
102
+ isLoading = false,
103
+ error,
104
+ onRetry,
81
105
  density,
82
106
  selectedEventId,
83
107
  timeZone,
@@ -136,27 +160,51 @@ export function CalendarWorkspace({
136
160
  </CalendarDateNav>
137
161
  </div>
138
162
 
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>
163
+ {/* The strip rolls its own range, so it answers for its own days: the
164
+ week this pane read is not what it is drawing, and gating it on that
165
+ read would blank a strip that has everything it needs. It also
166
+ scrolls itself, so it gets a column to fill rather than a box. */}
167
+ {calendarViewMountsAgenda(view) ? (
168
+ <div className="flex min-h-0 flex-1 flex-col">{agenda}</div>
169
+ ) : (
170
+ <div className="min-h-0 flex-1">
171
+ {error !== undefined && error !== null ? (
172
+ <div className="flex h-full items-center justify-center">
173
+ <ErrorState
174
+ title="Couldn't load this week"
175
+ error={error}
176
+ onRetry={onRetry}
177
+ />
178
+ </div>
179
+ ) : isLoading ? (
180
+ <div
181
+ role="status"
182
+ aria-label="Loading the calendar"
183
+ className="flex h-full items-center justify-center bg-surface"
184
+ >
185
+ <Loader2 className="size-6 animate-spin text-fg-subtle" />
186
+ </div>
187
+ ) : calendarViewMountsGrid(view) ? (
188
+ <CalendarGrid
189
+ view={view}
190
+ date={date}
191
+ events={events}
192
+ colorByCalendarId={colorByCalendarId}
193
+ density={density}
194
+ selectedEventId={selectedEventId}
195
+ timeZone={timeZone}
196
+ now={now}
197
+ onSelectEvent={onSelectEvent}
198
+ onPickSlot={onPickSlot}
199
+ onRangeChange={(measuredTitle) =>
200
+ setMeasured({ key: addressKey, title: measuredTitle })
201
+ }
202
+ />
203
+ ) : (
204
+ <CalendarViewPlaceholder view={view} />
205
+ )}
206
+ </div>
207
+ )}
160
208
  </div>
161
209
  );
162
210
  }