@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,265 @@
1
+ /**
2
+ * What the calendar asks the server, and how often.
3
+ *
4
+ * Three things are pinned here, and all three are about the request rather than
5
+ * about the pixels: the window is the screenful the address names, the same
6
+ * week asked for twice is one request, and a step lands on a window that was
7
+ * already warmed. A grid that refetches on every navigation looks identical in
8
+ * a screenshot and is a different product to use.
9
+ *
10
+ * The tick list is the fourth: ticking nothing is every calendar, and an
11
+ * address naming a calendar that is gone shows the rest rather than a week that
12
+ * reads as empty.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import type {
18
+ RemitImapCalendarEventInstance,
19
+ RemitImapCalendarResponse,
20
+ } from "@remit/api-http-client/types.gen.ts";
21
+ import { createElement } from "react";
22
+ import { type CalendarData, useCalendarData } from "@/hooks/useCalendarData";
23
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
24
+ import { type HttpMock, mockFetch } from "../../test-support/http";
25
+
26
+ const WORK = "11111111-1111-4111-8111-111111111111";
27
+ const HOME = "22222222-2222-4222-8222-222222222222";
28
+
29
+ let harness: DomHarness | undefined;
30
+ let http: HttpMock | undefined;
31
+ let data: CalendarData | undefined;
32
+
33
+ afterEach(() => {
34
+ harness?.close();
35
+ harness = undefined;
36
+ http?.restore();
37
+ http = undefined;
38
+ data = undefined;
39
+ });
40
+
41
+ const calendar = (
42
+ calendarId: string,
43
+ displayName: string,
44
+ ): RemitImapCalendarResponse =>
45
+ ({
46
+ calendarId,
47
+ accountConfigId: "cfg-1",
48
+ urlSegment: displayName.toLowerCase(),
49
+ displayName,
50
+ color: "Cal1",
51
+ componentSet: "VeventOnly",
52
+ source: "UserCreated",
53
+ timezone: "Europe/Amsterdam",
54
+ syncSequence: 1,
55
+ createdAt: 0,
56
+ updatedAt: 0,
57
+ }) as RemitImapCalendarResponse;
58
+
59
+ const instance = (
60
+ over: Partial<RemitImapCalendarEventInstance>,
61
+ ): RemitImapCalendarEventInstance =>
62
+ ({
63
+ calendarId: WORK,
64
+ calendarObjectId: "obj-1",
65
+ recurrenceId: "",
66
+ icalUid: "uid-1",
67
+ summary: "Roadmap review",
68
+ start: "2026-06-10T10:00:00+02:00",
69
+ end: "2026-06-10T11:30:00+02:00",
70
+ allDay: false,
71
+ status: "Confirmed",
72
+ transparency: "Opaque",
73
+ zoneCertainty: "Explicit",
74
+ etag: "etag-1",
75
+ hasRecurrence: false,
76
+ ...over,
77
+ }) as RemitImapCalendarEventInstance;
78
+
79
+ function Probe({
80
+ date,
81
+ calendarIds,
82
+ }: {
83
+ date: string;
84
+ calendarIds: readonly string[];
85
+ }) {
86
+ data = useCalendarData({ view: "week", date, calendarIds });
87
+ return null;
88
+ }
89
+
90
+ const mount = async (
91
+ date: string,
92
+ calendarIds: readonly string[],
93
+ instances: RemitImapCalendarEventInstance[] = [],
94
+ calendars: RemitImapCalendarResponse[] = [
95
+ calendar(WORK, "Work"),
96
+ calendar(HOME, "Home"),
97
+ ],
98
+ ) => {
99
+ http = mockFetch((call) =>
100
+ call.path.endsWith("/calendars")
101
+ ? { items: calendars }
102
+ : { items: instances },
103
+ );
104
+ harness = createDomHarness();
105
+ harness.renderApp(createElement(Probe, { date, calendarIds }));
106
+ await harness.flush();
107
+ await harness.wait(20);
108
+ await harness.flush();
109
+ };
110
+
111
+ const rerender = async (date: string, calendarIds: readonly string[]) => {
112
+ harness?.renderApp(createElement(Probe, { date, calendarIds }));
113
+ await harness?.flush();
114
+ await harness?.wait(20);
115
+ await harness?.flush();
116
+ };
117
+
118
+ /** Event listings whose window starts on a given day. */
119
+ const windowsFrom = (day: string): string[] =>
120
+ (http?.to("/calendar-events") ?? [])
121
+ .map((call) => call.url)
122
+ .filter((url) => url.includes(`from=${encodeURIComponent(day)}`));
123
+
124
+ describe("the window the grid asks for", () => {
125
+ it("is the week the address names, not the day", async () => {
126
+ await mount("2026-06-10", []);
127
+ const asked = windowsFrom("2026-06-08");
128
+ assert.equal(asked.length, 1, "the visible week was asked for once");
129
+ assert.ok(
130
+ asked[0].includes(`to=${encodeURIComponent("2026-06-15")}`),
131
+ `the window ends on the next Monday: ${asked[0]}`,
132
+ );
133
+ });
134
+
135
+ it("warms the week either side, so a step is not a wait", async () => {
136
+ await mount("2026-06-10", []);
137
+ assert.equal(windowsFrom("2026-06-01").length, 1);
138
+ assert.equal(windowsFrom("2026-06-15").length, 1);
139
+ });
140
+ });
141
+
142
+ describe("moving around inside a week already read", () => {
143
+ it("asks nothing again", async () => {
144
+ await mount("2026-06-08", []);
145
+ await rerender("2026-06-11", []);
146
+ assert.equal(
147
+ windowsFrom("2026-06-08").length,
148
+ 1,
149
+ "every day of one week shares the window that was already fetched",
150
+ );
151
+ });
152
+
153
+ it("steps onto a week the prefetch already holds", async () => {
154
+ await mount("2026-06-10", []);
155
+ const before = windowsFrom("2026-06-15").length;
156
+ await rerender("2026-06-17", []);
157
+ assert.equal(
158
+ windowsFrom("2026-06-15").length,
159
+ before,
160
+ "the next week was warmed, so stepping onto it fetches nothing",
161
+ );
162
+ });
163
+ });
164
+
165
+ describe("the calendars the address ticked", () => {
166
+ it("names none when the reader has ticked none", async () => {
167
+ await mount("2026-06-10", []);
168
+ assert.ok(
169
+ windowsFrom("2026-06-08").every((url) => !url.includes("calendarId=")),
170
+ "ticking nothing off is every calendar, which the API takes as no filter",
171
+ );
172
+ });
173
+
174
+ it("names the one the address ticked", async () => {
175
+ await mount("2026-06-10", [WORK]);
176
+ const asked = windowsFrom("2026-06-08");
177
+ assert.equal(asked.length, 1);
178
+ assert.ok(asked[0].includes(`calendarId=${WORK}`));
179
+ assert.ok(!asked[0].includes(`calendarId=${HOME}`));
180
+ });
181
+
182
+ it("shows the rest when the address names a calendar that is gone", async () => {
183
+ await mount("2026-06-10", ["33333333-3333-4333-8333-333333333333"]);
184
+ assert.ok(
185
+ windowsFrom("2026-06-08").every((url) => !url.includes("calendarId=")),
186
+ "an address pointing at nothing shows the calendar, not an empty week",
187
+ );
188
+ });
189
+ });
190
+
191
+ /**
192
+ * The zone every write is built in, so it has to be one the server will take.
193
+ *
194
+ * The server checks a TZID against `Intl.supportedValuesOf("timeZone")` — the
195
+ * canonical IANA list, which holds no spelling of UTC at all — and refuses
196
+ * anything absent from it. A collection naming no zone therefore cannot be
197
+ * answered with a substitute, and above all not with the reader's own: a
198
+ * browser on a UTC machine resolves that to "UTC", and every event written
199
+ * from one was refused for a zone nobody had chosen.
200
+ */
201
+ describe("the zone a collection is read on", () => {
202
+ const RESOLVABLE = new Set(Intl.supportedValuesOf("timeZone"));
203
+
204
+ it("is one the server will take, or none at all", async () => {
205
+ await mount(
206
+ "2026-06-10",
207
+ [],
208
+ [],
209
+ [
210
+ {
211
+ ...calendar(WORK, "Work"),
212
+ timezone: "",
213
+ } as RemitImapCalendarResponse,
214
+ ],
215
+ );
216
+ const zone = data?.timeZoneByCalendarId[WORK];
217
+ assert.ok(
218
+ zone === "" || RESOLVABLE.has(zone ?? ""),
219
+ `"${zone}" is neither absent nor a zone the server accepts as a TZID`,
220
+ );
221
+ });
222
+
223
+ it("is the collection's own where it has one", async () => {
224
+ await mount("2026-06-10", []);
225
+ assert.equal(data?.timeZoneByCalendarId[WORK], "Europe/Amsterdam");
226
+ });
227
+ });
228
+
229
+ describe("what the grid is handed", () => {
230
+ it("draws the occurrences the server expanded", async () => {
231
+ await mount(
232
+ "2026-06-10",
233
+ [],
234
+ [
235
+ instance({ calendarObjectId: "obj-1" }),
236
+ instance({
237
+ calendarObjectId: "obj-2",
238
+ recurrenceId: "2026-06-11T07:15:00Z",
239
+ summary: "Standup",
240
+ hasRecurrence: true,
241
+ }),
242
+ ],
243
+ );
244
+ assert.deepEqual(
245
+ data?.events.map((event) => event.id),
246
+ ["obj-1", "obj-2#2026-06-11T07:15:00Z"],
247
+ );
248
+ assert.equal(data?.events[1].title, "Standup");
249
+ });
250
+
251
+ it("leaves a cancelled occurrence off rather than drawing it as confirmed", async () => {
252
+ await mount(
253
+ "2026-06-10",
254
+ [],
255
+ [
256
+ instance({ calendarObjectId: "obj-1" }),
257
+ instance({ calendarObjectId: "obj-3", status: "Cancelled" }),
258
+ ],
259
+ );
260
+ assert.deepEqual(
261
+ data?.events.map((event) => event.id),
262
+ ["obj-1"],
263
+ );
264
+ });
265
+ });
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Writing an event, and what the calendar does with the answer.
3
+ *
4
+ * Three outcomes have to be distinguishable at the call site, which is why they
5
+ * are returned rather than thrown: the write landed, somebody else had already
6
+ * written it, or the server refused it. The one that must never be silent is
7
+ * the middle one — a 412 says the version on screen is stale, and applying the
8
+ * edit anyway would discard a change nobody would ever see go.
9
+ *
10
+ * A lapsed session is not one of the three. It escalates to the page that signs
11
+ * the reader back in, so the caches here are the real ones from
12
+ * `lib/query-error-handler.ts` rather than the harness's quiet default.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import type { RemitImapCalendarResponse } from "@remit/api-http-client/types.gen.ts";
18
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
19
+ import { act, createElement, Fragment } from "react";
20
+ import { FatalErrorOverlay } from "@/components/ui/FatalErrorOverlay";
21
+ import { useCalendarData } from "@/hooks/useCalendarData";
22
+ import { __resetFatalError } from "@/lib/fatal-error";
23
+ import {
24
+ handleMutationCacheError,
25
+ handleQueryCacheError,
26
+ } from "@/lib/query-error-handler";
27
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
28
+ import {
29
+ type HttpCall,
30
+ type HttpMock,
31
+ httpError,
32
+ mockFetch,
33
+ } from "../../test-support/http";
34
+ import {
35
+ type CalendarWriteOutcome,
36
+ type CalendarWrites,
37
+ useCalendarWrites,
38
+ } from "./useCalendarWrites";
39
+
40
+ const WORK = "11111111-1111-4111-8111-111111111111";
41
+ const OBJECT = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
42
+
43
+ let harness: DomHarness | undefined;
44
+ let http: HttpMock | undefined;
45
+ let writes: CalendarWrites | undefined;
46
+
47
+ afterEach(() => {
48
+ harness?.close();
49
+ harness = undefined;
50
+ http?.restore();
51
+ http = undefined;
52
+ writes = undefined;
53
+ __resetFatalError();
54
+ });
55
+
56
+ const calendars: RemitImapCalendarResponse[] = [
57
+ {
58
+ calendarId: WORK,
59
+ accountConfigId: "cfg-1",
60
+ urlSegment: "work",
61
+ displayName: "Work",
62
+ color: "Cal1",
63
+ componentSet: "VeventOnly",
64
+ source: "UserCreated",
65
+ timezone: "Europe/Amsterdam",
66
+ syncSequence: 1,
67
+ createdAt: 0,
68
+ updatedAt: 0,
69
+ } as RemitImapCalendarResponse,
70
+ ];
71
+
72
+ const escalatingClient = (): QueryClient =>
73
+ new QueryClient({
74
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
75
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
76
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
77
+ });
78
+
79
+ /** Reads the visible week and holds the writes, the way the routes do. */
80
+ function Probe() {
81
+ useCalendarData({ view: "week", date: "2026-06-10", calendarIds: [] });
82
+ writes = useCalendarWrites();
83
+ return null;
84
+ }
85
+
86
+ const mount = async (
87
+ respond: (call: HttpCall) => unknown,
88
+ options: { escalating?: boolean; calendarsRespond?: boolean } = {},
89
+ ) => {
90
+ const serveCalendars = options.calendarsRespond ?? true;
91
+ http = mockFetch((call) =>
92
+ serveCalendars && call.path.endsWith("/calendars") && call.method === "GET"
93
+ ? { items: calendars }
94
+ : respond(call),
95
+ );
96
+ harness = createDomHarness(
97
+ options.escalating ? { queryClient: escalatingClient() } : {},
98
+ );
99
+ harness.renderApp(
100
+ createElement(
101
+ Fragment,
102
+ null,
103
+ createElement(FatalErrorOverlay),
104
+ createElement(Probe),
105
+ ),
106
+ );
107
+ await settle();
108
+ };
109
+
110
+ const settle = async () => {
111
+ await harness?.flush();
112
+ await harness?.wait(20);
113
+ await harness?.flush();
114
+ };
115
+
116
+ /** The writes the probe is holding, driven inside `act` the way a click is. */
117
+ const write = async (
118
+ run: (writes: CalendarWrites) => Promise<CalendarWriteOutcome>,
119
+ ): Promise<CalendarWriteOutcome> => {
120
+ const held = writes;
121
+ if (!held) throw new Error("the writes are not mounted");
122
+ let outcome: CalendarWriteOutcome | undefined;
123
+ await act(async () => {
124
+ outcome = await run(held);
125
+ });
126
+ await settle();
127
+ if (!outcome) throw new Error("the write returned nothing");
128
+ return outcome;
129
+ };
130
+
131
+ const listings = (): number =>
132
+ (http?.to("/calendar-events") ?? []).filter(
133
+ (call) =>
134
+ call.method === "GET" &&
135
+ call.url.includes(`from=${encodeURIComponent("2026-06-08")}`),
136
+ ).length;
137
+
138
+ const scopedWrite = {
139
+ calendarObjectId: OBJECT,
140
+ calendarId: WORK,
141
+ recurrenceId: "2026-06-11T07:15:00Z",
142
+ scope: "this" as const,
143
+ etag: "etag-1",
144
+ };
145
+
146
+ describe("creating an event", () => {
147
+ it("posts it and reads the week back, so the grid shows it", async () => {
148
+ await mount(() => ({ items: [] }));
149
+ const before = listings();
150
+
151
+ const outcome = await write((writes) =>
152
+ writes.createEvent({
153
+ calendarId: WORK,
154
+ summary: "Roadmap review",
155
+ start: "2026-06-10T10:00:00+02:00",
156
+ end: "2026-06-10T11:30:00+02:00",
157
+ }),
158
+ );
159
+
160
+ assert.deepEqual(outcome, { kind: "written" });
161
+ const posted = (http?.to("/calendar-events") ?? []).filter(
162
+ (call) => call.method === "POST",
163
+ );
164
+ assert.equal(posted.length, 1);
165
+ assert.equal(posted[0].body?.summary, "Roadmap review");
166
+ assert.ok(
167
+ listings() > before,
168
+ "the window a write changed has to be read again",
169
+ );
170
+ });
171
+ });
172
+
173
+ describe("editing one occurrence of a series", () => {
174
+ it("names the occurrence, the scope, and the version it was built on", async () => {
175
+ await mount(() => ({ items: [] }));
176
+ await write((writes) =>
177
+ writes.updateEvent(scopedWrite, { summary: "Standup" }),
178
+ );
179
+
180
+ const patched = (http?.calls ?? []).find((call) => call.method === "PATCH");
181
+ assert.ok(patched, "the edit was sent");
182
+ assert.equal(
183
+ patched.headers["if-match"],
184
+ "etag-1",
185
+ "an unconditional write is one that can discard somebody else's edit",
186
+ );
187
+ assert.ok(patched.url.includes("scope=This"), patched.url);
188
+ assert.ok(
189
+ patched.url.includes(encodeURIComponent("2026-06-11T07:15:00Z")),
190
+ patched.url,
191
+ );
192
+ });
193
+
194
+ it("makes the delete conditional on the same version", async () => {
195
+ await mount(() => ({ items: [] }));
196
+ await write((writes) => writes.deleteEvent(scopedWrite));
197
+ const deleted = (http?.calls ?? []).find(
198
+ (call) => call.method === "DELETE",
199
+ );
200
+ assert.equal(
201
+ deleted?.headers["if-match"],
202
+ "etag-1",
203
+ "an unconditional delete removes whatever is there now, not what was read",
204
+ );
205
+ });
206
+ });
207
+
208
+ describe("a version somebody else has already replaced", () => {
209
+ it("comes back as a conflict rather than as a write that won", async () => {
210
+ await mount((call) =>
211
+ call.method === "PATCH" ? httpError(412, "etag mismatch") : { items: [] },
212
+ );
213
+ const outcome = await write((writes) =>
214
+ writes.updateEvent(scopedWrite, { summary: "Standup" }),
215
+ );
216
+ assert.deepEqual(outcome, { kind: "conflict" });
217
+ });
218
+
219
+ it("refuses the delete the same way", async () => {
220
+ await mount((call) =>
221
+ call.method === "DELETE"
222
+ ? httpError(412, "etag mismatch")
223
+ : { items: [] },
224
+ );
225
+ const outcome = await write((writes) => writes.deleteEvent(scopedWrite));
226
+ assert.deepEqual(outcome, { kind: "conflict" });
227
+ });
228
+ });
229
+
230
+ describe("a refusal the server states", () => {
231
+ it("comes back with what it said, for the form to show", async () => {
232
+ await mount((call) =>
233
+ call.method === "POST"
234
+ ? httpError(400, "start must be before end")
235
+ : { items: [] },
236
+ );
237
+ const outcome = await write((writes) =>
238
+ writes.createEvent({
239
+ calendarId: WORK,
240
+ summary: "Backwards",
241
+ start: "2026-06-10T11:00:00+02:00",
242
+ end: "2026-06-10T10:00:00+02:00",
243
+ }),
244
+ );
245
+ assert.deepEqual(outcome, {
246
+ kind: "refused",
247
+ message: "start must be before end",
248
+ });
249
+ });
250
+ });
251
+
252
+ describe("a session that has lapsed", () => {
253
+ /**
254
+ * The read is the dangerous one. A calendar that answers a 401 with no
255
+ * events draws a clear week, and a clear week is what a reader plans around
256
+ * — they never learn their session ended, and neither does the app.
257
+ */
258
+ it("never lets the event listing come back as an empty week", async () => {
259
+ await mount(
260
+ (call) =>
261
+ call.method === "GET"
262
+ ? httpError(401, "session expired")
263
+ : { items: [] },
264
+ { escalating: true, calendarsRespond: false },
265
+ );
266
+ assert.ok(
267
+ harness?.query('[data-testid="fatal-error-overlay"]'),
268
+ "a refused read must not be drawn as a week with nothing in it",
269
+ );
270
+ });
271
+
272
+ it("escalates a refused calendar list rather than showing no calendars", async () => {
273
+ await mount(
274
+ (call) =>
275
+ call.path.endsWith("/calendars")
276
+ ? httpError(401, "session expired")
277
+ : { items: [] },
278
+ { escalating: true, calendarsRespond: false },
279
+ );
280
+ assert.ok(harness?.query('[data-testid="fatal-error-overlay"]'));
281
+ });
282
+
283
+ it("takes the reader to the page that signs them back in", async () => {
284
+ await mount(
285
+ (call) =>
286
+ call.method === "POST"
287
+ ? httpError(401, "session expired")
288
+ : { items: [] },
289
+ { escalating: true },
290
+ );
291
+ await write((writes) =>
292
+ writes.createEvent({
293
+ calendarId: WORK,
294
+ summary: "Roadmap review",
295
+ start: "2026-06-10T10:00:00+02:00",
296
+ end: "2026-06-10T11:30:00+02:00",
297
+ }),
298
+ );
299
+
300
+ assert.ok(
301
+ harness?.query('[data-testid="fatal-error-overlay"]'),
302
+ "no banner on a calendar signs anybody back in",
303
+ );
304
+ });
305
+ });