@remit/web-client 0.0.297 → 0.0.299
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.
- package/package.json +1 -1
- package/src/components/calendar/CalendarEventPane.stories.tsx +16 -0
- package/src/components/calendar/OpenCalendarEvent.tsx +23 -6
- package/src/components/calendar/WriteCalendarEvent.tsx +8 -4
- package/src/components/calendar/calendar-write-flow.render.test.ts +39 -22
- package/src/components/compose/ComposeForm.tsx +116 -0
- package/src/components/compose/attachment-only-source.render.test.ts +1 -0
- package/src/components/compose/compose-send-stops-autosave.render.test.ts +1 -0
- package/src/components/compose/compose-starts-a-second-message.render.test.ts +1 -0
- package/src/components/compose/forward-autosave-is-not-fatal.render.test.ts +1 -0
- package/src/components/compose/quote-leaves-with-the-send.render.test.ts +1 -0
- package/src/components/compose/reopened-draft-writes-nothing.render.test.ts +1 -0
- package/src/components/compose/reply-all-on-resumed-draft.render.test.ts +1 -0
- package/src/hooks/calendar/calendar-collections.render.test.ts +23 -1
- package/src/hooks/calendar/draft.test.ts +179 -30
- package/src/hooks/calendar/draft.ts +59 -33
- package/src/hooks/calendar/index.ts +5 -1
- package/src/hooks/calendar/instance.test.ts +70 -0
- package/src/hooks/calendar/instance.ts +28 -15
- package/src/hooks/calendar/recurrence-rule.test.ts +43 -1
- package/src/hooks/calendar/recurrence-rule.ts +44 -0
- package/src/hooks/calendar/resource.test.ts +44 -1
- package/src/hooks/calendar/resource.ts +30 -0
- package/src/hooks/calendar/selection.ts +7 -4
- package/src/hooks/calendar/useDraftClashes.ts +3 -1
- package/src/hooks/calendar/window.test.ts +28 -0
- package/src/hooks/calendar/window.ts +20 -0
- package/src/hooks/useComposeAttachments.ts +103 -0
- package/src/hooks/useIntelligenceCalendar.ts +2 -0
- package/src/hooks/useIntelligenceData.ts +2 -2
- package/src/lib/calendar-format.ts +7 -2
- package/src/lib/compose-attachment-controller.test.ts +242 -0
- package/src/lib/compose-attachment-controller.ts +415 -0
- package/src/routes/settings/-accounts.test.ts +18 -0
- package/src/routes/settings/accounts.tsx +8 -1
- 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(),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: " " }),
|
|
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
|
-
|
|
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
|
-
|
|
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: "" }),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 },
|
|
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
|
|
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
|
-
*
|
|
275
|
-
* those digits, so
|
|
276
|
-
*
|
|
277
|
-
*
|
|
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("
|
|
285
|
-
const built = createInputFromDraft(draft(),
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(),
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
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,
|
|
88
|
-
end: isoAtInZone(draft.endDate, draft.endTime,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
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.
|
|
171
|
-
|
|
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 =
|
|
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
|
+
});
|