@remit/web-client 0.0.235 → 0.0.236

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.235",
3
+ "version": "0.0.236",
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": {
@@ -8,6 +8,7 @@ import {
8
8
  } from "@remit/ui";
9
9
  import type { Meta, StoryObj } from "@storybook/react-vite";
10
10
  import { useState } from "react";
11
+ import { expect, within } from "storybook/test";
11
12
  import { freeStretchesByDate } from "@/hooks/calendar";
12
13
  import { AgendaStrip } from "./AgendaStrip";
13
14
 
@@ -148,6 +149,7 @@ function Strip({
148
149
  loading,
149
150
  error,
150
151
  busy,
152
+ atCap,
151
153
  }: {
152
154
  events: CalendarEventData[];
153
155
  initialDensity: Density;
@@ -156,6 +158,8 @@ function Strip({
156
158
  error?: unknown;
157
159
  /** Busy time the strip is not drawing, as `/calendar-free-busy` returns it. */
158
160
  busy: { start: string; end: string }[];
161
+ /** The run has grown as far as scrolling takes it, at one end or both. */
162
+ atCap?: boolean;
159
163
  }) {
160
164
  const [density] = useState<Density>(initialDensity);
161
165
  const [selected, setSelected] = useState("");
@@ -185,6 +189,10 @@ function Strip({
185
189
  onGoToDate={() => undefined}
186
190
  onReachStart={() => undefined}
187
191
  onReachEnd={() => undefined}
192
+ atStartCap={atCap}
193
+ atEndCap={atCap}
194
+ onLoadEarlier={() => undefined}
195
+ onLoadLater={() => undefined}
188
196
  onVisibleDayChange={() => undefined}
189
197
  />
190
198
  );
@@ -254,6 +262,26 @@ export const BusyOnACalendarNotDrawn: Story = {
254
262
  },
255
263
  };
256
264
 
265
+ /**
266
+ * The run has grown as far as scrolling takes it, and stops there rather than
267
+ * fetching its way across years nobody asked for. A sparse diary draws shorter
268
+ * than the distance the strip fetches at, so an end measured off the content
269
+ * alone is reached on the first layout pass and never stops being reached —
270
+ * which is why the cap says so and hands the reader the next stretch instead.
271
+ */
272
+ export const AtTheCap: Story = {
273
+ args: { atCap: true },
274
+ play: async ({ canvasElement }) => {
275
+ const canvas = within(canvasElement);
276
+ await expect(
277
+ await canvas.findByRole("button", { name: "Show earlier days" }),
278
+ ).toBeVisible();
279
+ await expect(
280
+ canvas.getByRole("button", { name: "Show later days" }),
281
+ ).toBeVisible();
282
+ },
283
+ };
284
+
257
285
  /** A read that was refused. An empty strip would say the opposite of this. */
258
286
  export const CouldNotLoad: Story = {
259
287
  args: {
@@ -0,0 +1,116 @@
1
+ import type { Density } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, within } from "storybook/test";
4
+ import { AgendaView } from "./AgendaView";
5
+ import {
6
+ calendars,
7
+ fortnight,
8
+ instancesWithin,
9
+ STORY_DATE,
10
+ } from "./calendar-story-fixtures";
11
+ import {
12
+ type CalendarServer,
13
+ CalendarStory,
14
+ json,
15
+ never,
16
+ } from "./calendar-story-server";
17
+
18
+ /**
19
+ * The agenda bound to the address, which is the only way it exists.
20
+ *
21
+ * It holds no days of its own: the run comes from the day the URL names, the
22
+ * weeks are fetched one at a time as the reader scrolls, and every move it makes
23
+ * leaves as a new address. So the states worth a story are the ones the reader
24
+ * cannot tell apart by looking — a fortnight with two things in it, a fortnight
25
+ * nobody has heard back about, and a fortnight the server refused. Drawn wrong,
26
+ * the last two are the first one, and the reader plans a clear week they do not
27
+ * have.
28
+ */
29
+
30
+ const AGENDA = `/calendar/agenda/${STORY_DATE}`;
31
+
32
+ const answering = (events: (url: URL) => Response | Promise<Response>) =>
33
+ ((request: Request) => {
34
+ const url = new URL(request.url);
35
+ if (url.pathname.endsWith("/calendars")) return json({ items: calendars });
36
+ if (url.pathname.endsWith("/calendar-events")) return events(url);
37
+ return json({ items: [] });
38
+ }) satisfies CalendarServer;
39
+
40
+ function Agenda({
41
+ density,
42
+ server,
43
+ }: {
44
+ density: Density;
45
+ server: CalendarServer;
46
+ }) {
47
+ return (
48
+ <div className="flex h-dvh flex-col bg-canvas">
49
+ <CalendarStory
50
+ entry={AGENDA}
51
+ server={server}
52
+ pane={<AgendaView density={density} onPickSlot={() => undefined} />}
53
+ />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ const meta: Meta<typeof Agenda> = {
59
+ title: "App/Calendar/Agenda view",
60
+ component: Agenda,
61
+ parameters: { layout: "fullscreen" },
62
+ args: {
63
+ density: "comfortable",
64
+ server: answering((url) =>
65
+ json({ items: instancesWithin(url, fortnight) }),
66
+ ),
67
+ },
68
+ };
69
+ export default meta;
70
+
71
+ type Story = StoryObj<typeof Agenda>;
72
+
73
+ /** The weeks around the day the address names, drawn from what the server sent. */
74
+ export const Populated: Story = {
75
+ play: async ({ canvasElement }) => {
76
+ const canvas = within(canvasElement);
77
+ await expect(await canvas.findByText("Roadmap review")).toBeVisible();
78
+ await expect(canvas.getByText("Standup")).toBeVisible();
79
+ },
80
+ };
81
+
82
+ /**
83
+ * The weeks are still out. Every day keeps the row it will occupy, so nothing
84
+ * jumps when the answers land — and, which matters more, an unanswered day says
85
+ * so rather than drawing as a day with nothing booked on it.
86
+ */
87
+ export const LoadingWeeks: Story = {
88
+ args: { server: answering(never) },
89
+ play: async ({ canvasElement }) => {
90
+ const canvas = within(canvasElement);
91
+ await expect(
92
+ await canvas.findByTestId(`agenda-day-pending-${STORY_DATE}`),
93
+ ).toBeVisible();
94
+ await expect(canvas.queryByText("Roadmap review")).toBeNull();
95
+ },
96
+ };
97
+
98
+ /**
99
+ * The read was refused. This is the state an empty strip must never be confused
100
+ * with: a fortnight drawn blank because the request failed reads as a fortnight
101
+ * with nothing in it, and the reader gives the time away.
102
+ */
103
+ export const RefusedRead: Story = {
104
+ args: {
105
+ server: answering(() =>
106
+ json({ message: "The window has to be shorter than a year." }, 400),
107
+ ),
108
+ },
109
+ play: async ({ canvasElement }) => {
110
+ const canvas = within(canvasElement);
111
+ await expect(
112
+ await canvas.findByText("Couldn't load these days"),
113
+ ).toBeVisible();
114
+ await expect(canvas.queryByText("Roadmap review")).toBeNull();
115
+ },
116
+ };
@@ -0,0 +1,175 @@
1
+ import {
2
+ type CalendarColorId,
3
+ type CalendarEventData,
4
+ type Density,
5
+ NavSidebar,
6
+ } from "@remit/ui";
7
+ import type { Meta, StoryObj } from "@storybook/react-vite";
8
+ import { useState } from "react";
9
+ import { expect, userEvent, within } from "storybook/test";
10
+ import { CalendarShell, CalendarShellProvider } from "./CalendarShell";
11
+ import { CalendarWorkspace } from "./CalendarWorkspace";
12
+
13
+ /**
14
+ * The chrome the calendar shares with mail, at the two widths that change what
15
+ * the reader can reach.
16
+ *
17
+ * Above the boundary the nav is a pane and the calendar sits beside it. Below
18
+ * it the nav is a slide-over, nothing opens a slide-over on its own, and the
19
+ * calendar is the whole width of the app — so the way back to the mail has to be
20
+ * a control the calendar itself carries. Losing it strands a reader inside their
21
+ * diary with no route out, which is what these two stories hold the shell to.
22
+ */
23
+
24
+ const TIME_ZONE = "Europe/Amsterdam";
25
+ const DATE = "2026-06-10";
26
+ const NOW = `${DATE}T09:30:00+02:00`;
27
+ const CALENDAR = "cal_work";
28
+
29
+ const colorByCalendarId: Record<string, CalendarColorId> = {
30
+ [CALENDAR]: "cal-1",
31
+ };
32
+
33
+ const roadmap: CalendarEventData = {
34
+ id: "roadmap",
35
+ calendarId: CALENDAR,
36
+ title: "Roadmap review",
37
+ start: `${DATE}T10:00:00+02:00`,
38
+ end: `${DATE}T11:30:00+02:00`,
39
+ allDay: false,
40
+ location: "",
41
+ notes: "",
42
+ attendees: [],
43
+ myRsvp: "accepted",
44
+ threadId: "",
45
+ threadSubject: "",
46
+ timeZone: TIME_ZONE,
47
+ zoneCertainty: "explicit",
48
+ recurrenceRule: "",
49
+ seriesId: "",
50
+ seriesException: false,
51
+ status: "confirmed",
52
+ };
53
+
54
+ const nav = (
55
+ <NavSidebar
56
+ accounts={[
57
+ {
58
+ id: "acct_work",
59
+ label: "Work",
60
+ email: "work@example.invalid",
61
+ mailboxes: [
62
+ { id: "mb_inbox", name: "Inbox", role: "inbox", unseen: 3 },
63
+ { id: "mb_sent", name: "Sent", role: "sent" },
64
+ ],
65
+ },
66
+ ]}
67
+ selectedNavId="calendar"
68
+ calendarNav="shown"
69
+ />
70
+ );
71
+
72
+ const reading = (
73
+ <div className="flex h-full items-center justify-center bg-surface text-sm text-fg-muted">
74
+ Nothing open.
75
+ </div>
76
+ );
77
+
78
+ /** The layout publishes the chrome; a story holds the slide-over's own state. */
79
+ function Shell({
80
+ width,
81
+ isSinglePane,
82
+ }: {
83
+ /** The shell measures its own box, so the story gives it one. */
84
+ width: number;
85
+ isSinglePane: boolean;
86
+ }) {
87
+ const [navOpen, setNavOpen] = useState(false);
88
+ const [density, setDensity] = useState<Density>("comfortable");
89
+ return (
90
+ <div style={{ width }} className="bg-canvas">
91
+ <CalendarShellProvider
92
+ chrome={{
93
+ isSinglePane,
94
+ isLoading: false,
95
+ nav,
96
+ navOpen,
97
+ onOpenNav: () => setNavOpen(true),
98
+ onCloseNav: () => setNavOpen(false),
99
+ }}
100
+ >
101
+ <CalendarShell
102
+ hasOpenEvent={false}
103
+ reading={reading}
104
+ workspace={
105
+ <CalendarWorkspace
106
+ view="week"
107
+ date={DATE}
108
+ events={[roadmap]}
109
+ colorByCalendarId={colorByCalendarId}
110
+ agenda={null}
111
+ density={density}
112
+ selectedEventId=""
113
+ timeZone={TIME_ZONE}
114
+ now={NOW}
115
+ onChangeView={() => undefined}
116
+ onToday={() => undefined}
117
+ onStep={() => undefined}
118
+ onChangeDensity={setDensity}
119
+ onSelectEvent={() => undefined}
120
+ onPickSlot={() => undefined}
121
+ />
122
+ }
123
+ />
124
+ </CalendarShellProvider>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ const meta: Meta<typeof Shell> = {
130
+ title: "App/Calendar/Shell",
131
+ component: Shell,
132
+ parameters: { layout: "fullscreen" },
133
+ };
134
+ export default meta;
135
+
136
+ type Story = StoryObj<typeof Shell>;
137
+
138
+ /**
139
+ * Below the boundary. The nav is a slide-over with nothing else to open it, so
140
+ * the calendar's own header carries the control — and pressing it is the whole
141
+ * of the way back to the mail.
142
+ */
143
+ export const NavAsSlideOver: Story = {
144
+ args: { width: 900, isSinglePane: true },
145
+ play: async ({ canvasElement }) => {
146
+ const canvas = within(canvasElement);
147
+ await expect(canvas.queryByRole("button", { name: /Inbox/ })).toBeNull();
148
+
149
+ const folders = await canvas.findByRole("button", {
150
+ name: "Open folders",
151
+ });
152
+ await userEvent.click(folders);
153
+
154
+ await expect(
155
+ await canvas.findByRole("button", { name: /Inbox/ }),
156
+ ).toBeVisible();
157
+ },
158
+ };
159
+
160
+ /**
161
+ * Above it. The nav is a column of its own, so a second control that opened it
162
+ * would be a button for something already on screen.
163
+ */
164
+ export const NavAsPane: Story = {
165
+ args: { width: 1440, isSinglePane: false },
166
+ play: async ({ canvasElement }) => {
167
+ const canvas = within(canvasElement);
168
+ await expect(
169
+ await canvas.findByRole("button", { name: /Inbox/ }),
170
+ ).toBeVisible();
171
+ await expect(
172
+ canvas.queryByRole("button", { name: "Open folders" }),
173
+ ).toBeNull();
174
+ },
175
+ };
@@ -0,0 +1,54 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import { CalendarViewPlaceholder } from "./CalendarViewPlaceholder";
4
+
5
+ /**
6
+ * A zoom the ladder offers and does not draw yet.
7
+ *
8
+ * The route is addressable at all five zooms, so two of them land on this. What
9
+ * it has to do is say which of the two things it is: a view that rendered
10
+ * nothing would be indistinguishable from a month with nothing booked in it,
11
+ * and the reader would plan around an empty screen.
12
+ */
13
+ const meta: Meta<typeof CalendarViewPlaceholder> = {
14
+ title: "App/Calendar/Not built yet",
15
+ component: CalendarViewPlaceholder,
16
+ parameters: { layout: "fullscreen" },
17
+ render: (args) => (
18
+ <div className="h-dvh bg-canvas">
19
+ <CalendarViewPlaceholder {...args} />
20
+ </div>
21
+ ),
22
+ };
23
+ export default meta;
24
+
25
+ type Story = StoryObj<typeof CalendarViewPlaceholder>;
26
+
27
+ /** It names the zoom it is waiting on, and the three that work today. */
28
+ export const Year: Story = {
29
+ args: { view: "year" },
30
+ play: async ({ canvasElement }) => {
31
+ const canvas = within(canvasElement);
32
+ await expect(
33
+ canvas.getByTestId("calendar-placeholder-year"),
34
+ ).toHaveTextContent(
35
+ "The year grid arrives with the rest of the zoom ladder.",
36
+ );
37
+ await expect(canvas.getByText("Not built yet")).toBeVisible();
38
+ await expect(
39
+ canvas.getByText(/Week, Day and Agenda work now\./),
40
+ ).toBeVisible();
41
+ },
42
+ };
43
+
44
+ export const Month: Story = {
45
+ args: { view: "month" },
46
+ play: async ({ canvasElement }) => {
47
+ const canvas = within(canvasElement);
48
+ await expect(
49
+ canvas.getByTestId("calendar-placeholder-month"),
50
+ ).toHaveTextContent(
51
+ "The month grid arrives with the rest of the zoom ladder.",
52
+ );
53
+ },
54
+ };
@@ -0,0 +1,155 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, userEvent, within } from "storybook/test";
3
+ import {
4
+ calendars,
5
+ fortnight,
6
+ instancesWithin,
7
+ ROADMAP_OBJECT,
8
+ roadmapResource,
9
+ STANDUP_OBJECT,
10
+ STANDUP_RECURRENCE,
11
+ STORY_WEEK,
12
+ standupResource,
13
+ } from "./calendar-story-fixtures";
14
+ import {
15
+ type CalendarServer,
16
+ CalendarStory,
17
+ json,
18
+ } from "./calendar-story-server";
19
+ import { OpenCalendarEvent } from "./OpenCalendarEvent";
20
+
21
+ /**
22
+ * The event the address names, and everything a reader can do to it.
23
+ *
24
+ * The pane is reached at two addresses — a resource on its own, and one
25
+ * occurrence under a series — and which one it is decides whether an edit has a
26
+ * question to answer first. That question is the state worth holding: it is
27
+ * asked here and nowhere else, and asking it late, on the way out, would mean
28
+ * the reader has already typed the change before being told what it reaches.
29
+ */
30
+
31
+ const answering = (
32
+ fail?: (request: Request) => Response | undefined,
33
+ ): CalendarServer => {
34
+ return (request) => {
35
+ const refusal = fail?.(request);
36
+ if (refusal) return refusal;
37
+ const url = new URL(request.url);
38
+ if (url.pathname.endsWith("/calendars")) return json({ items: calendars });
39
+ if (url.pathname.endsWith("/calendar-events"))
40
+ return json({ items: instancesWithin(url, fortnight) });
41
+ if (url.pathname.includes(STANDUP_OBJECT)) return json(standupResource);
42
+ if (url.pathname.includes(ROADMAP_OBJECT)) return json(roadmapResource);
43
+ return json({ items: [] });
44
+ };
45
+ };
46
+
47
+ function Opened({
48
+ calendarObjectId,
49
+ recurrenceId,
50
+ server,
51
+ }: {
52
+ calendarObjectId: string;
53
+ recurrenceId?: string;
54
+ server: CalendarServer;
55
+ }) {
56
+ const address =
57
+ recurrenceId === undefined
58
+ ? `${STORY_WEEK}/${calendarObjectId}`
59
+ : `${STORY_WEEK}/${calendarObjectId}/${recurrenceId}`;
60
+ return (
61
+ <div className="h-dvh max-w-xl border-l border-line bg-canvas">
62
+ <CalendarStory
63
+ entry={address}
64
+ server={server}
65
+ pane={
66
+ <OpenCalendarEvent
67
+ calendarObjectId={calendarObjectId}
68
+ recurrenceId={recurrenceId}
69
+ />
70
+ }
71
+ />
72
+ </div>
73
+ );
74
+ }
75
+
76
+ const meta: Meta<typeof Opened> = {
77
+ title: "App/Calendar/Open event",
78
+ component: Opened,
79
+ parameters: { layout: "fullscreen" },
80
+ args: { calendarObjectId: ROADMAP_OBJECT, server: answering() },
81
+ };
82
+ export default meta;
83
+
84
+ type Story = StoryObj<typeof Opened>;
85
+
86
+ /**
87
+ * A resource that does not recur. Edit and Delete mean the whole of it, so
88
+ * there is nothing to ask and they open on the press.
89
+ */
90
+ export const NonRecurring: Story = {
91
+ play: async ({ canvasElement }) => {
92
+ const canvas = within(canvasElement);
93
+ await expect(await canvas.findByText("Roadmap review")).toBeVisible();
94
+ // Off the stored resource rather than the listing, so this is also the
95
+ // proof the pane read the version its writes will be conditional on.
96
+ await expect(await canvas.findByText("Room Zuid")).toBeVisible();
97
+ await expect(canvas.getByRole("button", { name: "Edit" })).toBeVisible();
98
+ },
99
+ };
100
+
101
+ /**
102
+ * One morning of a series. Which occurrences the edit reaches changes what the
103
+ * edit is, so it is settled while it can still be answered — and the rule is
104
+ * read back in words, not as an RRULE.
105
+ */
106
+ export const RecurringScopePrompt: Story = {
107
+ args: {
108
+ calendarObjectId: STANDUP_OBJECT,
109
+ recurrenceId: STANDUP_RECURRENCE,
110
+ },
111
+ play: async ({ canvasElement }) => {
112
+ const canvas = within(canvasElement);
113
+ await expect(await canvas.findByText("Standup")).toBeVisible();
114
+ await expect(
115
+ canvas.queryByText("What should the change apply to?"),
116
+ ).toBeNull();
117
+
118
+ await userEvent.click(await canvas.findByRole("button", { name: "Edit" }));
119
+
120
+ await expect(await canvas.findByText("Standup repeats")).toBeVisible();
121
+ await expect(
122
+ canvas.getByText("What should the change apply to?"),
123
+ ).toBeVisible();
124
+ await expect(
125
+ canvas.getByRole("button", { name: /This event/ }),
126
+ ).toBeVisible();
127
+ await expect(
128
+ canvas.getByRole("button", { name: /The whole series/ }),
129
+ ).toBeVisible();
130
+ },
131
+ };
132
+
133
+ /**
134
+ * Somebody replaced the event between it being read and the delete going out —
135
+ * over CalDAV, or in another tab. Nothing was removed, and the pane says so
136
+ * where the reader is looking rather than resolving it by winning.
137
+ */
138
+ export const ChangedElsewhere: Story = {
139
+ args: {
140
+ server: answering((request) =>
141
+ request.method === "DELETE"
142
+ ? json({ message: "The event has changed." }, 412)
143
+ : undefined,
144
+ ),
145
+ },
146
+ play: async ({ canvasElement }) => {
147
+ const canvas = within(canvasElement);
148
+ await userEvent.click(
149
+ await canvas.findByRole("button", { name: "Delete" }),
150
+ );
151
+ await expect(await canvas.findByRole("alert")).toHaveTextContent(
152
+ /changed somewhere else/,
153
+ );
154
+ },
155
+ };
@@ -0,0 +1,89 @@
1
+ import type { CalendarSlotPick } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, within } from "storybook/test";
4
+ import { calendars, STORY_DATE, STORY_WEEK } from "./calendar-story-fixtures";
5
+ import {
6
+ type CalendarServer,
7
+ CalendarStory,
8
+ json,
9
+ } from "./calendar-story-server";
10
+ import { WriteCalendarEvent } from "./WriteCalendarEvent";
11
+
12
+ /**
13
+ * Writing a new event, at `/calendar/{view}/{date}/new`.
14
+ *
15
+ * The draft it opens on comes from two places and neither is a prop: the day is
16
+ * the address's, and the hours are the slot the reader dragged out of the grid,
17
+ * handed over by the click that opened the form. The slot is deliberately not in
18
+ * the URL — a half-written event is not a fact about where the reader is — so
19
+ * the composer opened any other way starts on the day the calendar is showing,
20
+ * which is the second story here.
21
+ */
22
+
23
+ const server: CalendarServer = (request) => {
24
+ const url = new URL(request.url);
25
+ if (url.pathname.endsWith("/calendars")) return json({ items: calendars });
26
+ return json({ items: [] });
27
+ };
28
+
29
+ const slot: CalendarSlotPick = {
30
+ date: "2026-06-11",
31
+ startTime: "14:00",
32
+ endTime: "15:00",
33
+ allDay: false,
34
+ };
35
+
36
+ function Composer({ pick }: { pick?: CalendarSlotPick }) {
37
+ return (
38
+ <div className="h-dvh max-w-2xl border-l border-line bg-canvas">
39
+ <CalendarStory
40
+ entry={`${STORY_WEEK}/new`}
41
+ server={server}
42
+ pick={pick}
43
+ pane={<WriteCalendarEvent onClose={() => undefined} />}
44
+ />
45
+ </div>
46
+ );
47
+ }
48
+
49
+ const meta: Meta<typeof Composer> = {
50
+ title: "App/Calendar/Write event",
51
+ component: Composer,
52
+ parameters: { layout: "fullscreen" },
53
+ };
54
+ export default meta;
55
+
56
+ type Story = StoryObj<typeof Composer>;
57
+
58
+ /**
59
+ * Dragged out of Thursday afternoon. The hours the reader picked are already in
60
+ * the form, so the gesture is the answer rather than a hint they now retype.
61
+ */
62
+ export const SeededFromASlotPick: Story = {
63
+ args: { pick: slot },
64
+ play: async ({ canvasElement }) => {
65
+ const canvas = within(canvasElement);
66
+ await expect(await canvas.findByLabelText("Date")).toHaveValue(slot.date);
67
+ await expect(canvas.getByLabelText("Start time")).toHaveValue(
68
+ slot.startTime,
69
+ );
70
+ await expect(canvas.getByLabelText("End time")).toHaveValue(slot.endTime);
71
+ await expect(canvas.getByLabelText("Title")).toHaveValue("");
72
+ },
73
+ };
74
+
75
+ /**
76
+ * Opened with no slot behind it. It starts on the day the calendar is showing,
77
+ * and on the calendar the listing provisioned — a reader who has written nothing
78
+ * yet should not have to choose where it goes before they can type.
79
+ */
80
+ export const Empty: Story = {
81
+ play: async ({ canvasElement }) => {
82
+ const canvas = within(canvasElement);
83
+ await expect(await canvas.findByLabelText("Date")).toHaveValue(STORY_DATE);
84
+ await expect(canvas.getByLabelText("Title")).toHaveValue("");
85
+ await expect(
86
+ await canvas.findByRole("radio", { name: "Northwind" }),
87
+ ).toBeChecked();
88
+ },
89
+ };
@@ -0,0 +1,137 @@
1
+ import type {
2
+ RemitImapCalendarEventInstance,
3
+ RemitImapCalendarEventResponse,
4
+ RemitImapCalendarResponse,
5
+ } from "@remit/api-http-client/types.gen.ts";
6
+
7
+ /**
8
+ * The synthetic account the calendar stories read.
9
+ *
10
+ * One collection, on a real zone, with occurrences spelled the way the listing
11
+ * spells them. Nothing here is anybody's data: the ids are fixed strings and the
12
+ * week is one the calendar will never be looking at by accident.
13
+ */
14
+
15
+ export const WORK_CALENDAR = "11111111-1111-4111-8111-111111111111";
16
+ export const ROADMAP_OBJECT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
17
+ export const STANDUP_OBJECT = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
18
+ export const STANDUP_RECURRENCE = "2026-06-10T07:15:00Z";
19
+
20
+ export const STORY_DATE = "2026-06-10";
21
+ export const STORY_WEEK = `/calendar/week/${STORY_DATE}`;
22
+
23
+ export const calendars: RemitImapCalendarResponse[] = [
24
+ {
25
+ calendarId: WORK_CALENDAR,
26
+ accountConfigId: "cfg-story",
27
+ urlSegment: "work",
28
+ displayName: "Northwind",
29
+ color: "Cal1",
30
+ componentSet: "VeventOnly",
31
+ source: "UserCreated",
32
+ timezone: "Europe/Amsterdam",
33
+ syncSequence: 1,
34
+ createdAt: 0,
35
+ updatedAt: 0,
36
+ } as RemitImapCalendarResponse,
37
+ ];
38
+
39
+ const template = {
40
+ calendarId: WORK_CALENDAR,
41
+ calendarObjectId: ROADMAP_OBJECT,
42
+ recurrenceId: "",
43
+ icalUid: "uid-roadmap",
44
+ summary: "",
45
+ start: "",
46
+ end: "",
47
+ allDay: false,
48
+ status: "Confirmed",
49
+ transparency: "Opaque",
50
+ zoneCertainty: "Explicit",
51
+ etag: "etag-1",
52
+ hasRecurrence: false,
53
+ } as RemitImapCalendarEventInstance;
54
+
55
+ const at = (
56
+ summary: string,
57
+ day: string,
58
+ from: string,
59
+ to: string,
60
+ over: Partial<RemitImapCalendarEventInstance> = {},
61
+ ): RemitImapCalendarEventInstance =>
62
+ ({
63
+ ...template,
64
+ ...over,
65
+ summary,
66
+ start: `2026-06-${day}T${from}:00+02:00`,
67
+ end: `2026-06-${day}T${to}:00+02:00`,
68
+ }) as RemitImapCalendarEventInstance;
69
+
70
+ export const roadmap = at("Roadmap review", "10", "10:00", "11:30");
71
+
72
+ export const standup = at("Standup", "10", "09:15", "09:30", {
73
+ calendarObjectId: STANDUP_OBJECT,
74
+ icalUid: "uid-standup",
75
+ recurrenceId: STANDUP_RECURRENCE,
76
+ hasRecurrence: true,
77
+ });
78
+
79
+ /** A fortnight that is mostly empty, which is what a real fortnight looks like. */
80
+ export const fortnight: RemitImapCalendarEventInstance[] = [
81
+ standup,
82
+ roadmap,
83
+ at("Dentist", "11", "11:00", "12:00", { icalUid: "uid-dentist" }),
84
+ at("Retro", "18", "16:00", "17:00", { icalUid: "uid-retro" }),
85
+ ];
86
+
87
+ export const roadmapResource = {
88
+ calendarObjectId: ROADMAP_OBJECT,
89
+ calendarId: WORK_CALENDAR,
90
+ resourceName: "roadmap.ics",
91
+ icalUid: "uid-roadmap",
92
+ icalData: [
93
+ "BEGIN:VCALENDAR",
94
+ "BEGIN:VEVENT",
95
+ "SUMMARY:Roadmap review",
96
+ "LOCATION:Room Zuid",
97
+ "DESCRIPTION:Bring the staffing numbers.",
98
+ "END:VEVENT",
99
+ "END:VCALENDAR",
100
+ ].join("\r\n"),
101
+ etag: "etag-1",
102
+ } as RemitImapCalendarEventResponse;
103
+
104
+ export const standupResource = {
105
+ ...roadmapResource,
106
+ calendarObjectId: STANDUP_OBJECT,
107
+ resourceName: "standup.ics",
108
+ icalUid: "uid-standup",
109
+ icalData: [
110
+ "BEGIN:VCALENDAR",
111
+ "BEGIN:VEVENT",
112
+ "SUMMARY:Standup",
113
+ "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
114
+ "END:VEVENT",
115
+ "END:VCALENDAR",
116
+ ].join("\r\n"),
117
+ } as RemitImapCalendarEventResponse;
118
+
119
+ /**
120
+ * The occurrences inside the window a request asked for.
121
+ *
122
+ * The panes read a week at a time and hold several weeks at once, so a server
123
+ * that answered every window with the same list would draw each day's events
124
+ * once per window it appears in.
125
+ */
126
+ export const instancesWithin = (
127
+ url: URL,
128
+ instances: readonly RemitImapCalendarEventInstance[],
129
+ ): RemitImapCalendarEventInstance[] => {
130
+ const from = Date.parse(url.searchParams.get("from") ?? "");
131
+ const to = Date.parse(url.searchParams.get("to") ?? "");
132
+ if (Number.isNaN(from) || Number.isNaN(to)) return [...instances];
133
+ return instances.filter((instance) => {
134
+ const start = Date.parse(instance.start);
135
+ return start >= from && start < to;
136
+ });
137
+ };
@@ -0,0 +1,152 @@
1
+ import type { CalendarSlotPick } from "@remit/ui";
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import {
4
+ type AnyRouter,
5
+ createMemoryHistory,
6
+ createRootRoute,
7
+ createRoute,
8
+ createRouter,
9
+ Outlet,
10
+ RouterProvider,
11
+ } from "@tanstack/react-router";
12
+ import { type ReactNode, useState } from "react";
13
+ import { CalendarComposeSeedProvider } from "@/components/calendar/CalendarComposeSeed";
14
+ import { calendarSearchSchema } from "@/lib/calendar-route";
15
+ import { client } from "@/lib/client";
16
+ import { stringifySearch } from "@/lib/search-params";
17
+
18
+ /**
19
+ * The calendar's addresses and its server, stood up for a story.
20
+ *
21
+ * `AgendaView`, `OpenCalendarEvent` and `WriteCalendarEvent` are the three panes
22
+ * that read the address and read the API, so a story cannot hand them props:
23
+ * what they draw comes out of a URL and a response. Both are supplied here, with
24
+ * synthetic answers, so the story still names one state and asserts it.
25
+ *
26
+ * The routes are built out of the real paths rather than out of the generated
27
+ * tree — the route objects are module singletons and re-parenting one would
28
+ * mutate it for every other story in the run. Their ids are what the panes read
29
+ * their params `from`, which is what makes these the app's addresses rather than
30
+ * an imitation of them.
31
+ *
32
+ * The server is the app's own generated client with its `fetch` swapped, so the
33
+ * responses run through the same error interceptor the app installs: a 412 comes
34
+ * back as the conflict the pane states, and not as a message it happens to
35
+ * print.
36
+ */
37
+
38
+ /** A reply to one request, at a status the client classifies the way it would. */
39
+ export const json = (body: unknown, status = 200): Response =>
40
+ new Response(JSON.stringify(body), {
41
+ status,
42
+ headers: { "content-type": "application/json" },
43
+ });
44
+
45
+ /** A request that never comes back, which is what a loading state is. */
46
+ export const never = (): Promise<Response> => new Promise<Response>(() => {});
47
+
48
+ export type CalendarServer = (request: Request) => Response | Promise<Response>;
49
+
50
+ /** Nowhere. Every request is answered before it can leave. */
51
+ const STORY_BASE_URL = "https://calendar.story.invalid";
52
+
53
+ function storyRouter(
54
+ entry: string,
55
+ pane: ReactNode,
56
+ pick: CalendarSlotPick | undefined,
57
+ ): AnyRouter {
58
+ const leaf = () => <>{pane}</>;
59
+ const rootRoute = createRootRoute({ component: Outlet });
60
+ const viewRoute = createRoute({
61
+ getParentRoute: () => rootRoute,
62
+ path: "/calendar/$view/$date",
63
+ validateSearch: calendarSearchSchema,
64
+ component: () => (
65
+ <CalendarComposeSeedProvider pick={pick}>
66
+ <Outlet />
67
+ </CalendarComposeSeedProvider>
68
+ ),
69
+ });
70
+ const eventRoute = createRoute({
71
+ getParentRoute: () => viewRoute,
72
+ path: "$calendarObjectId",
73
+ component: Outlet,
74
+ });
75
+ const routeTree = rootRoute.addChildren([
76
+ viewRoute.addChildren([
77
+ createRoute({
78
+ getParentRoute: () => viewRoute,
79
+ path: "/",
80
+ component: leaf,
81
+ }),
82
+ createRoute({
83
+ getParentRoute: () => viewRoute,
84
+ path: "new",
85
+ component: leaf,
86
+ }),
87
+ eventRoute.addChildren([
88
+ createRoute({
89
+ getParentRoute: () => eventRoute,
90
+ path: "/",
91
+ component: leaf,
92
+ }),
93
+ createRoute({
94
+ getParentRoute: () => eventRoute,
95
+ path: "$recurrenceId",
96
+ component: leaf,
97
+ }),
98
+ ]),
99
+ ]),
100
+ ]);
101
+ return createRouter({
102
+ routeTree,
103
+ stringifySearch,
104
+ history: createMemoryHistory({ initialEntries: [entry] }),
105
+ }) as unknown as AnyRouter;
106
+ }
107
+
108
+ export interface CalendarStoryProps {
109
+ /** The pane under test, mounted at whichever address the story opens on. */
110
+ pane: ReactNode;
111
+ /** The address the story opens on. */
112
+ entry: string;
113
+ /** What the server answers, request by request. */
114
+ server: CalendarServer;
115
+ /** The slot a reader dragged out of the grid, for the composer. */
116
+ pick?: CalendarSlotPick;
117
+ }
118
+
119
+ export function CalendarStory({
120
+ pane,
121
+ entry,
122
+ server,
123
+ pick,
124
+ }: CalendarStoryProps) {
125
+ // Built once, and the client configured before anything can ask it a
126
+ // question: a pane that fetched against the default host would reach for the
127
+ // network on its first render.
128
+ const [mounted] = useState(() => {
129
+ client.setConfig({
130
+ baseUrl: STORY_BASE_URL,
131
+ fetch: ((input: RequestInfo | URL) =>
132
+ Promise.resolve(
133
+ server(input instanceof Request ? input : new Request(String(input))),
134
+ )) as typeof globalThis.fetch,
135
+ });
136
+ return {
137
+ router: storyRouter(entry, pane, pick),
138
+ queryClient: new QueryClient({
139
+ defaultOptions: {
140
+ queries: { retry: false },
141
+ mutations: { retry: false },
142
+ },
143
+ }),
144
+ };
145
+ });
146
+
147
+ return (
148
+ <QueryClientProvider client={mounted.queryClient}>
149
+ <RouterProvider router={mounted.router} />
150
+ </QueryClientProvider>
151
+ );
152
+ }