@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,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
+ });
@@ -0,0 +1,241 @@
1
+ /**
2
+ * What the form sends, and what it refuses to send.
3
+ *
4
+ * The two rules worth pinning: a refusal names the field and the fix rather
5
+ * than greying a button, and a patch carries only what changed. Absence means
6
+ * untouched on this API, so a patch built from every field would write back the
7
+ * empty location and notes a listing never carried and erase them without
8
+ * anybody asking for that.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "node:test";
12
+ import type { EventDraft } from "@remit/ui";
13
+ import {
14
+ type CreateInput,
15
+ createInputFromDraft,
16
+ emptyDraft,
17
+ patchFromDrafts,
18
+ type UpdatePatch,
19
+ } from "./draft";
20
+
21
+ const CALENDAR = "cal_work";
22
+ const AMSTERDAM = "Europe/Amsterdam";
23
+ const NEW_YORK = "America/New_York";
24
+
25
+ const draft = (over: Partial<EventDraft> = {}): EventDraft => ({
26
+ ...emptyDraft("2026-06-10", CALENDAR),
27
+ title: "Roadmap review",
28
+ ...over,
29
+ });
30
+
31
+ const refusal = (result: CreateInput | UpdatePatch): string => {
32
+ assert.ok(
33
+ !result.ok,
34
+ "the draft was accepted where it should have been refused",
35
+ );
36
+ return result.problem;
37
+ };
38
+
39
+ describe("creating an event", () => {
40
+ it("sends the calendar, the summary and a start with an offset", () => {
41
+ const built = createInputFromDraft(draft(), AMSTERDAM);
42
+ assert.ok(built.ok);
43
+ assert.equal(built.input.calendarId, CALENDAR);
44
+ assert.equal(built.input.summary, "Roadmap review");
45
+ assert.equal(built.input.timeZone, AMSTERDAM);
46
+ assert.equal(built.input.start, "2026-06-10T09:00:00+02:00");
47
+ assert.equal(built.input.end, "2026-06-10T10:00:00+02:00");
48
+ });
49
+
50
+ it("makes an all-day event a civil date ending on the next one", () => {
51
+ const built = createInputFromDraft(
52
+ draft({ allDay: true, startTime: "", endTime: "" }),
53
+ AMSTERDAM,
54
+ );
55
+ assert.ok(built.ok);
56
+ assert.equal(built.input.start, "2026-06-10");
57
+ assert.equal(built.input.end, "2026-06-11");
58
+ });
59
+
60
+ it("turns a picked repeat sentence into the rule it means", () => {
61
+ const built = createInputFromDraft(
62
+ draft({ repeat: "Every weekday, 09:00" }),
63
+ AMSTERDAM,
64
+ );
65
+ assert.ok(built.ok);
66
+ assert.equal(
67
+ built.input.recurrenceRule,
68
+ "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
69
+ );
70
+ });
71
+ });
72
+
73
+ describe("a refusal", () => {
74
+ it("names the missing title", () => {
75
+ assert.match(
76
+ refusal(createInputFromDraft(draft({ title: " " }), AMSTERDAM)),
77
+ /title/i,
78
+ );
79
+ });
80
+
81
+ it("names a span that runs backwards, and what to do about it", () => {
82
+ assert.match(
83
+ refusal(
84
+ createInputFromDraft(
85
+ draft({ startTime: "11:00", endTime: "10:00" }),
86
+ AMSTERDAM,
87
+ ),
88
+ ),
89
+ /end time is not after the start time/i,
90
+ );
91
+ });
92
+
93
+ it("names a missing time rather than saving a day-long event nobody asked for", () => {
94
+ assert.match(
95
+ refusal(createInputFromDraft(draft({ endTime: "" }), AMSTERDAM)),
96
+ /start and an end time/i,
97
+ );
98
+ });
99
+
100
+ it("refuses a repeat rule it cannot store, and says to pick another", () => {
101
+ assert.match(
102
+ refusal(
103
+ createInputFromDraft(
104
+ draft({ repeat: "Every other Tuesday" }),
105
+ AMSTERDAM,
106
+ ),
107
+ ),
108
+ /repeat rule/i,
109
+ );
110
+ });
111
+ });
112
+
113
+ describe("editing an event", () => {
114
+ it("sends only the field that changed", () => {
115
+ const before = draft({ location: "Room Zuid", notes: "Bring numbers." });
116
+ const patch = patchFromDrafts(
117
+ before,
118
+ { ...before, title: "Roadmap" },
119
+ AMSTERDAM,
120
+ );
121
+ assert.ok(patch.ok);
122
+ assert.deepEqual(patch.patch, { summary: "Roadmap" });
123
+ });
124
+
125
+ it("sends both ends and the all-day flag when the event moves", () => {
126
+ const before = draft();
127
+ const patch = patchFromDrafts(
128
+ before,
129
+ { ...before, startTime: "08:00" },
130
+ AMSTERDAM,
131
+ );
132
+ assert.ok(patch.ok);
133
+ assert.equal(patch.patch.allDay, false);
134
+ assert.equal(patch.patch.start, "2026-06-10T08:00:00+02:00");
135
+ assert.equal(patch.patch.end, "2026-06-10T10:00:00+02:00");
136
+ });
137
+
138
+ it("leaves an unreadable rule alone rather than refusing every other edit", () => {
139
+ const before = draft({ repeat: "Repeats" });
140
+ const patch = patchFromDrafts(
141
+ before,
142
+ { ...before, title: "Standup" },
143
+ AMSTERDAM,
144
+ );
145
+ assert.ok(patch.ok);
146
+ assert.deepEqual(patch.patch, { summary: "Standup" });
147
+ });
148
+
149
+ it("drops the rule when the reader turns the repeat off", () => {
150
+ const before = draft({ repeat: "Every weekday, 09:00" });
151
+ const patch = patchFromDrafts(before, { ...before, repeat: "" }, AMSTERDAM);
152
+ assert.ok(patch.ok);
153
+ assert.equal(patch.patch.recurrenceRule, "");
154
+ });
155
+
156
+ it("changes nothing when nothing was touched", () => {
157
+ const before = draft();
158
+ const patch = patchFromDrafts(before, { ...before }, AMSTERDAM);
159
+ assert.ok(patch.ok);
160
+ assert.deepEqual(patch.patch, {});
161
+ });
162
+ });
163
+
164
+ /**
165
+ * The clock the form shows is the calendar's, not the device's.
166
+ *
167
+ * An occurrence is returned in the collection's zone and the form displays
168
+ * those digits, so rebuilding them with whatever offset the reader's laptop
169
+ * happens to be on moves the event by the difference — six hours for the same
170
+ * meeting looked at from New York, with nothing on screen saying it moved.
171
+ *
172
+ * Both assertions are absolute rather than "whatever this runner is on": the
173
+ * bug is precisely that the runner's zone leaked in, so a test that reads it is
174
+ * a test that cannot see the bug.
175
+ */
176
+ describe("an event anchored somewhere other than the device", () => {
177
+ it("keeps a new event on the calendar's clock", () => {
178
+ const built = createInputFromDraft(draft(), NEW_YORK);
179
+ assert.ok(built.ok);
180
+ assert.equal(built.input.start, "2026-06-10T09:00:00-04:00");
181
+ assert.equal(built.input.timeZone, NEW_YORK);
182
+ });
183
+
184
+ it("moves an event to another day without moving it to another hour", () => {
185
+ const before = draft();
186
+ const patch = patchFromDrafts(
187
+ before,
188
+ { ...before, date: "2026-06-17" },
189
+ AMSTERDAM,
190
+ );
191
+ assert.ok(patch.ok);
192
+ assert.equal(patch.patch.start, "2026-06-17T09:00:00+02:00");
193
+ assert.equal(patch.patch.end, "2026-06-17T10:00:00+02:00");
194
+ assert.equal(
195
+ patch.patch.timeZone,
196
+ AMSTERDAM,
197
+ "the offset pins the instant; the zone is what survives a DST change",
198
+ );
199
+ });
200
+
201
+ it("reads the zone's own winter offset rather than one it saw in June", () => {
202
+ const built = createInputFromDraft(
203
+ draft({ date: "2026-01-14" }),
204
+ AMSTERDAM,
205
+ );
206
+ assert.ok(built.ok);
207
+ assert.equal(built.input.start, "2026-01-14T09:00:00+01:00");
208
+ });
209
+
210
+ it("falls back to the device rather than refusing a zone nothing can resolve", () => {
211
+ const built = createInputFromDraft(draft(), "Mars/Olympus_Mons");
212
+ assert.ok(built.ok);
213
+ assert.match(built.input.start, /^2026-06-10T09:00:00[+-]\d{2}:\d{2}$/);
214
+ });
215
+
216
+ /**
217
+ * The canonical IANA list holds no spelling of UTC, so there is nothing to
218
+ * send for a collection that names no zone. Absent is what the API asks for
219
+ * and the only thing it takes; the device's zone in its place was refused
220
+ * outright on a UTC runner.
221
+ */
222
+ it("sends no zone at all where the collection names none", () => {
223
+ const built = createInputFromDraft(draft(), "");
224
+ assert.ok(built.ok);
225
+ assert.equal(built.input.timeZone, undefined);
226
+ assert.equal(built.input.start, "2026-06-10T09:00:00+00:00");
227
+ assert.equal(built.input.end, "2026-06-10T10:00:00+00:00");
228
+ });
229
+
230
+ it("leaves the zone out of an edit on that collection too", () => {
231
+ const before = draft();
232
+ const patch = patchFromDrafts(
233
+ before,
234
+ { ...before, date: "2026-06-17" },
235
+ "",
236
+ );
237
+ assert.ok(patch.ok);
238
+ assert.equal(patch.patch.timeZone, undefined);
239
+ assert.equal(patch.patch.start, "2026-06-17T09:00:00+00:00");
240
+ });
241
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The editor's draft, and what the API makes of it.
3
+ *
4
+ * A refusal is returned rather than thrown, and it says which field is wrong and
5
+ * what to do about it — a form that only greys its save button leaves the reader
6
+ * hunting for the reason.
7
+ *
8
+ * An edit sends the fields that changed and nothing else. Absence means
9
+ * untouched on this API, so patching every field would write back the empty
10
+ * `location` and `description` a listing never carried and quietly erase them.
11
+ */
12
+ import type {
13
+ RemitImapCreateCalendarEventInput,
14
+ RemitImapUpdateCalendarEventInput,
15
+ } from "@remit/api-http-client/types.gen.ts";
16
+ import type { CalendarEventData, EventDraft } from "@remit/ui";
17
+ import { rruleFromText } from "./recurrence-rule";
18
+ import { addDays, isoAtInZone } from "./window";
19
+
20
+ export type DraftRefusal = { ok: false; problem: string };
21
+
22
+ export type CreateInput =
23
+ | { ok: true; input: RemitImapCreateCalendarEventInput }
24
+ | DraftRefusal;
25
+
26
+ export type UpdatePatch =
27
+ | { ok: true; patch: RemitImapUpdateCalendarEventInput }
28
+ | DraftRefusal;
29
+
30
+ /** A blank event, an hour long, at the start of a working day. */
31
+ export function emptyDraft(date: string, calendarId: string): EventDraft {
32
+ return {
33
+ title: "",
34
+ date,
35
+ startTime: "09:00",
36
+ endTime: "10:00",
37
+ allDay: false,
38
+ calendarId,
39
+ location: "",
40
+ guests: "",
41
+ notes: "",
42
+ repeat: "",
43
+ };
44
+ }
45
+
46
+ /**
47
+ * The draft an event opens into, so an edit starts from what is stored rather
48
+ * than from what a grid chip had room to draw. The occurrence listing carries
49
+ * neither the location nor the notes, so those come from the resource.
50
+ */
51
+ export function draftFromEvent(
52
+ event: CalendarEventData,
53
+ stored: { repeat: string; location: string; notes: string },
54
+ ): EventDraft {
55
+ return {
56
+ title: event.title,
57
+ date: event.start.slice(0, 10),
58
+ startTime: event.allDay ? "" : event.start.slice(11, 16),
59
+ endTime: event.allDay ? "" : event.end.slice(11, 16),
60
+ allDay: event.allDay,
61
+ calendarId: event.calendarId,
62
+ location: stored.location,
63
+ guests: "",
64
+ notes: stored.notes,
65
+ repeat: stored.repeat,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * The draft's wall clock, pinned to an instant in the calendar's own zone.
71
+ *
72
+ * The zone is the calendar's rather than the device's because that is the clock
73
+ * the times were read on: the listing returns an occurrence in the collection's
74
+ * zone, and the form shows those digits. Stamping the device's offset back onto
75
+ * them moves the event by the difference between the two — silently, and only
76
+ * for whoever is travelling.
77
+ */
78
+ function timesFor(
79
+ draft: EventDraft,
80
+ timeZone: string,
81
+ ): { start: string; end: string } {
82
+ if (draft.allDay) return { start: draft.date, end: addDays(draft.date, 1) };
83
+ return {
84
+ start: isoAtInZone(draft.date, draft.startTime, timeZone),
85
+ end: isoAtInZone(draft.date, draft.endTime, timeZone),
86
+ };
87
+ }
88
+
89
+ /**
90
+ * An unreadable rule is only a refusal where the reader chose it. An event that
91
+ * arrived carrying a rule this client cannot put into words is still editable —
92
+ * the rule is left where it is and every other field saves.
93
+ */
94
+ function refuse(draft: EventDraft, checkRepeat: boolean): string {
95
+ if (draft.title.trim() === "")
96
+ return "Give the event a title before saving it.";
97
+ if (draft.date === "") return "Pick the day the event is on.";
98
+ if (draft.calendarId === "") return "Pick the calendar to save the event in.";
99
+ if (!draft.allDay && (draft.startTime === "" || draft.endTime === ""))
100
+ return "Set a start and an end time, or mark the event all day.";
101
+ if (!draft.allDay && draft.endTime <= draft.startTime)
102
+ return "The end time is not after the start time. Move one of them.";
103
+ if (checkRepeat && rruleFromText(draft.repeat) === undefined)
104
+ return "This calendar can't store that repeat rule. Pick one of the offered rules, or turn the repeat off.";
105
+ return "";
106
+ }
107
+
108
+ export function createInputFromDraft(
109
+ draft: EventDraft,
110
+ timeZone: string,
111
+ ): CreateInput {
112
+ const problem = refuse(draft, true);
113
+ if (problem !== "") return { ok: false, problem };
114
+ const { start, end } = timesFor(draft, timeZone);
115
+ return {
116
+ ok: true,
117
+ input: {
118
+ calendarId: draft.calendarId,
119
+ summary: draft.title.trim(),
120
+ description: draft.notes,
121
+ location: draft.location,
122
+ start,
123
+ end,
124
+ allDay: draft.allDay,
125
+ // Absent anchors the event in UTC, which is exactly what a collection
126
+ // naming no zone means. There is no spelling of UTC the server takes
127
+ // as a TZID, so sending one instead is not an option.
128
+ ...(timeZone === "" ? {} : { timeZone }),
129
+ recurrenceRule: rruleFromText(draft.repeat) ?? "",
130
+ },
131
+ };
132
+ }
133
+
134
+ export function patchFromDrafts(
135
+ before: EventDraft,
136
+ after: EventDraft,
137
+ timeZone: string,
138
+ ): UpdatePatch {
139
+ const repeatChanged = after.repeat !== before.repeat;
140
+ const problem = refuse(after, repeatChanged);
141
+ if (problem !== "") return { ok: false, problem };
142
+
143
+ const patch: RemitImapUpdateCalendarEventInput = {};
144
+ if (after.title.trim() !== before.title.trim())
145
+ patch.summary = after.title.trim();
146
+ if (after.location !== before.location) patch.location = after.location;
147
+ if (after.notes !== before.notes) patch.description = after.notes;
148
+
149
+ const moved =
150
+ after.date !== before.date ||
151
+ after.startTime !== before.startTime ||
152
+ after.endTime !== before.endTime ||
153
+ after.allDay !== before.allDay;
154
+ if (moved) {
155
+ const { start, end } = timesFor(after, timeZone);
156
+ patch.start = start;
157
+ patch.end = end;
158
+ patch.allDay = after.allDay;
159
+ // The offset pins the instant; the zone is what a series needs to keep
160
+ // meeting at nine when the clocks go back. A collection with none of its
161
+ // own says so by sending nothing.
162
+ if (timeZone !== "") patch.timeZone = timeZone;
163
+ }
164
+
165
+ if (repeatChanged) patch.recurrenceRule = rruleFromText(after.repeat) ?? "";
166
+
167
+ return { ok: true, patch };
168
+ }