@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,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repeat rules, between the sentence a person picks and the RRULE the API
|
|
3
|
+
* stores.
|
|
4
|
+
*
|
|
5
|
+
* The kit's picker deals only in sentences — "Every weekday, 09:15" — and the
|
|
6
|
+
* calendar API deals only in RFC 5545 rules. This is the single table read both
|
|
7
|
+
* ways, so a rule this client wrote reads back as the sentence it was picked
|
|
8
|
+
* from. A rule written by anything else, a native client or an invitation, is
|
|
9
|
+
* not forced into one of these sentences: it comes back unread, and the surface
|
|
10
|
+
* says the event repeats without claiming to know how.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const WEEKDAYS = [
|
|
14
|
+
"Sunday",
|
|
15
|
+
"Monday",
|
|
16
|
+
"Tuesday",
|
|
17
|
+
"Wednesday",
|
|
18
|
+
"Thursday",
|
|
19
|
+
"Friday",
|
|
20
|
+
"Saturday",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const BYDAY = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
|
|
24
|
+
|
|
25
|
+
const MONTHS = [
|
|
26
|
+
"January",
|
|
27
|
+
"February",
|
|
28
|
+
"March",
|
|
29
|
+
"April",
|
|
30
|
+
"May",
|
|
31
|
+
"June",
|
|
32
|
+
"July",
|
|
33
|
+
"August",
|
|
34
|
+
"September",
|
|
35
|
+
"October",
|
|
36
|
+
"November",
|
|
37
|
+
"December",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const ORDINALS = ["first", "second", "third", "fourth", "last"];
|
|
41
|
+
|
|
42
|
+
const ORDINAL_NUMBERS = [1, 2, 3, 4, -1];
|
|
43
|
+
|
|
44
|
+
const WEEKDAY_SET = "MO,TU,WE,TH,FR";
|
|
45
|
+
|
|
46
|
+
/** The clock the picker appends, stripped so the rule itself can be read. */
|
|
47
|
+
const withoutClock = (text: string): string =>
|
|
48
|
+
text.replace(/,\s*\d{2}:\d{2}$/, "").trim();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The RRULE a picked sentence means, or `undefined` for a sentence this table
|
|
52
|
+
* does not cover. A caller refuses the write rather than storing a rule it
|
|
53
|
+
* guessed at.
|
|
54
|
+
*/
|
|
55
|
+
export function rruleFromText(text: string): string | undefined {
|
|
56
|
+
const rule = withoutClock(text);
|
|
57
|
+
if (rule === "") return "";
|
|
58
|
+
if (rule === "Every day") return "FREQ=DAILY";
|
|
59
|
+
if (rule === "Every weekday") return `FREQ=WEEKLY;BYDAY=${WEEKDAY_SET}`;
|
|
60
|
+
|
|
61
|
+
const weekly = rule.match(/^Every week on (\w+)$/);
|
|
62
|
+
if (weekly) {
|
|
63
|
+
const day = WEEKDAYS.indexOf(weekly[1]);
|
|
64
|
+
return day === -1 ? undefined : `FREQ=WEEKLY;BYDAY=${BYDAY[day]}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const monthly = rule.match(/^Every month on the (\w+) (\w+)$/);
|
|
68
|
+
if (monthly) {
|
|
69
|
+
const ordinal = ORDINALS.indexOf(monthly[1]);
|
|
70
|
+
const day = WEEKDAYS.indexOf(monthly[2]);
|
|
71
|
+
if (ordinal === -1 || day === -1) return undefined;
|
|
72
|
+
return `FREQ=MONTHLY;BYDAY=${ORDINAL_NUMBERS[ordinal]}${BYDAY[day]}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const yearly = rule.match(/^Every year on (\d{1,2}) (\w+)$/);
|
|
76
|
+
if (yearly) {
|
|
77
|
+
const month = MONTHS.indexOf(yearly[2]);
|
|
78
|
+
if (month === -1) return undefined;
|
|
79
|
+
return `FREQ=YEARLY;BYMONTH=${month + 1};BYMONTHDAY=${Number(yearly[1])}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const clockSuffix = (startTime: string): string =>
|
|
86
|
+
startTime === "" ? "" : `, ${startTime}`;
|
|
87
|
+
|
|
88
|
+
/** The sentence an RRULE was picked from, or `undefined` where it was not. */
|
|
89
|
+
export function textFromRrule(
|
|
90
|
+
rule: string,
|
|
91
|
+
startTime: string,
|
|
92
|
+
): string | undefined {
|
|
93
|
+
const parts = new Map(
|
|
94
|
+
rule
|
|
95
|
+
.split(";")
|
|
96
|
+
.map((part) => part.split("="))
|
|
97
|
+
.filter((pair): pair is [string, string] => pair.length === 2)
|
|
98
|
+
.map(([name, value]) => [name.toUpperCase(), value.toUpperCase()]),
|
|
99
|
+
);
|
|
100
|
+
const clock = clockSuffix(startTime);
|
|
101
|
+
const frequency = parts.get("FREQ");
|
|
102
|
+
const byDay = parts.get("BYDAY");
|
|
103
|
+
|
|
104
|
+
if (parts.has("INTERVAL") && parts.get("INTERVAL") !== "1") return undefined;
|
|
105
|
+
if (parts.has("UNTIL") || parts.has("COUNT")) return undefined;
|
|
106
|
+
|
|
107
|
+
if (frequency === "DAILY") return `Every day${clock}`;
|
|
108
|
+
|
|
109
|
+
if (frequency === "WEEKLY") {
|
|
110
|
+
if (byDay === undefined) return undefined;
|
|
111
|
+
if (byDay === WEEKDAY_SET) return `Every weekday${clock}`;
|
|
112
|
+
const day = BYDAY.indexOf(byDay);
|
|
113
|
+
return day === -1 ? undefined : `Every week on ${WEEKDAYS[day]}${clock}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (frequency === "MONTHLY") {
|
|
117
|
+
const match = byDay?.match(/^(-?\d)([A-Z]{2})$/);
|
|
118
|
+
if (!match) return undefined;
|
|
119
|
+
const ordinal = ORDINAL_NUMBERS.indexOf(Number(match[1]));
|
|
120
|
+
const day = BYDAY.indexOf(match[2]);
|
|
121
|
+
if (ordinal === -1 || day === -1) return undefined;
|
|
122
|
+
return `Every month on the ${ORDINALS[ordinal]} ${WEEKDAYS[day]}${clock}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (frequency === "YEARLY") {
|
|
126
|
+
const month = Number(parts.get("BYMONTH"));
|
|
127
|
+
const day = Number(parts.get("BYMONTHDAY"));
|
|
128
|
+
if (!month || !day || month > 12) return undefined;
|
|
129
|
+
return `Every year on ${day} ${MONTHS[month - 1]}${clock}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading three properties off a stored resource.
|
|
3
|
+
*
|
|
4
|
+
* RFC 5545 folds a long line by breaking it and indenting the remainder, so a
|
|
5
|
+
* value read line-first stops halfway and looks like a shorter value rather
|
|
6
|
+
* than a broken one — a rule truncated that way turns a weekly meeting into a
|
|
7
|
+
* daily one on screen with nothing to say it happened.
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { describe, it } from "node:test";
|
|
11
|
+
import { rruleFromIcalData, textFromIcalData } from "./resource";
|
|
12
|
+
|
|
13
|
+
const ics = (...lines: string[]): string =>
|
|
14
|
+
[
|
|
15
|
+
"BEGIN:VCALENDAR",
|
|
16
|
+
"BEGIN:VEVENT",
|
|
17
|
+
...lines,
|
|
18
|
+
"END:VEVENT",
|
|
19
|
+
"END:VCALENDAR",
|
|
20
|
+
].join("\r\n");
|
|
21
|
+
|
|
22
|
+
describe("the rule inside a resource", () => {
|
|
23
|
+
it("is read whole across a fold", () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
rruleFromIcalData(ics("RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,", " TH,FR")),
|
|
26
|
+
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("is empty where the event does not repeat", () => {
|
|
31
|
+
assert.equal(rruleFromIcalData(ics("SUMMARY:Roadmap review")), "");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("the text a listing does not carry", () => {
|
|
36
|
+
it("comes back unescaped", () => {
|
|
37
|
+
const text = textFromIcalData(
|
|
38
|
+
ics(
|
|
39
|
+
"LOCATION:Room Zuid\\, second floor",
|
|
40
|
+
"DESCRIPTION:Bring the numbers.\\nAnd the slides.",
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
assert.equal(text.location, "Room Zuid, second floor");
|
|
44
|
+
assert.equal(text.description, "Bring the numbers.\nAnd the slides.");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("reads a property that carries parameters", () => {
|
|
48
|
+
assert.equal(
|
|
49
|
+
textFromIcalData(ics("DESCRIPTION;LANGUAGE=en:Plain text")).description,
|
|
50
|
+
"Plain text",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A parameter value may be quoted precisely so it can hold a colon, which is
|
|
56
|
+
* what a URI does. Splitting the line on its first colon hands back the tail
|
|
57
|
+
* of that URI as though it were the property's own value.
|
|
58
|
+
*/
|
|
59
|
+
it("reads past a colon inside a quoted parameter", () => {
|
|
60
|
+
assert.equal(
|
|
61
|
+
textFromIcalData(ics('DESCRIPTION;ALTREP="cid:part1.0":Plain text'))
|
|
62
|
+
.description,
|
|
63
|
+
"Plain text",
|
|
64
|
+
);
|
|
65
|
+
assert.equal(
|
|
66
|
+
textFromIcalData(
|
|
67
|
+
ics('LOCATION;X-ADDRESS="https://maps.example/x:1":Room Zuid'),
|
|
68
|
+
).location,
|
|
69
|
+
"Room Zuid",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not answer for a property whose name merely starts the same", () => {
|
|
74
|
+
assert.equal(textFromIcalData(ics("LOCATION-TYPE:room")).location, "");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("takes the master's value rather than an override's", () => {
|
|
78
|
+
const overridden = [
|
|
79
|
+
"BEGIN:VCALENDAR",
|
|
80
|
+
"BEGIN:VEVENT",
|
|
81
|
+
"LOCATION:Room Zuid",
|
|
82
|
+
"END:VEVENT",
|
|
83
|
+
"BEGIN:VEVENT",
|
|
84
|
+
"RECURRENCE-ID:20260610T090000Z",
|
|
85
|
+
"LOCATION:Room Noord",
|
|
86
|
+
"END:VEVENT",
|
|
87
|
+
"END:VCALENDAR",
|
|
88
|
+
].join("\r\n");
|
|
89
|
+
assert.equal(textFromIcalData(overridden).location, "Room Zuid");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("is empty where the resource says nothing", () => {
|
|
93
|
+
assert.deepEqual(textFromIcalData(ics("SUMMARY:Standup")), {
|
|
94
|
+
location: "",
|
|
95
|
+
description: "",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The few properties a reading pane needs that the occurrence listing does not
|
|
3
|
+
* carry, read off the stored resource.
|
|
4
|
+
*
|
|
5
|
+
* This is not a parser and never becomes one. It reads three named properties
|
|
6
|
+
* of the master VEVENT so the pane can show a location and the editor can send
|
|
7
|
+
* one back unchanged; anything structural — recurrence expansion above all —
|
|
8
|
+
* belongs to the server, which is the only thing that expands a series.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* RFC 5545 breaks a long property across lines with a leading space or tab, so
|
|
13
|
+
* the folds are joined before anything is matched — a value read fold-first
|
|
14
|
+
* stops halfway through and looks like a shorter value rather than a broken one.
|
|
15
|
+
*/
|
|
16
|
+
const unfold = (icalData: string): string =>
|
|
17
|
+
icalData.replace(/\r?\n[ \t]/g, "");
|
|
18
|
+
|
|
19
|
+
/** RFC 5545 3.3.11: the four escapes a TEXT value carries. */
|
|
20
|
+
const unescapeText = (value: string): string =>
|
|
21
|
+
value
|
|
22
|
+
.replace(/\\n/gi, "\n")
|
|
23
|
+
.replace(/\\,/g, ",")
|
|
24
|
+
.replace(/\\;/g, ";")
|
|
25
|
+
.replace(/\\\\/g, "\\");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where a content line's parameters end and its value begins.
|
|
29
|
+
*
|
|
30
|
+
* The separator is the first colon outside a quoted string, not the first colon
|
|
31
|
+
* (RFC 5545 3.1). A parameter value is allowed to be quoted precisely so it can
|
|
32
|
+
* hold one — `ALTREP="cid:part1"` is ordinary — and splitting on the first
|
|
33
|
+
* colon hands back the tail of a URI as though it were the property's value.
|
|
34
|
+
*/
|
|
35
|
+
function valueAt(line: string, name: string): string | undefined {
|
|
36
|
+
let quoted = false;
|
|
37
|
+
for (let index = name.length; index < line.length; index += 1) {
|
|
38
|
+
const character = line[index];
|
|
39
|
+
if (index === name.length && character !== ";" && character !== ":")
|
|
40
|
+
return undefined;
|
|
41
|
+
if (character === '"') {
|
|
42
|
+
quoted = !quoted;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!quoted && character === ":") return line.slice(index + 1).trim();
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The first occurrence of a property, parameters and all. The master VEVENT is
|
|
52
|
+
* written before its overrides, so the first match is the series' own value.
|
|
53
|
+
*/
|
|
54
|
+
function firstProperty(icalData: string, name: string): string {
|
|
55
|
+
for (const line of unfold(icalData).split(/\r?\n/)) {
|
|
56
|
+
if (!line.startsWith(name)) continue;
|
|
57
|
+
const value = valueAt(line, name);
|
|
58
|
+
if (value !== undefined) return value;
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const rruleFromIcalData = (icalData: string): string =>
|
|
64
|
+
firstProperty(icalData, "RRULE");
|
|
65
|
+
|
|
66
|
+
export interface CalendarResourceText {
|
|
67
|
+
location: string;
|
|
68
|
+
description: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const textFromIcalData = (icalData: string): CalendarResourceText => ({
|
|
72
|
+
location: unescapeText(firstProperty(icalData, "LOCATION")),
|
|
73
|
+
description: unescapeText(firstProperty(icalData, "DESCRIPTION")),
|
|
74
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One stored resource — the bytes behind an event, and the etag a conditional
|
|
3
|
+
* write is built on.
|
|
4
|
+
*
|
|
5
|
+
* The listing carries what a grid chip draws; this carries what an edit needs.
|
|
6
|
+
* An event opened for editing is read here first, so the `If-Match` a write
|
|
7
|
+
* sends is the version the reader actually looked at rather than whatever the
|
|
8
|
+
* grid last cached.
|
|
9
|
+
*/
|
|
10
|
+
import { calendarEventDetailOperationsGetCalendarEventOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
11
|
+
import type { RemitImapCalendarEventResponse } from "@remit/api-http-client/types.gen.ts";
|
|
12
|
+
import { useQuery } from "@tanstack/react-query";
|
|
13
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
14
|
+
|
|
15
|
+
/** A resource the address names that is no longer there is an empty pane. */
|
|
16
|
+
const RESOURCE_META = softErrorStatuses(404);
|
|
17
|
+
|
|
18
|
+
export interface CalendarEventResource {
|
|
19
|
+
resource: RemitImapCalendarEventResponse | undefined;
|
|
20
|
+
isLoading: boolean;
|
|
21
|
+
error: unknown;
|
|
22
|
+
refetch: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useCalendarEvent(
|
|
26
|
+
calendarObjectId: string,
|
|
27
|
+
calendarId: string,
|
|
28
|
+
): CalendarEventResource {
|
|
29
|
+
const { data, isLoading, error, refetch } = useQuery({
|
|
30
|
+
...calendarEventDetailOperationsGetCalendarEventOptions({
|
|
31
|
+
path: { calendarObjectId },
|
|
32
|
+
query: { calendarId },
|
|
33
|
+
}),
|
|
34
|
+
meta: RESOURCE_META,
|
|
35
|
+
enabled: calendarObjectId !== "" && calendarId !== "",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
resource: data,
|
|
40
|
+
isLoading,
|
|
41
|
+
error,
|
|
42
|
+
refetch: () => {
|
|
43
|
+
refetch();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expanded occurrences over a window.
|
|
3
|
+
*
|
|
4
|
+
* The server does the expansion, so nothing here reads an RRULE: a week grid
|
|
5
|
+
* and a day strip draw the same instances because they asked the same question,
|
|
6
|
+
* not because they each ran the same maths.
|
|
7
|
+
*
|
|
8
|
+
* The query is keyed by the window rather than by the day the address names, so
|
|
9
|
+
* every day inside a week shares one cache entry and stepping back to a week
|
|
10
|
+
* already read draws from it. The window either side is prefetched, which is
|
|
11
|
+
* what makes prev and next land on a full calendar instead of a spinner.
|
|
12
|
+
*/
|
|
13
|
+
import { calendarEventOperationsListCalendarEventsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
14
|
+
import type { Options } from "@remit/api-http-client/sdk.gen.ts";
|
|
15
|
+
import type {
|
|
16
|
+
CalendarEventOperationsListCalendarEventsData,
|
|
17
|
+
RemitImapCalendarEventInstance,
|
|
18
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
19
|
+
import type { CalendarViewId } from "@remit/ui";
|
|
20
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
21
|
+
import { useEffect } from "react";
|
|
22
|
+
import { stepCalendarDate } from "@/lib/calendar-route";
|
|
23
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
24
|
+
import { type CalendarWindow, calendarWindow } from "./window";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The refusals this surface answers itself: a window the server will not read,
|
|
28
|
+
* and a calendar the address names that is no longer there. Everything else —
|
|
29
|
+
* a lapsed session above all — escalates.
|
|
30
|
+
*/
|
|
31
|
+
const CALENDAR_READ_META = softErrorStatuses(400, 404);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How long a window stays worth reusing.
|
|
35
|
+
*
|
|
36
|
+
* Explicit rather than inherited, because prefetching is the whole point: a
|
|
37
|
+
* warmed week that is already stale by the time the reader steps onto it is
|
|
38
|
+
* fetched twice and the step waits anyway. Nothing here goes out of date on a
|
|
39
|
+
* timer — every write invalidates the windows it touched — so this only needs
|
|
40
|
+
* to outlast the reader moving back and forth across a few weeks.
|
|
41
|
+
*/
|
|
42
|
+
const CALENDAR_WINDOW_STALE_TIME = 5 * 60_000;
|
|
43
|
+
|
|
44
|
+
export interface CalendarEventWindowRequest extends CalendarWindow {
|
|
45
|
+
/** Empty asks about every calendar the account holds. */
|
|
46
|
+
calendarIds: readonly string[];
|
|
47
|
+
/** False while the tick list is still being resolved against real calendars. */
|
|
48
|
+
enabled?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CalendarEventWindowResult {
|
|
52
|
+
instances: RemitImapCalendarEventInstance[];
|
|
53
|
+
isLoading: boolean;
|
|
54
|
+
/** A refusal this surface renders inline. Null when the read succeeded. */
|
|
55
|
+
error: unknown;
|
|
56
|
+
refetch: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const requestOptions = ({
|
|
60
|
+
from,
|
|
61
|
+
to,
|
|
62
|
+
calendarIds,
|
|
63
|
+
}: CalendarEventWindowRequest): Options<CalendarEventOperationsListCalendarEventsData> => ({
|
|
64
|
+
query:
|
|
65
|
+
calendarIds.length === 0
|
|
66
|
+
? { from, to }
|
|
67
|
+
: { from, to, calendarId: [...calendarIds] },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** One window, asked for once however many surfaces are drawing it. */
|
|
71
|
+
export function useCalendarEventWindow(
|
|
72
|
+
request: CalendarEventWindowRequest,
|
|
73
|
+
): CalendarEventWindowResult {
|
|
74
|
+
const { data, isLoading, error, refetch } = useQuery({
|
|
75
|
+
...calendarEventOperationsListCalendarEventsOptions(
|
|
76
|
+
requestOptions(request),
|
|
77
|
+
),
|
|
78
|
+
meta: CALENDAR_READ_META,
|
|
79
|
+
staleTime: CALENDAR_WINDOW_STALE_TIME,
|
|
80
|
+
enabled: request.enabled ?? true,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
instances: data?.items ?? [],
|
|
85
|
+
isLoading,
|
|
86
|
+
error,
|
|
87
|
+
refetch: () => {
|
|
88
|
+
refetch();
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The windows either side of the one on screen, warmed while the reader is
|
|
95
|
+
* looking at this one. A step is a navigation, and a navigation that has to
|
|
96
|
+
* wait for a request reads as the calendar being slow rather than as the week
|
|
97
|
+
* being fetched.
|
|
98
|
+
*/
|
|
99
|
+
export function usePrefetchAdjacentWindows(
|
|
100
|
+
view: CalendarViewId,
|
|
101
|
+
date: string,
|
|
102
|
+
calendarIds: readonly string[],
|
|
103
|
+
enabled: boolean,
|
|
104
|
+
): void {
|
|
105
|
+
const queryClient = useQueryClient();
|
|
106
|
+
const key = [...calendarIds].join(",");
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!enabled) return;
|
|
110
|
+
const ids = key === "" ? [] : key.split(",");
|
|
111
|
+
for (const direction of [-1, 1] as const) {
|
|
112
|
+
const neighbour = calendarWindow(
|
|
113
|
+
view,
|
|
114
|
+
stepCalendarDate(date, view, direction),
|
|
115
|
+
);
|
|
116
|
+
void queryClient.prefetchQuery({
|
|
117
|
+
...calendarEventOperationsListCalendarEventsOptions(
|
|
118
|
+
requestOptions({ ...neighbour, calendarIds: ids }),
|
|
119
|
+
),
|
|
120
|
+
staleTime: CALENDAR_WINDOW_STALE_TIME,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}, [queryClient, view, date, key, enabled]);
|
|
124
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creating, editing and deleting an event.
|
|
3
|
+
*
|
|
4
|
+
* Every write is conditional on the version the reader was looking at. A 412
|
|
5
|
+
* means somebody replaced the resource in between — over CalDAV, in another
|
|
6
|
+
* tab, from an accepted invitation — and applying the edit would discard their
|
|
7
|
+
* change with nobody seeing it happen. That comes back as an outcome the
|
|
8
|
+
* surface states, never as a write that quietly wins.
|
|
9
|
+
*
|
|
10
|
+
* Outcomes are returned rather than thrown: a caught value is `unknown`, and a
|
|
11
|
+
* surface that has to switch on one cannot tell a refusal it must render from a
|
|
12
|
+
* fault it must not swallow. Faults still escalate — these carry only the
|
|
13
|
+
* statuses this surface owns, so a lapsed session takes the screen the way it
|
|
14
|
+
* does everywhere else.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
calendarEventDetailOperationsDeleteCalendarEventMutation,
|
|
18
|
+
calendarEventDetailOperationsUpdateCalendarEventMutation,
|
|
19
|
+
calendarEventOperationsCreateCalendarEventMutation,
|
|
20
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
21
|
+
import type {
|
|
22
|
+
RemitImapCreateCalendarEventInput,
|
|
23
|
+
RemitImapRecurrenceScope,
|
|
24
|
+
RemitImapUpdateCalendarEventInput,
|
|
25
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
26
|
+
import type { RecurrenceScope } from "@remit/ui";
|
|
27
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
28
|
+
import { useCallback, useMemo } from "react";
|
|
29
|
+
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
30
|
+
import { getErrorStatus, softErrorStatuses } from "@/lib/error-classifier";
|
|
31
|
+
|
|
32
|
+
/** The refusals a calendar surface renders itself. A 401 is not among them. */
|
|
33
|
+
const CALENDAR_WRITE_META = softErrorStatuses(400, 404, 412);
|
|
34
|
+
|
|
35
|
+
/** Every read whose answer a write can change. */
|
|
36
|
+
const CALENDAR_QUERY_IDS = new Set([
|
|
37
|
+
"calendarEventOperationsListCalendarEvents",
|
|
38
|
+
"calendarEventDetailOperationsGetCalendarEvent",
|
|
39
|
+
"calendarFreeBusyOperationsListCalendarFreeBusy",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export type CalendarWriteOutcome =
|
|
43
|
+
| { kind: "written" }
|
|
44
|
+
| { kind: "conflict" }
|
|
45
|
+
| { kind: "refused"; message: string };
|
|
46
|
+
|
|
47
|
+
const WRITTEN: CalendarWriteOutcome = { kind: "written" };
|
|
48
|
+
|
|
49
|
+
const outcomeFor = (error: unknown): CalendarWriteOutcome =>
|
|
50
|
+
getErrorStatus(error) === 412
|
|
51
|
+
? { kind: "conflict" }
|
|
52
|
+
: { kind: "refused", message: formatErrorMessage(error) };
|
|
53
|
+
|
|
54
|
+
const SCOPES: Record<RecurrenceScope, RemitImapRecurrenceScope> = {
|
|
55
|
+
this: "This",
|
|
56
|
+
following: "Following",
|
|
57
|
+
all: "All",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface ScopedWrite {
|
|
61
|
+
calendarObjectId: string;
|
|
62
|
+
calendarId: string;
|
|
63
|
+
/** The occurrence the reader clicked. `""` on a resource that does not recur. */
|
|
64
|
+
recurrenceId: string;
|
|
65
|
+
/** Absent on a resource that does not recur — the server defaults to `All`. */
|
|
66
|
+
scope?: RecurrenceScope;
|
|
67
|
+
/** The version the edit was built on. */
|
|
68
|
+
etag: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CalendarWrites {
|
|
72
|
+
createEvent: (
|
|
73
|
+
input: RemitImapCreateCalendarEventInput,
|
|
74
|
+
) => Promise<CalendarWriteOutcome>;
|
|
75
|
+
updateEvent: (
|
|
76
|
+
write: ScopedWrite,
|
|
77
|
+
patch: RemitImapUpdateCalendarEventInput,
|
|
78
|
+
) => Promise<CalendarWriteOutcome>;
|
|
79
|
+
deleteEvent: (write: ScopedWrite) => Promise<CalendarWriteOutcome>;
|
|
80
|
+
isWriting: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Everything a write invalidates: the windows, the resource, the busy spans. */
|
|
84
|
+
export function useInvalidateCalendarReads(): () => void {
|
|
85
|
+
const queryClient = useQueryClient();
|
|
86
|
+
return useCallback(() => {
|
|
87
|
+
void queryClient.invalidateQueries({
|
|
88
|
+
predicate: (query) => {
|
|
89
|
+
const head = query.queryKey[0];
|
|
90
|
+
if (typeof head !== "object" || head === null) return false;
|
|
91
|
+
const id = (head as { _id?: unknown })._id;
|
|
92
|
+
return typeof id === "string" && CALENDAR_QUERY_IDS.has(id);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}, [queryClient]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const scopeQuery = ({ scope, recurrenceId }: ScopedWrite) =>
|
|
99
|
+
scope === undefined || scope === "all"
|
|
100
|
+
? {}
|
|
101
|
+
: { scope: SCOPES[scope], recurrenceId };
|
|
102
|
+
|
|
103
|
+
export function useCalendarWrites(): CalendarWrites {
|
|
104
|
+
const invalidate = useInvalidateCalendarReads();
|
|
105
|
+
|
|
106
|
+
const create = useMutation({
|
|
107
|
+
...calendarEventOperationsCreateCalendarEventMutation(),
|
|
108
|
+
meta: CALENDAR_WRITE_META,
|
|
109
|
+
onSuccess: invalidate,
|
|
110
|
+
});
|
|
111
|
+
const update = useMutation({
|
|
112
|
+
...calendarEventDetailOperationsUpdateCalendarEventMutation(),
|
|
113
|
+
meta: CALENDAR_WRITE_META,
|
|
114
|
+
onSuccess: invalidate,
|
|
115
|
+
});
|
|
116
|
+
const remove = useMutation({
|
|
117
|
+
...calendarEventDetailOperationsDeleteCalendarEventMutation(),
|
|
118
|
+
meta: CALENDAR_WRITE_META,
|
|
119
|
+
onSuccess: invalidate,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const createEvent = useCallback(
|
|
123
|
+
(input: RemitImapCreateCalendarEventInput) =>
|
|
124
|
+
create
|
|
125
|
+
.mutateAsync({ body: input })
|
|
126
|
+
.then(() => WRITTEN)
|
|
127
|
+
.catch(outcomeFor),
|
|
128
|
+
[create],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const updateEvent = useCallback(
|
|
132
|
+
(write: ScopedWrite, patch: RemitImapUpdateCalendarEventInput) =>
|
|
133
|
+
update
|
|
134
|
+
.mutateAsync({
|
|
135
|
+
path: { calendarObjectId: write.calendarObjectId },
|
|
136
|
+
query: { calendarId: write.calendarId, ...scopeQuery(write) },
|
|
137
|
+
headers: { "If-Match": write.etag },
|
|
138
|
+
body: patch,
|
|
139
|
+
})
|
|
140
|
+
.then(() => WRITTEN)
|
|
141
|
+
.catch(outcomeFor),
|
|
142
|
+
[update],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const deleteEvent = useCallback(
|
|
146
|
+
(write: ScopedWrite) =>
|
|
147
|
+
remove
|
|
148
|
+
.mutateAsync({
|
|
149
|
+
path: { calendarObjectId: write.calendarObjectId },
|
|
150
|
+
query: { calendarId: write.calendarId, ...scopeQuery(write) },
|
|
151
|
+
headers: { "If-Match": write.etag },
|
|
152
|
+
})
|
|
153
|
+
.then(() => WRITTEN)
|
|
154
|
+
.catch(outcomeFor),
|
|
155
|
+
[remove],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
return useMemo(
|
|
159
|
+
() => ({
|
|
160
|
+
createEvent,
|
|
161
|
+
updateEvent,
|
|
162
|
+
deleteEvent,
|
|
163
|
+
isWriting: create.isPending || update.isPending || remove.isPending,
|
|
164
|
+
}),
|
|
165
|
+
[
|
|
166
|
+
createEvent,
|
|
167
|
+
updateEvent,
|
|
168
|
+
deleteEvent,
|
|
169
|
+
create.isPending,
|
|
170
|
+
update.isPending,
|
|
171
|
+
remove.isPending,
|
|
172
|
+
],
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The calendars the account holds.
|
|
3
|
+
*
|
|
4
|
+
* The listing provisions a default collection on first read, so this never
|
|
5
|
+
* comes back empty for a working account and no surface has to know how a
|
|
6
|
+
* calendar gets made. It carries no `softError` meta: a session that has lapsed
|
|
7
|
+
* escalates to the signed-out state rather than drawing an empty week, which is
|
|
8
|
+
* indistinguishable from a week with nothing in it.
|
|
9
|
+
*/
|
|
10
|
+
import { calendarOperationsListCalendarsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
11
|
+
import type { CalendarColorId, CalendarDescriptor } from "@remit/ui";
|
|
12
|
+
import { useQuery } from "@tanstack/react-query";
|
|
13
|
+
import { useMemo } from "react";
|
|
14
|
+
import { toCalendarDescriptor } from "./instance";
|
|
15
|
+
|
|
16
|
+
export interface CalendarsResult {
|
|
17
|
+
calendars: CalendarDescriptor[];
|
|
18
|
+
colorByCalendarId: Record<string, CalendarColorId>;
|
|
19
|
+
/** The zone each collection's floating times are read in. */
|
|
20
|
+
timeZoneByCalendarId: Record<string, string>;
|
|
21
|
+
isLoading: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useCalendars(): CalendarsResult {
|
|
25
|
+
const { data, isLoading } = useQuery(
|
|
26
|
+
calendarOperationsListCalendarsOptions(),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return useMemo(() => {
|
|
30
|
+
const items = data?.items ?? [];
|
|
31
|
+
const calendars = items.map(toCalendarDescriptor);
|
|
32
|
+
return {
|
|
33
|
+
calendars,
|
|
34
|
+
colorByCalendarId: Object.fromEntries(
|
|
35
|
+
calendars.map((calendar) => [calendar.id, calendar.color]),
|
|
36
|
+
),
|
|
37
|
+
timeZoneByCalendarId: Object.fromEntries(
|
|
38
|
+
items.map((item) => [item.calendarId, item.timezone]),
|
|
39
|
+
),
|
|
40
|
+
isLoading,
|
|
41
|
+
};
|
|
42
|
+
}, [data, isLoading]);
|
|
43
|
+
}
|