@remit/web-client 0.0.294 → 0.0.295

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 (28) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarUnavailable.tsx +56 -0
  3. package/src/components/calendar/CalendarWaiting.tsx +99 -0
  4. package/src/components/calendar/CalendarWorkspace.stories.tsx +100 -0
  5. package/src/components/calendar/CalendarWorkspace.tsx +49 -40
  6. package/src/components/calendar/PendingSuggestions.tsx +70 -0
  7. package/src/components/calendar/pending-suggestions.render.test.ts +132 -0
  8. package/src/components/mail/DailyBrief.tsx +2 -1
  9. package/src/components/mail/IntelligencePane.tsx +5 -0
  10. package/src/components/mail/authenticity-warning.stories.tsx +6 -1
  11. package/src/components/mail/intelligence-drawer.stories.tsx +11 -2
  12. package/src/components/mail/intelligence-story-calendar.ts +19 -0
  13. package/src/hooks/calendar/agenda-window.test.ts +216 -132
  14. package/src/hooks/calendar/index.ts +8 -0
  15. package/src/hooks/calendar/useCalendarSuggestions.ts +180 -0
  16. package/src/hooks/calendar/useCalendars.ts +14 -2
  17. package/src/hooks/intelligence-calendar.render.test.ts +277 -0
  18. package/src/hooks/useBriefSections.ts +2 -1
  19. package/src/hooks/useIntelligenceCalendar.ts +347 -0
  20. package/src/lib/calendar-format.ts +41 -1
  21. package/src/lib/calendar-report.ts +11 -0
  22. package/src/lib/calendar-suggestion.test.ts +169 -0
  23. package/src/lib/calendar-suggestion.ts +251 -0
  24. package/src/lib/calendar-suggestion.west.test.ts +56 -0
  25. package/src/lib/starred-rows.ts +4 -2
  26. package/src/routes/calendar/$view.$date.tsx +2 -0
  27. package/src/routing/calendar.ts +29 -0
  28. package/src/routing/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.294",
3
+ "version": "0.0.295",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -0,0 +1,56 @@
1
+ import type { ReactNode } from "react";
2
+ import type { CalendarsResult } from "@/hooks/calendar";
3
+ import { NavLink } from "@/routing";
4
+
5
+ /**
6
+ * Whether there is a calendar an answer can write to. Declining and muting
7
+ * write none, so only adding waits on this.
8
+ */
9
+ export type CalendarWriteGate = "ready" | "loading" | "failed" | "none";
10
+
11
+ export function calendarWriteGate(
12
+ calendars: Pick<CalendarsResult, "defaultCalendarId" | "isLoading" | "error">,
13
+ ): CalendarWriteGate {
14
+ if (calendars.defaultCalendarId !== "") return "ready";
15
+ if (calendars.error !== null) return "failed";
16
+ if (calendars.isLoading) return "loading";
17
+ return "none";
18
+ }
19
+
20
+ /**
21
+ * Why adding waits, with the way past it. Undefined when nothing stops it, so a
22
+ * host can hand the answer straight to a card's `addBlocked`.
23
+ */
24
+ export function calendarUnavailable(
25
+ gate: CalendarWriteGate,
26
+ reportHref: string,
27
+ ): ReactNode | undefined {
28
+ if (gate === "ready") return undefined;
29
+ if (gate === "loading") return "Reading your calendars…";
30
+ if (gate === "failed")
31
+ return (
32
+ <>
33
+ Couldn't read your calendars, so there is nowhere to add this yet.
34
+ Reload to try again.{" "}
35
+ <a
36
+ href={reportHref}
37
+ target="_blank"
38
+ rel="noreferrer"
39
+ className="font-medium text-accent hover:underline"
40
+ >
41
+ Report an issue
42
+ </a>
43
+ </>
44
+ );
45
+ return (
46
+ <>
47
+ You have no calendar yet, so there is nowhere to add this.{" "}
48
+ <NavLink
49
+ to="/settings/calendars"
50
+ className="font-medium text-accent hover:underline"
51
+ >
52
+ Create a calendar
53
+ </NavLink>
54
+ </>
55
+ );
56
+ }
@@ -0,0 +1,99 @@
1
+ import {
2
+ CalendarFailureNote,
3
+ CalendarSuggestionDeck,
4
+ type CalendarSuggestionIntel,
5
+ EventSuggestionCard,
6
+ settleZone,
7
+ ZONE_UNSETTLED_REASON,
8
+ } from "@remit/ui";
9
+ import { Sparkles } from "lucide-react";
10
+ import type { ReactNode } from "react";
11
+
12
+ /**
13
+ * What the mail is still asking about time, beside the calendar it would land
14
+ * on. One reading at a time, as a deck: each is a decision, and the reader
15
+ * makes it with the week in view rather than from a list of them.
16
+ *
17
+ * Presentational. The readings, the answer on its way and the refusal all come
18
+ * in from the host, which reads them off the server.
19
+ */
20
+ export interface CalendarWaitingProps {
21
+ suggestions: CalendarSuggestionIntel[];
22
+ /** An answer to the top reading is on its way. */
23
+ busy: boolean;
24
+ /** Why the last answer did not land. Empty when it did. */
25
+ failure: string;
26
+ /** A prefilled issue report for the failure above. */
27
+ reportHref?: string;
28
+ /** Why nothing can be added yet, with the way past it; holds Add. */
29
+ addBlocked?: ReactNode;
30
+ onAdd: (suggestionId: string) => void;
31
+ onDismiss: (suggestionId: string) => void;
32
+ }
33
+
34
+ export function CalendarWaiting({
35
+ suggestions,
36
+ busy,
37
+ failure,
38
+ reportHref,
39
+ addBlocked,
40
+ onAdd,
41
+ onDismiss,
42
+ }: CalendarWaitingProps) {
43
+ const top = suggestions[0];
44
+ const settlement = top ? settleZone(top.suggestion, "") : undefined;
45
+
46
+ return (
47
+ <aside
48
+ aria-label="Waiting for you"
49
+ className="hidden w-72 shrink-0 flex-col gap-2 overflow-y-auto border-r border-line bg-surface-sunken py-3 lg:flex"
50
+ >
51
+ <h3 className="flex items-center gap-1.5 px-row-inset text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
52
+ <Sparkles className="size-3" aria-hidden />
53
+ Waiting for you
54
+ </h3>
55
+ {failure !== "" && (
56
+ <CalendarFailureNote
57
+ text={failure}
58
+ reportHref={reportHref}
59
+ className="mx-row-inset"
60
+ />
61
+ )}
62
+ {addBlocked !== undefined && (
63
+ <div className="mx-row-inset rounded-md border border-warning/40 bg-warning-soft p-2 text-xs text-fg">
64
+ {addBlocked}
65
+ </div>
66
+ )}
67
+ <CalendarSuggestionDeck
68
+ className="px-row-inset"
69
+ hasCard={top !== undefined}
70
+ remaining={suggestions.length}
71
+ blocked={settlement !== undefined && !settlement.settled}
72
+ blockedReason={ZONE_UNSETTLED_REASON}
73
+ onConfirm={() => {
74
+ if (top && !busy && addBlocked === undefined)
75
+ onAdd(top.suggestion.id);
76
+ }}
77
+ onReject={() => {
78
+ if (top && !busy) onDismiss(top.suggestion.id);
79
+ }}
80
+ >
81
+ {top && (
82
+ <EventSuggestionCard
83
+ suggestion={top.suggestion}
84
+ whenText={top.whenText}
85
+ onAdd={() => onAdd(top.suggestion.id)}
86
+ onDismiss={() => onDismiss(top.suggestion.id)}
87
+ addLabel="Add to calendar"
88
+ busy={busy}
89
+ addBlocked={addBlocked !== undefined}
90
+ />
91
+ )}
92
+ </CalendarSuggestionDeck>
93
+ <p className="px-row-inset text-2xs text-fg-subtle">
94
+ None of this is on your calendar until you add it. Adding sends the
95
+ organiser nothing.
96
+ </p>
97
+ </aside>
98
+ );
99
+ }
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  type CalendarColorId,
3
3
  type CalendarEventData,
4
+ type CalendarSuggestionIntel,
4
5
  type CalendarViewId,
5
6
  type Density,
6
7
  NavSidebar,
7
8
  } from "@remit/ui";
8
9
  import type { Meta, StoryObj } from "@storybook/react-vite";
9
10
  import { useState } from "react";
11
+ import { CalendarWaiting } from "./CalendarWaiting";
10
12
  import { CalendarWorkspace } from "./CalendarWorkspace";
11
13
 
12
14
  /**
@@ -105,6 +107,88 @@ const week: CalendarEventData[] = [
105
107
  at("climbing", "Climbing", "13", "10:00", "12:00", { calendarId: HOME }),
106
108
  ];
107
109
 
110
+ const reading = (
111
+ id: string,
112
+ title: string,
113
+ day: string,
114
+ from: string,
115
+ to: string,
116
+ sender: string,
117
+ ): CalendarSuggestionIntel => ({
118
+ suggestion: {
119
+ id,
120
+ title,
121
+ start: `2026-06-${day}T${from}:00+02:00`,
122
+ end: `2026-06-${day}T${to}:00+02:00`,
123
+ allDay: false,
124
+ location: "",
125
+ threadId: "",
126
+ threadSubject: "",
127
+ sender,
128
+ senderAddress: sender,
129
+ confidence: 1,
130
+ ambiguity: "",
131
+ suggestedCalendarId: WORK,
132
+ timeZone: "",
133
+ zoneCertainty: "explicit",
134
+ },
135
+ whenText: `Thu ${Number(day)} June, ${from} – ${to}`,
136
+ });
137
+
138
+ const mailWaiting: CalendarSuggestionIntel[] = [
139
+ reading(
140
+ "sug-kickoff",
141
+ "Billing migration kickoff",
142
+ "11",
143
+ "14:00",
144
+ "15:00",
145
+ "priya@example.invalid",
146
+ ),
147
+ reading(
148
+ "sug-review",
149
+ "Quarterly review",
150
+ "12",
151
+ "10:00",
152
+ "11:00",
153
+ "finance@example.invalid",
154
+ ),
155
+ ];
156
+
157
+ /**
158
+ * The column `PendingSuggestions` mounts beside the grid, with the answers a
159
+ * story can give: adding or dropping takes the reading off, and a refusal is
160
+ * stated the way the server's would be.
161
+ */
162
+ function Waiting({ refuse }: { refuse: boolean }) {
163
+ const [left, setLeft] = useState(mailWaiting);
164
+ const [failure, setFailure] = useState("");
165
+ const answer = (suggestionId: string) => {
166
+ if (refuse) {
167
+ setFailure(
168
+ "Couldn't add this to your calendar: This suggestion was already declined, so it can't be accepted.",
169
+ );
170
+ return;
171
+ }
172
+ setLeft((prev) =>
173
+ prev.filter((entry) => entry.suggestion.id !== suggestionId),
174
+ );
175
+ };
176
+ if (left.length === 0) return null;
177
+ return (
178
+ <CalendarWaiting
179
+ suggestions={left}
180
+ busy={false}
181
+ failure={failure}
182
+ onAdd={answer}
183
+ onDismiss={(suggestionId) =>
184
+ setLeft((prev) =>
185
+ prev.filter((entry) => entry.suggestion.id !== suggestionId),
186
+ )
187
+ }
188
+ />
189
+ );
190
+ }
191
+
108
192
  /** The shell drives view and date through the router; a story drives its own. */
109
193
  function Workspace({
110
194
  initialView,
@@ -112,12 +196,14 @@ function Workspace({
112
196
  isLoading,
113
197
  error,
114
198
  touch,
199
+ waiting,
115
200
  }: {
116
201
  initialView: CalendarViewId;
117
202
  events: CalendarEventData[];
118
203
  isLoading?: boolean;
119
204
  error?: unknown;
120
205
  touch?: boolean;
206
+ waiting?: "none" | "some" | "refused";
121
207
  }) {
122
208
  const [view, setView] = useState<CalendarViewId>(initialView);
123
209
  const [density, setDensity] = useState<Density>("comfortable");
@@ -135,6 +221,11 @@ function Workspace({
135
221
  The strip, which App/Calendar/Agenda has to itself.
136
222
  </div>
137
223
  }
224
+ waiting={
225
+ waiting === undefined || waiting === "none" ? undefined : (
226
+ <Waiting refuse={waiting === "refused"} />
227
+ )
228
+ }
138
229
  isLoading={isLoading}
139
230
  error={error}
140
231
  onRetry={() => undefined}
@@ -204,6 +295,15 @@ export const YearNotBuiltYet: Story = { args: { initialView: "year" } };
204
295
 
205
296
  export const MonthNotBuiltYet: Story = { args: { initialView: "month" } };
206
297
 
298
+ /**
299
+ * Invitations the mail is still asking about, beside the week they would land
300
+ * in. One at a time, as a deck; nothing here is on the calendar until added.
301
+ */
302
+ export const MailWaiting: Story = { args: { waiting: "some" } };
303
+
304
+ /** An answer the server refused, stated in the column rather than swallowed. */
305
+ export const MailWaitingRefused: Story = { args: { waiting: "refused" } };
306
+
207
307
  /** The zoom that mounts the strip instead of the grid. */
208
308
  export const Agenda: Story = { args: { initialView: "agenda" } };
209
309
 
@@ -50,6 +50,11 @@ export interface CalendarWorkspaceProps {
50
50
  * pane holds no events and knows no router.
51
51
  */
52
52
  agenda: ReactNode;
53
+ /**
54
+ * What the mail is still asking about time, beside whichever zoom is drawn.
55
+ * A slot for the reason the strip is one: it reads its own data.
56
+ */
57
+ waiting?: ReactNode;
53
58
  /** The first read of this window is still out; the grid has nothing yet. */
54
59
  isLoading?: boolean;
55
60
  /**
@@ -118,6 +123,7 @@ export function CalendarWorkspace({
118
123
  events,
119
124
  colorByCalendarId,
120
125
  agenda,
126
+ waiting,
121
127
  isLoading = false,
122
128
  error,
123
129
  onRetry,
@@ -197,47 +203,50 @@ export function CalendarWorkspace({
197
203
  week this pane read is not what it is drawing, and gating it on that
198
204
  read would blank a strip that has everything it needs. It also
199
205
  scrolls itself, so it gets a column to fill rather than a box. */}
200
- {calendarViewMountsAgenda(view) ? (
201
- <div className="flex min-h-0 flex-1 flex-col">{agenda}</div>
202
- ) : (
203
- <div className="min-h-0 flex-1">
204
- {error !== undefined && error !== null ? (
205
- <div className="flex h-full items-center justify-center">
206
- <ErrorState
207
- title="Couldn't load this week"
208
- error={error}
209
- onRetry={onRetry}
206
+ <div className="flex min-h-0 flex-1">
207
+ {waiting}
208
+ {calendarViewMountsAgenda(view) ? (
209
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">{agenda}</div>
210
+ ) : (
211
+ <div className="min-h-0 min-w-0 flex-1">
212
+ {error !== undefined && error !== null ? (
213
+ <div className="flex h-full items-center justify-center">
214
+ <ErrorState
215
+ title="Couldn't load this week"
216
+ error={error}
217
+ onRetry={onRetry}
218
+ />
219
+ </div>
220
+ ) : isLoading ? (
221
+ <div
222
+ role="status"
223
+ aria-label="Loading the calendar"
224
+ className="flex h-full items-center justify-center bg-surface"
225
+ >
226
+ <Loader2 className="size-6 animate-spin text-fg-subtle" />
227
+ </div>
228
+ ) : calendarViewMountsGrid(view) ? (
229
+ <CalendarGrid
230
+ view={view}
231
+ date={date}
232
+ events={events}
233
+ colorByCalendarId={colorByCalendarId}
234
+ density={density}
235
+ selectedEventId={selectedEventId}
236
+ timeZone={timeZone}
237
+ now={now}
238
+ onSelectEvent={onSelectEvent}
239
+ onPickSlot={onPickSlot}
240
+ onRangeChange={(measuredTitle) =>
241
+ setMeasured({ key: addressKey, title: measuredTitle })
242
+ }
210
243
  />
211
- </div>
212
- ) : isLoading ? (
213
- <div
214
- role="status"
215
- aria-label="Loading the calendar"
216
- className="flex h-full items-center justify-center bg-surface"
217
- >
218
- <Loader2 className="size-6 animate-spin text-fg-subtle" />
219
- </div>
220
- ) : calendarViewMountsGrid(view) ? (
221
- <CalendarGrid
222
- view={view}
223
- date={date}
224
- events={events}
225
- colorByCalendarId={colorByCalendarId}
226
- density={density}
227
- selectedEventId={selectedEventId}
228
- timeZone={timeZone}
229
- now={now}
230
- onSelectEvent={onSelectEvent}
231
- onPickSlot={onPickSlot}
232
- onRangeChange={(measuredTitle) =>
233
- setMeasured({ key: addressKey, title: measuredTitle })
234
- }
235
- />
236
- ) : (
237
- <CalendarViewPlaceholder view={view} />
238
- )}
239
- </div>
240
- )}
244
+ ) : (
245
+ <CalendarViewPlaceholder view={view} />
246
+ )}
247
+ </div>
248
+ )}
249
+ </div>
241
250
  </div>
242
251
  );
243
252
  }
@@ -0,0 +1,70 @@
1
+ import type { CalendarSuggestionIntel } from "@remit/ui";
2
+ import { useState } from "react";
3
+ import {
4
+ type SuggestionAnswer,
5
+ useCalendarSuggestionAnswers,
6
+ useCalendars,
7
+ usePendingCalendarSuggestions,
8
+ } from "@/hooks/calendar";
9
+ import { calendarReportHref } from "@/lib/calendar-report";
10
+ import { suggestionWhen, toEventSuggestion } from "@/lib/calendar-suggestion";
11
+ import { calendarUnavailable, calendarWriteGate } from "./CalendarUnavailable";
12
+ import { CalendarWaiting } from "./CalendarWaiting";
13
+
14
+ const refusal = (what: string, answer: SuggestionAnswer): string =>
15
+ answer.kind === "refused" ? `Couldn't ${what}: ${answer.message}` : "";
16
+
17
+ /**
18
+ * Every reading still waiting on the reader, read off the server and answered
19
+ * through it. Nothing is drawn when nothing is waiting: an empty column beside
20
+ * the grid is width the week could have had.
21
+ */
22
+ export function PendingSuggestions() {
23
+ const { suggestions, error } = usePendingCalendarSuggestions();
24
+ const calendars = useCalendars();
25
+ const { defaultCalendarId } = calendars;
26
+ const answers = useCalendarSuggestionAnswers();
27
+ const [failure, setFailure] = useState("");
28
+
29
+ const deck: CalendarSuggestionIntel[] = suggestions.map((suggestion) => ({
30
+ suggestion: toEventSuggestion(suggestion, {
31
+ threadId: "",
32
+ threadSubject: "",
33
+ calendarId: defaultCalendarId,
34
+ sender:
35
+ suggestion.organizer === "" ? "From your mail" : suggestion.organizer,
36
+ }),
37
+ whenText: suggestionWhen(suggestion),
38
+ }));
39
+
40
+ const readFailure =
41
+ error === null ? "" : "Couldn't read what your mail is waiting on.";
42
+ if (deck.length === 0 && readFailure === "" && failure === "") return null;
43
+
44
+ const reportHref = calendarReportHref(
45
+ readFailure || failure || "pending invitations could not be answered",
46
+ );
47
+
48
+ const answer = (what: string, request: () => Promise<SuggestionAnswer>) => {
49
+ setFailure("");
50
+ void request().then((result) => setFailure(refusal(what, result)));
51
+ };
52
+
53
+ return (
54
+ <CalendarWaiting
55
+ suggestions={deck}
56
+ busy={answers.isAnswering}
57
+ failure={readFailure || failure}
58
+ reportHref={reportHref}
59
+ addBlocked={calendarUnavailable(calendarWriteGate(calendars), reportHref)}
60
+ onAdd={(suggestionId) =>
61
+ answer("add this to your calendar", () =>
62
+ answers.accept(suggestionId, defaultCalendarId),
63
+ )
64
+ }
65
+ onDismiss={(suggestionId) =>
66
+ answer("dismiss this", () => answers.dismiss(suggestionId, false))
67
+ }
68
+ />
69
+ );
70
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * What the mail is still waiting on, beside the calendar (#1277).
3
+ *
4
+ * The column reads `GET /calendar-suggestions?state=Pending` and answers through
5
+ * the same endpoints the card beside a message does, so an answer given here
6
+ * and one given there cannot disagree about what is on the calendar.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { afterEach, describe, it } from "node:test";
10
+ import type {
11
+ RemitImapCalendarResponse,
12
+ RemitImapCalendarSuggestionResponse,
13
+ } from "@remit/api-http-client/types.gen.ts";
14
+ import { createElement } from "react";
15
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
16
+ import {
17
+ type HttpCall,
18
+ type HttpMock,
19
+ httpError,
20
+ mockFetch,
21
+ } from "@/test-support/http";
22
+ import { PendingSuggestions } from "./PendingSuggestions";
23
+
24
+ const CALENDAR = "11111111-1111-4111-8111-111111111111";
25
+ const SUGGESTION = "22222222-2222-4222-8222-222222222222";
26
+
27
+ const calendars = [
28
+ {
29
+ calendarId: CALENDAR,
30
+ accountConfigId: "cfg-1",
31
+ urlSegment: "default",
32
+ displayName: "Personal",
33
+ color: "Cal2",
34
+ componentSet: "VeventOnly",
35
+ source: "Default",
36
+ timezone: "Europe/Amsterdam",
37
+ syncSequence: 1,
38
+ createdAt: 0,
39
+ updatedAt: 0,
40
+ },
41
+ ] satisfies RemitImapCalendarResponse[];
42
+
43
+ const pending: RemitImapCalendarSuggestionResponse = {
44
+ suggestionId: SUGGESTION,
45
+ accountConfigId: "cfg-1",
46
+ messageId: "msg-1",
47
+ bodyPartId: "part-1",
48
+ icalUid: "uid-1",
49
+ sequence: 0,
50
+ method: "Request",
51
+ source: "IcalendarPart",
52
+ state: "Pending",
53
+ summary: "Quarterly review",
54
+ dtStart: "2026-09-01T08:00:00+00:00",
55
+ dtEnd: "2026-09-01T09:00:00+00:00",
56
+ allDay: false,
57
+ location: "",
58
+ organizer: "organizer@example.test",
59
+ zoneCertainty: "Explicit",
60
+ acceptedCalendarObjectId: "",
61
+ createdAt: 0,
62
+ updatedAt: 0,
63
+ };
64
+
65
+ let harness: DomHarness | undefined;
66
+ let http: HttpMock | undefined;
67
+
68
+ afterEach(() => {
69
+ harness?.close();
70
+ harness = undefined;
71
+ http?.restore();
72
+ http = undefined;
73
+ });
74
+
75
+ const server = (accept: () => Response | undefined) => {
76
+ let waiting = [pending];
77
+ return (call: HttpCall): unknown => {
78
+ if (call.path.endsWith("/calendars")) return { items: calendars };
79
+ if (call.path.endsWith("/calendar-suggestions")) return { items: waiting };
80
+ if (call.path.endsWith("/accept")) {
81
+ const refusal = accept();
82
+ if (refusal) return refusal;
83
+ waiting = [];
84
+ return { ...pending, state: "Accepted" };
85
+ }
86
+ return { items: [] };
87
+ };
88
+ };
89
+
90
+ const mount = async (respond: (call: HttpCall) => unknown) => {
91
+ http = mockFetch(respond);
92
+ harness = createDomHarness();
93
+ harness.renderApp(createElement(PendingSuggestions));
94
+ await harness.waitFor(
95
+ () => harness?.text().includes("Quarterly review") === true,
96
+ "the waiting reading to be drawn",
97
+ );
98
+ };
99
+
100
+ describe("what the mail is waiting on, beside the calendar", () => {
101
+ it("asks only for the pending ones", async () => {
102
+ await mount(server(() => undefined));
103
+ const listing = http?.to("/calendar-suggestions") ?? [];
104
+ assert.ok(listing.length > 0);
105
+ assert.match(listing[0].url, /state=Pending/);
106
+ });
107
+
108
+ it("adds the top reading to the default calendar and leaves once it is on it", async () => {
109
+ await mount(server(() => undefined));
110
+ harness?.click(harness.byText("button", "Add"));
111
+
112
+ await harness?.waitFor(
113
+ () => harness?.text().includes("Quarterly review") === false,
114
+ "the column to leave once nothing waits",
115
+ );
116
+ const accepted =
117
+ http?.to(`/calendar-suggestions/${SUGGESTION}/accept`) ?? [];
118
+ assert.deepEqual(accepted[0]?.body, { calendarId: CALENDAR });
119
+ });
120
+
121
+ it("states a refusal instead of leaving a card that did nothing", async () => {
122
+ await mount(server(() => httpError(400, "Already declined.")));
123
+ harness?.click(harness.byText("button", "Add"));
124
+
125
+ await harness?.waitFor(
126
+ () => harness?.query('[role="alert"]') !== null,
127
+ "the refusal to be stated",
128
+ );
129
+ assert.match(harness?.text() ?? "", /Couldn't add this to your calendar/);
130
+ assert.match(harness?.text() ?? "", /Quarterly review/);
131
+ });
132
+ });
@@ -16,7 +16,8 @@
16
16
  * here loads a mailbox.
17
17
  *
18
18
  * Sections are the unsearched brief only. A search is one request and one flat
19
- * list, newest first across every category: sectioning the matches would order
19
+ * list in the server's order — sender matches first, then mentions, each newest
20
+ * first across every category: sectioning the matches would order
20
21
  * them by category first and recency second, putting last spring's newsletter
21
22
  * above a mail that arrived this morning.
22
23
  *
@@ -12,6 +12,7 @@ import {
12
12
  } from "@remit/ui";
13
13
  import { Sparkles } from "lucide-react";
14
14
  import { useCallback, useRef, useState } from "react";
15
+ import { useIntelligenceCalendar } from "@/hooks/useIntelligenceCalendar";
15
16
  import { useIntelligenceData } from "@/hooks/useIntelligenceData";
16
17
  import { useReportSpam } from "@/hooks/useReportSpam";
17
18
  import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
@@ -272,6 +273,7 @@ function WiredPanel({
272
273
  similarErrorIsFatal,
273
274
  semanticEnabled,
274
275
  } = useIntelligenceData(thread, mailboxId);
276
+ const calendar = useIntelligenceCalendar(thread);
275
277
  const [reclassifyOpen, setReclassifyOpen] = useState(false);
276
278
  const senderEmail = thread.fromEmail ?? undefined;
277
279
 
@@ -391,6 +393,9 @@ function WiredPanel({
391
393
  <>
392
394
  <IntelligencePanel
393
395
  data={data}
396
+ calendar={calendar.surface}
397
+ tab={calendar.tab}
398
+ onTabChange={calendar.onTabChange}
394
399
  onClose={onClose}
395
400
  hideCloseButton={hideCloseButton}
396
401
  onShowSimilar={handleShowSimilar}
@@ -3,6 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
3
3
  import { useState } from "react";
4
4
  import { Drawer } from "@/components/layout/Drawer";
5
5
  import { AuthenticityBanner } from "@/components/mail/AuthenticityBanner";
6
+ import { quietCalendar } from "./intelligence-story-calendar";
6
7
 
7
8
  /**
8
9
  * The authenticity warning over the reading pane, and where its "Why?" goes.
@@ -113,7 +114,11 @@ const TwoPaneReading = () => {
113
114
  ariaLabel="Message details"
114
115
  side="right"
115
116
  >
116
- <IntelligencePanel data={intelligence} hideCloseButton />
117
+ <IntelligencePanel
118
+ data={intelligence}
119
+ calendar={quietCalendar}
120
+ hideCloseButton
121
+ />
117
122
  </Drawer>
118
123
  </div>
119
124
  );