@remit/web-client 0.0.230 → 0.0.232

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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.stories.tsx +115 -0
  3. package/src/components/calendar/CalendarComposePane.tsx +94 -0
  4. package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
  5. package/src/components/calendar/CalendarEventPane.stories.tsx +110 -0
  6. package/src/components/calendar/CalendarEventPane.tsx +90 -0
  7. package/src/components/calendar/CalendarShell.tsx +83 -0
  8. package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
  9. package/src/components/calendar/CalendarWorkspace.stories.tsx +219 -0
  10. package/src/components/calendar/CalendarWorkspace.tsx +192 -0
  11. package/src/components/calendar/OpenCalendarEvent.tsx +221 -0
  12. package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
  13. package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
  14. package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
  15. package/src/components/mail/MailSidebarAdapter.tsx +17 -0
  16. package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
  17. package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
  18. package/src/hooks/calendar/draft.test.ts +241 -0
  19. package/src/hooks/calendar/draft.ts +168 -0
  20. package/src/hooks/calendar/index.ts +59 -0
  21. package/src/hooks/calendar/instance.ts +136 -0
  22. package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
  23. package/src/hooks/calendar/recurrence-rule.ts +133 -0
  24. package/src/hooks/calendar/resource.test.ts +98 -0
  25. package/src/hooks/calendar/resource.ts +74 -0
  26. package/src/hooks/calendar/useCalendarEvent.ts +46 -0
  27. package/src/hooks/calendar/useCalendarEvents.ts +124 -0
  28. package/src/hooks/calendar/useCalendarWrites.ts +174 -0
  29. package/src/hooks/calendar/useCalendars.ts +43 -0
  30. package/src/hooks/calendar/window.test.ts +148 -0
  31. package/src/hooks/calendar/window.ts +163 -0
  32. package/src/hooks/useCalendarData.ts +120 -0
  33. package/src/lib/calendar-density.ts +34 -0
  34. package/src/lib/calendar-format.ts +29 -0
  35. package/src/lib/calendar-route.test.ts +210 -0
  36. package/src/lib/calendar-route.ts +187 -0
  37. package/src/lib/search-params.test.ts +77 -0
  38. package/src/lib/search-params.ts +59 -0
  39. package/src/routeTree.gen.ts +199 -0
  40. package/src/router.tsx +5 -0
  41. package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
  42. package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
  43. package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
  44. package/src/routes/calendar/$view.$date/index.tsx +25 -0
  45. package/src/routes/calendar/$view.$date/new.tsx +20 -0
  46. package/src/routes/calendar/$view.$date.tsx +128 -0
  47. package/src/routes/calendar/-calendar-tree.test.ts +267 -0
  48. package/src/routes/calendar/index.tsx +27 -0
  49. package/src/routes/calendar.tsx +98 -0
  50. package/src/routing/calendar-fragment.test.ts +66 -0
  51. package/src/routing/calendar.ts +150 -0
  52. package/src/routing/fragment.ts +19 -0
  53. package/src/routing/index.ts +10 -0
  54. package/src/routing/nav-selection.ts +2 -0
  55. package/src/test-support/http.ts +8 -0
@@ -0,0 +1,94 @@
1
+ import type { EventDraft } from "@remit/ui";
2
+ import { useEffect, useState } from "react";
3
+ import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
4
+ import { useCalendarComposeSeed } from "@/components/calendar/CalendarComposeSeed";
5
+ import {
6
+ createInputFromDraft,
7
+ emptyDraft,
8
+ UNZONED_CALENDAR,
9
+ useCalendars,
10
+ useCalendarWrites,
11
+ } from "@/hooks/calendar";
12
+ import { useCalendarAddress } from "@/routing";
13
+
14
+ /**
15
+ * Writing a new event.
16
+ *
17
+ * The draft starts on the slot the reader dragged out of the grid, or on the
18
+ * day the calendar is showing when they opened the composer some other way. The
19
+ * write goes through the same service function every other path uses, so an
20
+ * event typed here, one accepted from an invitation and one written over CalDAV
21
+ * are the same bytes stored the same way.
22
+ */
23
+ export interface WriteCalendarEventProps {
24
+ onClose: () => void;
25
+ }
26
+
27
+ export function WriteCalendarEvent({ onClose }: WriteCalendarEventProps) {
28
+ const { date } = useCalendarAddress();
29
+ const seed = useCalendarComposeSeed();
30
+ const { calendars, timeZoneByCalendarId } = useCalendars();
31
+ const { createEvent, isWriting } = useCalendarWrites();
32
+ const [problem, setProblem] = useState("");
33
+ const [draft, setDraft] = useState<EventDraft>(() => {
34
+ const blank = emptyDraft(seed?.date ?? date, "");
35
+ if (!seed) return blank;
36
+ return {
37
+ ...blank,
38
+ allDay: seed.allDay,
39
+ startTime: seed.allDay ? "" : seed.startTime,
40
+ endTime: seed.allDay ? "" : seed.endTime,
41
+ };
42
+ });
43
+
44
+ // The default calendar is provisioned by the listing itself, so the picker
45
+ // has one the moment the calendars arrive rather than making the reader
46
+ // choose before they can type anything.
47
+ const firstCalendarId = calendars[0]?.id ?? "";
48
+ useEffect(() => {
49
+ if (firstCalendarId === "") return;
50
+ setDraft((current) =>
51
+ current.calendarId === ""
52
+ ? { ...current, calendarId: firstCalendarId }
53
+ : current,
54
+ );
55
+ }, [firstCalendarId]);
56
+
57
+ const save = () => {
58
+ const built = createInputFromDraft(
59
+ draft,
60
+ timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR,
61
+ );
62
+ if (!built.ok) {
63
+ setProblem(built.problem);
64
+ return;
65
+ }
66
+ void createEvent(built.input).then((outcome) => {
67
+ if (outcome.kind === "written") {
68
+ setProblem("");
69
+ onClose();
70
+ return;
71
+ }
72
+ setProblem(
73
+ outcome.kind === "conflict"
74
+ ? "This calendar changed while you were writing. Close the form and try again."
75
+ : outcome.message,
76
+ );
77
+ });
78
+ };
79
+
80
+ return (
81
+ <CalendarComposePane
82
+ title="New event"
83
+ subtitle={draft.date}
84
+ calendars={calendars}
85
+ draft={draft}
86
+ onChange={setDraft}
87
+ problem={problem}
88
+ saveLabel="Add"
89
+ isSaving={isWriting}
90
+ onSave={save}
91
+ onCancel={onClose}
92
+ />
93
+ );
94
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * What each zoom puts on screen.
3
+ *
4
+ * The route is one route for all five views, so "the view mounts its own
5
+ * component" is a decision this pane makes and the place to assert it. A zoom
6
+ * that is not drawn yet says so out loud: an empty surface would be
7
+ * indistinguishable from a week with nothing in it, which is the dead route
8
+ * this stage exists to avoid.
9
+ */
10
+ import "@remit/test-dom";
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import type { CalendarColorId, CalendarEventData } from "@remit/ui";
14
+ import { AppShellSlotted } from "@remit/ui";
15
+ import { createElement, type ReactElement } from "react";
16
+ import { renderToString } from "react-dom/server";
17
+ import {
18
+ CalendarWorkspace,
19
+ type CalendarWorkspaceProps,
20
+ } from "./CalendarWorkspace.js";
21
+
22
+ const TIME_ZONE = "Europe/Amsterdam";
23
+ const DATE = "2026-06-10";
24
+ const NOW = `${DATE}T09:30:00+02:00`;
25
+ const CALENDAR = "cal_work";
26
+
27
+ const colorByCalendarId: Record<string, CalendarColorId> = {
28
+ [CALENDAR]: "cal-1",
29
+ };
30
+
31
+ const roadmap: CalendarEventData = {
32
+ id: "roadmap",
33
+ calendarId: CALENDAR,
34
+ title: "Roadmap review",
35
+ start: `${DATE}T10:00:00+02:00`,
36
+ end: `${DATE}T11:00:00+02:00`,
37
+ allDay: false,
38
+ location: "",
39
+ notes: "",
40
+ attendees: [],
41
+ myRsvp: "accepted",
42
+ threadId: "",
43
+ threadSubject: "",
44
+ timeZone: TIME_ZONE,
45
+ zoneCertainty: "explicit",
46
+ recurrenceRule: "",
47
+ seriesId: "",
48
+ seriesException: false,
49
+ status: "confirmed",
50
+ };
51
+
52
+ const noop = () => {};
53
+
54
+ const workspaceProps = (
55
+ view: "year" | "month" | "week" | "day" | "agenda",
56
+ events: CalendarEventData[] = [roadmap],
57
+ ): CalendarWorkspaceProps => ({
58
+ view,
59
+ date: DATE,
60
+ events,
61
+ colorByCalendarId,
62
+ density: "comfortable",
63
+ selectedEventId: "",
64
+ timeZone: TIME_ZONE,
65
+ now: NOW,
66
+ onChangeView: noop,
67
+ onToday: noop,
68
+ onStep: noop,
69
+ onChangeDensity: noop,
70
+ onSelectEvent: noop,
71
+ onPickSlot: noop,
72
+ });
73
+
74
+ /**
75
+ * The calendar engine schedules browser timers while it renders. Nothing mounts
76
+ * here, so nothing ever clears them and the suite would sit on a live event
77
+ * loop after its last assertion. Take them back.
78
+ */
79
+ function renderQuietly(element: ReactElement): string {
80
+ const scheduled: ReturnType<typeof setTimeout>[] = [];
81
+ const native = globalThis.setTimeout;
82
+ globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
83
+ const handle = native(...args);
84
+ scheduled.push(handle);
85
+ return handle;
86
+ }) as typeof globalThis.setTimeout;
87
+ try {
88
+ return renderToString(element);
89
+ } finally {
90
+ globalThis.setTimeout = native;
91
+ for (const handle of scheduled) clearTimeout(handle);
92
+ }
93
+ }
94
+
95
+ const render = (
96
+ view: "year" | "month" | "week" | "day" | "agenda",
97
+ events: CalendarEventData[] = [roadmap],
98
+ ): string =>
99
+ renderQuietly(createElement(CalendarWorkspace, workspaceProps(view, events)));
100
+
101
+ describe("the zoom the address names", () => {
102
+ it("draws the grid for the week and the day", () => {
103
+ for (const view of ["week", "day"] as const) {
104
+ const html = render(view);
105
+ assert.ok(
106
+ html.includes("Roadmap review"),
107
+ `${view} did not draw the events it was given`,
108
+ );
109
+ assert.equal(html.includes("Not built yet"), false);
110
+ }
111
+ });
112
+
113
+ it("says so for a zoom that is not drawn yet", () => {
114
+ for (const view of ["year", "month", "agenda"] as const) {
115
+ const html = render(view);
116
+ assert.ok(
117
+ html.includes(`calendar-placeholder-${view}`),
118
+ `${view} rendered no placeholder`,
119
+ );
120
+ assert.ok(html.includes("Not built yet"));
121
+ assert.ok(html.includes("Week and Day work now."));
122
+ }
123
+ });
124
+
125
+ it("names the agenda's own stage rather than a generic one", () => {
126
+ assert.ok(render("agenda").includes("scrolling day strip"));
127
+ });
128
+ });
129
+
130
+ describe("the toolbar", () => {
131
+ it("offers every zoom, whichever one is on", () => {
132
+ const html = render("year");
133
+ for (const label of ["Year", "Month", "Week", "Day", "Agenda"]) {
134
+ assert.ok(html.includes(`>${label}<`), `no way to reach ${label}`);
135
+ }
136
+ });
137
+
138
+ it("offers back, forward and today at a zoom that draws no grid", () => {
139
+ const html = render("month");
140
+ assert.ok(html.includes('aria-label="Previous"'));
141
+ assert.ok(html.includes('aria-label="Next"'));
142
+ assert.ok(html.includes(">Today<"));
143
+ });
144
+
145
+ it("names the range before the grid has measured one", () => {
146
+ // The words are the device's locale's; the year is the part that is this
147
+ // component's answer rather than `Intl`'s.
148
+ assert.ok(render("month").includes("2026"));
149
+ assert.ok(render("year").includes("2026"));
150
+ });
151
+
152
+ it("carries the density control, which the address never names", () => {
153
+ const html = render("week");
154
+ assert.ok(html.includes("Calendar density"));
155
+ assert.ok(html.includes(">Detail<"));
156
+ assert.ok(html.includes(">Glance<"));
157
+ });
158
+ });
159
+
160
+ describe("a week with nothing in it", () => {
161
+ it("draws the grid rather than an empty surface", () => {
162
+ const html = render("week", []);
163
+ assert.equal(html.includes("Not built yet"), false);
164
+ assert.ok(html.includes('aria-label="Previous"'));
165
+ });
166
+ });
167
+
168
+ /**
169
+ * The way back to the mail, at a width where the nav is not a pane.
170
+ *
171
+ * The shell renders the sidebar as a slide-over below 1024px and nothing opens
172
+ * a slide-over on its own, so a surface that is the whole width of the app has
173
+ * to carry the control itself — mail's own list headers do, and the calendar is
174
+ * reached from the same sidebar it would otherwise strand a reader away from.
175
+ */
176
+ describe("the folders control", () => {
177
+ const shell = (width: number): string =>
178
+ renderQuietly(
179
+ createElement(AppShellSlotted, {
180
+ initialWidth: width,
181
+ nav: createElement("nav", null, "Folders"),
182
+ list: createElement(CalendarWorkspace, workspaceProps("week")),
183
+ }),
184
+ );
185
+
186
+ it("is in the calendar's header where the nav is a slide-over", () => {
187
+ assert.match(shell(900), /aria-label="Open folders"/);
188
+ });
189
+
190
+ it("is absent where the nav is a pane of its own", () => {
191
+ assert.doesNotMatch(shell(1440), /aria-label="Open folders"/);
192
+ });
193
+ });
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Writing an event and deleting one, through the addresses that carry them.
3
+ *
4
+ * The addresses are the real ones — `/calendar/{view}/{date}/new` and the event
5
+ * segment under the view — and so are the components each mounts. What is
6
+ * asserted is the request that left and the address the reader ends up at,
7
+ * because a form that posts and then leaves the composer up looks exactly like
8
+ * one that posted nothing.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { afterEach, describe, it } from "node:test";
13
+ import type {
14
+ RemitImapCalendarEventInstance,
15
+ RemitImapCalendarEventResponse,
16
+ RemitImapCalendarResponse,
17
+ } from "@remit/api-http-client/types.gen.ts";
18
+ import {
19
+ type AnyRouter,
20
+ createMemoryHistory,
21
+ createRootRoute,
22
+ createRoute,
23
+ createRouter,
24
+ Outlet,
25
+ type RouteComponent,
26
+ RouterProvider,
27
+ } from "@tanstack/react-router";
28
+ import { createElement } from "react";
29
+ import { CalendarComposeSeedProvider } from "@/components/calendar/CalendarComposeSeed";
30
+ import { calendarSearchSchema } from "@/lib/calendar-route";
31
+ import { stringifySearch } from "@/lib/search-params";
32
+ import { Route as OccurrenceRoute } from "../../routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.js";
33
+ import { Route as EventIndexRoute } from "../../routes/calendar/$view.$date/$calendarObjectId/index.js";
34
+ import { Route as EventRoute } from "../../routes/calendar/$view.$date/$calendarObjectId.js";
35
+ import { Route as ComposeRoute } from "../../routes/calendar/$view.$date/new.js";
36
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
37
+ import {
38
+ type HttpCall,
39
+ type HttpMock,
40
+ mockFetch,
41
+ } from "../../test-support/http";
42
+
43
+ // The router reads `self` at construction; the shared globals stop at `window`.
44
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
45
+
46
+ const WORK = "11111111-1111-4111-8111-111111111111";
47
+ const OBJECT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
48
+ const WEEK = "/calendar/week/2026-06-10";
49
+
50
+ let harness: DomHarness | undefined;
51
+ let http: HttpMock | undefined;
52
+
53
+ afterEach(() => {
54
+ harness?.close();
55
+ harness = undefined;
56
+ http?.restore();
57
+ http = undefined;
58
+ });
59
+
60
+ /**
61
+ * The component a file route mounts, lifted off the route itself.
62
+ *
63
+ * The route objects are module singletons and re-parenting one mutates it for
64
+ * every other test in the process, so the tree below is built fresh per case
65
+ * out of the real components rather than out of the real routes. Which routes
66
+ * an address mounts is `routes/calendar/-calendar-tree.test.ts`'s subject; this
67
+ * one is about what those components then do.
68
+ */
69
+ const componentOf = (route: unknown): RouteComponent => {
70
+ const { component } = (route as { options: { component?: RouteComponent } })
71
+ .options;
72
+ if (!component) throw new Error("the route mounts no component");
73
+ return component;
74
+ };
75
+
76
+ const calendars: RemitImapCalendarResponse[] = [
77
+ {
78
+ calendarId: WORK,
79
+ accountConfigId: "cfg-1",
80
+ urlSegment: "work",
81
+ displayName: "Work",
82
+ color: "Cal1",
83
+ componentSet: "VeventOnly",
84
+ source: "UserCreated",
85
+ timezone: "Europe/Amsterdam",
86
+ syncSequence: 1,
87
+ createdAt: 0,
88
+ updatedAt: 0,
89
+ } as RemitImapCalendarResponse,
90
+ ];
91
+
92
+ const instance: RemitImapCalendarEventInstance = {
93
+ calendarId: WORK,
94
+ calendarObjectId: OBJECT,
95
+ recurrenceId: "",
96
+ icalUid: "uid-1",
97
+ summary: "Roadmap review",
98
+ start: "2026-06-10T10:00:00+02:00",
99
+ end: "2026-06-10T11:30:00+02:00",
100
+ allDay: false,
101
+ status: "Confirmed",
102
+ transparency: "Opaque",
103
+ zoneCertainty: "Explicit",
104
+ etag: "etag-1",
105
+ hasRecurrence: false,
106
+ } as RemitImapCalendarEventInstance;
107
+
108
+ const resource = {
109
+ calendarObjectId: OBJECT,
110
+ calendarId: WORK,
111
+ resourceName: "roadmap.ics",
112
+ icalUid: "uid-1",
113
+ icalData: [
114
+ "BEGIN:VCALENDAR",
115
+ "BEGIN:VEVENT",
116
+ "SUMMARY:Roadmap review",
117
+ "LOCATION:Room Zuid",
118
+ "END:VEVENT",
119
+ "END:VCALENDAR",
120
+ ].join("\r\n"),
121
+ etag: "etag-1",
122
+ } as RemitImapCalendarEventResponse;
123
+
124
+ /**
125
+ * The calendar's addresses, at the shape the generated tree gives them: the
126
+ * view, the composer beside the open event, and one occurrence under the series.
127
+ * The grid is left off — nothing here is about how a week is drawn, and the
128
+ * pane routes do not need one mounted beside them to be reached.
129
+ */
130
+ const testRouter = (entry: string): AnyRouter => {
131
+ const rootRoute = createRootRoute({ component: Outlet });
132
+ const viewRoute = createRoute({
133
+ getParentRoute: () => rootRoute,
134
+ path: "/calendar/$view/$date",
135
+ validateSearch: calendarSearchSchema,
136
+ component: () =>
137
+ createElement(
138
+ CalendarComposeSeedProvider,
139
+ { pick: undefined },
140
+ createElement(Outlet),
141
+ ),
142
+ });
143
+ const eventRoute = createRoute({
144
+ getParentRoute: () => viewRoute,
145
+ path: "$calendarObjectId",
146
+ component: componentOf(EventRoute),
147
+ });
148
+ const routeTree = rootRoute.addChildren([
149
+ viewRoute.addChildren([
150
+ createRoute({
151
+ getParentRoute: () => viewRoute,
152
+ path: "new",
153
+ component: componentOf(ComposeRoute),
154
+ }),
155
+ eventRoute.addChildren([
156
+ createRoute({
157
+ getParentRoute: () => eventRoute,
158
+ path: "/",
159
+ component: componentOf(EventIndexRoute),
160
+ }),
161
+ createRoute({
162
+ getParentRoute: () => eventRoute,
163
+ path: "$recurrenceId",
164
+ component: componentOf(OccurrenceRoute),
165
+ }),
166
+ ]),
167
+ ]),
168
+ ]);
169
+ return createRouter({
170
+ routeTree,
171
+ stringifySearch,
172
+ history: createMemoryHistory({ initialEntries: [entry] }),
173
+ }) as unknown as AnyRouter;
174
+ };
175
+
176
+ const mount = async (entry: string, respond: (call: HttpCall) => unknown) => {
177
+ http = mockFetch(respond);
178
+ const router = testRouter(entry);
179
+ await router.load();
180
+ harness = createDomHarness();
181
+ harness.renderApp(createElement(RouterProvider, { router }));
182
+ await settle();
183
+ return router;
184
+ };
185
+
186
+ const settle = async () => {
187
+ await harness?.flush();
188
+ await harness?.wait(30);
189
+ await harness?.flush();
190
+ };
191
+
192
+ const answering =
193
+ (
194
+ respond: (call: HttpCall) => unknown,
195
+ items: RemitImapCalendarEventInstance[] = [instance],
196
+ stored: RemitImapCalendarEventResponse = resource,
197
+ ) =>
198
+ (call: HttpCall) => {
199
+ if (call.path.endsWith("/calendars") && call.method === "GET")
200
+ return { items: calendars };
201
+ if (call.path.endsWith("/calendar-events") && call.method === "GET")
202
+ return { items };
203
+ if (call.path.includes("/calendar-events/") && call.method === "GET")
204
+ return stored;
205
+ return respond(call);
206
+ };
207
+
208
+ const RECURRENCE = "2026-06-10T07:15:00Z";
209
+
210
+ const occurrence = {
211
+ ...instance,
212
+ recurrenceId: RECURRENCE,
213
+ summary: "Standup",
214
+ start: "2026-06-10T09:15:00+02:00",
215
+ end: "2026-06-10T09:30:00+02:00",
216
+ hasRecurrence: true,
217
+ } as RemitImapCalendarEventInstance;
218
+
219
+ const series = {
220
+ ...resource,
221
+ icalData: [
222
+ "BEGIN:VCALENDAR",
223
+ "BEGIN:VEVENT",
224
+ "SUMMARY:Standup",
225
+ "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
226
+ "END:VEVENT",
227
+ "END:VCALENDAR",
228
+ ].join("\r\n"),
229
+ } as RemitImapCalendarEventResponse;
230
+
231
+ describe("writing an event from the composer route", () => {
232
+ it("posts what was typed and puts the reader back on the calendar", async () => {
233
+ const router = await mount(
234
+ `${WEEK}/new`,
235
+ answering(() => resource),
236
+ );
237
+
238
+ harness?.type(harness.byLabel("Title"), "Supplier call");
239
+ await settle();
240
+ harness?.click(harness.byText("button", "Add"));
241
+ await settle();
242
+
243
+ const posted = (http?.calls ?? []).filter(
244
+ (call) =>
245
+ call.method === "POST" && call.path.endsWith("/calendar-events"),
246
+ );
247
+ assert.equal(posted.length, 1, "the event was written once");
248
+ assert.equal(posted[0].body?.summary, "Supplier call");
249
+ assert.equal(posted[0].body?.calendarId, WORK);
250
+ assert.equal(
251
+ router.state.location.pathname,
252
+ WEEK,
253
+ "a saved event closes the composer rather than leaving it up",
254
+ );
255
+ });
256
+
257
+ it("says what is missing rather than posting a nameless event", async () => {
258
+ await mount(
259
+ `${WEEK}/new`,
260
+ answering(() => resource),
261
+ );
262
+
263
+ harness?.click(harness.byText("button", "Add"));
264
+ await settle();
265
+
266
+ assert.equal(
267
+ (http?.calls ?? []).filter((call) => call.method === "POST").length,
268
+ 0,
269
+ );
270
+ assert.match(harness?.text() ?? "", /title/i);
271
+ });
272
+ });
273
+
274
+ describe("deleting an event from the event route", () => {
275
+ it("sends the delete with the version on screen and closes the pane", async () => {
276
+ const router = await mount(
277
+ `${WEEK}/${OBJECT}`,
278
+ answering(() => ({})),
279
+ );
280
+
281
+ assert.match(harness?.text() ?? "", /Roadmap review/);
282
+ harness?.click(harness.byText("button", "Delete"));
283
+ await settle();
284
+
285
+ const deleted = (http?.calls ?? []).filter(
286
+ (call) => call.method === "DELETE",
287
+ );
288
+ assert.equal(deleted.length, 1);
289
+ assert.ok(deleted[0].url.includes(`calendarId=${WORK}`), deleted[0].url);
290
+ assert.equal(
291
+ deleted[0].headers["if-match"],
292
+ "etag-1",
293
+ "a delete built on a version nobody stated can remove somebody else's event",
294
+ );
295
+ assert.equal(router.state.location.pathname, WEEK);
296
+ });
297
+ });
298
+
299
+ describe("editing one morning of a repeating event", () => {
300
+ it("asks what the change applies to before opening the form", async () => {
301
+ await mount(
302
+ `${WEEK}/${OBJECT}/${RECURRENCE}`,
303
+ answering(() => ({}), [occurrence], series),
304
+ );
305
+
306
+ assert.doesNotMatch(
307
+ harness?.text() ?? "",
308
+ /What should the change apply to/,
309
+ "the question is asked when Edit is pressed, not before",
310
+ );
311
+ harness?.click(harness.byText("button", "Edit"));
312
+ await settle();
313
+
314
+ const asked = harness?.text() ?? "";
315
+ assert.match(asked, /Standup repeats/);
316
+ assert.match(asked, /What should the change apply to/);
317
+ assert.match(
318
+ asked,
319
+ /Every weekday, 09:15/,
320
+ "the rule is read back in words, not as an RRULE",
321
+ );
322
+ });
323
+
324
+ it("sends the answer with the edit, naming the occurrence it meant", async () => {
325
+ await mount(
326
+ `${WEEK}/${OBJECT}/${RECURRENCE}`,
327
+ answering(() => ({}), [occurrence], series),
328
+ );
329
+
330
+ harness?.click(harness.byText("button", "Edit"));
331
+ await settle();
332
+ harness?.click(harness.byText("button", "This event"));
333
+ await settle();
334
+
335
+ harness?.type(harness.byLabel("Title"), "Standup, short");
336
+ await settle();
337
+ harness?.click(harness.byText("button", "Save"));
338
+ await settle();
339
+
340
+ const patched = (http?.calls ?? []).filter(
341
+ (call) => call.method === "PATCH",
342
+ );
343
+ assert.equal(patched.length, 1);
344
+ assert.equal(patched[0].body?.summary, "Standup, short");
345
+ assert.equal(
346
+ patched[0].headers["if-match"],
347
+ "etag-1",
348
+ "the write is conditional on the version the form was opened from",
349
+ );
350
+ assert.ok(patched[0].url.includes("scope=This"), patched[0].url);
351
+ assert.ok(
352
+ patched[0].url.includes(encodeURIComponent(RECURRENCE)),
353
+ patched[0].url,
354
+ );
355
+ });
356
+ });
@@ -219,6 +219,22 @@ export function MailSidebarAdapter({
219
219
  </NavLink>
220
220
  );
221
221
  }
222
+ if (navId === "calendar") {
223
+ // No list segment to name: the calendar's index route sends the reader
224
+ // to the week they are in, so a bookmark of the nav entry keeps working
225
+ // tomorrow.
226
+ return (
227
+ <NavLink
228
+ to="/calendar"
229
+ onClick={() => onClick?.()}
230
+ className={className}
231
+ aria-label={ariaLabel}
232
+ title={title}
233
+ >
234
+ {children}
235
+ </NavLink>
236
+ );
237
+ }
222
238
  if (navId === "outbox") {
223
239
  return (
224
240
  <NavLink
@@ -332,6 +348,7 @@ export function MailSidebarAdapter({
332
348
  selectedNavId={selectedNavId}
333
349
  onSelectNav={handleSelectNav}
334
350
  linkComponent={linkComponent}
351
+ calendarNav="shown"
335
352
  variant={variant}
336
353
  savedSearches={savedSearches}
337
354
  saveableQuery={saveableQuery}