@remit/web-client 0.0.313 → 0.0.315

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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.tsx +15 -2
  3. package/src/components/calendar/CalendarWaiting.tsx +3 -0
  4. package/src/components/calendar/ChangeCalendarSuggestion.stories.tsx +184 -0
  5. package/src/components/calendar/ChangeCalendarSuggestion.tsx +159 -0
  6. package/src/components/calendar/PendingSuggestions.tsx +35 -5
  7. package/src/components/calendar/calendar-story-server.tsx +5 -0
  8. package/src/components/calendar/change-suggestion-first.render.test.ts +301 -0
  9. package/src/components/calendar/pending-suggestions.render.test.ts +70 -3
  10. package/src/components/mail/BriefPane.tsx +6 -1
  11. package/src/components/mail/FlaggedPane.tsx +6 -1
  12. package/src/components/mail/IntelligenceDrawer.tsx +4 -0
  13. package/src/components/mail/IntelligencePane.tsx +11 -2
  14. package/src/components/mail/MailboxPane.tsx +5 -0
  15. package/src/components/mail/offer-times-reply.render.test.ts +2 -0
  16. package/src/hooks/calendar/draft.ts +1 -1
  17. package/src/hooks/calendar/index.ts +7 -0
  18. package/src/hooks/calendar/suggestion-draft.ts +77 -0
  19. package/src/hooks/calendar/useCalendarSuggestions.ts +59 -1
  20. package/src/hooks/intelligence-calendar.render.test.ts +192 -3
  21. package/src/hooks/useIntelligenceCalendar.ts +46 -5
  22. package/src/lib/calendar-suggestion.test.ts +12 -1
  23. package/src/lib/calendar-suggestion.ts +12 -2
  24. package/src/lib/calendar-suggestion.west.test.ts +2 -0
  25. package/src/routeTree.gen.ts +23 -0
  26. package/src/routes/calendar/$view.$date/suggestion.$suggestionId.tsx +22 -0
  27. package/src/routes/calendar/$view.$date.tsx +9 -3
  28. package/src/routes/calendar/-calendar-tree.test.ts +18 -0
  29. package/src/routing/calendar.ts +36 -1
  30. package/src/routing/index.ts +2 -0
  31. package/src/test-support/thread-router.ts +54 -0
@@ -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 type { RemitImapCalendarSuggestionResponse } from "@remit/api-http-client/types.gen.ts";
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,
@@ -14,7 +14,16 @@ import type {
14
14
  RemitImapCalendarSuggestionResponse,
15
15
  } from "@remit/api-http-client/types.gen.ts";
16
16
  import { type IntelligenceData, IntelligencePanel } from "@remit/ui";
17
+ import {
18
+ type AnyRouter,
19
+ createMemoryHistory,
20
+ createRootRoute,
21
+ createRoute,
22
+ createRouter,
23
+ RouterProvider,
24
+ } from "@tanstack/react-router";
17
25
  import { createElement } from "react";
26
+ import { useOpenThread } from "@/routing";
18
27
  import { createDomHarness, type DomHarness } from "@/test-support/dom";
19
28
  import { makeThreadMessage } from "@/test-support/fixtures";
20
29
  import {
@@ -23,6 +32,7 @@ import {
23
32
  httpError,
24
33
  mockFetch,
25
34
  } from "@/test-support/http";
35
+ import { threadRouter } from "@/test-support/thread-router";
26
36
  import { useIntelligenceCalendar } from "./useIntelligenceCalendar";
27
37
 
28
38
  const CALENDAR = "11111111-1111-4111-8111-111111111111";
@@ -87,12 +97,14 @@ const invitation = (
87
97
  organizer: "organizer@example.test",
88
98
  zoneCertainty: "Explicit",
89
99
  acceptedCalendarObjectId: "",
100
+ supersededByMessageId: "",
101
+ supersededByThreadId: "",
90
102
  createdAt: 0,
91
103
  updatedAt: 0,
92
104
  });
93
105
 
94
106
  const Harness = () => {
95
- const calendar = useIntelligenceCalendar(thread);
107
+ const calendar = useIntelligenceCalendar(thread, undefined, useOpenThread());
96
108
  return createElement(IntelligencePanel, {
97
109
  data: sender,
98
110
  calendar: calendar.surface,
@@ -138,14 +150,19 @@ const server = (
138
150
  };
139
151
  };
140
152
 
141
- const mount = async (respond: (call: HttpCall) => unknown) => {
153
+ const mount = async (
154
+ respond: (call: HttpCall) => unknown,
155
+ ): Promise<AnyRouter> => {
142
156
  http = mockFetch(respond);
143
157
  harness = createDomHarness();
144
- harness.renderApp(createElement(Harness));
158
+ const router = threadRouter(Harness, "/mail/brief/thread-1/msg-1");
159
+ await router.load();
160
+ harness.renderApp(createElement(RouterProvider, { router }));
145
161
  await harness.waitFor(
146
162
  () => harness?.text().includes("Quarterly review") === true,
147
163
  "the invitation to be drawn",
148
164
  );
165
+ return router;
149
166
  };
150
167
 
151
168
  const press = (label: string) => {
@@ -261,3 +278,175 @@ describe("the invitation beside the open message", () => {
261
278
  assert.deepEqual(dismissed[0].body, { muteSender: true });
262
279
  });
263
280
  });
281
+
282
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
283
+
284
+ const READING = "33333333-3333-4333-8333-333333333333";
285
+
286
+ const routedMount = async (
287
+ items: RemitImapCalendarSuggestionResponse[],
288
+ ): Promise<AnyRouter> => {
289
+ http = mockFetch((call: HttpCall): unknown => {
290
+ if (call.path.endsWith("/calendars")) return { items: calendars };
291
+ if (call.path.endsWith("/messages/msg-1/calendar-suggestions"))
292
+ return { items };
293
+ return { items: [] };
294
+ });
295
+ const rootRoute = createRootRoute({ component: Harness });
296
+ const routeTree = rootRoute.addChildren([
297
+ createRoute({
298
+ getParentRoute: () => rootRoute,
299
+ path: "/mail",
300
+ component: () => null,
301
+ }),
302
+ createRoute({
303
+ getParentRoute: () => rootRoute,
304
+ path: "/calendar/$view/$date/suggestion/$suggestionId",
305
+ component: () => null,
306
+ }),
307
+ ]);
308
+ const router = createRouter({
309
+ routeTree,
310
+ history: createMemoryHistory({ initialEntries: ["/mail"] }),
311
+ }) as unknown as AnyRouter;
312
+ await router.load();
313
+ harness = createDomHarness();
314
+ harness.renderApp(createElement(RouterProvider, { router }));
315
+ await harness.waitFor(
316
+ () => harness?.text().includes("Quarterly review") === true,
317
+ "the invitation to be drawn",
318
+ );
319
+ return router;
320
+ };
321
+
322
+ const answered = (): HttpCall[] =>
323
+ (http?.calls ?? []).filter((call) => call.method === "POST");
324
+
325
+ describe("changing a suggestion beside the message before adding it", () => {
326
+ it("opens the editor for a reading from the thread on the day it falls", async () => {
327
+ const router = await routedMount([
328
+ invitation("Pending"),
329
+ {
330
+ ...invitation("Pending"),
331
+ suggestionId: READING,
332
+ source: "TextHeuristic",
333
+ summary: "Lunch with Sam",
334
+ },
335
+ ]);
336
+ await harness?.waitFor(
337
+ () => harness?.text().includes("Lunch with Sam") === true,
338
+ "the reading to be drawn",
339
+ );
340
+
341
+ press("Change first");
342
+ await harness?.waitFor(
343
+ () => router.state.location.pathname.includes(READING),
344
+ "the editor to be opened",
345
+ );
346
+
347
+ assert.equal(
348
+ router.state.location.pathname,
349
+ `/calendar/day/2026-09-01/suggestion/${READING}`,
350
+ );
351
+ assert.deepEqual(answered(), []);
352
+ });
353
+
354
+ it("sends an invitation nobody could put on a clock to the editor instead of adding it", async () => {
355
+ const router = await routedMount([
356
+ { ...invitation("Pending"), zoneCertainty: "Ambiguous" },
357
+ ]);
358
+
359
+ press("Add to calendar");
360
+ await harness?.waitFor(
361
+ () => router.state.location.pathname.includes(SUGGESTION),
362
+ "the editor to be opened",
363
+ );
364
+
365
+ assert.equal(
366
+ router.state.location.pathname,
367
+ `/calendar/day/2026-09-01/suggestion/${SUGGESTION}`,
368
+ );
369
+ assert.deepEqual(answered(), []);
370
+ });
371
+
372
+ it("applies a cancellation read out of the thread, and offers no editor for it", async () => {
373
+ const router = await routedMount([
374
+ invitation("Accepted"),
375
+ {
376
+ ...invitation("Pending"),
377
+ suggestionId: READING,
378
+ source: "TextHeuristic",
379
+ method: "Cancel",
380
+ zoneCertainty: "Ambiguous",
381
+ summary: "Lunch with Sam",
382
+ },
383
+ ]);
384
+ await harness?.waitFor(
385
+ () => harness?.text().includes("Lunch with Sam") === true,
386
+ "the reading to be drawn",
387
+ );
388
+ const card = harness?.query('article[aria-label="Lunch with Sam"]');
389
+ if (!card) throw new Error("the reading's card is not drawn");
390
+ const buttons = [...card.querySelectorAll("button")];
391
+ assert.equal(
392
+ buttons.some((button) => button.textContent?.includes("Change first")),
393
+ false,
394
+ );
395
+ const add = buttons.find((button) =>
396
+ button.textContent?.includes("Add to calendar"),
397
+ );
398
+ if (!add) throw new Error("the reading offers no Add");
399
+ harness?.click(add);
400
+
401
+ await harness?.waitFor(
402
+ () => answered().some((call) => call.path.endsWith(`/${READING}/accept`)),
403
+ "the cancellation to be applied",
404
+ );
405
+ assert.equal(router.state.location.pathname, "/mail");
406
+ });
407
+ });
408
+
409
+ describe("an invitation a newer revision replaced", () => {
410
+ const retired = (
411
+ supersededByThreadId: string,
412
+ ): RemitImapCalendarSuggestionResponse => ({
413
+ ...invitation("Superseded"),
414
+ supersededByMessageId: "msg-2",
415
+ supersededByThreadId,
416
+ });
417
+
418
+ const serving =
419
+ (suggestion: RemitImapCalendarSuggestionResponse) =>
420
+ (call: HttpCall): unknown => {
421
+ if (call.path.endsWith("/calendars")) return { items: calendars };
422
+ if (call.path.endsWith("/messages/msg-1/calendar-suggestions"))
423
+ return { items: [suggestion] };
424
+ return { items: [] };
425
+ };
426
+
427
+ it("opens the conversation that carries the newer revision", async () => {
428
+ const router = await mount(serving(retired("thread-2")));
429
+ assert.match(harness?.text() ?? "", /has sent a newer version of this/);
430
+
431
+ press("Open the newer invitation");
432
+
433
+ await harness?.waitFor(
434
+ () => router.state.location.pathname === "/mail/brief/thread-2/msg-2",
435
+ "the reading pane to move to the newer revision",
436
+ );
437
+ });
438
+
439
+ it("offers no way there when the newer message sits in no conversation", async () => {
440
+ await mount(serving(retired("")));
441
+
442
+ assert.match(harness?.text() ?? "", /has sent a newer version of this/);
443
+ assert.equal(
444
+ harness
445
+ ?.queryAll("button")
446
+ .some((button) =>
447
+ button.textContent?.includes("Open the newer invitation"),
448
+ ),
449
+ false,
450
+ );
451
+ });
452
+ });
@@ -50,7 +50,11 @@ import {
50
50
  toCalendarInvite,
51
51
  toEventSuggestion,
52
52
  } from "@/lib/calendar-suggestion";
53
- import { useOpenEventOnCalendar } from "@/routing";
53
+ import {
54
+ type OpenThreadTarget,
55
+ useChangeSuggestionOnCalendar,
56
+ useOpenEventOnCalendar,
57
+ } from "@/routing";
54
58
 
55
59
  /** What the reader is in the middle of, for one message and no other. */
56
60
  interface Working {
@@ -92,6 +96,7 @@ export interface IntelligenceCalendar {
92
96
  export function useIntelligenceCalendar(
93
97
  thread: RemitImapThreadMessageResponse,
94
98
  replyWithText?: ReplyWithText,
99
+ openThread?: (target: OpenThreadTarget) => void,
95
100
  ): IntelligenceCalendar {
96
101
  const messageId = thread.messageId;
97
102
  const { suggestions, isLoading, error } =
@@ -107,6 +112,7 @@ export function useIntelligenceCalendar(
107
112
  const answers = useCalendarSuggestionAnswers();
108
113
 
109
114
  const openEvent = useOpenEventOnCalendar();
115
+ const changeOnCalendar = useChangeSuggestionOnCalendar();
110
116
 
111
117
  const [held, setHeld] = useState<Working>(() => fresh(messageId));
112
118
  const working = held.messageId === messageId ? held : fresh(messageId);
@@ -238,14 +244,34 @@ export function useIntelligenceCalendar(
238
244
  : "Couldn't read the invitations in this message. Reopen it to try again.";
239
245
 
240
246
  const cancellation = invitation?.method === "Cancel";
247
+ const changeFirst = (suggestionId: string) => {
248
+ const suggestion = suggestions.find(
249
+ (candidate) => candidate.suggestionId === suggestionId,
250
+ );
251
+ if (suggestion) changeOnCalendar(suggestionDate(suggestion), suggestionId);
252
+ };
253
+ const unplaced = (suggestionId: string): boolean => {
254
+ const suggestion = suggestions.find(
255
+ (candidate) => candidate.suggestionId === suggestionId,
256
+ );
257
+ return (
258
+ suggestion?.zoneCertainty === "Ambiguous" &&
259
+ suggestion.method !== "Cancel"
260
+ );
261
+ };
241
262
  const pickedText =
242
263
  date === "" ? "" : slotsAsText(date, slots, working.picked);
243
264
 
244
265
  const actions: IntelligenceCalendarActions = {
245
- onAddInvite: () =>
266
+ onAddInvite: () => {
267
+ if (unplaced(invitationId)) {
268
+ changeFirst(invitationId);
269
+ return;
270
+ }
246
271
  answer(invitationId, "add this to your calendar", () =>
247
272
  answers.accept(invitationId, defaultCalendarId),
248
- ),
273
+ );
274
+ },
249
275
  onTentativeInvite: cancellation
250
276
  ? undefined
251
277
  : () =>
@@ -270,6 +296,14 @@ export function useIntelligenceCalendar(
270
296
  answers.dismiss(invitationId, false),
271
297
  )
272
298
  : undefined,
299
+ onOpenNewerInvite:
300
+ openThread && invitation && invitation.supersededByThreadId !== ""
301
+ ? () =>
302
+ openThread({
303
+ threadId: invitation.supersededByThreadId,
304
+ messageId: invitation.supersededByMessageId,
305
+ })
306
+ : undefined,
273
307
  onOfferOtherTimes: () => update({ offering: true }),
274
308
  onToggleSlot: (slot) =>
275
309
  update({
@@ -281,10 +315,17 @@ export function useIntelligenceCalendar(
281
315
  ? () =>
282
316
  replyWithText({ threadId: thread.threadId, messageId }, pickedText)
283
317
  : undefined,
284
- onAddSuggestion: (suggestionId) =>
318
+ onAddSuggestion: (suggestionId) => {
319
+ if (unplaced(suggestionId)) {
320
+ changeFirst(suggestionId);
321
+ return;
322
+ }
285
323
  answer(suggestionId, "add this to your calendar", () =>
286
324
  answers.accept(suggestionId, defaultCalendarId),
287
- ),
325
+ );
326
+ },
327
+ onReviewSuggestion:
328
+ readings[0]?.method === "Cancel" ? undefined : changeFirst,
288
329
  onDismissSuggestion: (suggestionId) =>
289
330
  answer(suggestionId, "dismiss this", () =>
290
331
  answers.dismiss(suggestionId, false),
@@ -36,6 +36,8 @@ const suggestion = (
36
36
  organizer: "organizer@example.test",
37
37
  zoneCertainty: "Explicit",
38
38
  acceptedCalendarObjectId: "",
39
+ supersededByMessageId: "",
40
+ supersededByThreadId: "",
39
41
  createdAt: 0,
40
42
  updatedAt: 0,
41
43
  ...over,
@@ -113,10 +115,19 @@ describe("toEventSuggestion", () => {
113
115
  suggestion({ zoneCertainty: "Ambiguous", source: "TextHeuristic" }),
114
116
  { ...context, sender: "Organizer" },
115
117
  );
116
- assert.match(reading.ambiguity, /hours out/);
118
+ assert.match(reading.ambiguity, /Add opens the editor/);
117
119
  assert.equal(reading.zoneCertainty, "ambiguous");
118
120
  assert.ok(reading.confidence < 1);
119
121
  });
122
+
123
+ it("promises no editor for a cancellation, which Add applies as it is", () => {
124
+ const reading = toEventSuggestion(
125
+ suggestion({ zoneCertainty: "Ambiguous", method: "Cancel" }),
126
+ { ...context, sender: "Organizer" },
127
+ );
128
+ assert.match(reading.ambiguity, /could not be resolved/);
129
+ assert.doesNotMatch(reading.ambiguity, /editor/);
130
+ });
120
131
  });
121
132
 
122
133
  describe("clashesOver", () => {
@@ -47,7 +47,10 @@ const ZONES: Record<CalendarSuggestion["zoneCertainty"], ZoneCertainty> = {
47
47
  };
48
48
 
49
49
  const AMBIGUOUS_ZONE =
50
- "The invitation named a time zone nothing could resolve, so this time may be hours out.";
50
+ "The invitation's time zone could not be resolved. Add opens the editor so you can set the time.";
51
+
52
+ const AMBIGUOUS_CANCELLATION =
53
+ "The invitation's time zone could not be resolved.";
51
54
 
52
55
  /** Where the card stands, or nothing when there is no card left to draw. */
53
56
  export interface InviteStanding {
@@ -163,6 +166,13 @@ export function toCalendarInvite(
163
166
  };
164
167
  }
165
168
 
169
+ function ambiguityOf(suggestion: CalendarSuggestion): string {
170
+ if (suggestion.zoneCertainty !== "Ambiguous") return "";
171
+ return suggestion.method === "Cancel"
172
+ ? AMBIGUOUS_CANCELLATION
173
+ : AMBIGUOUS_ZONE;
174
+ }
175
+
166
176
  export function toEventSuggestion(
167
177
  suggestion: CalendarSuggestion,
168
178
  context: Omit<InviteContext, "senderName"> & { sender: string },
@@ -179,7 +189,7 @@ export function toEventSuggestion(
179
189
  sender: context.sender,
180
190
  senderAddress: suggestion.organizer,
181
191
  confidence: isFromInvitation(suggestion) ? 1 : 0.6,
182
- ambiguity: suggestion.zoneCertainty === "Ambiguous" ? AMBIGUOUS_ZONE : "",
192
+ ambiguity: ambiguityOf(suggestion),
183
193
  suggestedCalendarId: context.calendarId,
184
194
  timeZone: "",
185
195
  zoneCertainty: ZONES[suggestion.zoneCertainty],
@@ -31,6 +31,8 @@ const allDay = {
31
31
  organizer: "organizer@example.test",
32
32
  zoneCertainty: "Explicit",
33
33
  acceptedCalendarObjectId: "",
34
+ supersededByMessageId: "",
35
+ supersededByThreadId: "",
34
36
  createdAt: 0,
35
37
  updatedAt: 0,
36
38
  } as const;
@@ -55,6 +55,7 @@ import { Route as MailOutboxComposeChar123OutboxMessageIdChar125RouteImport } fr
55
55
  import { Route as MailOutboxDraftOutboxMessageIdRouteImport } from './routes/mail/outbox/draft/$outboxMessageId'
56
56
  import { Route as CalendarViewDateCalendarObjectIdIndexRouteImport } from './routes/calendar/$view.$date/$calendarObjectId/index'
57
57
  import { Route as CalendarViewDateCalendarObjectIdRecurrenceIdRouteImport } from './routes/calendar/$view.$date/$calendarObjectId/$recurrenceId'
58
+ import { Route as CalendarViewDateSuggestionSuggestionIdRouteImport } from './routes/calendar/$view.$date/suggestion.$suggestionId'
58
59
  import { Route as MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/$mailboxId/$threadId/$messageId/$mode.{-$outboxMessageId}'
59
60
  import { Route as MailBriefThreadIdMessageIdModeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/brief/$threadId/$messageId/$mode.{-$outboxMessageId}'
60
61
  import { Route as MailFlaggedThreadIdMessageIdModeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/flagged/$threadId/$messageId/$mode.{-$outboxMessageId}'
@@ -302,6 +303,12 @@ const CalendarViewDateCalendarObjectIdRecurrenceIdRoute =
302
303
  path: '/$recurrenceId',
303
304
  getParentRoute: () => CalendarViewDateCalendarObjectIdRoute,
304
305
  } as any)
306
+ const CalendarViewDateSuggestionSuggestionIdRoute =
307
+ CalendarViewDateSuggestionSuggestionIdRouteImport.update({
308
+ id: '/suggestion/$suggestionId',
309
+ path: '/suggestion/$suggestionId',
310
+ getParentRoute: () => CalendarViewDateRoute,
311
+ } as any)
305
312
  const MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125Route =
306
313
  MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125RouteImport.update(
307
314
  {
@@ -373,6 +380,7 @@ export interface FileRoutesByFullPath {
373
380
  '/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
374
381
  '/mail/flagged/$threadId/': typeof MailFlaggedThreadIdIndexRoute
375
382
  '/calendar/$view/$date/$calendarObjectId/$recurrenceId': typeof CalendarViewDateCalendarObjectIdRecurrenceIdRoute
383
+ '/calendar/$view/$date/suggestion/$suggestionId': typeof CalendarViewDateSuggestionSuggestionIdRoute
376
384
  '/calendar/$view/$date/$calendarObjectId/': typeof CalendarViewDateCalendarObjectIdIndexRoute
377
385
  '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
378
386
  '/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailBriefThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
@@ -412,6 +420,7 @@ export interface FileRoutesByTo {
412
420
  '/mail/brief/$threadId': typeof MailBriefThreadIdIndexRoute
413
421
  '/mail/flagged/$threadId': typeof MailFlaggedThreadIdIndexRoute
414
422
  '/calendar/$view/$date/$calendarObjectId/$recurrenceId': typeof CalendarViewDateCalendarObjectIdRecurrenceIdRoute
423
+ '/calendar/$view/$date/suggestion/$suggestionId': typeof CalendarViewDateSuggestionSuggestionIdRoute
415
424
  '/calendar/$view/$date/$calendarObjectId': typeof CalendarViewDateCalendarObjectIdIndexRoute
416
425
  '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
417
426
  '/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailBriefThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
@@ -464,6 +473,7 @@ export interface FileRoutesById {
464
473
  '/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
465
474
  '/mail/flagged/$threadId/': typeof MailFlaggedThreadIdIndexRoute
466
475
  '/calendar/$view/$date/$calendarObjectId/$recurrenceId': typeof CalendarViewDateCalendarObjectIdRecurrenceIdRoute
476
+ '/calendar/$view/$date/suggestion/$suggestionId': typeof CalendarViewDateSuggestionSuggestionIdRoute
467
477
  '/calendar/$view/$date/$calendarObjectId/': typeof CalendarViewDateCalendarObjectIdIndexRoute
468
478
  '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
469
479
  '/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailBriefThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
@@ -517,6 +527,7 @@ export interface FileRouteTypes {
517
527
  | '/mail/brief/$threadId/'
518
528
  | '/mail/flagged/$threadId/'
519
529
  | '/calendar/$view/$date/$calendarObjectId/$recurrenceId'
530
+ | '/calendar/$view/$date/suggestion/$suggestionId'
520
531
  | '/calendar/$view/$date/$calendarObjectId/'
521
532
  | '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}'
522
533
  | '/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}'
@@ -556,6 +567,7 @@ export interface FileRouteTypes {
556
567
  | '/mail/brief/$threadId'
557
568
  | '/mail/flagged/$threadId'
558
569
  | '/calendar/$view/$date/$calendarObjectId/$recurrenceId'
570
+ | '/calendar/$view/$date/suggestion/$suggestionId'
559
571
  | '/calendar/$view/$date/$calendarObjectId'
560
572
  | '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}'
561
573
  | '/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}'
@@ -607,6 +619,7 @@ export interface FileRouteTypes {
607
619
  | '/mail/brief/$threadId/'
608
620
  | '/mail/flagged/$threadId/'
609
621
  | '/calendar/$view/$date/$calendarObjectId/$recurrenceId'
622
+ | '/calendar/$view/$date/suggestion/$suggestionId'
610
623
  | '/calendar/$view/$date/$calendarObjectId/'
611
624
  | '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}'
612
625
  | '/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}'
@@ -946,6 +959,13 @@ declare module '@tanstack/react-router' {
946
959
  preLoaderRoute: typeof CalendarViewDateCalendarObjectIdRecurrenceIdRouteImport
947
960
  parentRoute: typeof CalendarViewDateCalendarObjectIdRoute
948
961
  }
962
+ '/calendar/$view/$date/suggestion/$suggestionId': {
963
+ id: '/calendar/$view/$date/suggestion/$suggestionId'
964
+ path: '/suggestion/$suggestionId'
965
+ fullPath: '/calendar/$view/$date/suggestion/$suggestionId'
966
+ preLoaderRoute: typeof CalendarViewDateSuggestionSuggestionIdRouteImport
967
+ parentRoute: typeof CalendarViewDateRoute
968
+ }
949
969
  '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}': {
950
970
  id: '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}'
951
971
  path: '/$mode/{-$outboxMessageId}'
@@ -992,6 +1012,7 @@ interface CalendarViewDateRouteChildren {
992
1012
  CalendarViewDateCalendarObjectIdRoute: typeof CalendarViewDateCalendarObjectIdRouteWithChildren
993
1013
  CalendarViewDateNewRoute: typeof CalendarViewDateNewRoute
994
1014
  CalendarViewDateIndexRoute: typeof CalendarViewDateIndexRoute
1015
+ CalendarViewDateSuggestionSuggestionIdRoute: typeof CalendarViewDateSuggestionSuggestionIdRoute
995
1016
  }
996
1017
 
997
1018
  const CalendarViewDateRouteChildren: CalendarViewDateRouteChildren = {
@@ -999,6 +1020,8 @@ const CalendarViewDateRouteChildren: CalendarViewDateRouteChildren = {
999
1020
  CalendarViewDateCalendarObjectIdRouteWithChildren,
1000
1021
  CalendarViewDateNewRoute: CalendarViewDateNewRoute,
1001
1022
  CalendarViewDateIndexRoute: CalendarViewDateIndexRoute,
1023
+ CalendarViewDateSuggestionSuggestionIdRoute:
1024
+ CalendarViewDateSuggestionSuggestionIdRoute,
1002
1025
  }
1003
1026
 
1004
1027
  const CalendarViewDateRouteWithChildren =