@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
@@ -0,0 +1,241 @@
1
+ /**
2
+ * What the agenda puts on screen.
3
+ *
4
+ * Every claim here is one the strip makes to a reader rather than one the
5
+ * arithmetic makes to itself: that it opened on the day the address named, that
6
+ * a week with nothing in it is a sentence rather than seven blank screens, that
7
+ * an afternoon booked on a calendar it is not drawing is not offered as free,
8
+ * and that a read it was refused says so instead of drawing an empty diary.
9
+ */
10
+ import "@remit/test-dom";
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import {
14
+ buildCalendarDay,
15
+ type CalendarDescriptor,
16
+ type CalendarEventData,
17
+ datesBetween,
18
+ freeStretchesOn,
19
+ } from "@remit/ui";
20
+ import { createElement, type ReactElement } from "react";
21
+ import { renderToString } from "react-dom/server";
22
+ import { freeStretchesByDate, startOfDay } from "@/hooks/calendar";
23
+ import { AgendaStrip, type AgendaStripProps } from "./AgendaStrip.js";
24
+
25
+ const TODAY = "2026-06-10";
26
+ /**
27
+ * Events are read off the wall time they print, so their offset is arbitrary.
28
+ * A busy span is an instant, so it carries this machine's own offset and the
29
+ * suite answers the same in Los Angeles as in Amsterdam.
30
+ */
31
+ const OFFSET = "+02:00";
32
+ const busyAt = (date: string, time: string): string =>
33
+ `${date}T${time}:00${startOfDay(date).slice(19)}`;
34
+ const NOW = `${TODAY}T09:30:00${OFFSET}`;
35
+ const WORK = "cal_work";
36
+
37
+ const calendars: CalendarDescriptor[] = [
38
+ {
39
+ id: WORK,
40
+ accountId: "acct_work",
41
+ accountLabel: "Work",
42
+ name: "Northwind",
43
+ color: "cal-3",
44
+ },
45
+ ];
46
+
47
+ const event = (
48
+ id: string,
49
+ date: string,
50
+ from: string,
51
+ to: string,
52
+ title = id,
53
+ ): CalendarEventData => ({
54
+ id,
55
+ calendarId: WORK,
56
+ title,
57
+ start: `${date}T${from}:00${OFFSET}`,
58
+ end: `${date}T${to}:00${OFFSET}`,
59
+ allDay: false,
60
+ location: "",
61
+ notes: "",
62
+ attendees: [],
63
+ myRsvp: "accepted",
64
+ threadId: "",
65
+ threadSubject: "",
66
+ timeZone: "Europe/Amsterdam",
67
+ zoneCertainty: "explicit",
68
+ recurrenceRule: "",
69
+ seriesId: "",
70
+ seriesException: false,
71
+ status: "confirmed",
72
+ });
73
+
74
+ const events = [
75
+ event("evt_roadmap", TODAY, "10:00", "11:30", "Q3 roadmap review"),
76
+ event("evt_retro", "2026-06-18", "16:00", "17:00", "Retro"),
77
+ ];
78
+
79
+ const dates = datesBetween("2026-06-08", "2026-06-20");
80
+ const days = dates.map((date) => buildCalendarDay(date, events, TODAY));
81
+
82
+ const noop = () => {};
83
+
84
+ /** Every day answered for, which is what the cases below are about. */
85
+ const NOTHING_PENDING: ReadonlySet<string> = new Set();
86
+
87
+ const base: AgendaStripProps = {
88
+ days,
89
+ calendars,
90
+ density: "comfortable",
91
+ today: TODAY,
92
+ anchorDate: TODAY,
93
+ now: NOW,
94
+ selectedEventId: "",
95
+ // The gaps between the rows: what the strip reads before the merged busy
96
+ // spans land, and what every case here but one is measured against.
97
+ freeOn: freeStretchesOn,
98
+ loadingDates: NOTHING_PENDING,
99
+ onSelectEvent: noop,
100
+ onPickSlot: noop,
101
+ onZoomDay: noop,
102
+ onGoToDate: noop,
103
+ onReachStart: noop,
104
+ onReachEnd: noop,
105
+ onVisibleDayChange: noop,
106
+ };
107
+
108
+ /** React writes a marker between adjacent text nodes; a reader sees one sentence. */
109
+ const words = (html: string) => html.replaceAll("<!-- -->", "");
110
+
111
+ const render = (props: Partial<AgendaStripProps> = {}): string =>
112
+ words(
113
+ renderToString(
114
+ createElement(AgendaStrip, { ...base, ...props }) as ReactElement,
115
+ ),
116
+ );
117
+
118
+ describe("the day the address named", () => {
119
+ it("names it under the sticky header the strip opens on", () => {
120
+ assert.match(render(), /Wednesday/);
121
+ assert.match(render(), />Today</);
122
+ });
123
+
124
+ it("keeps it out of a run, however empty it is", () => {
125
+ const quiet = dates.map((date) => buildCalendarDay(date, [], TODAY));
126
+ const html = render({ days: quiet, anchorDate: "2026-06-16" });
127
+ // The run either side of the 16th, rather than one run over it.
128
+ assert.match(html, /5 days with nothing booked/);
129
+ assert.match(html, /4 days with nothing booked/);
130
+ });
131
+ });
132
+
133
+ describe("a run of days with nothing on them", () => {
134
+ it("collapses into one sentence rather than seven empty screens", () => {
135
+ const html = render();
136
+ assert.match(html, /Thu 11 – Wed 17 Jun/);
137
+ assert.match(html, /7 days with nothing booked/);
138
+ });
139
+ });
140
+
141
+ describe("free time", () => {
142
+ /**
143
+ * The reader has unticked the calendar those meetings are on, so the strip
144
+ * draws nothing on the 11th. They are booked all morning regardless, and
145
+ * "Free all day" is the one answer this view must never give.
146
+ */
147
+ it("comes from the merged busy spans, not from the rows on screen", () => {
148
+ const free = freeStretchesByDate(dates, [
149
+ {
150
+ start: busyAt("2026-06-11", "09:00"),
151
+ end: busyAt("2026-06-11", "16:00"),
152
+ },
153
+ ]);
154
+ const html = render({
155
+ anchorDate: "2026-06-11",
156
+ freeOn: (day) => free.get(day.date) ?? [],
157
+ });
158
+ assert.doesNotMatch(html, /Free all day/);
159
+ assert.match(html, /16:00 – 22:00/);
160
+ });
161
+
162
+ it("names the stretch a booked day still leaves open", () => {
163
+ assert.match(render(), /11:30 – 22:00/);
164
+ });
165
+ });
166
+
167
+ describe("what is next", () => {
168
+ it("leads today, so it is the first thing on screen and the first gone", () => {
169
+ const html = render();
170
+ assert.match(html, /Q3 roadmap review/);
171
+ assert.match(html, /Next · in 30m/);
172
+ });
173
+
174
+ it("says so plainly when the diary is empty ahead", () => {
175
+ const past = [event("evt_done", TODAY, "08:00", "09:00", "Breakfast")];
176
+ const html = render({
177
+ days: dates.map((date) => buildCalendarDay(date, past, TODAY)),
178
+ });
179
+ assert.match(html, /Nothing else booked/);
180
+ });
181
+ });
182
+
183
+ /**
184
+ * One preference for the whole calendar, which the grid already reads. The
185
+ * strip's third reading — a month of coloured dots with no titles on it — is
186
+ * not reachable from Glance on purpose: Glance says how much of a day fits, not
187
+ * whether to say what is on it.
188
+ */
189
+ describe("the density the device holds", () => {
190
+ it("names the calendar an event is on at the detail reading", () => {
191
+ assert.match(render({ density: "comfortable" }), /Northwind/);
192
+ });
193
+
194
+ it("drops that line at a glance, still naming every event", () => {
195
+ const html = render({ density: "compact" });
196
+ assert.match(html, /Q3 roadmap review/);
197
+ assert.doesNotMatch(html, /Northwind/);
198
+ });
199
+ });
200
+
201
+ describe("a read that did not happen", () => {
202
+ it("states the refusal rather than drawing an empty diary", () => {
203
+ const html = render({
204
+ days: [],
205
+ error: new Error("The window has to be shorter than a year."),
206
+ });
207
+ assert.match(html, /load these days/);
208
+ assert.match(html, /shorter than a year/);
209
+ assert.doesNotMatch(html, /days with nothing booked/);
210
+ });
211
+
212
+ /**
213
+ * The bug this pins: a week still in flight has no events, so every day of
214
+ * it built as "nothing booked" and a run of them collapsed into "7 days with
215
+ * nothing booked" — a claim about a diary nobody had read yet.
216
+ */
217
+ it("draws an unanswered day as a skeleton, never as a day with nothing on it", () => {
218
+ const pending = new Set(["2026-06-11", "2026-06-12", "2026-06-13"]);
219
+ const html = render({ loadingDates: pending });
220
+ for (const date of pending) {
221
+ assert.match(html, new RegExp(`agenda-day-pending-${date}`));
222
+ }
223
+ assert.match(html, /aria-busy="true"/);
224
+ // The run that would have swallowed them, had they been read as empty.
225
+ assert.doesNotMatch(html, /Thu 11 – /);
226
+ });
227
+
228
+ it("keeps the days that have answered while their neighbours arrive", () => {
229
+ const html = render({ loadingDates: new Set(["2026-06-11"]) });
230
+ assert.match(html, /Q3 roadmap review/);
231
+ assert.match(html, /agenda-day-pending-2026-06-11/);
232
+ });
233
+
234
+ it("holds back what is next until today has answered", () => {
235
+ assert.match(render(), /Next · in 30m/);
236
+ assert.doesNotMatch(
237
+ render({ loadingDates: new Set([TODAY]) }),
238
+ /Nothing else booked/,
239
+ );
240
+ });
241
+ });
@@ -24,6 +24,9 @@ const DATE = "2026-06-10";
24
24
  const NOW = `${DATE}T09:30:00+02:00`;
25
25
  const CALENDAR = "cal_work";
26
26
 
27
+ /** Stands in for the strip, which loads its own days and is tested on its own. */
28
+ const AGENDA_SLOT = "the strip";
29
+
27
30
  const colorByCalendarId: Record<string, CalendarColorId> = {
28
31
  [CALENDAR]: "cal-1",
29
32
  };
@@ -59,6 +62,7 @@ const workspaceProps = (
59
62
  date: DATE,
60
63
  events,
61
64
  colorByCalendarId,
65
+ agenda: createElement("p", null, AGENDA_SLOT),
62
66
  density: "comfortable",
63
67
  selectedEventId: "",
64
68
  timeZone: TIME_ZONE,
@@ -110,21 +114,24 @@ describe("the zoom the address names", () => {
110
114
  }
111
115
  });
112
116
 
117
+ it("mounts the strip for the agenda, and not the grid", () => {
118
+ const html = render("agenda");
119
+ assert.ok(html.includes(AGENDA_SLOT), "the agenda slot was not mounted");
120
+ assert.equal(html.includes("Not built yet"), false);
121
+ assert.equal(html.includes("Roadmap review"), false);
122
+ });
123
+
113
124
  it("says so for a zoom that is not drawn yet", () => {
114
- for (const view of ["year", "month", "agenda"] as const) {
125
+ for (const view of ["year", "month"] as const) {
115
126
  const html = render(view);
116
127
  assert.ok(
117
128
  html.includes(`calendar-placeholder-${view}`),
118
129
  `${view} rendered no placeholder`,
119
130
  );
120
131
  assert.ok(html.includes("Not built yet"));
121
- assert.ok(html.includes("Week and Day work now."));
132
+ assert.ok(html.includes("Week, Day and Agenda work now."));
122
133
  }
123
134
  });
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
135
  });
129
136
 
130
137
  describe("the toolbar", () => {
@@ -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
+ });