@remit/web-client 0.0.291 → 0.0.292

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.
@@ -0,0 +1,43 @@
1
+ import { Select } from "@remit/ui";
2
+ import { UNZONED_CALENDAR } from "@/hooks/calendar";
3
+
4
+ /**
5
+ * The zone a calendar's floating times are read in. The empty choice is how
6
+ * the API spells "no zone", which reads as UTC — there is no IANA name for UTC
7
+ * the server accepts, so it is offered as its own option rather than as a zone.
8
+ */
9
+ export interface TimeZoneSelectProps {
10
+ id: string;
11
+ value: string;
12
+ disabled?: boolean;
13
+ onChange: (timeZone: string) => void;
14
+ }
15
+
16
+ const ZONES = Intl.supportedValuesOf("timeZone");
17
+
18
+ export function TimeZoneSelect({
19
+ id,
20
+ value,
21
+ disabled,
22
+ onChange,
23
+ }: TimeZoneSelectProps) {
24
+ const zones =
25
+ value === UNZONED_CALENDAR || ZONES.includes(value)
26
+ ? ZONES
27
+ : [value, ...ZONES];
28
+ return (
29
+ <Select
30
+ id={id}
31
+ value={value}
32
+ disabled={disabled}
33
+ onChange={(event) => onChange(event.target.value)}
34
+ >
35
+ <option value={UNZONED_CALENDAR}>None (read as UTC)</option>
36
+ {zones.map((zone) => (
37
+ <option key={zone} value={zone}>
38
+ {zone}
39
+ </option>
40
+ ))}
41
+ </Select>
42
+ );
43
+ }
@@ -0,0 +1,149 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { CalendarClash, EventDraft } from "@remit/ui";
4
+ import React, { createElement } from "react";
5
+ import { renderToString } from "react-dom/server";
6
+ import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
7
+ import { CalendarCreateCard } from "./CalendarCreateCard";
8
+ import { CalendarDetailsForm } from "./CalendarDetailsForm";
9
+ import { CalendarFeedCard } from "./CalendarFeedCard";
10
+
11
+ // The node test loader transpiles remit-ui's `.tsx` with the classic JSX
12
+ // runtime, which references a global `React`.
13
+ (globalThis as { React?: typeof React }).React = React;
14
+
15
+ const details = (problem = "", timezone = "Europe/Amsterdam") =>
16
+ renderToString(
17
+ createElement(CalendarDetailsForm, {
18
+ calendarName: "Work",
19
+ timezone,
20
+ isBusy: false,
21
+ problem,
22
+ onSave: () => undefined,
23
+ onDelete: () => undefined,
24
+ }) as never,
25
+ );
26
+
27
+ describe("CalendarDetailsForm", () => {
28
+ it("offers the name, the zone, a save and a delete", () => {
29
+ const html = details();
30
+ assert.match(html, /value="Work"/);
31
+ assert.match(html, /Europe\/Amsterdam/);
32
+ assert.match(html, /Save changes/);
33
+ assert.match(html, /Delete calendar/);
34
+ });
35
+
36
+ it("offers no zone as its own choice, read as UTC", () => {
37
+ assert.match(details("", ""), /None \(read as UTC\)/);
38
+ });
39
+
40
+ it("keeps a zone the list does not hold rather than dropping it", () => {
41
+ assert.match(details("", "Mars/Olympus"), /Mars\/Olympus/);
42
+ });
43
+
44
+ it("states a refusal in the server's words", () => {
45
+ const html = details("cannot be removed");
46
+ assert.match(html, /role="alert"/);
47
+ assert.match(html.replaceAll("<!-- -->", ""), /Work was not changed/);
48
+ assert.match(html, /cannot be removed/);
49
+ });
50
+ });
51
+
52
+ describe("CalendarCreateCard", () => {
53
+ const create = (problem = "") =>
54
+ renderToString(
55
+ createElement(CalendarCreateCard, {
56
+ isBusy: false,
57
+ problem,
58
+ onCreate: () => Promise.resolve(true),
59
+ }) as never,
60
+ );
61
+
62
+ it("asks for a name, an address and a zone", () => {
63
+ const html = create();
64
+ assert.match(html, /New calendar/);
65
+ assert.match(html, />Name</);
66
+ assert.match(html, />Address</);
67
+ assert.match(html, />Time zone</);
68
+ assert.match(html, /Add calendar/);
69
+ });
70
+
71
+ it("states a refused create where the button is", () => {
72
+ const html = create("already addresses a calendar");
73
+ assert.match(html, /The calendar was not added/);
74
+ assert.match(html, /already addresses a calendar/);
75
+ });
76
+ });
77
+
78
+ describe("CalendarFeedCard with details", () => {
79
+ it("draws the calendar's settings above its address", () => {
80
+ const html = renderToString(
81
+ createElement(CalendarFeedCard, {
82
+ calendarName: "Work",
83
+ details: createElement("p", null, "calendar details here"),
84
+ state: { status: "absent" },
85
+ mintedUrl: "",
86
+ isBusy: false,
87
+ actionError: undefined,
88
+ onMint: () => undefined,
89
+ onRevoke: () => undefined,
90
+ onDismissMinted: () => undefined,
91
+ onRetry: () => undefined,
92
+ }) as never,
93
+ );
94
+ assert.ok(
95
+ html.indexOf("calendar details here") <
96
+ html.indexOf("Create subscription address"),
97
+ );
98
+ });
99
+ });
100
+
101
+ describe("CalendarComposePane clashes", () => {
102
+ const draft: EventDraft = {
103
+ title: "Roadmap review",
104
+ date: "2026-06-10",
105
+ endDate: "2026-06-10",
106
+ startTime: "10:00",
107
+ endTime: "11:00",
108
+ allDay: false,
109
+ calendarId: "cal_work",
110
+ location: "",
111
+ guests: "",
112
+ notes: "",
113
+ repeat: "",
114
+ };
115
+ const pane = (clashes: CalendarClash[] | undefined) =>
116
+ renderToString(
117
+ createElement(CalendarComposePane, {
118
+ title: "New event",
119
+ calendars: [],
120
+ draft,
121
+ onChange: () => undefined,
122
+ problem: "",
123
+ saveLabel: "Add",
124
+ isSaving: false,
125
+ clashes,
126
+ onSave: () => undefined,
127
+ onCancel: () => undefined,
128
+ }) as never,
129
+ );
130
+
131
+ it("names what the span runs into", () => {
132
+ const html = pane([{ id: "a", label: "Board prep, Wed 10 June" }]);
133
+ assert.match(
134
+ html,
135
+ /This clashes with something you have already agreed to/,
136
+ );
137
+ assert.match(html, /Board prep, Wed 10 June/);
138
+ });
139
+
140
+ it("says a checked span is clear", () => {
141
+ assert.match(pane([]), /Nothing else is booked at this time/);
142
+ });
143
+
144
+ it("draws nothing before there is an answer", () => {
145
+ const html = pane(undefined);
146
+ assert.doesNotMatch(html, /Nothing else is booked/);
147
+ assert.doesNotMatch(html, /This clashes/);
148
+ });
149
+ });
@@ -0,0 +1,259 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import type {
4
+ RemitImapCalendarEventInstance,
5
+ RemitImapCalendarResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import type { CalendarClash, EventDraft } from "@remit/ui";
8
+ import { act, createElement } from "react";
9
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
10
+ import {
11
+ type HttpCall,
12
+ type HttpMock,
13
+ httpError,
14
+ mockFetch,
15
+ } from "../../test-support/http";
16
+ import {
17
+ type CalendarCollectionOutcome,
18
+ type CalendarCollectionWrites,
19
+ useCalendarCollectionWrites,
20
+ } from "./useCalendarCollectionWrites";
21
+ import { useCalendars } from "./useCalendars";
22
+ import { useDraftClashes } from "./useDraftClashes";
23
+
24
+ const WORK = "11111111-1111-4111-8111-111111111111";
25
+
26
+ let harness: DomHarness | undefined;
27
+ let http: HttpMock | undefined;
28
+ let writes: CalendarCollectionWrites | undefined;
29
+ let clashes: CalendarClash[] | undefined;
30
+
31
+ afterEach(() => {
32
+ harness?.close();
33
+ harness = undefined;
34
+ http?.restore();
35
+ http = undefined;
36
+ writes = undefined;
37
+ clashes = undefined;
38
+ });
39
+
40
+ const calendars: RemitImapCalendarResponse[] = [
41
+ {
42
+ calendarId: WORK,
43
+ accountConfigId: "cfg-1",
44
+ urlSegment: "work",
45
+ displayName: "Work",
46
+ color: "Cal1",
47
+ componentSet: "VeventOnly",
48
+ source: "UserCreated",
49
+ timezone: "",
50
+ syncSequence: 1,
51
+ createdAt: 0,
52
+ updatedAt: 0,
53
+ } as RemitImapCalendarResponse,
54
+ ];
55
+
56
+ const instance = (
57
+ over: Partial<RemitImapCalendarEventInstance>,
58
+ ): RemitImapCalendarEventInstance =>
59
+ ({
60
+ calendarId: WORK,
61
+ calendarObjectId: "obj-1",
62
+ recurrenceId: "",
63
+ icalUid: "uid-1",
64
+ summary: "Board prep",
65
+ start: "2026-06-10T10:30:00+00:00",
66
+ end: "2026-06-10T11:00:00+00:00",
67
+ allDay: false,
68
+ status: "Confirmed",
69
+ transparency: "Opaque",
70
+ zoneCertainty: "Explicit",
71
+ etag: "etag-1",
72
+ hasRecurrence: false,
73
+ ...over,
74
+ }) as RemitImapCalendarEventInstance;
75
+
76
+ const draft = (over: Partial<EventDraft>): EventDraft => ({
77
+ title: "Roadmap review",
78
+ date: "2026-06-10",
79
+ endDate: "2026-06-10",
80
+ startTime: "10:00",
81
+ endTime: "11:00",
82
+ allDay: false,
83
+ calendarId: WORK,
84
+ location: "",
85
+ guests: "",
86
+ notes: "",
87
+ repeat: "",
88
+ ...over,
89
+ });
90
+
91
+ const settle = async () => {
92
+ await harness?.flush();
93
+ await harness?.wait(20);
94
+ await harness?.flush();
95
+ };
96
+
97
+ const serve = (respond: (call: HttpCall) => unknown) => {
98
+ http = mockFetch((call) =>
99
+ call.path.endsWith("/calendars") && call.method === "GET"
100
+ ? { items: calendars }
101
+ : respond(call),
102
+ );
103
+ harness = createDomHarness();
104
+ };
105
+
106
+ function WritesProbe() {
107
+ useCalendars();
108
+ writes = useCalendarCollectionWrites();
109
+ return null;
110
+ }
111
+
112
+ const write = async (
113
+ run: (writes: CalendarCollectionWrites) => Promise<CalendarCollectionOutcome>,
114
+ ): Promise<CalendarCollectionOutcome> => {
115
+ const held = writes;
116
+ if (!held) throw new Error("the writes are not mounted");
117
+ let outcome: CalendarCollectionOutcome | undefined;
118
+ await act(async () => {
119
+ outcome = await run(held);
120
+ });
121
+ await settle();
122
+ if (!outcome) throw new Error("the write returned nothing");
123
+ return outcome;
124
+ };
125
+
126
+ const listingReads = (): number =>
127
+ (http?.calls ?? []).filter(
128
+ (call) => call.method === "GET" && call.path.endsWith("/calendars"),
129
+ ).length;
130
+
131
+ describe("managing a calendar collection", () => {
132
+ it("creates one and reads the listing back", async () => {
133
+ serve(() => calendars[0]);
134
+ harness?.renderApp(createElement(WritesProbe));
135
+ await settle();
136
+ const before = listingReads();
137
+
138
+ const outcome = await write((writes) =>
139
+ writes.createCalendar({ displayName: "Harbour", urlSegment: "harbour" }),
140
+ );
141
+
142
+ assert.deepEqual(outcome, { kind: "written" });
143
+ const posted = (http?.calls ?? []).find((call) => call.method === "POST");
144
+ assert.equal(posted?.body?.urlSegment, "harbour");
145
+ assert.ok(listingReads() > before, "the listing has to be read again");
146
+ });
147
+
148
+ it("renames and rezones one through a patch", async () => {
149
+ serve(() => calendars[0]);
150
+ harness?.renderApp(createElement(WritesProbe));
151
+ await settle();
152
+
153
+ await write((writes) =>
154
+ writes.updateCalendar(WORK, {
155
+ displayName: "Works",
156
+ timezone: "Europe/Lisbon",
157
+ }),
158
+ );
159
+
160
+ const patched = (http?.calls ?? []).find((call) => call.method === "PATCH");
161
+ assert.equal(patched?.path.endsWith(`/calendars/${WORK}`), true);
162
+ assert.equal(patched?.body?.timezone, "Europe/Lisbon");
163
+ });
164
+
165
+ it("hands back the server's words when it refuses a delete", async () => {
166
+ serve((call) =>
167
+ call.method === "DELETE"
168
+ ? httpError(400, '"default" cannot be removed')
169
+ : calendars[0],
170
+ );
171
+ harness?.renderApp(createElement(WritesProbe));
172
+ await settle();
173
+
174
+ const outcome = await write((writes) => writes.deleteCalendar(WORK));
175
+ assert.equal(outcome.kind, "refused");
176
+ assert.match(
177
+ outcome.kind === "refused" ? outcome.message : "",
178
+ /cannot be removed/,
179
+ );
180
+ });
181
+ });
182
+
183
+ function ClashProbe({
184
+ of,
185
+ editing = "",
186
+ }: {
187
+ of: EventDraft;
188
+ editing?: string;
189
+ }) {
190
+ clashes = useDraftClashes(of, editing);
191
+ return null;
192
+ }
193
+
194
+ const clashesFor = async (
195
+ of: EventDraft,
196
+ items: RemitImapCalendarEventInstance[],
197
+ editing = "",
198
+ ): Promise<CalendarClash[] | undefined> => {
199
+ serve(() => ({ items }));
200
+ harness?.renderApp(createElement(ClashProbe, { of, editing }));
201
+ await settle();
202
+ return clashes;
203
+ };
204
+
205
+ describe("the clashes a draft runs into", () => {
206
+ it("names an event the span overlaps", async () => {
207
+ const found = await clashesFor(draft({}), [instance({})]);
208
+ assert.equal(found?.length, 1);
209
+ assert.match(found?.[0]?.label ?? "", /^Board prep, /);
210
+ });
211
+
212
+ it("finds none for a span in a free slot", async () => {
213
+ const found = await clashesFor(
214
+ draft({ startTime: "14:00", endTime: "15:00" }),
215
+ [instance({})],
216
+ );
217
+ assert.deepEqual(found, []);
218
+ });
219
+
220
+ it("never counts the event being edited against itself", async () => {
221
+ const found = await clashesFor(draft({}), [instance({})], "obj-1");
222
+ assert.deepEqual(found, []);
223
+ });
224
+
225
+ it("checks a multi-day span through its last day", async () => {
226
+ const found = await clashesFor(
227
+ draft({
228
+ endDate: "2026-06-16",
229
+ startTime: "09:00",
230
+ endTime: "17:00",
231
+ }),
232
+ [
233
+ instance({
234
+ start: "2026-06-15T12:00:00+00:00",
235
+ end: "2026-06-15T13:00:00+00:00",
236
+ }),
237
+ ],
238
+ );
239
+ assert.equal(found?.length, 1);
240
+ const read = http?.to("/calendar-events")[0]?.url ?? "";
241
+ assert.ok(
242
+ read.includes(`to=${encodeURIComponent("2026-06-22")}`),
243
+ "the read has to cover the week of the last day",
244
+ );
245
+ });
246
+
247
+ it("checks nothing when the end is not after the start", async () => {
248
+ const found = await clashesFor(
249
+ draft({ startTime: "10:00", endTime: "10:00" }),
250
+ [instance({})],
251
+ );
252
+ assert.equal(found, undefined);
253
+ });
254
+
255
+ it("checks nothing for an all-day draft", async () => {
256
+ const found = await clashesFor(draft({ allDay: true }), [instance({})]);
257
+ assert.equal(found, undefined);
258
+ });
259
+ });
@@ -57,6 +57,11 @@ export {
57
57
  useCalendarSelection,
58
58
  useDrawnEvents,
59
59
  } from "./selection";
60
+ export {
61
+ type CalendarCollectionOutcome,
62
+ type CalendarCollectionWrites,
63
+ useCalendarCollectionWrites,
64
+ } from "./useCalendarCollectionWrites";
60
65
  export {
61
66
  type CalendarEventResource,
62
67
  useCalendarEvent,
@@ -82,6 +87,7 @@ export {
82
87
  useCalendarWrites,
83
88
  useInvalidateCalendarReads,
84
89
  } from "./useCalendarWrites";
90
+ export { useDraftClashes } from "./useDraftClashes";
85
91
  export {
86
92
  addDays,
87
93
  type CalendarWindow,
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Creating, renaming, rezoning and deleting a calendar collection.
3
+ *
4
+ * A refusal comes back as an outcome carrying the server's own words — a url
5
+ * segment already taken, a zone it cannot resolve, the default calendar that
6
+ * cannot be removed — so the settings card states it where the reader acted.
7
+ * A lapsed session is not among the statuses owned here and escalates.
8
+ *
9
+ * Every write invalidates the listing, which is what the settings page, the
10
+ * editor's picker and the address filter all read. A delete or a rezone also
11
+ * moves or removes occurrences, so the event reads are invalidated with it.
12
+ */
13
+ import {
14
+ calendarDetailOperationsDeleteCalendarMutation,
15
+ calendarDetailOperationsUpdateCalendarMutation,
16
+ calendarOperationsCreateCalendarMutation,
17
+ calendarOperationsListCalendarsQueryKey,
18
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
19
+ import type {
20
+ RemitImapCreateCalendarInput,
21
+ RemitImapUpdateCalendarInput,
22
+ } from "@remit/api-http-client/types.gen.ts";
23
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
24
+ import { useCallback, useMemo } from "react";
25
+ import { formatErrorMessage } from "@/components/ui/ErrorState";
26
+ import { softErrorStatuses } from "@/lib/error-classifier";
27
+ import { useInvalidateCalendarReads } from "./useCalendarWrites";
28
+
29
+ const COLLECTION_WRITE_META = softErrorStatuses(400, 404);
30
+
31
+ export type CalendarCollectionOutcome =
32
+ | { kind: "written" }
33
+ | { kind: "refused"; message: string };
34
+
35
+ const WRITTEN: CalendarCollectionOutcome = { kind: "written" };
36
+
37
+ const refused = (error: unknown): CalendarCollectionOutcome => ({
38
+ kind: "refused",
39
+ message: formatErrorMessage(error),
40
+ });
41
+
42
+ export interface CalendarCollectionWrites {
43
+ createCalendar: (
44
+ input: RemitImapCreateCalendarInput,
45
+ ) => Promise<CalendarCollectionOutcome>;
46
+ updateCalendar: (
47
+ calendarId: string,
48
+ patch: RemitImapUpdateCalendarInput,
49
+ ) => Promise<CalendarCollectionOutcome>;
50
+ deleteCalendar: (calendarId: string) => Promise<CalendarCollectionOutcome>;
51
+ isWriting: boolean;
52
+ }
53
+
54
+ export function useCalendarCollectionWrites(): CalendarCollectionWrites {
55
+ const queryClient = useQueryClient();
56
+ const invalidateReads = useInvalidateCalendarReads();
57
+
58
+ const invalidateListing = useCallback(
59
+ () =>
60
+ queryClient.invalidateQueries({
61
+ queryKey: calendarOperationsListCalendarsQueryKey(),
62
+ }),
63
+ [queryClient],
64
+ );
65
+
66
+ const invalidateAll = useCallback(() => {
67
+ invalidateReads();
68
+ return invalidateListing();
69
+ }, [invalidateReads, invalidateListing]);
70
+
71
+ const create = useMutation({
72
+ ...calendarOperationsCreateCalendarMutation(),
73
+ meta: COLLECTION_WRITE_META,
74
+ onSuccess: invalidateListing,
75
+ });
76
+ const update = useMutation({
77
+ ...calendarDetailOperationsUpdateCalendarMutation(),
78
+ meta: COLLECTION_WRITE_META,
79
+ onSuccess: invalidateAll,
80
+ });
81
+ const remove = useMutation({
82
+ ...calendarDetailOperationsDeleteCalendarMutation(),
83
+ meta: COLLECTION_WRITE_META,
84
+ onSuccess: invalidateAll,
85
+ });
86
+
87
+ const createCalendar = useCallback(
88
+ (input: RemitImapCreateCalendarInput) =>
89
+ create
90
+ .mutateAsync({ body: input })
91
+ .then(() => WRITTEN)
92
+ .catch(refused),
93
+ [create],
94
+ );
95
+
96
+ const updateCalendar = useCallback(
97
+ (calendarId: string, patch: RemitImapUpdateCalendarInput) =>
98
+ update
99
+ .mutateAsync({ path: { calendarId }, body: patch })
100
+ .then(() => WRITTEN)
101
+ .catch(refused),
102
+ [update],
103
+ );
104
+
105
+ const deleteCalendar = useCallback(
106
+ (calendarId: string) =>
107
+ remove
108
+ .mutateAsync({ path: { calendarId } })
109
+ .then(() => WRITTEN)
110
+ .catch(refused),
111
+ [remove],
112
+ );
113
+
114
+ return useMemo(
115
+ () => ({
116
+ createCalendar,
117
+ updateCalendar,
118
+ deleteCalendar,
119
+ isWriting: create.isPending || update.isPending || remove.isPending,
120
+ }),
121
+ [
122
+ createCalendar,
123
+ updateCalendar,
124
+ deleteCalendar,
125
+ create.isPending,
126
+ update.isPending,
127
+ remove.isPending,
128
+ ],
129
+ );
130
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What the span a draft names runs into, across every calendar the reader
3
+ * holds — so the editor says so before Save rather than the grid after it.
4
+ *
5
+ * Read on the weeks from the draft's first day through its last, which for a
6
+ * single-day draft is the grid's own cache entry, and measured by the same rule the invitation card uses. Undefined
7
+ * while there is no span to check (all day, a time missing, an end not after
8
+ * the start) or no answer to
9
+ * check it against: drawing "nothing else is booked" over a read that has not
10
+ * landed is a claim, not a delay.
11
+ */
12
+ import {
13
+ type CalendarClash,
14
+ type CalendarEventData,
15
+ clashesWith,
16
+ type EventDraft,
17
+ } from "@remit/ui";
18
+ import { useMemo } from "react";
19
+ import { formatEventWhen } from "@/lib/calendar-format";
20
+ import {
21
+ isDrawnInstance,
22
+ toCalendarEventData,
23
+ UNZONED_CALENDAR,
24
+ } from "./instance";
25
+ import { useCalendarEventWindow } from "./useCalendarEvents";
26
+ import { useCalendars } from "./useCalendars";
27
+ import { type CalendarWindow, calendarWindow, isoAtInZone } from "./window";
28
+
29
+ const EVERY_CALENDAR: readonly string[] = [];
30
+ const NO_WINDOW: CalendarWindow = { from: "", to: "" };
31
+
32
+ export function useDraftClashes(
33
+ draft: EventDraft,
34
+ /** The resource being edited, which never clashes with itself. */
35
+ calendarObjectId = "",
36
+ ): CalendarClash[] | undefined {
37
+ const { timeZoneByCalendarId } = useCalendars();
38
+ const checkable =
39
+ draft.date !== "" &&
40
+ !draft.allDay &&
41
+ draft.endDate !== "" &&
42
+ draft.startTime !== "" &&
43
+ draft.endTime !== "" &&
44
+ `${draft.endDate}T${draft.endTime}` > `${draft.date}T${draft.startTime}`;
45
+ const { instances, isLoading, error } = useCalendarEventWindow({
46
+ ...(checkable
47
+ ? {
48
+ from: calendarWindow("week", draft.date).from,
49
+ to: calendarWindow("week", draft.endDate).to,
50
+ }
51
+ : NO_WINDOW),
52
+ calendarIds: EVERY_CALENDAR,
53
+ enabled: checkable,
54
+ });
55
+
56
+ const zone = timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR;
57
+
58
+ return useMemo(() => {
59
+ if (!checkable || isLoading || error) return undefined;
60
+ const start = isoAtInZone(draft.date, draft.startTime, zone);
61
+ const end = isoAtInZone(draft.endDate, draft.endTime, zone);
62
+ const others: CalendarEventData[] = instances
63
+ .filter(
64
+ (instance) =>
65
+ isDrawnInstance(instance) &&
66
+ instance.calendarObjectId !== calendarObjectId,
67
+ )
68
+ .map((instance) =>
69
+ toCalendarEventData(
70
+ instance,
71
+ timeZoneByCalendarId[instance.calendarId] ?? UNZONED_CALENDAR,
72
+ ),
73
+ );
74
+ return clashesWith({ start, end }, others).map((event) => ({
75
+ id: event.id,
76
+ label: `${event.title}, ${formatEventWhen(event)}`,
77
+ }));
78
+ }, [
79
+ checkable,
80
+ isLoading,
81
+ error,
82
+ draft.date,
83
+ draft.endDate,
84
+ draft.startTime,
85
+ draft.endTime,
86
+ zone,
87
+ instances,
88
+ calendarObjectId,
89
+ timeZoneByCalendarId,
90
+ ]);
91
+ }