@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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/AgendaStrip.stories.tsx +263 -0
  3. package/src/components/calendar/AgendaStrip.tsx +145 -0
  4. package/src/components/calendar/AgendaView.tsx +222 -0
  5. package/src/components/calendar/CalendarComposePane.stories.tsx +101 -11
  6. package/src/components/calendar/CalendarComposePane.tsx +83 -23
  7. package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
  8. package/src/components/calendar/CalendarEventPane.stories.tsx +30 -1
  9. package/src/components/calendar/CalendarEventPane.tsx +28 -5
  10. package/src/components/calendar/CalendarViewPlaceholder.tsx +3 -4
  11. package/src/components/calendar/CalendarWorkspace.stories.tsx +34 -1
  12. package/src/components/calendar/CalendarWorkspace.tsx +71 -23
  13. package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
  14. package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
  15. package/src/components/calendar/agenda-address.render.test.ts +389 -0
  16. package/src/components/calendar/agenda-strip.render.test.ts +241 -0
  17. package/src/components/calendar/calendar-workspace.render.test.ts +13 -6
  18. package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
  19. package/src/hooks/calendar/agenda-window.test.ts +235 -0
  20. package/src/hooks/calendar/agenda-window.ts +183 -0
  21. package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
  22. package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
  23. package/src/hooks/calendar/draft.test.ts +241 -0
  24. package/src/hooks/calendar/draft.ts +168 -0
  25. package/src/hooks/calendar/index.ts +88 -0
  26. package/src/hooks/calendar/instance.ts +136 -0
  27. package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
  28. package/src/hooks/calendar/recurrence-rule.ts +133 -0
  29. package/src/hooks/calendar/resource.test.ts +98 -0
  30. package/src/hooks/calendar/resource.ts +74 -0
  31. package/src/hooks/calendar/selection.ts +107 -0
  32. package/src/hooks/calendar/useCalendarEvent.ts +46 -0
  33. package/src/hooks/calendar/useCalendarEvents.ts +195 -0
  34. package/src/hooks/calendar/useCalendarFreeBusy.ts +80 -0
  35. package/src/hooks/calendar/useCalendarWrites.ts +174 -0
  36. package/src/hooks/calendar/useCalendars.ts +43 -0
  37. package/src/hooks/calendar/window.test.ts +148 -0
  38. package/src/hooks/calendar/window.ts +168 -0
  39. package/src/hooks/useCalendarData.ts +62 -40
  40. package/src/lib/calendar-density.ts +13 -1
  41. package/src/lib/calendar-route.test.ts +20 -1
  42. package/src/lib/calendar-route.ts +21 -7
  43. package/src/routes/calendar/$view.$date/new.tsx +2 -2
  44. package/src/routes/calendar/$view.$date.tsx +51 -15
  45. package/src/routing/calendar.ts +32 -4
  46. package/src/test-support/http.ts +8 -0
  47. 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
+ }