@remit/web-client 0.0.231 → 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.
- package/package.json +1 -1
- package/src/components/calendar/CalendarComposePane.stories.tsx +101 -11
- package/src/components/calendar/CalendarComposePane.tsx +83 -23
- package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +30 -1
- package/src/components/calendar/CalendarEventPane.tsx +28 -5
- package/src/components/calendar/CalendarWorkspace.stories.tsx +22 -0
- package/src/components/calendar/CalendarWorkspace.tsx +31 -1
- package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
- package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
- package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
- package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
- package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
- package/src/hooks/calendar/draft.test.ts +241 -0
- package/src/hooks/calendar/draft.ts +168 -0
- package/src/hooks/calendar/index.ts +59 -0
- package/src/hooks/calendar/instance.ts +136 -0
- package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
- package/src/hooks/calendar/recurrence-rule.ts +133 -0
- package/src/hooks/calendar/resource.test.ts +98 -0
- package/src/hooks/calendar/resource.ts +74 -0
- package/src/hooks/calendar/useCalendarEvent.ts +46 -0
- package/src/hooks/calendar/useCalendarEvents.ts +124 -0
- package/src/hooks/calendar/useCalendarWrites.ts +174 -0
- package/src/hooks/calendar/useCalendars.ts +43 -0
- package/src/hooks/calendar/window.test.ts +148 -0
- package/src/hooks/calendar/window.ts +163 -0
- package/src/hooks/useCalendarData.ts +70 -22
- package/src/lib/calendar-route.ts +7 -3
- package/src/routes/calendar/$view.$date/new.tsx +2 -2
- package/src/routes/calendar/$view.$date.tsx +43 -15
- package/src/routing/calendar.ts +19 -8
- package/src/test-support/http.ts +8 -0
- package/src/lib/calendar-fixtures.ts +0 -207
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every calendar surface reads and writes, in one module.
|
|
3
|
+
*
|
|
4
|
+
* The grid, the reading pane and the day strip all ask the same questions, so
|
|
5
|
+
* they ask them through the same hooks: one window is fetched once however many
|
|
6
|
+
* of them are on screen, and a write invalidates what all of them are showing.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
type CreateInput,
|
|
10
|
+
createInputFromDraft,
|
|
11
|
+
type DraftRefusal,
|
|
12
|
+
draftFromEvent,
|
|
13
|
+
emptyDraft,
|
|
14
|
+
patchFromDrafts,
|
|
15
|
+
type UpdatePatch,
|
|
16
|
+
} from "./draft";
|
|
17
|
+
export {
|
|
18
|
+
type CalendarInstanceRef,
|
|
19
|
+
calendarInstanceId,
|
|
20
|
+
deviceTimeZone,
|
|
21
|
+
isDrawnInstance,
|
|
22
|
+
readCalendarInstanceId,
|
|
23
|
+
toCalendarDescriptor,
|
|
24
|
+
toCalendarEventData,
|
|
25
|
+
UNZONED_CALENDAR,
|
|
26
|
+
} from "./instance";
|
|
27
|
+
export { rruleFromText, textFromRrule } from "./recurrence-rule";
|
|
28
|
+
export {
|
|
29
|
+
type CalendarResourceText,
|
|
30
|
+
rruleFromIcalData,
|
|
31
|
+
textFromIcalData,
|
|
32
|
+
} from "./resource";
|
|
33
|
+
export {
|
|
34
|
+
type CalendarEventResource,
|
|
35
|
+
useCalendarEvent,
|
|
36
|
+
} from "./useCalendarEvent";
|
|
37
|
+
export {
|
|
38
|
+
type CalendarEventWindowRequest,
|
|
39
|
+
type CalendarEventWindowResult,
|
|
40
|
+
useCalendarEventWindow,
|
|
41
|
+
usePrefetchAdjacentWindows,
|
|
42
|
+
} from "./useCalendarEvents";
|
|
43
|
+
export { type CalendarsResult, useCalendars } from "./useCalendars";
|
|
44
|
+
export {
|
|
45
|
+
type CalendarWriteOutcome,
|
|
46
|
+
type CalendarWrites,
|
|
47
|
+
type ScopedWrite,
|
|
48
|
+
useCalendarWrites,
|
|
49
|
+
useInvalidateCalendarReads,
|
|
50
|
+
} from "./useCalendarWrites";
|
|
51
|
+
export {
|
|
52
|
+
addDays,
|
|
53
|
+
type CalendarWindow,
|
|
54
|
+
calendarWindow,
|
|
55
|
+
calendarWindowOfDays,
|
|
56
|
+
isoAt,
|
|
57
|
+
isoAtInZone,
|
|
58
|
+
startOfDay,
|
|
59
|
+
} from "./window";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API's shapes, as the calendar kit draws them.
|
|
3
|
+
*
|
|
4
|
+
* One place does the translating, so the grid, the strip and the reading pane
|
|
5
|
+
* cannot disagree about what an occurrence is. Nothing here expands a
|
|
6
|
+
* recurrence: the server returns instances, and this reads them.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
RemitImapCalendarEventInstance,
|
|
10
|
+
RemitImapCalendarResponse,
|
|
11
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
12
|
+
import type {
|
|
13
|
+
CalendarColorId,
|
|
14
|
+
CalendarDescriptor,
|
|
15
|
+
CalendarEventData,
|
|
16
|
+
ZoneCertainty,
|
|
17
|
+
} from "@remit/ui";
|
|
18
|
+
|
|
19
|
+
/** The device's own clock, the only zone a browser can honestly draw in. */
|
|
20
|
+
export const deviceTimeZone = (): string =>
|
|
21
|
+
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A collection that names no zone of its own, spelled the way the API spells
|
|
25
|
+
* it: an empty string, which both ends read as UTC.
|
|
26
|
+
*
|
|
27
|
+
* There is deliberately no substitute value. The server checks a TZID against
|
|
28
|
+
* `Intl.supportedValuesOf("timeZone")`, which is the canonical zone list and so
|
|
29
|
+
* holds no spelling of UTC at all — not `UTC`, not `Etc/UTC` — and refuses
|
|
30
|
+
* anything else. Sending nothing is what the API asks for, and it is the only
|
|
31
|
+
* thing it accepts.
|
|
32
|
+
*
|
|
33
|
+
* The device's zone is not the fallback either. The listing returns this
|
|
34
|
+
* collection's occurrences on its own clock, so answering with the reader's
|
|
35
|
+
* would rewrite every time they saved by the difference between the two.
|
|
36
|
+
*/
|
|
37
|
+
export const UNZONED_CALENDAR = "";
|
|
38
|
+
|
|
39
|
+
const COLORS: Record<string, CalendarColorId> = {
|
|
40
|
+
Cal1: "cal-1",
|
|
41
|
+
Cal2: "cal-2",
|
|
42
|
+
Cal3: "cal-3",
|
|
43
|
+
Cal4: "cal-4",
|
|
44
|
+
Cal5: "cal-5",
|
|
45
|
+
Cal6: "cal-6",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ZONES: Record<string, ZoneCertainty> = {
|
|
49
|
+
Local: "local",
|
|
50
|
+
Explicit: "explicit",
|
|
51
|
+
Ambiguous: "ambiguous",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The address of one occurrence, as a single string, because the grid selects
|
|
56
|
+
* an event by one id. A series draws many occurrences from one resource, so the
|
|
57
|
+
* resource id alone would key every Monday standup the same and select all of
|
|
58
|
+
* them at once.
|
|
59
|
+
*/
|
|
60
|
+
const OCCURRENCE_SEPARATOR = "#";
|
|
61
|
+
|
|
62
|
+
export function calendarInstanceId(
|
|
63
|
+
calendarObjectId: string,
|
|
64
|
+
recurrenceId: string,
|
|
65
|
+
): string {
|
|
66
|
+
return recurrenceId === ""
|
|
67
|
+
? calendarObjectId
|
|
68
|
+
: `${calendarObjectId}${OCCURRENCE_SEPARATOR}${recurrenceId}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CalendarInstanceRef {
|
|
72
|
+
calendarObjectId: string;
|
|
73
|
+
/** `""` on a resource that does not recur. */
|
|
74
|
+
recurrenceId: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function readCalendarInstanceId(id: string): CalendarInstanceRef {
|
|
78
|
+
const cut = id.indexOf(OCCURRENCE_SEPARATOR);
|
|
79
|
+
if (cut === -1) return { calendarObjectId: id, recurrenceId: "" };
|
|
80
|
+
return {
|
|
81
|
+
calendarObjectId: id.slice(0, cut),
|
|
82
|
+
recurrenceId: id.slice(cut + 1),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function toCalendarDescriptor(
|
|
87
|
+
calendar: RemitImapCalendarResponse,
|
|
88
|
+
): CalendarDescriptor {
|
|
89
|
+
return {
|
|
90
|
+
id: calendar.calendarId,
|
|
91
|
+
accountId: calendar.accountConfigId,
|
|
92
|
+
accountLabel: "",
|
|
93
|
+
name: calendar.displayName,
|
|
94
|
+
color: COLORS[calendar.color] ?? "cal-1",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One occurrence, drawn. `location`, `notes` and the guest list are projections
|
|
100
|
+
* the listing does not carry — the reading pane fetches the resource itself for
|
|
101
|
+
* those, and a grid chip has no room for them anyway.
|
|
102
|
+
*/
|
|
103
|
+
export function toCalendarEventData(
|
|
104
|
+
instance: RemitImapCalendarEventInstance,
|
|
105
|
+
timeZone: string,
|
|
106
|
+
): CalendarEventData {
|
|
107
|
+
return {
|
|
108
|
+
id: calendarInstanceId(instance.calendarObjectId, instance.recurrenceId),
|
|
109
|
+
calendarId: instance.calendarId,
|
|
110
|
+
title: instance.summary,
|
|
111
|
+
start: instance.start,
|
|
112
|
+
end: instance.end,
|
|
113
|
+
allDay: instance.allDay,
|
|
114
|
+
location: "",
|
|
115
|
+
notes: "",
|
|
116
|
+
attendees: [],
|
|
117
|
+
myRsvp: "accepted",
|
|
118
|
+
threadId: "",
|
|
119
|
+
threadSubject: "",
|
|
120
|
+
timeZone,
|
|
121
|
+
zoneCertainty: ZONES[instance.zoneCertainty] ?? "explicit",
|
|
122
|
+
recurrenceRule: instance.hasRecurrence ? "Repeats" : "",
|
|
123
|
+
seriesId: instance.hasRecurrence ? instance.icalUid : "",
|
|
124
|
+
seriesException: false,
|
|
125
|
+
status: instance.status === "Tentative" ? "tentative" : "confirmed",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A cancelled occurrence is not a lighter shade of confirmed, and the kit has
|
|
131
|
+
* no third state to draw it in, so it is left off the calendar entirely rather
|
|
132
|
+
* than shown as an event that is still happening.
|
|
133
|
+
*/
|
|
134
|
+
export const isDrawnInstance = (
|
|
135
|
+
instance: RemitImapCalendarEventInstance,
|
|
136
|
+
): boolean => instance.status !== "Cancelled";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repeat rules, both ways.
|
|
3
|
+
*
|
|
4
|
+
* The picker only ever offers the sentences `repeatChoices` derives, so the
|
|
5
|
+
* table has to cover exactly those and refuse everything else — a sentence
|
|
6
|
+
* turned into a rule it does not mean is an event that repeats on days nobody
|
|
7
|
+
* chose, and nothing on screen would say so.
|
|
8
|
+
*
|
|
9
|
+
* A rule written by something else — a native client, an invitation — must come
|
|
10
|
+
* back unread rather than snapped to the nearest sentence.
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import { repeatChoices } from "@remit/ui";
|
|
15
|
+
import { rruleFromText, textFromRrule } from "./recurrence-rule";
|
|
16
|
+
|
|
17
|
+
const DATE = "2026-06-10";
|
|
18
|
+
const TIME = "09:15";
|
|
19
|
+
|
|
20
|
+
describe("every rule the picker offers", () => {
|
|
21
|
+
it("becomes an RRULE, and reads back as the sentence it was picked from", () => {
|
|
22
|
+
for (const choice of repeatChoices(DATE, TIME)) {
|
|
23
|
+
const rule = rruleFromText(choice);
|
|
24
|
+
assert.ok(
|
|
25
|
+
rule !== undefined && rule !== "",
|
|
26
|
+
`no rule for the offered choice "${choice}"`,
|
|
27
|
+
);
|
|
28
|
+
assert.equal(textFromRrule(rule, TIME), choice);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("says what each one means", () => {
|
|
33
|
+
assert.equal(rruleFromText("Every day, 09:15"), "FREQ=DAILY");
|
|
34
|
+
assert.equal(
|
|
35
|
+
rruleFromText("Every weekday, 09:15"),
|
|
36
|
+
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
|
|
37
|
+
);
|
|
38
|
+
assert.equal(
|
|
39
|
+
rruleFromText("Every week on Wednesday, 09:15"),
|
|
40
|
+
"FREQ=WEEKLY;BYDAY=WE",
|
|
41
|
+
);
|
|
42
|
+
assert.equal(
|
|
43
|
+
rruleFromText("Every month on the second Wednesday, 09:15"),
|
|
44
|
+
"FREQ=MONTHLY;BYDAY=2WE",
|
|
45
|
+
);
|
|
46
|
+
assert.equal(
|
|
47
|
+
rruleFromText("Every year on 10 June, 09:15"),
|
|
48
|
+
"FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=10",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("reads the last week of a month as the rule that means it", () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
rruleFromText("Every month on the last Friday"),
|
|
55
|
+
"FREQ=MONTHLY;BYDAY=-1FR",
|
|
56
|
+
);
|
|
57
|
+
assert.equal(
|
|
58
|
+
textFromRrule("FREQ=MONTHLY;BYDAY=-1FR", ""),
|
|
59
|
+
"Every month on the last Friday",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("no repeat", () => {
|
|
65
|
+
it("is the empty rule rather than a refusal", () => {
|
|
66
|
+
assert.equal(rruleFromText(""), "");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("a sentence this table does not cover", () => {
|
|
71
|
+
it("is refused rather than guessed at", () => {
|
|
72
|
+
assert.equal(rruleFromText("Every other Tuesday"), undefined);
|
|
73
|
+
assert.equal(rruleFromText("Every week on Caturday"), undefined);
|
|
74
|
+
assert.equal(rruleFromText("Repeats"), undefined);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("a rule written by something else", () => {
|
|
79
|
+
it("comes back unread rather than snapped to the nearest sentence", () => {
|
|
80
|
+
assert.equal(
|
|
81
|
+
textFromRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=TU", ""),
|
|
82
|
+
undefined,
|
|
83
|
+
);
|
|
84
|
+
assert.equal(textFromRrule("FREQ=DAILY;COUNT=10", ""), undefined);
|
|
85
|
+
assert.equal(
|
|
86
|
+
textFromRrule("FREQ=DAILY;UNTIL=20261231T000000Z", ""),
|
|
87
|
+
undefined,
|
|
88
|
+
);
|
|
89
|
+
assert.equal(textFromRrule("FREQ=HOURLY", ""), undefined);
|
|
90
|
+
assert.equal(textFromRrule("FREQ=WEEKLY", ""), undefined);
|
|
91
|
+
});
|
|
92
|
+
});
|