@remit/web-client 0.0.312 → 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.
Files changed (29) 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 +182 -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 +299 -0
  9. package/src/components/calendar/pending-suggestions.render.test.ts +68 -3
  10. package/src/components/compose/ComposeForm.tsx +38 -4
  11. package/src/components/compose/compose-inline-image.render.test.ts +542 -0
  12. package/src/hooks/calendar/draft.ts +1 -1
  13. package/src/hooks/calendar/index.ts +7 -0
  14. package/src/hooks/calendar/suggestion-draft.ts +77 -0
  15. package/src/hooks/calendar/useCalendarSuggestions.ts +59 -1
  16. package/src/hooks/intelligence-calendar.render.test.ts +135 -0
  17. package/src/hooks/useComposeAttachments.ts +6 -1
  18. package/src/hooks/useInlineImages.ts +93 -0
  19. package/src/hooks/useIntelligenceCalendar.ts +36 -5
  20. package/src/lib/calendar-suggestion.test.ts +10 -1
  21. package/src/lib/calendar-suggestion.ts +12 -2
  22. package/src/lib/compose-attachment-controller.test.ts +110 -0
  23. package/src/lib/compose-attachment-controller.ts +120 -16
  24. package/src/routeTree.gen.ts +23 -0
  25. package/src/routes/calendar/$view.$date/suggestion.$suggestionId.tsx +22 -0
  26. package/src/routes/calendar/$view.$date.tsx +9 -3
  27. package/src/routes/calendar/-calendar-tree.test.ts +18 -0
  28. package/src/routing/calendar.ts +36 -1
  29. package/src/routing/index.ts +2 -0
@@ -12,9 +12,26 @@ export type DraftForAttachment =
12
12
  export interface MintedAttachment {
13
13
  outboxAttachmentId: string;
14
14
  filename: string;
15
+ contentType: string;
16
+ contentId: string;
15
17
  uploadUrl: string;
16
18
  }
17
19
 
20
+ export interface CompletedAttachment {
21
+ contentUrl: string;
22
+ }
23
+
24
+ export interface AttachmentPlacement {
25
+ stored: (file: File, contentId: string) => void;
26
+ failed: (file: File) => void;
27
+ }
28
+
29
+ export interface StoredContent {
30
+ filename: string;
31
+ contentType: string;
32
+ contentUrl: string;
33
+ }
34
+
18
35
  /** The four requests a file on a draft is made of. Each throws on failure. */
19
36
  export interface AttachmentTransport {
20
37
  mint: (outboxMessageId: string, file: File) => Promise<MintedAttachment>;
@@ -22,7 +39,7 @@ export interface AttachmentTransport {
22
39
  complete: (
23
40
  outboxMessageId: string,
24
41
  outboxAttachmentId: string,
25
- ) => Promise<void>;
42
+ ) => Promise<CompletedAttachment>;
26
43
  keep: (outboxMessageId: string, attachmentIds: string[]) => Promise<void>;
27
44
  }
28
45
 
@@ -34,6 +51,10 @@ export interface ComposeAttachmentControllerDeps {
34
51
  interface Entry extends ComposeAttachmentItem {
35
52
  serverId: string | null;
36
53
  file: File | null;
54
+ placement: AttachmentPlacement | null;
55
+ contentId: string | null;
56
+ contentType: string;
57
+ contentUrl: string | null;
37
58
  }
38
59
 
39
60
  interface AttachmentRejection {
@@ -91,6 +112,10 @@ const fromServer = (attachment: RemitImapOutboxAttachmentResponse): Entry => ({
91
112
  key: attachment.outboxAttachmentId,
92
113
  serverId: attachment.outboxAttachmentId,
93
114
  file: null,
115
+ placement: null,
116
+ contentId: attachment.contentId,
117
+ contentType: attachment.contentType,
118
+ contentUrl: attachment.contentUrl,
94
119
  filename: attachment.filename,
95
120
  sizeBytes: attachment.sizeBytes,
96
121
  state:
@@ -124,6 +149,8 @@ export class ComposeAttachmentController {
124
149
  private readonly inFlight = new Map<string, AbortController>();
125
150
  private readonly mintedAfterRemoval = new Map<string, string>();
126
151
  private readonly removing = new Map<string, string>();
152
+ private readonly removedListeners = new Set<(contentId: string) => void>();
153
+ private loaded = false;
127
154
 
128
155
  constructor(private readonly deps: ComposeAttachmentControllerDeps) {}
129
156
 
@@ -134,6 +161,47 @@ export class ComposeAttachmentController {
134
161
 
135
162
  getSnapshot = (): ComposeAttachmentItem[] => this.snapshot;
136
163
 
164
+ owns = (contentId: string): boolean =>
165
+ this.entries.some((entry) => entry.contentId === contentId);
166
+
167
+ storedContent = (contentId: string): StoredContent | null => {
168
+ const entry = this.entries.find(
169
+ (candidate) =>
170
+ candidate.contentId === contentId && candidate.contentUrl !== null,
171
+ );
172
+ if (!entry?.contentUrl) return null;
173
+ return {
174
+ filename: entry.filename,
175
+ contentType: entry.contentType,
176
+ contentUrl: entry.contentUrl,
177
+ };
178
+ };
179
+
180
+ isLoaded = (): boolean => this.loaded;
181
+
182
+ refreshLinks = (
183
+ attachments: readonly RemitImapOutboxAttachmentResponse[],
184
+ ): void => {
185
+ const links = new Map(
186
+ attachments.map((attachment) => [
187
+ attachment.outboxAttachmentId,
188
+ attachment.contentUrl,
189
+ ]),
190
+ );
191
+ this.set(
192
+ this.entries.map((entry) => {
193
+ const contentUrl =
194
+ entry.serverId === null ? undefined : links.get(entry.serverId);
195
+ return contentUrl === undefined ? entry : { ...entry, contentUrl };
196
+ }),
197
+ );
198
+ };
199
+
200
+ onRemoved = (listener: (contentId: string) => void): (() => void) => {
201
+ this.removedListeners.add(listener);
202
+ return () => this.removedListeners.delete(listener);
203
+ };
204
+
137
205
  blockingReason = (): string | undefined => {
138
206
  const [removing] = this.removing.values();
139
207
  if (removing !== undefined) return `Removing "${removing}"…`;
@@ -210,6 +278,8 @@ export class ComposeAttachmentController {
210
278
  this.patch(generation, key, {
211
279
  serverId: minted.outboxAttachmentId,
212
280
  filename: minted.filename,
281
+ contentId: minted.contentId,
282
+ contentType: minted.contentType,
213
283
  });
214
284
  }
215
285
  settle();
@@ -232,19 +302,29 @@ export class ComposeAttachmentController {
232
302
 
233
303
  const completed = await transport
234
304
  .complete(outboxMessageId, minted.outboxAttachmentId)
235
- .then(
236
- () => true,
237
- (error: unknown) => {
238
- this.patch(generation, key, {
239
- state: refusalOf(minted.filename, error),
240
- });
241
- return false;
242
- },
243
- );
244
- if (!completed || signal.aborted) return;
245
- this.patch(generation, key, { state: ATTACHED });
305
+ .catch((error: unknown) => {
306
+ this.patch(generation, key, {
307
+ state: refusalOf(minted.filename, error),
308
+ });
309
+ return null;
310
+ });
311
+ if (completed === null || signal.aborted) return;
312
+ this.patch(generation, key, {
313
+ state: ATTACHED,
314
+ contentUrl: completed.contentUrl,
315
+ });
316
+ if (generation !== this.generation) return;
317
+ this.entries
318
+ .find((entry) => entry.key === key)
319
+ ?.placement?.stored(file, minted.contentId);
246
320
  };
247
321
 
322
+ private settleFailure(generation: number, key: string, file: File): void {
323
+ if (generation !== this.generation) return;
324
+ const entry = this.entries.find((candidate) => candidate.key === key);
325
+ if (entry?.state.status === "failed") entry.placement?.failed(file);
326
+ }
327
+
248
328
  private run = async (
249
329
  generation: number,
250
330
  key: string,
@@ -254,6 +334,7 @@ export class ComposeAttachmentController {
254
334
  ): Promise<void> => {
255
335
  await this.upload(generation, key, file, outboxMessageId, signal);
256
336
  if (this.inFlight.get(key)?.signal === signal) this.inFlight.delete(key);
337
+ this.settleFailure(generation, key, file);
257
338
  };
258
339
 
259
340
  /**
@@ -282,7 +363,10 @@ export class ComposeAttachmentController {
282
363
  );
283
364
  };
284
365
 
285
- attach = async (files: File[]): Promise<void> => {
366
+ attach = async (
367
+ files: File[],
368
+ placement?: AttachmentPlacement,
369
+ ): Promise<void> => {
286
370
  const generation = this.generation;
287
371
  const added = files.map((file) => {
288
372
  this.nextKey += 1;
@@ -290,6 +374,10 @@ export class ComposeAttachmentController {
290
374
  key: `local-${this.nextKey}`,
291
375
  serverId: null,
292
376
  file,
377
+ placement: placement ?? null,
378
+ contentId: null,
379
+ contentType: file.type || "application/octet-stream",
380
+ contentUrl: null,
293
381
  filename: file.name,
294
382
  sizeBytes: file.size,
295
383
  state: UPLOADING,
@@ -302,11 +390,12 @@ export class ComposeAttachmentController {
302
390
 
303
391
  const draft = await this.deps.ensureDraft();
304
392
  if (draft.outcome === "refused") {
305
- for (const { entry } of added) {
393
+ for (const { entry, file } of added) {
306
394
  this.inFlight.delete(entry.key);
307
395
  this.patch(generation, entry.key, {
308
396
  state: failed(draft.reason, true),
309
397
  });
398
+ this.settleFailure(generation, entry.key, file);
310
399
  }
311
400
  return;
312
401
  }
@@ -379,7 +468,12 @@ export class ComposeAttachmentController {
379
468
  this.set(this.entries);
380
469
  const serverId = entry.serverId ?? this.mintedAfterRemoval.get(key) ?? null;
381
470
  this.mintedAfterRemoval.delete(key);
382
- if (failure === null || generation !== this.generation) return;
471
+ if (generation !== this.generation) return;
472
+ if (failure === null) {
473
+ if (entry.contentId === null) return;
474
+ for (const listener of this.removedListeners) listener(entry.contentId);
475
+ return;
476
+ }
383
477
  if (serverId === null) return;
384
478
  this.set([
385
479
  ...this.entries,
@@ -400,7 +494,16 @@ export class ComposeAttachmentController {
400
494
  attachments: readonly RemitImapOutboxAttachmentResponse[],
401
495
  ): void => {
402
496
  this.draftId = outboxMessageId;
403
- this.set(attachments.map(fromServer));
497
+ this.loaded = true;
498
+ const listed = new Set(
499
+ attachments.map((attachment) => attachment.outboxAttachmentId),
500
+ );
501
+ const attachedHere = this.entries.filter(
502
+ (entry) =>
503
+ entry.file !== null &&
504
+ (entry.serverId === null || !listed.has(entry.serverId)),
505
+ );
506
+ this.set([...attachments.map(fromServer), ...attachedHere]);
404
507
  };
405
508
 
406
509
  reset = (outboxMessageId: string | undefined): void => {
@@ -410,6 +513,7 @@ export class ComposeAttachmentController {
410
513
  this.mintedAfterRemoval.clear();
411
514
  this.removing.clear();
412
515
  this.draftId = outboxMessageId;
516
+ this.loaded = false;
413
517
  this.set([]);
414
518
  };
415
519
  }
@@ -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 =
@@ -0,0 +1,22 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ import { createFileRoute } from "@tanstack/react-router";
3
+ import { ChangeCalendarSuggestion } from "@/components/calendar/ChangeCalendarSuggestion";
4
+ import { useCalendarNavigation, useChangingSuggestionId } from "@/routing";
5
+
6
+ function ChangeSuggestionRoute() {
7
+ const suggestionId = useChangingSuggestionId();
8
+ const { closeEvent } = useCalendarNavigation();
9
+ if (suggestionId === undefined) return null;
10
+ return (
11
+ <ChangeCalendarSuggestion
12
+ suggestionId={suggestionId}
13
+ onClose={closeEvent}
14
+ />
15
+ );
16
+ }
17
+
18
+ export const Route = createFileRoute(
19
+ "/calendar/$view/$date/suggestion/$suggestionId",
20
+ )({
21
+ component: ChangeSuggestionRoute,
22
+ });
@@ -86,8 +86,14 @@ function CalendarView({ isPhone }: { isPhone: boolean }) {
86
86
  const drawsGrid = !calendarViewMountsAgenda(view);
87
87
  const { events, colorByCalendarId, isLoading, error, retry, instanceOf } =
88
88
  useCalendarData({ view, date, calendarIds, enabled: drawsGrid });
89
- const { goToView, goToToday, step, openEvent, openComposer } =
90
- useCalendarNavigation();
89
+ const {
90
+ goToView,
91
+ goToToday,
92
+ step,
93
+ openEvent,
94
+ openComposer,
95
+ changeSuggestionFirst,
96
+ } = useCalendarNavigation();
91
97
  const openedEvent = useOpenCalendarEvent();
92
98
  const isWriting = useIsWritingEvent();
93
99
  // Held in state rather than read back from storage each render: how much of
@@ -123,7 +129,7 @@ function CalendarView({ isPhone }: { isPhone: boolean }) {
123
129
  events={events}
124
130
  colorByCalendarId={colorByCalendarId}
125
131
  agenda={<AgendaView density={density} onPickSlot={pickSlot} />}
126
- waiting={<PendingSuggestions />}
132
+ waiting={<PendingSuggestions onChangeFirst={changeSuggestionFirst} />}
127
133
  isLoading={isLoading}
128
134
  error={error}
129
135
  onRetry={retry}
@@ -31,6 +31,7 @@ import { Route as EventIndexRoute } from "./$view.$date/$calendarObjectId/index.
31
31
  import { Route as EventRoute } from "./$view.$date/$calendarObjectId.js";
32
32
  import { Route as ReadingPaneRoute } from "./$view.$date/index.js";
33
33
  import { Route as ComposeRoute } from "./$view.$date/new.js";
34
+ import { Route as SuggestionRoute } from "./$view.$date/suggestion.$suggestionId.js";
34
35
  import { Route as ViewRoute } from "./$view.$date.js";
35
36
  import { Route as CalendarIndexRoute } from "./index.js";
36
37
 
@@ -68,6 +69,10 @@ eventRoute.addChildren([
68
69
  viewRoute.addChildren([
69
70
  attach(ReadingPaneRoute, { path: "/", getParentRoute: () => viewRoute }),
70
71
  attach(ComposeRoute, { path: "new", getParentRoute: () => viewRoute }),
72
+ attach(SuggestionRoute, {
73
+ path: "suggestion/$suggestionId",
74
+ getParentRoute: () => viewRoute,
75
+ }),
71
76
  eventRoute,
72
77
  ]);
73
78
  const routeTree = rootRoute.addChildren([
@@ -253,6 +258,19 @@ describe("the composer and the open event cannot both be addressed", () => {
253
258
  assert.equal(mounted.routeIds.includes("/calendar/$view/$date/new"), false);
254
259
  });
255
260
 
261
+ it("mounts the editor for a suggestion, and nothing of the event route", () => {
262
+ const mounted = match("/calendar/day/2026-06-11/suggestion/sug_1");
263
+ assert.equal(
264
+ mounted.routeIds.at(-1),
265
+ "/calendar/$view/$date/suggestion/$suggestionId",
266
+ );
267
+ assert.equal(mounted.params.suggestionId, "sug_1");
268
+ assert.equal(
269
+ mounted.routeIds.some((id) => id.includes("$calendarObjectId")),
270
+ false,
271
+ );
272
+ });
273
+
256
274
  it("keeps the series matched under one of its occurrences", () => {
257
275
  const mounted = match(
258
276
  "/calendar/week/2026-06-10/evt_1/2026-06-11T09%3A15%3A00%2B02%3A00",
@@ -9,6 +9,8 @@ import {
9
9
  import { useRetainOpenPanels } from "./fragment";
10
10
 
11
11
  const VIEW_ROUTE = "/calendar/$view/$date" as const;
12
+ const SUGGESTION_ROUTE =
13
+ "/calendar/$view/$date/suggestion/$suggestionId" as const;
12
14
 
13
15
  /** Which zoom the calendar is at, on which day, over which calendars. */
14
16
  export interface CalendarAddress {
@@ -67,12 +69,21 @@ export function useOpenCalendarEvent(): OpenCalendarEvent | undefined {
67
69
  }
68
70
 
69
71
  /** Whether the composer is up, which is a route rather than a flag. */
72
+ export function useChangingSuggestionId(): string | undefined {
73
+ return useParams({ from: SUGGESTION_ROUTE, shouldThrow: false })
74
+ ?.suggestionId;
75
+ }
76
+
70
77
  export function useIsWritingEvent(): boolean {
71
78
  const composer = useParams({
72
79
  from: "/calendar/$view/$date/new",
73
80
  shouldThrow: false,
74
81
  });
75
- return composer !== undefined;
82
+ const changing = useParams({
83
+ from: SUGGESTION_ROUTE,
84
+ shouldThrow: false,
85
+ });
86
+ return composer !== undefined || changing !== undefined;
76
87
  }
77
88
 
78
89
  export interface CalendarNavigation {
@@ -97,6 +108,7 @@ export interface CalendarNavigation {
97
108
  openEvent: (calendarObjectId: string, recurrenceId?: string) => void;
98
109
  closeEvent: () => void;
99
110
  openComposer: () => void;
111
+ changeSuggestionFirst: (suggestionId: string, date: string) => void;
100
112
  }
101
113
 
102
114
  /**
@@ -161,6 +173,13 @@ export function useCalendarNavigation(): CalendarNavigation {
161
173
  search: true,
162
174
  hash: retainPanels,
163
175
  }),
176
+ changeSuggestionFirst: (suggestionId: string, on: string) =>
177
+ navigate({
178
+ to: SUGGESTION_ROUTE,
179
+ params: { view, date: on, suggestionId },
180
+ search: true,
181
+ hash: retainPanels,
182
+ }),
164
183
  }),
165
184
  [goTo, navigate, retainPanels, view, date],
166
185
  );
@@ -194,3 +213,19 @@ export function useOpenEventOnCalendar(): (
194
213
  [navigate],
195
214
  );
196
215
  }
216
+
217
+ export function useChangeSuggestionOnCalendar(): (
218
+ date: string,
219
+ suggestionId: string,
220
+ ) => void {
221
+ const navigate = useNavigate();
222
+ return useCallback(
223
+ (date: string, suggestionId: string) => {
224
+ navigate({
225
+ to: SUGGESTION_ROUTE,
226
+ params: { view: "day", date, suggestionId },
227
+ });
228
+ },
229
+ [navigate],
230
+ );
231
+ }
@@ -9,6 +9,8 @@ export {
9
9
  type OpenCalendarEvent,
10
10
  useCalendarAddress,
11
11
  useCalendarNavigation,
12
+ useChangeSuggestionOnCalendar,
13
+ useChangingSuggestionId,
12
14
  useIsWritingEvent,
13
15
  useOpenCalendarEvent,
14
16
  useOpenEventOnCalendar,