@remit/web-client 0.0.313 → 0.0.314
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.tsx +15 -2
- package/src/components/calendar/CalendarWaiting.tsx +3 -0
- package/src/components/calendar/ChangeCalendarSuggestion.stories.tsx +182 -0
- package/src/components/calendar/ChangeCalendarSuggestion.tsx +159 -0
- package/src/components/calendar/PendingSuggestions.tsx +35 -5
- package/src/components/calendar/calendar-story-server.tsx +5 -0
- package/src/components/calendar/change-suggestion-first.render.test.ts +299 -0
- package/src/components/calendar/pending-suggestions.render.test.ts +68 -3
- package/src/hooks/calendar/draft.ts +1 -1
- package/src/hooks/calendar/index.ts +7 -0
- package/src/hooks/calendar/suggestion-draft.ts +77 -0
- package/src/hooks/calendar/useCalendarSuggestions.ts +59 -1
- package/src/hooks/intelligence-calendar.render.test.ts +135 -0
- package/src/hooks/useIntelligenceCalendar.ts +36 -5
- package/src/lib/calendar-suggestion.test.ts +10 -1
- package/src/lib/calendar-suggestion.ts +12 -2
- package/src/routeTree.gen.ts +23 -0
- package/src/routes/calendar/$view.$date/suggestion.$suggestionId.tsx +22 -0
- package/src/routes/calendar/$view.$date.tsx +9 -3
- package/src/routes/calendar/-calendar-tree.test.ts +18 -0
- package/src/routing/calendar.ts +36 -1
- package/src/routing/index.ts +2 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, afterEach, before, describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
RemitImapCalendarResponse,
|
|
5
|
+
RemitImapCalendarSuggestionResponse,
|
|
6
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
7
|
+
import {
|
|
8
|
+
type AnyRouter,
|
|
9
|
+
createMemoryHistory,
|
|
10
|
+
createRootRoute,
|
|
11
|
+
createRoute,
|
|
12
|
+
createRouter,
|
|
13
|
+
Outlet,
|
|
14
|
+
type RouteComponent,
|
|
15
|
+
RouterProvider,
|
|
16
|
+
} from "@tanstack/react-router";
|
|
17
|
+
import { createElement } from "react";
|
|
18
|
+
import { calendarSearchSchema } from "@/lib/calendar-route";
|
|
19
|
+
import { stringifySearch } from "@/lib/search-params";
|
|
20
|
+
import { Route as SuggestionRoute } from "../../routes/calendar/$view.$date/suggestion.$suggestionId.js";
|
|
21
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
22
|
+
import {
|
|
23
|
+
type HttpCall,
|
|
24
|
+
type HttpMock,
|
|
25
|
+
httpError,
|
|
26
|
+
mockFetch,
|
|
27
|
+
} from "../../test-support/http";
|
|
28
|
+
|
|
29
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
30
|
+
|
|
31
|
+
const WORK = "11111111-1111-4111-8111-111111111111";
|
|
32
|
+
const SUGGESTION = "22222222-2222-4222-8222-222222222222";
|
|
33
|
+
const WEEK = "/calendar/week/2026-06-10";
|
|
34
|
+
const ENTRY = `${WEEK}/suggestion/${SUGGESTION}`;
|
|
35
|
+
|
|
36
|
+
const calendars = [
|
|
37
|
+
{
|
|
38
|
+
calendarId: WORK,
|
|
39
|
+
accountConfigId: "cfg-1",
|
|
40
|
+
urlSegment: "work",
|
|
41
|
+
displayName: "Work",
|
|
42
|
+
color: "Cal1",
|
|
43
|
+
componentSet: "VeventOnly",
|
|
44
|
+
source: "Default",
|
|
45
|
+
timezone: "Europe/Amsterdam",
|
|
46
|
+
syncSequence: 1,
|
|
47
|
+
subscriptionUrl: "",
|
|
48
|
+
subscriptionEnabled: false,
|
|
49
|
+
subscriptionCheckedAt: 0,
|
|
50
|
+
subscriptionFetchedAt: 0,
|
|
51
|
+
subscriptionError: "",
|
|
52
|
+
createdAt: 0,
|
|
53
|
+
updatedAt: 0,
|
|
54
|
+
},
|
|
55
|
+
] satisfies RemitImapCalendarResponse[];
|
|
56
|
+
|
|
57
|
+
const reading = (
|
|
58
|
+
overrides: Partial<RemitImapCalendarSuggestionResponse> = {},
|
|
59
|
+
): RemitImapCalendarSuggestionResponse => ({
|
|
60
|
+
suggestionId: SUGGESTION,
|
|
61
|
+
accountConfigId: "cfg-1",
|
|
62
|
+
messageId: "msg-1",
|
|
63
|
+
bodyPartId: "part-1",
|
|
64
|
+
icalUid: "uid-1",
|
|
65
|
+
sequence: 0,
|
|
66
|
+
method: "Request",
|
|
67
|
+
source: "IcalendarPart",
|
|
68
|
+
state: "Pending",
|
|
69
|
+
summary: "Billing migration kickoff",
|
|
70
|
+
dtStart: "2026-06-11T12:00:00+00:00",
|
|
71
|
+
dtEnd: "2026-06-11T13:00:00+00:00",
|
|
72
|
+
allDay: false,
|
|
73
|
+
location: "Room Noord",
|
|
74
|
+
organizer: "priya@example.test",
|
|
75
|
+
zoneCertainty: "Explicit",
|
|
76
|
+
acceptedCalendarObjectId: "",
|
|
77
|
+
createdAt: 0,
|
|
78
|
+
updatedAt: 0,
|
|
79
|
+
...overrides,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
let harness: DomHarness | undefined;
|
|
83
|
+
let http: HttpMock | undefined;
|
|
84
|
+
const runnerZone = process.env.TZ;
|
|
85
|
+
|
|
86
|
+
before(() => {
|
|
87
|
+
process.env.TZ = "Europe/Amsterdam";
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
after(() => {
|
|
91
|
+
if (runnerZone === undefined) Reflect.deleteProperty(process.env, "TZ");
|
|
92
|
+
else process.env.TZ = runnerZone;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
harness?.close();
|
|
97
|
+
harness = undefined;
|
|
98
|
+
http?.restore();
|
|
99
|
+
http = undefined;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const componentOf = (route: unknown): RouteComponent => {
|
|
103
|
+
const { component } = (route as { options: { component?: RouteComponent } })
|
|
104
|
+
.options;
|
|
105
|
+
if (!component) throw new Error("the route mounts no component");
|
|
106
|
+
return component;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const testRouter = (): AnyRouter => {
|
|
110
|
+
const rootRoute = createRootRoute({ component: Outlet });
|
|
111
|
+
const viewRoute = createRoute({
|
|
112
|
+
getParentRoute: () => rootRoute,
|
|
113
|
+
path: "/calendar/$view/$date",
|
|
114
|
+
validateSearch: calendarSearchSchema,
|
|
115
|
+
component: Outlet,
|
|
116
|
+
});
|
|
117
|
+
const routeTree = rootRoute.addChildren([
|
|
118
|
+
viewRoute.addChildren([
|
|
119
|
+
createRoute({
|
|
120
|
+
getParentRoute: () => viewRoute,
|
|
121
|
+
path: "/",
|
|
122
|
+
component: () => null,
|
|
123
|
+
}),
|
|
124
|
+
createRoute({
|
|
125
|
+
getParentRoute: () => viewRoute,
|
|
126
|
+
path: "suggestion/$suggestionId",
|
|
127
|
+
component: componentOf(SuggestionRoute),
|
|
128
|
+
}),
|
|
129
|
+
]),
|
|
130
|
+
]);
|
|
131
|
+
return createRouter({
|
|
132
|
+
routeTree,
|
|
133
|
+
stringifySearch,
|
|
134
|
+
history: createMemoryHistory({ initialEntries: [ENTRY] }),
|
|
135
|
+
}) as unknown as AnyRouter;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const server = (
|
|
139
|
+
suggestion: RemitImapCalendarSuggestionResponse,
|
|
140
|
+
answer: (call: HttpCall) => Response | undefined = () => undefined,
|
|
141
|
+
) => {
|
|
142
|
+
let pending = [suggestion];
|
|
143
|
+
return (call: HttpCall): unknown => {
|
|
144
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
145
|
+
if (call.path.endsWith("/calendar-suggestions")) return { items: pending };
|
|
146
|
+
if (call.path.endsWith("/accept")) {
|
|
147
|
+
const refusal = answer(call);
|
|
148
|
+
if (refusal) return refusal;
|
|
149
|
+
pending = [];
|
|
150
|
+
return { ...suggestion, state: "Accepted" };
|
|
151
|
+
}
|
|
152
|
+
return { items: [] };
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const mount = async (respond: (call: HttpCall) => unknown) => {
|
|
157
|
+
http = mockFetch(respond);
|
|
158
|
+
const router = testRouter();
|
|
159
|
+
await router.load();
|
|
160
|
+
harness = createDomHarness();
|
|
161
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
162
|
+
await harness.waitFor(
|
|
163
|
+
() =>
|
|
164
|
+
(harness?.queryAll<HTMLInputElement>("input") ?? []).some(
|
|
165
|
+
(input) => input.value === "Billing migration kickoff",
|
|
166
|
+
),
|
|
167
|
+
"the editor to open seeded from the suggestion",
|
|
168
|
+
);
|
|
169
|
+
await harness.waitFor(
|
|
170
|
+
() => harness?.query("input[type=radio]:checked") !== null,
|
|
171
|
+
"the default calendar to be picked",
|
|
172
|
+
);
|
|
173
|
+
return router;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const fieldValue = (label: string): string => {
|
|
177
|
+
if (!harness) throw new Error("not mounted");
|
|
178
|
+
return (harness.byLabel(label) as HTMLInputElement).value;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const acceptedEdits = (): HttpCall[] =>
|
|
182
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/accept`) ?? [];
|
|
183
|
+
|
|
184
|
+
describe("changing a suggestion before adding it", () => {
|
|
185
|
+
it("opens the editor on what the mail said, on this device's clock", async () => {
|
|
186
|
+
await mount(server(reading()));
|
|
187
|
+
|
|
188
|
+
assert.equal(fieldValue("Title"), "Billing migration kickoff");
|
|
189
|
+
assert.equal(fieldValue("Location"), "Room Noord");
|
|
190
|
+
assert.equal(fieldValue("Date"), "2026-06-11");
|
|
191
|
+
assert.equal(fieldValue("Start time"), "14:00");
|
|
192
|
+
assert.equal(fieldValue("End time"), "15:00");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("adds the corrected event, settles the suggestion and closes the editor", async () => {
|
|
196
|
+
const router = await mount(server(reading()));
|
|
197
|
+
|
|
198
|
+
harness?.type(harness.byLabel("Title"), "Billing migration kickoff, moved");
|
|
199
|
+
harness?.type(harness.byLabel("Start time"), "16:00");
|
|
200
|
+
harness?.type(harness.byLabel("End time"), "17:30");
|
|
201
|
+
await harness?.flush();
|
|
202
|
+
harness?.click(harness.byText("button", "Add"));
|
|
203
|
+
|
|
204
|
+
await harness?.waitFor(
|
|
205
|
+
() => router.state.location.pathname === WEEK,
|
|
206
|
+
"the editor to close once the server settled the suggestion",
|
|
207
|
+
);
|
|
208
|
+
const sent = acceptedEdits();
|
|
209
|
+
assert.equal(sent.length, 1);
|
|
210
|
+
assert.deepEqual(sent[0].body, {
|
|
211
|
+
calendarId: WORK,
|
|
212
|
+
event: {
|
|
213
|
+
summary: "Billing migration kickoff, moved",
|
|
214
|
+
start: "2026-06-11T16:00:00+02:00",
|
|
215
|
+
end: "2026-06-11T17:30:00+02:00",
|
|
216
|
+
allDay: false,
|
|
217
|
+
timeZone: "Europe/Amsterdam",
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
const listings = http?.to("/calendar-suggestions") ?? [];
|
|
221
|
+
assert.ok(
|
|
222
|
+
listings.length > 1,
|
|
223
|
+
"the waiting list is read again once the suggestion is settled",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("places a reading nobody could put on a clock, even when the time is left alone", async () => {
|
|
228
|
+
await mount(
|
|
229
|
+
server(
|
|
230
|
+
reading({
|
|
231
|
+
dtStart: "2026-06-11T09:00:00+00:00",
|
|
232
|
+
dtEnd: "2026-06-11T10:00:00+00:00",
|
|
233
|
+
zoneCertainty: "Ambiguous",
|
|
234
|
+
}),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
assert.equal(fieldValue("Start time"), "09:00");
|
|
239
|
+
assert.match(harness?.text() ?? "", /time zone nothing could resolve/);
|
|
240
|
+
harness?.click(harness.byText("button", "Add"));
|
|
241
|
+
await harness?.waitFor(
|
|
242
|
+
() => acceptedEdits().length === 1,
|
|
243
|
+
"the correction to be sent",
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
assert.deepEqual(acceptedEdits()[0].body?.event, {
|
|
247
|
+
start: "2026-06-11T09:00:00+02:00",
|
|
248
|
+
end: "2026-06-11T10:00:00+02:00",
|
|
249
|
+
allDay: false,
|
|
250
|
+
timeZone: "Europe/Amsterdam",
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("states a refusal and keeps the editor open with what was typed", async () => {
|
|
255
|
+
const router = await mount(
|
|
256
|
+
server(reading(), () => httpError(400, "Already declined.")),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
harness?.type(harness.byLabel("Title"), "Kickoff, renamed");
|
|
260
|
+
await harness?.flush();
|
|
261
|
+
harness?.click(harness.byText("button", "Add"));
|
|
262
|
+
|
|
263
|
+
await harness?.waitFor(
|
|
264
|
+
() => harness?.query('[role="alert"]') !== null,
|
|
265
|
+
"the refusal to be stated",
|
|
266
|
+
);
|
|
267
|
+
assert.match(harness?.text() ?? "", /Couldn't add this to your calendar/);
|
|
268
|
+
assert.equal(fieldValue("Title"), "Kickoff, renamed");
|
|
269
|
+
assert.equal(router.state.location.pathname, ENTRY);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("finds a suggestion that is not on the first page of what waits", async () => {
|
|
273
|
+
http = mockFetch((call: HttpCall): unknown => {
|
|
274
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
275
|
+
if (call.path.endsWith("/calendar-suggestions"))
|
|
276
|
+
return call.url.includes("continuationToken=page-2")
|
|
277
|
+
? { items: [reading()] }
|
|
278
|
+
: {
|
|
279
|
+
items: [{ ...reading(), suggestionId: "other" }],
|
|
280
|
+
continuationToken: "page-2",
|
|
281
|
+
};
|
|
282
|
+
return { items: [] };
|
|
283
|
+
});
|
|
284
|
+
const router = testRouter();
|
|
285
|
+
await router.load();
|
|
286
|
+
harness = createDomHarness();
|
|
287
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
288
|
+
|
|
289
|
+
await harness.waitFor(
|
|
290
|
+
() =>
|
|
291
|
+
harness
|
|
292
|
+
?.queryAll<HTMLInputElement>("input")
|
|
293
|
+
.some((input) => input.value === "Billing migration kickoff") ===
|
|
294
|
+
true,
|
|
295
|
+
"the editor to open on the suggestion from the second page",
|
|
296
|
+
);
|
|
297
|
+
assert.doesNotMatch(harness.text(), /no longer waiting/);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -69,16 +69,21 @@ const pending: RemitImapCalendarSuggestionResponse = {
|
|
|
69
69
|
|
|
70
70
|
let harness: DomHarness | undefined;
|
|
71
71
|
let http: HttpMock | undefined;
|
|
72
|
+
let changedFirst: { suggestionId: string; date: string }[] = [];
|
|
72
73
|
|
|
73
74
|
afterEach(() => {
|
|
74
75
|
harness?.close();
|
|
75
76
|
harness = undefined;
|
|
76
77
|
http?.restore();
|
|
77
78
|
http = undefined;
|
|
79
|
+
changedFirst = [];
|
|
78
80
|
});
|
|
79
81
|
|
|
80
|
-
const server = (
|
|
81
|
-
|
|
82
|
+
const server = (
|
|
83
|
+
accept: () => Response | undefined,
|
|
84
|
+
reading: RemitImapCalendarSuggestionResponse = pending,
|
|
85
|
+
) => {
|
|
86
|
+
let waiting = [reading];
|
|
82
87
|
return (call: HttpCall): unknown => {
|
|
83
88
|
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
84
89
|
if (call.path.endsWith("/calendar-suggestions")) return { items: waiting };
|
|
@@ -95,7 +100,12 @@ const server = (accept: () => Response | undefined) => {
|
|
|
95
100
|
const mount = async (respond: (call: HttpCall) => unknown) => {
|
|
96
101
|
http = mockFetch(respond);
|
|
97
102
|
harness = createDomHarness();
|
|
98
|
-
harness.renderApp(
|
|
103
|
+
harness.renderApp(
|
|
104
|
+
createElement(PendingSuggestions, {
|
|
105
|
+
onChangeFirst: (suggestionId: string, date: string) =>
|
|
106
|
+
changedFirst.push({ suggestionId, date }),
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
99
109
|
await harness.waitFor(
|
|
100
110
|
() => harness?.text().includes("Quarterly review") === true,
|
|
101
111
|
"the waiting reading to be drawn",
|
|
@@ -134,4 +144,59 @@ describe("what the mail is waiting on, beside the calendar", () => {
|
|
|
134
144
|
assert.match(harness?.text() ?? "", /Couldn't add this to your calendar/);
|
|
135
145
|
assert.match(harness?.text() ?? "", /Quarterly review/);
|
|
136
146
|
});
|
|
147
|
+
|
|
148
|
+
it("hands the top reading to the editor on the day it falls", async () => {
|
|
149
|
+
await mount(server(() => undefined));
|
|
150
|
+
harness?.click(harness.byText("button", "Change first"));
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(changedFirst, [
|
|
153
|
+
{ suggestionId: SUGGESTION, date: "2026-09-01" },
|
|
154
|
+
]);
|
|
155
|
+
assert.equal(
|
|
156
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length,
|
|
157
|
+
0,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("sends a reading nobody could put on a clock to the editor instead of adding it", async () => {
|
|
162
|
+
await mount(
|
|
163
|
+
server(() => undefined, { ...pending, zoneCertainty: "Ambiguous" }),
|
|
164
|
+
);
|
|
165
|
+
harness?.click(harness.byText("button", "Add"));
|
|
166
|
+
|
|
167
|
+
assert.deepEqual(
|
|
168
|
+
changedFirst.map((entry) => entry.suggestionId),
|
|
169
|
+
[SUGGESTION],
|
|
170
|
+
);
|
|
171
|
+
assert.equal(
|
|
172
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length,
|
|
173
|
+
0,
|
|
174
|
+
);
|
|
175
|
+
assert.match(harness?.text() ?? "", /Quarterly review/);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("applies a cancellation nobody could put on a clock, and offers no editor for it", async () => {
|
|
179
|
+
await mount(
|
|
180
|
+
server(() => undefined, {
|
|
181
|
+
...pending,
|
|
182
|
+
method: "Cancel",
|
|
183
|
+
zoneCertainty: "Ambiguous",
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
assert.equal(
|
|
187
|
+
harness
|
|
188
|
+
?.queryAll("button")
|
|
189
|
+
.some((button) => button.textContent?.includes("Change first")),
|
|
190
|
+
false,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
harness?.click(harness.byText("button", "Add"));
|
|
194
|
+
await harness?.waitFor(
|
|
195
|
+
() =>
|
|
196
|
+
(http?.to(`/calendar-suggestions/${SUGGESTION}/accept`).length ?? 0) >
|
|
197
|
+
0,
|
|
198
|
+
"the cancellation to be applied",
|
|
199
|
+
);
|
|
200
|
+
assert.deepEqual(changedFirst, []);
|
|
201
|
+
});
|
|
137
202
|
});
|
|
@@ -85,7 +85,7 @@ export function draftFromEvent(
|
|
|
85
85
|
* Stamping any other zone's offset onto those digits moves the event by the
|
|
86
86
|
* difference between the two — silently, and only for whoever is travelling.
|
|
87
87
|
*/
|
|
88
|
-
function timesFor(
|
|
88
|
+
export function timesFor(
|
|
89
89
|
draft: EventDraft,
|
|
90
90
|
clock: string,
|
|
91
91
|
): { start: string; end: string } {
|
|
@@ -63,6 +63,11 @@ export {
|
|
|
63
63
|
useCalendarSelection,
|
|
64
64
|
useDrawnEvents,
|
|
65
65
|
} from "./selection";
|
|
66
|
+
export {
|
|
67
|
+
type AcceptEditedInput,
|
|
68
|
+
acceptEditedInputFromDrafts,
|
|
69
|
+
draftFromSuggestion,
|
|
70
|
+
} from "./suggestion-draft";
|
|
66
71
|
export {
|
|
67
72
|
type CalendarCollectionOutcome,
|
|
68
73
|
type CalendarCollectionWrites,
|
|
@@ -89,9 +94,11 @@ export { type CalendarJumpSearch, useCalendarJump } from "./useCalendarJump";
|
|
|
89
94
|
export {
|
|
90
95
|
type CalendarSuggestionAnswers,
|
|
91
96
|
type CalendarSuggestionsResult,
|
|
97
|
+
type PendingCalendarSuggestionResult,
|
|
92
98
|
type SuggestionAnswer,
|
|
93
99
|
useCalendarSuggestionAnswers,
|
|
94
100
|
useMessageCalendarSuggestions,
|
|
101
|
+
usePendingCalendarSuggestion,
|
|
95
102
|
usePendingCalendarSuggestions,
|
|
96
103
|
} from "./useCalendarSuggestions";
|
|
97
104
|
export { type CalendarsResult, useCalendars } from "./useCalendars";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapAcceptCalendarSuggestionInput,
|
|
3
|
+
RemitImapCalendarSuggestionResponse,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import type { EventDraft } from "@remit/ui";
|
|
6
|
+
import {
|
|
7
|
+
type DraftRefusal,
|
|
8
|
+
type DraftZones,
|
|
9
|
+
emptyDraft,
|
|
10
|
+
patchFromDrafts,
|
|
11
|
+
timesFor,
|
|
12
|
+
} from "./draft";
|
|
13
|
+
import { addDays, isoOnClock } from "./window";
|
|
14
|
+
|
|
15
|
+
export type AcceptEditedInput =
|
|
16
|
+
| { ok: true; input: RemitImapAcceptCalendarSuggestionInput }
|
|
17
|
+
| DraftRefusal;
|
|
18
|
+
|
|
19
|
+
type Suggestion = Pick<
|
|
20
|
+
RemitImapCalendarSuggestionResponse,
|
|
21
|
+
"summary" | "location" | "dtStart" | "dtEnd" | "allDay" | "zoneCertainty"
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
export function draftFromSuggestion(
|
|
25
|
+
suggestion: Suggestion,
|
|
26
|
+
calendarId: string,
|
|
27
|
+
clock: string,
|
|
28
|
+
): EventDraft {
|
|
29
|
+
const base = {
|
|
30
|
+
title: suggestion.summary,
|
|
31
|
+
location: suggestion.location,
|
|
32
|
+
};
|
|
33
|
+
if (suggestion.allDay) {
|
|
34
|
+
const date = suggestion.dtStart.slice(0, 10);
|
|
35
|
+
const end = suggestion.dtEnd.slice(0, 10);
|
|
36
|
+
return {
|
|
37
|
+
...emptyDraft(date, calendarId),
|
|
38
|
+
...base,
|
|
39
|
+
allDay: true,
|
|
40
|
+
startTime: "",
|
|
41
|
+
endDate: end > date ? addDays(end, -1) : date,
|
|
42
|
+
endTime: "",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const wall = (iso: string): string =>
|
|
46
|
+
suggestion.zoneCertainty === "Ambiguous" ? iso : isoOnClock(iso, clock);
|
|
47
|
+
const start = wall(suggestion.dtStart);
|
|
48
|
+
const end = wall(suggestion.dtEnd);
|
|
49
|
+
return {
|
|
50
|
+
...emptyDraft(start.slice(0, 10), calendarId),
|
|
51
|
+
...base,
|
|
52
|
+
startTime: start.slice(11, 16),
|
|
53
|
+
endDate: end.slice(0, 10),
|
|
54
|
+
endTime: end.slice(11, 16),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function acceptEditedInputFromDrafts(
|
|
59
|
+
suggestion: Suggestion,
|
|
60
|
+
seed: EventDraft,
|
|
61
|
+
draft: EventDraft,
|
|
62
|
+
zones: DraftZones,
|
|
63
|
+
): AcceptEditedInput {
|
|
64
|
+
const built = patchFromDrafts(seed, draft, zones);
|
|
65
|
+
if (!built.ok) return built;
|
|
66
|
+
const placed =
|
|
67
|
+
suggestion.zoneCertainty === "Ambiguous" || built.patch.start !== undefined;
|
|
68
|
+
const event = placed
|
|
69
|
+
? {
|
|
70
|
+
...built.patch,
|
|
71
|
+
...timesFor(draft, zones.clock),
|
|
72
|
+
allDay: draft.allDay,
|
|
73
|
+
timeZone: zones.anchor,
|
|
74
|
+
}
|
|
75
|
+
: built.patch;
|
|
76
|
+
return { ok: true, input: { calendarId: draft.calendarId, event } };
|
|
77
|
+
}
|
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
calendarSuggestionOperationsListCalendarSuggestionsOptions,
|
|
19
19
|
messageCalendarSuggestionOperationsListMessageCalendarSuggestionsOptions,
|
|
20
20
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
21
|
-
import
|
|
21
|
+
import { calendarSuggestionOperationsListCalendarSuggestions } from "@remit/api-http-client/sdk.gen.ts";
|
|
22
|
+
import type {
|
|
23
|
+
RemitImapAcceptCalendarSuggestionInput,
|
|
24
|
+
RemitImapCalendarSuggestionResponse,
|
|
25
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
22
26
|
import {
|
|
23
27
|
type QueryClient,
|
|
24
28
|
useMutation,
|
|
@@ -89,6 +93,46 @@ export function usePendingCalendarSuggestions(): CalendarSuggestionsResult {
|
|
|
89
93
|
};
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
const findPending = async (
|
|
97
|
+
suggestionId: string,
|
|
98
|
+
signal: AbortSignal,
|
|
99
|
+
): Promise<RemitImapCalendarSuggestionResponse | null> => {
|
|
100
|
+
let continuationToken: string | undefined;
|
|
101
|
+
do {
|
|
102
|
+
const { data } = await calendarSuggestionOperationsListCalendarSuggestions({
|
|
103
|
+
query: { state: "Pending", continuationToken },
|
|
104
|
+
signal,
|
|
105
|
+
throwOnError: true,
|
|
106
|
+
});
|
|
107
|
+
const found = data.items.find((item) => item.suggestionId === suggestionId);
|
|
108
|
+
if (found) return found;
|
|
109
|
+
continuationToken = data.continuationToken;
|
|
110
|
+
} while (continuationToken);
|
|
111
|
+
return null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export interface PendingCalendarSuggestionResult {
|
|
115
|
+
suggestion: RemitImapCalendarSuggestionResponse | null | undefined;
|
|
116
|
+
isLoading: boolean;
|
|
117
|
+
error: unknown;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function usePendingCalendarSuggestion(
|
|
121
|
+
suggestionId: string,
|
|
122
|
+
): PendingCalendarSuggestionResult {
|
|
123
|
+
const { data, isLoading, error } = useQuery({
|
|
124
|
+
queryKey: [
|
|
125
|
+
{
|
|
126
|
+
_id: "calendarSuggestionOperationsListCalendarSuggestions",
|
|
127
|
+
suggestionId,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
queryFn: ({ signal }) => findPending(suggestionId, signal),
|
|
131
|
+
meta: SUGGESTION_META,
|
|
132
|
+
});
|
|
133
|
+
return { suggestion: data, isLoading, error: error ?? null };
|
|
134
|
+
}
|
|
135
|
+
|
|
92
136
|
export type SuggestionAnswer =
|
|
93
137
|
| { kind: "answered" }
|
|
94
138
|
| { kind: "refused"; message: string };
|
|
@@ -109,6 +153,10 @@ export interface CalendarSuggestionAnswers {
|
|
|
109
153
|
suggestionId: string,
|
|
110
154
|
calendarId: string,
|
|
111
155
|
) => Promise<SuggestionAnswer>;
|
|
156
|
+
acceptEdited: (
|
|
157
|
+
suggestionId: string,
|
|
158
|
+
input: RemitImapAcceptCalendarSuggestionInput,
|
|
159
|
+
) => Promise<SuggestionAnswer>;
|
|
112
160
|
decline: (suggestionId: string) => Promise<SuggestionAnswer>;
|
|
113
161
|
dismiss: (
|
|
114
162
|
suggestionId: string,
|
|
@@ -159,6 +207,14 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
159
207
|
.catch(refused),
|
|
160
208
|
[accept],
|
|
161
209
|
);
|
|
210
|
+
const acceptEditedSuggestion = useCallback(
|
|
211
|
+
(suggestionId: string, input: RemitImapAcceptCalendarSuggestionInput) =>
|
|
212
|
+
accept
|
|
213
|
+
.mutateAsync({ path: { suggestionId }, body: input })
|
|
214
|
+
.then(() => ANSWERED)
|
|
215
|
+
.catch(refused),
|
|
216
|
+
[accept],
|
|
217
|
+
);
|
|
162
218
|
const declineSuggestion = useCallback(
|
|
163
219
|
(suggestionId: string) =>
|
|
164
220
|
decline
|
|
@@ -180,6 +236,7 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
180
236
|
() => ({
|
|
181
237
|
accept: acceptSuggestion,
|
|
182
238
|
tentative: tentativeSuggestion,
|
|
239
|
+
acceptEdited: acceptEditedSuggestion,
|
|
183
240
|
decline: declineSuggestion,
|
|
184
241
|
dismiss: dismissSuggestion,
|
|
185
242
|
isAnswering: accept.isPending || decline.isPending || dismiss.isPending,
|
|
@@ -187,6 +244,7 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
187
244
|
[
|
|
188
245
|
acceptSuggestion,
|
|
189
246
|
tentativeSuggestion,
|
|
247
|
+
acceptEditedSuggestion,
|
|
190
248
|
declineSuggestion,
|
|
191
249
|
dismissSuggestion,
|
|
192
250
|
accept.isPending,
|