@remit/web-client 0.0.231 → 0.0.233
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/calendar/AgendaStrip.stories.tsx +263 -0
- package/src/components/calendar/AgendaStrip.tsx +145 -0
- package/src/components/calendar/AgendaView.tsx +222 -0
- 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/CalendarViewPlaceholder.tsx +3 -4
- package/src/components/calendar/CalendarWorkspace.stories.tsx +34 -1
- package/src/components/calendar/CalendarWorkspace.tsx +71 -23
- package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
- package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
- package/src/components/calendar/agenda-address.render.test.ts +389 -0
- package/src/components/calendar/agenda-strip.render.test.ts +241 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +13 -6
- package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
- package/src/hooks/calendar/agenda-window.test.ts +235 -0
- package/src/hooks/calendar/agenda-window.ts +183 -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 +88 -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/selection.ts +107 -0
- package/src/hooks/calendar/useCalendarEvent.ts +46 -0
- package/src/hooks/calendar/useCalendarEvents.ts +195 -0
- package/src/hooks/calendar/useCalendarFreeBusy.ts +80 -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 +168 -0
- package/src/hooks/useCalendarData.ts +62 -40
- package/src/lib/calendar-density.ts +13 -1
- package/src/lib/calendar-route.test.ts +20 -1
- package/src/lib/calendar-route.ts +21 -7
- package/src/routes/calendar/$view.$date/new.tsx +2 -2
- package/src/routes/calendar/$view.$date.tsx +51 -15
- package/src/routing/calendar.ts +32 -4
- package/src/test-support/http.ts +8 -0
- package/src/lib/calendar-fixtures.ts +0 -207
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which calendars a surface is drawing, and how their occurrences become rows.
|
|
3
|
+
*
|
|
4
|
+
* Both readings of the calendar ask this and neither may answer it its own way:
|
|
5
|
+
* the grid reads one window and the strip reads many, but "which calendars did
|
|
6
|
+
* the address tick, and what does an occurrence look like once drawn" has one
|
|
7
|
+
* answer, and a second copy of it drifts into the two surfaces disagreeing
|
|
8
|
+
* about the same day.
|
|
9
|
+
*/
|
|
10
|
+
import type { RemitImapCalendarEventInstance } from "@remit/api-http-client/types.gen.ts";
|
|
11
|
+
import type {
|
|
12
|
+
CalendarColorId,
|
|
13
|
+
CalendarDescriptor,
|
|
14
|
+
CalendarEventData,
|
|
15
|
+
} from "@remit/ui";
|
|
16
|
+
import { useMemo } from "react";
|
|
17
|
+
import {
|
|
18
|
+
isDrawnInstance,
|
|
19
|
+
toCalendarEventData,
|
|
20
|
+
UNZONED_CALENDAR,
|
|
21
|
+
} from "./instance";
|
|
22
|
+
import { useCalendars } from "./useCalendars";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The calendars a request is asking about. An empty tick list is every
|
|
26
|
+
* calendar rather than none: a reader who has ticked nothing off is looking at
|
|
27
|
+
* all of them, and a URL naming a calendar that no longer exists shows the
|
|
28
|
+
* rest instead of an empty week.
|
|
29
|
+
*/
|
|
30
|
+
export function selectCalendarIds(
|
|
31
|
+
calendars: readonly CalendarDescriptor[],
|
|
32
|
+
ticked: readonly string[],
|
|
33
|
+
): string[] {
|
|
34
|
+
const known = calendars.map((calendar) => calendar.id);
|
|
35
|
+
if (ticked.length === 0) return known;
|
|
36
|
+
const wanted = new Set(ticked);
|
|
37
|
+
const shown = known.filter((id) => wanted.has(id));
|
|
38
|
+
return shown.length === 0 ? known : shown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CalendarSelection {
|
|
42
|
+
calendars: CalendarDescriptor[];
|
|
43
|
+
colorByCalendarId: Record<string, CalendarColorId>;
|
|
44
|
+
timeZoneByCalendarId: Record<string, string>;
|
|
45
|
+
/** Every calendar this address draws, resolved against the real ones. */
|
|
46
|
+
shown: string[];
|
|
47
|
+
/** What to ask the server for: empty where that is every calendar. */
|
|
48
|
+
narrowed: string[];
|
|
49
|
+
/** False while the tick list still has no real calendars to resolve against. */
|
|
50
|
+
resolved: boolean;
|
|
51
|
+
isLoading: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useCalendarSelection(
|
|
55
|
+
calendarIds: readonly string[],
|
|
56
|
+
): CalendarSelection {
|
|
57
|
+
const { calendars, colorByCalendarId, timeZoneByCalendarId, isLoading } =
|
|
58
|
+
useCalendars();
|
|
59
|
+
|
|
60
|
+
const shown = selectCalendarIds(calendars, calendarIds);
|
|
61
|
+
// Asking for every calendar by name and asking for all of them are the same
|
|
62
|
+
// question, so they are the same cache entry: the address ticking each one
|
|
63
|
+
// off must not refetch the week the address ticking none already holds.
|
|
64
|
+
const narrowed = shown.length === calendars.length ? [] : shown;
|
|
65
|
+
// A tick list can only be resolved against calendars that have loaded, so a
|
|
66
|
+
// narrowed address waits for them rather than asking about ids it cannot
|
|
67
|
+
// know are real.
|
|
68
|
+
const resolved = calendarIds.length === 0 || calendars.length > 0;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
calendars,
|
|
72
|
+
colorByCalendarId,
|
|
73
|
+
timeZoneByCalendarId,
|
|
74
|
+
shown,
|
|
75
|
+
narrowed,
|
|
76
|
+
resolved,
|
|
77
|
+
isLoading,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Occurrences as rows: the ones on a calendar being drawn, on the clock their
|
|
83
|
+
* collection keeps. A collection with no zone of its own is sent as unzoned
|
|
84
|
+
* rather than as the reader's — answering with the device's clock would rewrite
|
|
85
|
+
* every time they saved by the difference between the two.
|
|
86
|
+
*/
|
|
87
|
+
export function useDrawnEvents(
|
|
88
|
+
instances: readonly RemitImapCalendarEventInstance[],
|
|
89
|
+
timeZoneByCalendarId: Record<string, string>,
|
|
90
|
+
shown: readonly string[],
|
|
91
|
+
): CalendarEventData[] {
|
|
92
|
+
const shownKey = shown.join(",");
|
|
93
|
+
return useMemo(() => {
|
|
94
|
+
const drawn = new Set(shownKey === "" ? [] : shownKey.split(","));
|
|
95
|
+
return instances
|
|
96
|
+
.filter(
|
|
97
|
+
(instance) =>
|
|
98
|
+
isDrawnInstance(instance) && drawn.has(instance.calendarId),
|
|
99
|
+
)
|
|
100
|
+
.map((instance) =>
|
|
101
|
+
toCalendarEventData(
|
|
102
|
+
instance,
|
|
103
|
+
timeZoneByCalendarId[instance.calendarId] ?? UNZONED_CALENDAR,
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
}, [instances, timeZoneByCalendarId, shownKey]);
|
|
107
|
+
}
|
|
@@ -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,195 @@
|
|
|
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 { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
21
|
+
import { useEffect, useMemo } 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
|
+
export 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
|
+
export interface CalendarEventWeeksResult {
|
|
94
|
+
instances: RemitImapCalendarEventInstance[];
|
|
95
|
+
/** Windows whose answer has not arrived, by their `from`. */
|
|
96
|
+
pendingWindows: Set<string>;
|
|
97
|
+
/** The first refusal any of them met. Null when they all succeeded. */
|
|
98
|
+
error: unknown;
|
|
99
|
+
refetch: () => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Several windows at once, each fetched and cached on its own.
|
|
104
|
+
*
|
|
105
|
+
* This is what lets a surface hold a range wider than a single read may ask
|
|
106
|
+
* for: the server refuses a window over a year, and one request that grows
|
|
107
|
+
* every time the reader reaches an end eventually becomes that window and
|
|
108
|
+
* replaces what is on screen with a refusal. A week per request has no such
|
|
109
|
+
* ceiling — reaching an end adds a key rather than widening one — and because
|
|
110
|
+
* the keys are the grid's own, dropping into the week grid and coming back out
|
|
111
|
+
* draws from what the strip already fetched, and vice versa.
|
|
112
|
+
*
|
|
113
|
+
* Windows already held keep their entries while a new one is in flight, so
|
|
114
|
+
* nothing on screen is given up to fetch what is beside it.
|
|
115
|
+
*/
|
|
116
|
+
export function useCalendarEventWeeks(
|
|
117
|
+
windows: readonly CalendarWindow[],
|
|
118
|
+
calendarIds: readonly string[],
|
|
119
|
+
enabled: boolean,
|
|
120
|
+
): CalendarEventWeeksResult {
|
|
121
|
+
const results = useQueries({
|
|
122
|
+
queries: windows.map((window) => ({
|
|
123
|
+
...calendarEventOperationsListCalendarEventsOptions(
|
|
124
|
+
requestOptions({ ...window, calendarIds }),
|
|
125
|
+
),
|
|
126
|
+
meta: CALENDAR_READ_META,
|
|
127
|
+
staleTime: CALENDAR_WINDOW_STALE_TIME,
|
|
128
|
+
enabled,
|
|
129
|
+
})),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const froms = windows.map((window) => window.from).join("|");
|
|
133
|
+
const settled = results.map((result) => result.data);
|
|
134
|
+
const pendingKey = results.map((result) => result.isPending).join("|");
|
|
135
|
+
const firstError = results.find((result) => result.error)?.error ?? null;
|
|
136
|
+
|
|
137
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the joined keys stand for the per-window results, which are a new array every render
|
|
138
|
+
const instances = useMemo(
|
|
139
|
+
() => settled.flatMap((data) => data?.items ?? []),
|
|
140
|
+
[froms, pendingKey],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: as above
|
|
144
|
+
const pendingWindows = useMemo(() => {
|
|
145
|
+
const pending = new Set<string>();
|
|
146
|
+
windows.forEach((window, index) => {
|
|
147
|
+
if (results[index]?.isPending) pending.add(window.from);
|
|
148
|
+
});
|
|
149
|
+
return pending;
|
|
150
|
+
}, [froms, pendingKey]);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
instances,
|
|
154
|
+
pendingWindows,
|
|
155
|
+
error: firstError,
|
|
156
|
+
refetch: () => {
|
|
157
|
+
for (const result of results) {
|
|
158
|
+
if (result.error) result.refetch();
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The windows either side of the one on screen, warmed while the reader is
|
|
166
|
+
* looking at this one. A step is a navigation, and a navigation that has to
|
|
167
|
+
* wait for a request reads as the calendar being slow rather than as the week
|
|
168
|
+
* being fetched.
|
|
169
|
+
*/
|
|
170
|
+
export function usePrefetchAdjacentWindows(
|
|
171
|
+
view: CalendarViewId,
|
|
172
|
+
date: string,
|
|
173
|
+
calendarIds: readonly string[],
|
|
174
|
+
enabled: boolean,
|
|
175
|
+
): void {
|
|
176
|
+
const queryClient = useQueryClient();
|
|
177
|
+
const key = [...calendarIds].join(",");
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!enabled) return;
|
|
181
|
+
const ids = key === "" ? [] : key.split(",");
|
|
182
|
+
for (const direction of [-1, 1] as const) {
|
|
183
|
+
const neighbour = calendarWindow(
|
|
184
|
+
view,
|
|
185
|
+
stepCalendarDate(date, view, direction),
|
|
186
|
+
);
|
|
187
|
+
void queryClient.prefetchQuery({
|
|
188
|
+
...calendarEventOperationsListCalendarEventsOptions(
|
|
189
|
+
requestOptions({ ...neighbour, calendarIds: ids }),
|
|
190
|
+
),
|
|
191
|
+
staleTime: CALENDAR_WINDOW_STALE_TIME,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}, [queryClient, view, date, key, enabled]);
|
|
195
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Busy time over a window, merged across every calendar the account holds.
|
|
3
|
+
*
|
|
4
|
+
* Not the same question as "what is on the strip". A calendar the reader has
|
|
5
|
+
* unticked still takes their Thursday afternoon, so "am I free" is answered off
|
|
6
|
+
* this and never off the rows on screen. The server does the merging, so two
|
|
7
|
+
* overlapping meetings in two calendars are one span here, and an event marked
|
|
8
|
+
* transparent or cancelled is not one at all.
|
|
9
|
+
*
|
|
10
|
+
* The endpoint takes no calendar filter, on purpose: narrowing it would answer
|
|
11
|
+
* a question nobody asked. It carries the same refusals as the window read — a
|
|
12
|
+
* window the server will not accept is stated in place, and a lapsed session
|
|
13
|
+
* escalates rather than being drawn as a clear diary.
|
|
14
|
+
*/
|
|
15
|
+
import { calendarFreeBusyOperationsListCalendarFreeBusyOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
16
|
+
import type { RemitImapCalendarFreeBusySpan } from "@remit/api-http-client/types.gen.ts";
|
|
17
|
+
import { useQueries } from "@tanstack/react-query";
|
|
18
|
+
import { useMemo } from "react";
|
|
19
|
+
import { softErrorStatuses } from "@/lib/error-classifier";
|
|
20
|
+
import { CALENDAR_WINDOW_STALE_TIME } from "./useCalendarEvents";
|
|
21
|
+
import type { CalendarWindow } from "./window";
|
|
22
|
+
|
|
23
|
+
const CALENDAR_READ_META = softErrorStatuses(400, 404);
|
|
24
|
+
|
|
25
|
+
export interface CalendarFreeBusyResult {
|
|
26
|
+
spans: RemitImapCalendarFreeBusySpan[];
|
|
27
|
+
/** Windows whose answer has not arrived, by their `from`. */
|
|
28
|
+
pendingWindows: Set<string>;
|
|
29
|
+
error: unknown;
|
|
30
|
+
refetch: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Busy time a window at a time, for the same reason the events are read that
|
|
35
|
+
* way: this endpoint refuses a window over a year too, so a range that grows
|
|
36
|
+
* as the reader scrolls cannot be one request.
|
|
37
|
+
*/
|
|
38
|
+
export function useCalendarFreeBusyWeeks(
|
|
39
|
+
windows: readonly CalendarWindow[],
|
|
40
|
+
): CalendarFreeBusyResult {
|
|
41
|
+
const results = useQueries({
|
|
42
|
+
queries: windows.map((window) => ({
|
|
43
|
+
...calendarFreeBusyOperationsListCalendarFreeBusyOptions({
|
|
44
|
+
query: { from: window.from, to: window.to },
|
|
45
|
+
}),
|
|
46
|
+
meta: CALENDAR_READ_META,
|
|
47
|
+
staleTime: CALENDAR_WINDOW_STALE_TIME,
|
|
48
|
+
})),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const froms = windows.map((window) => window.from).join("|");
|
|
52
|
+
const settled = results.map((result) => result.data);
|
|
53
|
+
const pendingKey = results.map((result) => result.isPending).join("|");
|
|
54
|
+
|
|
55
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the joined keys stand for the per-window results, which are a new array every render
|
|
56
|
+
const spans = useMemo(
|
|
57
|
+
() => settled.flatMap((data) => data?.items ?? []),
|
|
58
|
+
[froms, pendingKey],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: as above
|
|
62
|
+
const pendingWindows = useMemo(() => {
|
|
63
|
+
const pending = new Set<string>();
|
|
64
|
+
windows.forEach((window, index) => {
|
|
65
|
+
if (results[index]?.isPending) pending.add(window.from);
|
|
66
|
+
});
|
|
67
|
+
return pending;
|
|
68
|
+
}, [froms, pendingKey]);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
spans,
|
|
72
|
+
pendingWindows,
|
|
73
|
+
error: results.find((result) => result.error)?.error ?? null,
|
|
74
|
+
refetch: () => {
|
|
75
|
+
for (const result of results) {
|
|
76
|
+
if (result.error) result.refetch();
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -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
|
+
}
|