@remit/web-client 0.0.293 → 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.
- package/package.json +1 -1
- package/src/components/calendar/CalendarUnavailable.tsx +56 -0
- package/src/components/calendar/CalendarWaiting.tsx +99 -0
- package/src/components/calendar/CalendarWorkspace.stories.tsx +100 -0
- package/src/components/calendar/CalendarWorkspace.tsx +49 -40
- package/src/components/calendar/PendingSuggestions.tsx +70 -0
- package/src/components/calendar/pending-suggestions.render.test.ts +132 -0
- package/src/components/mail/DailyBrief.tsx +2 -1
- package/src/components/mail/IntelligencePane.tsx +5 -0
- package/src/components/mail/authenticity-warning.stories.tsx +6 -1
- package/src/components/mail/intelligence-drawer.stories.tsx +11 -2
- package/src/components/mail/intelligence-story-calendar.ts +19 -0
- package/src/hooks/calendar/agenda-window.test.ts +216 -132
- package/src/hooks/calendar/index.ts +8 -0
- package/src/hooks/calendar/useCalendarSuggestions.ts +180 -0
- package/src/hooks/calendar/useCalendars.ts +14 -2
- package/src/hooks/intelligence-calendar.render.test.ts +277 -0
- package/src/hooks/useBriefSections.ts +2 -1
- package/src/hooks/useIntelligenceCalendar.ts +347 -0
- package/src/lib/calendar-format.ts +41 -1
- package/src/lib/calendar-report.ts +11 -0
- package/src/lib/calendar-suggestion.test.ts +169 -0
- package/src/lib/calendar-suggestion.ts +251 -0
- package/src/lib/calendar-suggestion.west.test.ts +56 -0
- package/src/lib/starred-rows.ts +4 -2
- package/src/routes/calendar/$view.$date.tsx +2 -0
- package/src/routing/calendar.ts +29 -0
- package/src/routing/index.ts +1 -0
- package/src/test-support/fixtures.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
|
117
|
+
<IntelligencePanel
|
|
118
|
+
data={intelligence}
|
|
119
|
+
calendar={quietCalendar}
|
|
120
|
+
hideCloseButton
|
|
121
|
+
/>
|
|
117
122
|
</Drawer>
|
|
118
123
|
</div>
|
|
119
124
|
);
|