@remit/web-client 0.0.298 → 0.0.300

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarEventPane.stories.tsx +16 -0
  3. package/src/components/calendar/OpenCalendarEvent.tsx +23 -6
  4. package/src/components/calendar/WriteCalendarEvent.tsx +8 -4
  5. package/src/components/calendar/calendar-write-flow.render.test.ts +39 -22
  6. package/src/components/compose/ComposeForm.tsx +116 -0
  7. package/src/components/compose/attachment-only-source.render.test.ts +1 -0
  8. package/src/components/compose/compose-send-stops-autosave.render.test.ts +1 -0
  9. package/src/components/compose/compose-starts-a-second-message.render.test.ts +1 -0
  10. package/src/components/compose/forward-autosave-is-not-fatal.render.test.ts +1 -0
  11. package/src/components/compose/quote-leaves-with-the-send.render.test.ts +1 -0
  12. package/src/components/compose/reopened-draft-writes-nothing.render.test.ts +1 -0
  13. package/src/components/compose/reply-all-on-resumed-draft.render.test.ts +1 -0
  14. package/src/components/mail/MailSidebarAdapter.tsx +2 -0
  15. package/src/hooks/calendar/calendar-collections.render.test.ts +23 -1
  16. package/src/hooks/calendar/draft.test.ts +179 -30
  17. package/src/hooks/calendar/draft.ts +59 -33
  18. package/src/hooks/calendar/index.ts +5 -1
  19. package/src/hooks/calendar/instance.test.ts +70 -0
  20. package/src/hooks/calendar/instance.ts +28 -15
  21. package/src/hooks/calendar/recurrence-rule.test.ts +43 -1
  22. package/src/hooks/calendar/recurrence-rule.ts +44 -0
  23. package/src/hooks/calendar/resource.test.ts +44 -1
  24. package/src/hooks/calendar/resource.ts +30 -0
  25. package/src/hooks/calendar/selection.ts +7 -4
  26. package/src/hooks/calendar/useDraftClashes.ts +3 -1
  27. package/src/hooks/calendar/window.test.ts +28 -0
  28. package/src/hooks/calendar/window.ts +20 -0
  29. package/src/hooks/useComposeAttachments.ts +103 -0
  30. package/src/hooks/useIntelligenceCalendar.ts +2 -0
  31. package/src/lib/calendar-format.ts +7 -2
  32. package/src/lib/compose-attachment-controller.test.ts +242 -0
  33. package/src/lib/compose-attachment-controller.ts +415 -0
  34. package/src/lib/folder-failure.test.ts +29 -0
  35. package/src/lib/folder-failure.ts +10 -0
  36. package/src/routes/settings/folders.tsx +2 -0
  37. package/src/test-support/fixtures.ts +1 -0
@@ -27,6 +27,8 @@ import {
27
27
  const CALENDAR = "cal_work";
28
28
  const AMSTERDAM = "Europe/Amsterdam";
29
29
  const NEW_YORK = "America/New_York";
30
+ const TOKYO = "Asia/Tokyo";
31
+ const IN_AMSTERDAM = { clock: AMSTERDAM, anchor: AMSTERDAM };
30
32
 
31
33
  const draft = (over: Partial<EventDraft> = {}): EventDraft => ({
32
34
  ...emptyDraft("2026-06-10", CALENDAR),
@@ -68,7 +70,7 @@ const refusal = (result: CreateInput | UpdatePatch): string => {
68
70
 
69
71
  describe("creating an event", () => {
70
72
  it("sends the calendar, the summary and a start with an offset", () => {
71
- const built = createInputFromDraft(draft(), AMSTERDAM);
73
+ const built = createInputFromDraft(draft(), IN_AMSTERDAM);
72
74
  assert.ok(built.ok);
73
75
  assert.equal(built.input.calendarId, CALENDAR);
74
76
  assert.equal(built.input.summary, "Roadmap review");
@@ -80,7 +82,7 @@ describe("creating an event", () => {
80
82
  it("makes an all-day event a civil date ending on the next one", () => {
81
83
  const built = createInputFromDraft(
82
84
  draft({ allDay: true, startTime: "", endTime: "" }),
83
- AMSTERDAM,
85
+ IN_AMSTERDAM,
84
86
  );
85
87
  assert.ok(built.ok);
86
88
  assert.equal(built.input.start, "2026-06-10");
@@ -90,7 +92,7 @@ describe("creating an event", () => {
90
92
  it("ends a night that runs past midnight on the next day", () => {
91
93
  const built = createInputFromDraft(
92
94
  draft({ startTime: "22:00", endDate: "2026-06-11", endTime: "01:00" }),
93
- AMSTERDAM,
95
+ IN_AMSTERDAM,
94
96
  );
95
97
  assert.ok(built.ok);
96
98
  assert.equal(built.input.start, "2026-06-10T22:00:00+02:00");
@@ -105,7 +107,7 @@ describe("creating an event", () => {
105
107
  endTime: "",
106
108
  endDate: "2026-06-12",
107
109
  }),
108
- AMSTERDAM,
110
+ IN_AMSTERDAM,
109
111
  );
110
112
  assert.ok(built.ok);
111
113
  assert.equal(built.input.start, "2026-06-10");
@@ -115,7 +117,7 @@ describe("creating an event", () => {
115
117
  it("turns a picked repeat sentence into the rule it means", () => {
116
118
  const built = createInputFromDraft(
117
119
  draft({ repeat: "Every weekday, 09:00" }),
118
- AMSTERDAM,
120
+ IN_AMSTERDAM,
119
121
  );
120
122
  assert.ok(built.ok);
121
123
  assert.equal(
@@ -128,7 +130,7 @@ describe("creating an event", () => {
128
130
  describe("a refusal", () => {
129
131
  it("names the missing title", () => {
130
132
  assert.match(
131
- refusal(createInputFromDraft(draft({ title: " " }), AMSTERDAM)),
133
+ refusal(createInputFromDraft(draft({ title: " " }), IN_AMSTERDAM)),
132
134
  /title/i,
133
135
  );
134
136
  });
@@ -138,7 +140,7 @@ describe("a refusal", () => {
138
140
  refusal(
139
141
  createInputFromDraft(
140
142
  draft({ startTime: "11:00", endTime: "10:00" }),
141
- AMSTERDAM,
143
+ IN_AMSTERDAM,
142
144
  ),
143
145
  ),
144
146
  /end is not after the start/i,
@@ -155,7 +157,7 @@ describe("a refusal", () => {
155
157
  endTime: "",
156
158
  endDate: "2026-06-09",
157
159
  }),
158
- AMSTERDAM,
160
+ IN_AMSTERDAM,
159
161
  ),
160
162
  ),
161
163
  /last day is before the first/i,
@@ -164,7 +166,7 @@ describe("a refusal", () => {
164
166
 
165
167
  it("names a missing time rather than saving a day-long event nobody asked for", () => {
166
168
  assert.match(
167
- refusal(createInputFromDraft(draft({ endTime: "" }), AMSTERDAM)),
169
+ refusal(createInputFromDraft(draft({ endTime: "" }), IN_AMSTERDAM)),
168
170
  /start and an end time/i,
169
171
  );
170
172
  });
@@ -174,7 +176,7 @@ describe("a refusal", () => {
174
176
  refusal(
175
177
  createInputFromDraft(
176
178
  draft({ repeat: "Every other Tuesday" }),
177
- AMSTERDAM,
179
+ IN_AMSTERDAM,
178
180
  ),
179
181
  ),
180
182
  /repeat rule/i,
@@ -188,7 +190,7 @@ describe("editing an event", () => {
188
190
  const patch = patchFromDrafts(
189
191
  before,
190
192
  { ...before, title: "Roadmap" },
191
- AMSTERDAM,
193
+ IN_AMSTERDAM,
192
194
  );
193
195
  assert.ok(patch.ok);
194
196
  assert.deepEqual(patch.patch, { summary: "Roadmap" });
@@ -199,7 +201,7 @@ describe("editing an event", () => {
199
201
  const patch = patchFromDrafts(
200
202
  before,
201
203
  { ...before, startTime: "08:00" },
202
- AMSTERDAM,
204
+ IN_AMSTERDAM,
203
205
  );
204
206
  assert.ok(patch.ok);
205
207
  assert.equal(patch.patch.allDay, false);
@@ -212,7 +214,7 @@ describe("editing an event", () => {
212
214
  const patch = patchFromDrafts(
213
215
  before,
214
216
  { ...before, title: "Standup" },
215
- AMSTERDAM,
217
+ IN_AMSTERDAM,
216
218
  );
217
219
  assert.ok(patch.ok);
218
220
  assert.deepEqual(patch.patch, { summary: "Standup" });
@@ -220,7 +222,11 @@ describe("editing an event", () => {
220
222
 
221
223
  it("drops the rule when the reader turns the repeat off", () => {
222
224
  const before = draft({ repeat: "Every weekday, 09:00" });
223
- const patch = patchFromDrafts(before, { ...before, repeat: "" }, AMSTERDAM);
225
+ const patch = patchFromDrafts(
226
+ before,
227
+ { ...before, repeat: "" },
228
+ IN_AMSTERDAM,
229
+ );
224
230
  assert.ok(patch.ok);
225
231
  assert.equal(patch.patch.recurrenceRule, "");
226
232
  });
@@ -237,7 +243,7 @@ describe("editing an event", () => {
237
243
  const patch = patchFromDrafts(
238
244
  before,
239
245
  { ...before, title: "Night shift" },
240
- AMSTERDAM,
246
+ IN_AMSTERDAM,
241
247
  );
242
248
  assert.ok(patch.ok);
243
249
  assert.deepEqual(patch.patch, { summary: "Night shift" });
@@ -253,7 +259,7 @@ describe("editing an event", () => {
253
259
  const patch = patchFromDrafts(
254
260
  before,
255
261
  { ...before, date: "2026-06-17", endDate: "2026-06-19" },
256
- AMSTERDAM,
262
+ IN_AMSTERDAM,
257
263
  );
258
264
  assert.ok(patch.ok);
259
265
  assert.equal(patch.patch.start, "2026-06-17");
@@ -262,30 +268,35 @@ describe("editing an event", () => {
262
268
 
263
269
  it("changes nothing when nothing was touched", () => {
264
270
  const before = draft();
265
- const patch = patchFromDrafts(before, { ...before }, AMSTERDAM);
271
+ const patch = patchFromDrafts(before, { ...before }, IN_AMSTERDAM);
266
272
  assert.ok(patch.ok);
267
273
  assert.deepEqual(patch.patch, {});
268
274
  });
269
275
  });
270
276
 
271
277
  /**
272
- * The clock the form shows is the calendar's, not the device's.
278
+ * The clock the form shows is the device's; the zone the event is anchored in
279
+ * is the calendar's.
273
280
  *
274
- * An occurrence is returned in the collection's zone and the form displays
275
- * those digits, so rebuilding them with whatever offset the reader's laptop
276
- * happens to be on moves the event by the difference — six hours for the same
277
- * meeting looked at from New York, with nothing on screen saying it moved.
281
+ * Every surface draws occurrences on the device's clock and the form shows
282
+ * those digits, so the offset has to be the device's — stamping the calendar's
283
+ * onto them moves the event by the difference, six hours for an Amsterdam
284
+ * calendar looked at from New York. The calendar's zone still travels as the
285
+ * TZID, which is what keeps a series at its hour across a DST change.
278
286
  *
279
287
  * Both assertions are absolute rather than "whatever this runner is on": the
280
288
  * bug is precisely that the runner's zone leaked in, so a test that reads it is
281
289
  * a test that cannot see the bug.
282
290
  */
283
291
  describe("an event anchored somewhere other than the device", () => {
284
- it("keeps a new event on the calendar's clock", () => {
285
- const built = createInputFromDraft(draft(), NEW_YORK);
292
+ it("pins a new event to the device's clock and anchors it in the calendar's zone", () => {
293
+ const built = createInputFromDraft(draft(), {
294
+ clock: NEW_YORK,
295
+ anchor: AMSTERDAM,
296
+ });
286
297
  assert.ok(built.ok);
287
298
  assert.equal(built.input.start, "2026-06-10T09:00:00-04:00");
288
- assert.equal(built.input.timeZone, NEW_YORK);
299
+ assert.equal(built.input.timeZone, AMSTERDAM);
289
300
  });
290
301
 
291
302
  it("moves an event to another day without moving it to another hour", () => {
@@ -293,7 +304,7 @@ describe("an event anchored somewhere other than the device", () => {
293
304
  const patch = patchFromDrafts(
294
305
  before,
295
306
  { ...before, date: "2026-06-17", endDate: "2026-06-17" },
296
- AMSTERDAM,
307
+ IN_AMSTERDAM,
297
308
  );
298
309
  assert.ok(patch.ok);
299
310
  assert.equal(patch.patch.start, "2026-06-17T09:00:00+02:00");
@@ -308,14 +319,17 @@ describe("an event anchored somewhere other than the device", () => {
308
319
  it("reads the zone's own winter offset rather than one it saw in June", () => {
309
320
  const built = createInputFromDraft(
310
321
  draft({ date: "2026-01-14", endDate: "2026-01-14" }),
311
- AMSTERDAM,
322
+ IN_AMSTERDAM,
312
323
  );
313
324
  assert.ok(built.ok);
314
325
  assert.equal(built.input.start, "2026-01-14T09:00:00+01:00");
315
326
  });
316
327
 
317
328
  it("falls back to the device rather than refusing a zone nothing can resolve", () => {
318
- const built = createInputFromDraft(draft(), "Mars/Olympus_Mons");
329
+ const built = createInputFromDraft(draft(), {
330
+ clock: "Mars/Olympus_Mons",
331
+ anchor: AMSTERDAM,
332
+ });
319
333
  assert.ok(built.ok);
320
334
  assert.match(built.input.start, /^2026-06-10T09:00:00[+-]\d{2}:\d{2}$/);
321
335
  });
@@ -327,7 +341,7 @@ describe("an event anchored somewhere other than the device", () => {
327
341
  * outright on a UTC runner.
328
342
  */
329
343
  it("sends no zone at all where the collection names none", () => {
330
- const built = createInputFromDraft(draft(), "");
344
+ const built = createInputFromDraft(draft(), { clock: "UTC", anchor: "" });
331
345
  assert.ok(built.ok);
332
346
  assert.equal(built.input.timeZone, undefined);
333
347
  assert.equal(built.input.start, "2026-06-10T09:00:00+00:00");
@@ -339,7 +353,7 @@ describe("an event anchored somewhere other than the device", () => {
339
353
  const patch = patchFromDrafts(
340
354
  before,
341
355
  { ...before, date: "2026-06-17", endDate: "2026-06-17" },
342
- "",
356
+ { clock: "UTC", anchor: "" },
343
357
  );
344
358
  assert.ok(patch.ok);
345
359
  assert.equal(patch.patch.timeZone, undefined);
@@ -386,3 +400,138 @@ describe("the form moving its own dates", () => {
386
400
  assert.equal(withAllDay(span, true).endDate, "2026-06-12");
387
401
  });
388
402
  });
403
+
404
+ /**
405
+ * A Tokyo afternoon read in New York runs 23:30 to 00:30. The end digits are
406
+ * before the start digits and the event is still an hour long.
407
+ */
408
+ describe("an event that crosses midnight on the device's clock", () => {
409
+ const FROM_NEW_YORK = { clock: NEW_YORK, anchor: TOKYO };
410
+ const crossing = draft({
411
+ startTime: "23:30",
412
+ endDate: "2026-06-11",
413
+ endTime: "00:30",
414
+ });
415
+
416
+ it("still takes an edit that leaves the times alone", () => {
417
+ const patch = patchFromDrafts(
418
+ crossing,
419
+ { ...crossing, title: "Roadmap" },
420
+ FROM_NEW_YORK,
421
+ );
422
+ assert.ok(patch.ok);
423
+ assert.deepEqual(patch.patch, { summary: "Roadmap" });
424
+ });
425
+
426
+ it("ends it the next morning when it moves day", () => {
427
+ const patch = patchFromDrafts(
428
+ crossing,
429
+ { ...crossing, date: "2026-06-17", endDate: "2026-06-18" },
430
+ FROM_NEW_YORK,
431
+ );
432
+ assert.ok(patch.ok);
433
+ assert.equal(patch.patch.start, "2026-06-17T23:30:00-04:00");
434
+ assert.equal(patch.patch.end, "2026-06-18T00:30:00-04:00");
435
+ assert.equal(patch.patch.timeZone, TOKYO);
436
+ });
437
+ });
438
+
439
+ describe("a repeat picked on the device's day", () => {
440
+ const FROM_NEW_YORK = { clock: NEW_YORK, anchor: TOKYO };
441
+ const sundayNight = draft({
442
+ date: "2026-06-14",
443
+ endDate: "2026-06-14",
444
+ startTime: "21:00",
445
+ endTime: "22:00",
446
+ });
447
+
448
+ it("is stored on the day the event's own zone starts it on", () => {
449
+ const built = createInputFromDraft(
450
+ { ...sundayNight, repeat: "Every week on Sunday, 21:00" },
451
+ FROM_NEW_YORK,
452
+ );
453
+ assert.ok(built.ok);
454
+ assert.equal(built.input.recurrenceRule, "FREQ=WEEKLY;BYDAY=MO");
455
+ });
456
+
457
+ it("keeps the day it was picked on where both clocks agree", () => {
458
+ const built = createInputFromDraft(
459
+ { ...sundayNight, repeat: "Every week on Sunday, 21:00" },
460
+ { clock: NEW_YORK, anchor: NEW_YORK },
461
+ );
462
+ assert.ok(built.ok);
463
+ assert.equal(built.input.recurrenceRule, "FREQ=WEEKLY;BYDAY=SU");
464
+ });
465
+ });
466
+
467
+ describe("an edit to the end alone", () => {
468
+ it("leaves the stored start as it is", () => {
469
+ const before = draft();
470
+ const patch = patchFromDrafts(
471
+ before,
472
+ { ...before, endTime: "11:00" },
473
+ IN_AMSTERDAM,
474
+ );
475
+ assert.ok(patch.ok);
476
+ assert.equal(patch.patch.start, undefined);
477
+ assert.equal(patch.patch.end, "2026-06-10T11:00:00+02:00");
478
+ });
479
+ });
480
+
481
+ /**
482
+ * An event stored as the UTC instant 2026-06-15T01:00Z: Sunday 21:00 in New
483
+ * York, Monday 03:00 in Amsterdam. The server expands its rule in UTC, where
484
+ * it is a Monday, whichever device picked the rule.
485
+ */
486
+ describe("a repeat added to an event stored in UTC", () => {
487
+ const inUtc = (clock: string) => ({ clock, anchor: "" });
488
+
489
+ it("is stored on the UTC day from a New York device", () => {
490
+ const before = draft({
491
+ date: "2026-06-14",
492
+ endDate: "2026-06-14",
493
+ startTime: "21:00",
494
+ endTime: "22:00",
495
+ });
496
+ const patch = patchFromDrafts(
497
+ before,
498
+ { ...before, repeat: "Every week on Sunday, 21:00" },
499
+ inUtc(NEW_YORK),
500
+ );
501
+ assert.ok(patch.ok);
502
+ assert.deepEqual(patch.patch, { recurrenceRule: "FREQ=WEEKLY;BYDAY=MO" });
503
+ });
504
+
505
+ it("is stored on the UTC day from an Amsterdam device", () => {
506
+ const before = draft({
507
+ date: "2026-06-15",
508
+ endDate: "2026-06-15",
509
+ startTime: "03:00",
510
+ endTime: "04:00",
511
+ });
512
+ const patch = patchFromDrafts(
513
+ before,
514
+ { ...before, repeat: "Every week on Monday, 03:00" },
515
+ inUtc(AMSTERDAM),
516
+ );
517
+ assert.ok(patch.ok);
518
+ assert.deepEqual(patch.patch, { recurrenceRule: "FREQ=WEEKLY;BYDAY=MO" });
519
+ });
520
+
521
+ it("stays anchored in UTC when its time moves", () => {
522
+ const before = draft({
523
+ date: "2026-06-14",
524
+ endDate: "2026-06-14",
525
+ startTime: "21:00",
526
+ endTime: "22:00",
527
+ });
528
+ const patch = patchFromDrafts(
529
+ before,
530
+ { ...before, startTime: "21:30" },
531
+ inUtc(NEW_YORK),
532
+ );
533
+ assert.ok(patch.ok);
534
+ assert.equal(patch.patch.start, "2026-06-14T21:30:00-04:00");
535
+ assert.equal(patch.patch.timeZone, undefined);
536
+ });
537
+ });
@@ -14,11 +14,23 @@ import type {
14
14
  RemitImapUpdateCalendarEventInput,
15
15
  } from "@remit/api-http-client/types.gen.ts";
16
16
  import { type CalendarEventData, type EventDraft, lastDayOf } from "@remit/ui";
17
- import { rruleFromText } from "./recurrence-rule";
18
- import { addDays, isoAtInZone } from "./window";
17
+ import { moveRule, rruleFromText } from "./recurrence-rule";
18
+ import { addDays, isoAtInZone, isoOnClock } from "./window";
19
19
 
20
20
  export type DraftRefusal = { ok: false; problem: string };
21
21
 
22
+ /**
23
+ * The two zones a write needs. `clock` is the one the form's digits were read
24
+ * on — the device's, which every surface draws on — and it decides the offset,
25
+ * so the instant saved is the instant shown. `anchor` is the zone the event is
26
+ * stored in, written as the TZID a series keeps its wall time in across a DST
27
+ * change (`anchorZoneFor`); `""` anchors it in UTC.
28
+ */
29
+ export interface DraftZones {
30
+ clock: string;
31
+ anchor: string;
32
+ }
33
+
22
34
  export type CreateInput =
23
35
  | { ok: true; input: RemitImapCreateCalendarEventInput }
24
36
  | DraftRefusal;
@@ -69,26 +81,41 @@ export function draftFromEvent(
69
81
  }
70
82
 
71
83
  /**
72
- * The draft's wall clock, pinned to an instant in the calendar's own zone.
73
- *
74
- * The zone is the calendar's rather than the device's because that is the clock
75
- * the times were read on: the listing returns an occurrence in the collection's
76
- * zone, and the form shows those digits. Stamping the device's offset back onto
77
- * them moves the event by the difference between the two — silently, and only
78
- * for whoever is travelling.
84
+ * The draft's wall clock, pinned to an instant on the clock it was read on.
85
+ * Stamping any other zone's offset onto those digits moves the event by the
86
+ * difference between the two — silently, and only for whoever is travelling.
79
87
  */
80
88
  function timesFor(
81
89
  draft: EventDraft,
82
- timeZone: string,
90
+ clock: string,
83
91
  ): { start: string; end: string } {
84
92
  if (draft.allDay)
85
93
  return { start: draft.date, end: addDays(draft.endDate, 1) };
86
94
  return {
87
- start: isoAtInZone(draft.date, draft.startTime, timeZone),
88
- end: isoAtInZone(draft.endDate, draft.endTime, timeZone),
95
+ start: isoAtInZone(draft.date, draft.startTime, clock),
96
+ end: isoAtInZone(draft.endDate, draft.endTime, clock),
89
97
  };
90
98
  }
91
99
 
100
+ /**
101
+ * The picked rule, stored on the day the event's own zone starts it on rather
102
+ * than on the day the device showed when it was picked.
103
+ */
104
+ function storedRule(
105
+ draft: EventDraft,
106
+ start: string,
107
+ zones: DraftZones,
108
+ ): string {
109
+ const rule = rruleFromText(draft.repeat) ?? "";
110
+ const anchorDate = draft.allDay
111
+ ? draft.date
112
+ : isoOnClock(start, zones.anchor === "" ? "UTC" : zones.anchor).slice(
113
+ 0,
114
+ 10,
115
+ );
116
+ return moveRule(rule, draft.date, anchorDate);
117
+ }
118
+
92
119
  /**
93
120
  * An unreadable rule is only a refusal where the reader chose it. An event that
94
121
  * arrived carrying a rule this client cannot put into words is still editable —
@@ -116,11 +143,11 @@ function refuse(draft: EventDraft, checkRepeat: boolean): string {
116
143
 
117
144
  export function createInputFromDraft(
118
145
  draft: EventDraft,
119
- timeZone: string,
146
+ zones: DraftZones,
120
147
  ): CreateInput {
121
148
  const problem = refuse(draft, true);
122
149
  if (problem !== "") return { ok: false, problem };
123
- const { start, end } = timesFor(draft, timeZone);
150
+ const { start, end } = timesFor(draft, zones.clock);
124
151
  return {
125
152
  ok: true,
126
153
  input: {
@@ -131,11 +158,8 @@ export function createInputFromDraft(
131
158
  start,
132
159
  end,
133
160
  allDay: draft.allDay,
134
- // Absent anchors the event in UTC, which is exactly what a collection
135
- // naming no zone means. There is no spelling of UTC the server takes
136
- // as a TZID, so sending one instead is not an option.
137
- ...(timeZone === "" ? {} : { timeZone }),
138
- recurrenceRule: rruleFromText(draft.repeat) ?? "",
161
+ ...(zones.anchor === "" ? {} : { timeZone: zones.anchor }),
162
+ recurrenceRule: storedRule(draft, start, zones),
139
163
  },
140
164
  };
141
165
  }
@@ -143,9 +167,18 @@ export function createInputFromDraft(
143
167
  export function patchFromDrafts(
144
168
  before: EventDraft,
145
169
  after: EventDraft,
146
- timeZone: string,
170
+ zones: DraftZones,
147
171
  ): UpdatePatch {
148
172
  const repeatChanged = after.repeat !== before.repeat;
173
+ const allDayChanged = after.allDay !== before.allDay;
174
+ const startMoved =
175
+ allDayChanged ||
176
+ after.date !== before.date ||
177
+ after.startTime !== before.startTime;
178
+ const endMoved =
179
+ startMoved ||
180
+ after.endDate !== before.endDate ||
181
+ after.endTime !== before.endTime;
149
182
  const problem = refuse(after, repeatChanged);
150
183
  if (problem !== "") return { ok: false, problem };
151
184
 
@@ -155,24 +188,17 @@ export function patchFromDrafts(
155
188
  if (after.location !== before.location) patch.location = after.location;
156
189
  if (after.notes !== before.notes) patch.description = after.notes;
157
190
 
158
- const moved =
159
- after.date !== before.date ||
160
- after.startTime !== before.startTime ||
161
- after.endDate !== before.endDate ||
162
- after.endTime !== before.endTime ||
163
- after.allDay !== before.allDay;
164
- if (moved) {
165
- const { start, end } = timesFor(after, timeZone);
166
- patch.start = start;
191
+ const { start, end } = timesFor(after, zones.clock);
192
+ if (startMoved) patch.start = start;
193
+ if (endMoved) {
167
194
  patch.end = end;
168
195
  patch.allDay = after.allDay;
169
196
  // The offset pins the instant; the zone is what a series needs to keep
170
- // meeting at nine when the clocks go back. A collection with none of its
171
- // own says so by sending nothing.
172
- if (timeZone !== "") patch.timeZone = timeZone;
197
+ // meeting at nine when the clocks go back.
198
+ if (zones.anchor !== "") patch.timeZone = zones.anchor;
173
199
  }
174
200
 
175
- if (repeatChanged) patch.recurrenceRule = rruleFromText(after.repeat) ?? "";
201
+ if (repeatChanged) patch.recurrenceRule = storedRule(after, start, zones);
176
202
 
177
203
  return { ok: true, patch };
178
204
  }
@@ -30,12 +30,14 @@ export {
30
30
  type CreateInput,
31
31
  createInputFromDraft,
32
32
  type DraftRefusal,
33
+ type DraftZones,
33
34
  draftFromEvent,
34
35
  emptyDraft,
35
36
  patchFromDrafts,
36
37
  type UpdatePatch,
37
38
  } from "./draft";
38
39
  export {
40
+ anchorZoneFor,
39
41
  type CalendarInstanceRef,
40
42
  calendarInstanceId,
41
43
  deviceTimeZone,
@@ -45,10 +47,11 @@ export {
45
47
  toCalendarEventData,
46
48
  UNZONED_CALENDAR,
47
49
  } from "./instance";
48
- export { rruleFromText, textFromRrule } from "./recurrence-rule";
50
+ export { moveRule, rruleFromText, textFromRrule } from "./recurrence-rule";
49
51
  export {
50
52
  type CalendarResourceText,
51
53
  rruleFromIcalData,
54
+ storedAnchorZone,
52
55
  textFromIcalData,
53
56
  } from "./resource";
54
57
  export {
@@ -103,5 +106,6 @@ export {
103
106
  calendarWindowOfDays,
104
107
  isoAt,
105
108
  isoAtInZone,
109
+ isoOnClock,
106
110
  startOfDay,
107
111
  } from "./window";
@@ -0,0 +1,70 @@
1
+ /** An occurrence as the calendar draws it, and the zone a write anchors it in. */
2
+ import assert from "node:assert/strict";
3
+ import { describe, it } from "node:test";
4
+ import type { RemitImapCalendarEventInstance } from "@remit/api-http-client/types.gen.ts";
5
+ import { anchorZoneFor, toCalendarEventData } from "./instance";
6
+
7
+ const instance = (
8
+ over: Partial<RemitImapCalendarEventInstance>,
9
+ ): RemitImapCalendarEventInstance =>
10
+ ({
11
+ calendarId: "cal_work",
12
+ calendarObjectId: "obj_1",
13
+ recurrenceId: "",
14
+ icalUid: "uid-1",
15
+ summary: "Audit day",
16
+ start: "2034-03-01T00:00:00+09:00",
17
+ end: "2034-03-02T00:00:00+09:00",
18
+ allDay: true,
19
+ status: "Confirmed",
20
+ transparency: "Opaque",
21
+ zoneCertainty: "Explicit",
22
+ etag: "etag-1",
23
+ hasRecurrence: false,
24
+ ...over,
25
+ }) as RemitImapCalendarEventInstance;
26
+
27
+ describe("an occurrence drawn on a device", () => {
28
+ it("keeps an all-day occurrence on its civil date wherever the device is", () => {
29
+ const drawn = toCalendarEventData(
30
+ instance({}),
31
+ "Asia/Tokyo",
32
+ "Pacific/Pago_Pago",
33
+ );
34
+ assert.equal(drawn.start, "2034-03-01");
35
+ assert.equal(drawn.end, "2034-03-02");
36
+ });
37
+
38
+ it("re-reads a timed occurrence on the device's clock", () => {
39
+ const drawn = toCalendarEventData(
40
+ instance({
41
+ allDay: false,
42
+ start: "2034-02-22T08:00:00+09:00",
43
+ end: "2034-02-22T09:00:00+09:00",
44
+ }),
45
+ "Asia/Tokyo",
46
+ "America/New_York",
47
+ );
48
+ assert.equal(drawn.start, "2034-02-21T18:00:00-05:00");
49
+ assert.equal(drawn.end, "2034-02-21T19:00:00-05:00");
50
+ });
51
+ });
52
+
53
+ describe("the zone a write anchors an event in", () => {
54
+ it("is the collection's where it names one", () => {
55
+ assert.equal(anchorZoneFor("Asia/Tokyo"), "Asia/Tokyo");
56
+ });
57
+
58
+ it("is the device's where the collection names none, and nothing on UTC", () => {
59
+ const runner = process.env.TZ;
60
+ try {
61
+ process.env.TZ = "America/New_York";
62
+ assert.equal(anchorZoneFor(""), "America/New_York");
63
+ process.env.TZ = "UTC";
64
+ assert.equal(anchorZoneFor(""), "");
65
+ } finally {
66
+ if (runner === undefined) Reflect.deleteProperty(process.env, "TZ");
67
+ else process.env.TZ = runner;
68
+ }
69
+ });
70
+ });