@remit/web-client 0.0.294 → 0.0.296
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 -6
- 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/hooks/useIntelligenceData.ts +0 -1
- package/src/hooks/useUpdateAddressFlags.ts +1 -1
- 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
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The calendar half of the intelligence panel for the open message.
|
|
3
|
+
*
|
|
4
|
+
* The invitation, the day it lands on and the answers all come from the server:
|
|
5
|
+
* the suggestions the message produced, the events already on that day, and the
|
|
6
|
+
* busy time merged across every calendar. What is held here is only what the
|
|
7
|
+
* reader is in the middle of — the half-hours ticked, whether the offer is open,
|
|
8
|
+
* which answer is on its way and why the last one was refused — and none of it
|
|
9
|
+
* outlives the message it is about.
|
|
10
|
+
*/
|
|
11
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
12
|
+
import type {
|
|
13
|
+
CalendarDayEntry,
|
|
14
|
+
CalendarSlotPick,
|
|
15
|
+
CalendarSuggestionIntel,
|
|
16
|
+
IntelligenceCalendarActions,
|
|
17
|
+
IntelligenceCalendarSurface,
|
|
18
|
+
IntelligenceTabId,
|
|
19
|
+
} from "@remit/ui";
|
|
20
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
21
|
+
import {
|
|
22
|
+
calendarUnavailable,
|
|
23
|
+
calendarWriteGate,
|
|
24
|
+
} from "@/components/calendar/CalendarUnavailable";
|
|
25
|
+
import {
|
|
26
|
+
calendarWindowOfDays,
|
|
27
|
+
isDrawnInstance,
|
|
28
|
+
readCalendarInstanceId,
|
|
29
|
+
type SuggestionAnswer,
|
|
30
|
+
toCalendarEventData,
|
|
31
|
+
useCalendarEventWindow,
|
|
32
|
+
useCalendarFreeBusyWeeks,
|
|
33
|
+
useCalendarSuggestionAnswers,
|
|
34
|
+
useCalendars,
|
|
35
|
+
useMessageCalendarSuggestions,
|
|
36
|
+
} from "@/hooks/calendar";
|
|
37
|
+
import { formatDayLabel, formatInstantClock } from "@/lib/calendar-format";
|
|
38
|
+
import { calendarReportHref } from "@/lib/calendar-report";
|
|
39
|
+
import {
|
|
40
|
+
type CalendarSuggestion,
|
|
41
|
+
clashesOver,
|
|
42
|
+
inviteStanding,
|
|
43
|
+
isFromInvitation,
|
|
44
|
+
slotOffersOn,
|
|
45
|
+
slotsAsText,
|
|
46
|
+
suggestionDate,
|
|
47
|
+
suggestionWhen,
|
|
48
|
+
toCalendarInvite,
|
|
49
|
+
toEventSuggestion,
|
|
50
|
+
} from "@/lib/calendar-suggestion";
|
|
51
|
+
import { useOpenEventOnCalendar } from "@/routing";
|
|
52
|
+
|
|
53
|
+
/** What the reader is in the middle of, for one message and no other. */
|
|
54
|
+
interface Working {
|
|
55
|
+
messageId: string;
|
|
56
|
+
tab: IntelligenceTabId | undefined;
|
|
57
|
+
offering: boolean;
|
|
58
|
+
picked: string[];
|
|
59
|
+
copy: "idle" | "copied" | "failed";
|
|
60
|
+
answering: string;
|
|
61
|
+
/** The card the last refusal was about, and what it said. */
|
|
62
|
+
failure: { suggestionId: string; text: string };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fresh = (messageId: string): Working => ({
|
|
66
|
+
messageId,
|
|
67
|
+
tab: undefined,
|
|
68
|
+
offering: false,
|
|
69
|
+
picked: [],
|
|
70
|
+
copy: "idle",
|
|
71
|
+
answering: "",
|
|
72
|
+
failure: { suggestionId: "", text: "" },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** Pending cards first, then the latest revision, so the card answers the newest ask. */
|
|
76
|
+
const byUrgency = (a: CalendarSuggestion, b: CalendarSuggestion): number => {
|
|
77
|
+
const pending = Number(b.state === "Pending") - Number(a.state === "Pending");
|
|
78
|
+
return pending !== 0 ? pending : b.sequence - a.sequence;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const refusal = (what: string, answer: SuggestionAnswer): string =>
|
|
82
|
+
answer.kind === "refused"
|
|
83
|
+
? `Couldn't ${what}: ${answer.message} Try again, or answer from your calendar app.`
|
|
84
|
+
: "";
|
|
85
|
+
|
|
86
|
+
export interface IntelligenceCalendar {
|
|
87
|
+
surface: IntelligenceCalendarSurface;
|
|
88
|
+
tab: IntelligenceTabId;
|
|
89
|
+
onTabChange: (tab: IntelligenceTabId) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function useIntelligenceCalendar(
|
|
93
|
+
thread: RemitImapThreadMessageResponse,
|
|
94
|
+
): IntelligenceCalendar {
|
|
95
|
+
const messageId = thread.messageId;
|
|
96
|
+
const { suggestions, isLoading, error } =
|
|
97
|
+
useMessageCalendarSuggestions(messageId);
|
|
98
|
+
const calendarsRead = useCalendars();
|
|
99
|
+
const {
|
|
100
|
+
calendars,
|
|
101
|
+
defaultCalendarId,
|
|
102
|
+
colorByCalendarId,
|
|
103
|
+
timeZoneByCalendarId,
|
|
104
|
+
} = calendarsRead;
|
|
105
|
+
const gate = calendarWriteGate(calendarsRead);
|
|
106
|
+
const answers = useCalendarSuggestionAnswers();
|
|
107
|
+
|
|
108
|
+
const openEvent = useOpenEventOnCalendar();
|
|
109
|
+
|
|
110
|
+
const [held, setHeld] = useState<Working>(() => fresh(messageId));
|
|
111
|
+
const working = held.messageId === messageId ? held : fresh(messageId);
|
|
112
|
+
const onScreen = useRef(messageId);
|
|
113
|
+
onScreen.current = messageId;
|
|
114
|
+
// An answer can come back after the reader has moved on, and what it says
|
|
115
|
+
// is about the message it was given for, not the one now open.
|
|
116
|
+
const update = (change: Partial<Working>) => {
|
|
117
|
+
if (onScreen.current !== messageId) return;
|
|
118
|
+
setHeld((prev) => ({
|
|
119
|
+
...(prev.messageId === messageId ? prev : fresh(messageId)),
|
|
120
|
+
...change,
|
|
121
|
+
}));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const invitation = useMemo(
|
|
125
|
+
() =>
|
|
126
|
+
suggestions
|
|
127
|
+
.filter(isFromInvitation)
|
|
128
|
+
.filter((suggestion) => inviteStanding(suggestion) !== undefined)
|
|
129
|
+
.sort(byUrgency)[0],
|
|
130
|
+
[suggestions],
|
|
131
|
+
);
|
|
132
|
+
const readings = suggestions.filter(
|
|
133
|
+
(suggestion) => suggestion !== invitation && suggestion.state === "Pending",
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const anchor = invitation ?? readings[0];
|
|
137
|
+
const date = anchor ? suggestionDate(anchor) : "";
|
|
138
|
+
const dayWindow = calendarWindowOfDays(date === "" ? "1970-01-01" : date, 1);
|
|
139
|
+
const dayEvents = useCalendarEventWindow({
|
|
140
|
+
...dayWindow,
|
|
141
|
+
calendarIds: [],
|
|
142
|
+
enabled: date !== "",
|
|
143
|
+
});
|
|
144
|
+
const freeBusy = useCalendarFreeBusyWeeks(date === "" ? [] : [dayWindow]);
|
|
145
|
+
|
|
146
|
+
const context = {
|
|
147
|
+
threadId: thread.threadId,
|
|
148
|
+
threadSubject: thread.subject ?? "",
|
|
149
|
+
calendarId: defaultCalendarId,
|
|
150
|
+
};
|
|
151
|
+
const senderName = thread.fromName ?? thread.fromEmail ?? "";
|
|
152
|
+
const reportHref = calendarReportHref(
|
|
153
|
+
working.failure.text || "the invitations in a message could not be read",
|
|
154
|
+
);
|
|
155
|
+
const addBlocked = calendarUnavailable(gate, reportHref);
|
|
156
|
+
|
|
157
|
+
const answer = (
|
|
158
|
+
suggestionId: string,
|
|
159
|
+
what: string,
|
|
160
|
+
request: () => Promise<SuggestionAnswer>,
|
|
161
|
+
) => {
|
|
162
|
+
update({ answering: suggestionId, failure: { suggestionId, text: "" } });
|
|
163
|
+
void request().then((result) =>
|
|
164
|
+
update({
|
|
165
|
+
answering: "",
|
|
166
|
+
failure: { suggestionId, text: refusal(what, result) },
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const invitationId = invitation?.suggestionId ?? "";
|
|
172
|
+
const inviteFailure =
|
|
173
|
+
working.failure.suggestionId === invitationId ? working.failure.text : "";
|
|
174
|
+
const otherFailure =
|
|
175
|
+
working.failure.suggestionId === invitationId ? "" : working.failure.text;
|
|
176
|
+
|
|
177
|
+
const standing = invitation ? inviteStanding(invitation) : undefined;
|
|
178
|
+
const invite =
|
|
179
|
+
invitation && standing
|
|
180
|
+
? {
|
|
181
|
+
invite: toCalendarInvite(invitation, standing.state, {
|
|
182
|
+
...context,
|
|
183
|
+
senderName,
|
|
184
|
+
}),
|
|
185
|
+
whenText: suggestionWhen(invitation),
|
|
186
|
+
calendarName:
|
|
187
|
+
calendars.find((calendar) => calendar.id === defaultCalendarId)
|
|
188
|
+
?.name ?? "Calendar",
|
|
189
|
+
color: colorByCalendarId[defaultCalendarId] ?? "cal-1",
|
|
190
|
+
clashes:
|
|
191
|
+
standing.state === "pending"
|
|
192
|
+
? clashesOver(
|
|
193
|
+
{ start: invitation.dtStart, end: invitation.dtEnd },
|
|
194
|
+
freeBusy.spans,
|
|
195
|
+
)
|
|
196
|
+
: [],
|
|
197
|
+
rsvp: standing.rsvp,
|
|
198
|
+
busy: working.answering === invitation.suggestionId,
|
|
199
|
+
failure: inviteFailure,
|
|
200
|
+
reportHref,
|
|
201
|
+
addBlocked,
|
|
202
|
+
sender: senderName,
|
|
203
|
+
}
|
|
204
|
+
: undefined;
|
|
205
|
+
|
|
206
|
+
const slots: CalendarSlotPick[] =
|
|
207
|
+
date === "" ? [] : slotOffersOn(date, freeBusy.spans);
|
|
208
|
+
|
|
209
|
+
const day: CalendarDayEntry[] = dayEvents.instances
|
|
210
|
+
.filter(isDrawnInstance)
|
|
211
|
+
.filter((instance) => !instance.allDay)
|
|
212
|
+
.sort((a, b) => a.start.localeCompare(b.start))
|
|
213
|
+
.map((instance) => {
|
|
214
|
+
const event = toCalendarEventData(
|
|
215
|
+
instance,
|
|
216
|
+
timeZoneByCalendarId[instance.calendarId] ?? "",
|
|
217
|
+
);
|
|
218
|
+
return {
|
|
219
|
+
event,
|
|
220
|
+
timeText: formatInstantClock(instance.start),
|
|
221
|
+
color: colorByCalendarId[instance.calendarId] ?? "cal-1",
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const deck: CalendarSuggestionIntel[] = readings.map((suggestion) => ({
|
|
226
|
+
suggestion: toEventSuggestion(suggestion, {
|
|
227
|
+
...context,
|
|
228
|
+
sender: senderName,
|
|
229
|
+
}),
|
|
230
|
+
whenText: suggestionWhen(suggestion),
|
|
231
|
+
}));
|
|
232
|
+
|
|
233
|
+
const readFailure =
|
|
234
|
+
error === null
|
|
235
|
+
? ""
|
|
236
|
+
: "Couldn't read the invitations in this message. Reopen it to try again.";
|
|
237
|
+
|
|
238
|
+
const cancellation = invitation?.method === "Cancel";
|
|
239
|
+
const copyText = date === "" ? "" : slotsAsText(date, slots, working.picked);
|
|
240
|
+
|
|
241
|
+
const actions: IntelligenceCalendarActions = {
|
|
242
|
+
onAddInvite: () =>
|
|
243
|
+
answer(invitationId, "add this to your calendar", () =>
|
|
244
|
+
answers.accept(invitationId, defaultCalendarId),
|
|
245
|
+
),
|
|
246
|
+
onDeclineInvite: () =>
|
|
247
|
+
answer(invitationId, "decline this", () => answers.decline(invitationId)),
|
|
248
|
+
onMuteInvite: () =>
|
|
249
|
+
answer(invitationId, "stop these invitations", () =>
|
|
250
|
+
answers.dismiss(invitationId, true),
|
|
251
|
+
),
|
|
252
|
+
onRemoveInvite: cancellation
|
|
253
|
+
? () =>
|
|
254
|
+
answer(invitationId, "take this off your calendar", () =>
|
|
255
|
+
answers.accept(invitationId, defaultCalendarId),
|
|
256
|
+
)
|
|
257
|
+
: undefined,
|
|
258
|
+
onReopenInvite: cancellation
|
|
259
|
+
? () =>
|
|
260
|
+
answer(invitationId, "keep this on your calendar", () =>
|
|
261
|
+
answers.dismiss(invitationId, false),
|
|
262
|
+
)
|
|
263
|
+
: undefined,
|
|
264
|
+
onOfferOtherTimes: () => update({ offering: true }),
|
|
265
|
+
onToggleSlot: (slot) =>
|
|
266
|
+
update({
|
|
267
|
+
copy: "idle",
|
|
268
|
+
picked: working.picked.includes(slot.startTime)
|
|
269
|
+
? working.picked.filter((start) => start !== slot.startTime)
|
|
270
|
+
: [...working.picked, slot.startTime],
|
|
271
|
+
}),
|
|
272
|
+
onCopySlots: () => {
|
|
273
|
+
// Outside a secure context — plain http on a tailnet host — the
|
|
274
|
+
// browser exposes no clipboard at all, so the absence is the failure.
|
|
275
|
+
const clipboard: Clipboard | undefined = navigator.clipboard;
|
|
276
|
+
if (!clipboard) {
|
|
277
|
+
update({ copy: "failed" });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
void Promise.resolve()
|
|
281
|
+
.then(() => clipboard.writeText(copyText))
|
|
282
|
+
.then(() => update({ copy: "copied" }))
|
|
283
|
+
.catch(() => update({ copy: "failed" }));
|
|
284
|
+
},
|
|
285
|
+
onAddSuggestion: (suggestionId) =>
|
|
286
|
+
answer(suggestionId, "add this to your calendar", () =>
|
|
287
|
+
answers.accept(suggestionId, defaultCalendarId),
|
|
288
|
+
),
|
|
289
|
+
onDismissSuggestion: (suggestionId) =>
|
|
290
|
+
answer(suggestionId, "dismiss this", () =>
|
|
291
|
+
answers.dismiss(suggestionId, false),
|
|
292
|
+
),
|
|
293
|
+
onSelectEvent: (eventId) => {
|
|
294
|
+
const { calendarObjectId, recurrenceId } =
|
|
295
|
+
readCalendarInstanceId(eventId);
|
|
296
|
+
openEvent(date, calendarObjectId, recurrenceId);
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const aboutTime = invite !== undefined || deck.length > 0 || error !== null;
|
|
301
|
+
const latched = working.tab !== undefined;
|
|
302
|
+
|
|
303
|
+
// The tab is decided once, when the message's suggestions have been read,
|
|
304
|
+
// and then held: answering the last card must not pull the tab out from
|
|
305
|
+
// under the reader, and a read still in flight must not decide it.
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (isLoading || latched) return;
|
|
308
|
+
setHeld((prev) =>
|
|
309
|
+
prev.messageId === messageId && prev.tab !== undefined
|
|
310
|
+
? prev
|
|
311
|
+
: {
|
|
312
|
+
...(prev.messageId === messageId ? prev : fresh(messageId)),
|
|
313
|
+
tab: aboutTime ? "calendar" : "sender",
|
|
314
|
+
},
|
|
315
|
+
);
|
|
316
|
+
}, [isLoading, latched, aboutTime, messageId]);
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
surface: {
|
|
320
|
+
data: {
|
|
321
|
+
invite,
|
|
322
|
+
prose:
|
|
323
|
+
working.offering && date !== ""
|
|
324
|
+
? {
|
|
325
|
+
dayLabel: formatDayLabel(date),
|
|
326
|
+
proposals: [],
|
|
327
|
+
slots,
|
|
328
|
+
picked: working.picked,
|
|
329
|
+
copy: working.copy,
|
|
330
|
+
copyText,
|
|
331
|
+
}
|
|
332
|
+
: undefined,
|
|
333
|
+
suggestions: deck,
|
|
334
|
+
day,
|
|
335
|
+
dayLabel: date === "" ? "" : formatDayLabel(date),
|
|
336
|
+
suggestionsBusy: working.answering !== "" && !invite?.busy,
|
|
337
|
+
failure: readFailure,
|
|
338
|
+
suggestionsFailure: otherFailure,
|
|
339
|
+
reportHref,
|
|
340
|
+
addBlocked,
|
|
341
|
+
},
|
|
342
|
+
actions,
|
|
343
|
+
},
|
|
344
|
+
tab: working.tab ?? "sender",
|
|
345
|
+
onTabChange: (tab) => update({ tab }),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
@@ -55,7 +55,7 @@ function applyFlagPatch(
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* Optimistic PATCH for per-sender flags (VIP / Mute / Block
|
|
58
|
+
* Optimistic PATCH for per-sender flags (VIP / Mute / Block).
|
|
59
59
|
* Mirrors the `useToggleTrusted` pattern: patch the cached
|
|
60
60
|
* address-search response so the toggle flips instantly, roll back on error,
|
|
61
61
|
* and invalidate on settle to reconcile with the server.
|
|
@@ -17,8 +17,33 @@ const CLOCK = new Intl.DateTimeFormat(undefined, {
|
|
|
17
17
|
hour12: false,
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
const LONG_DAY = new Intl.DateTimeFormat(undefined, {
|
|
21
|
+
weekday: "long",
|
|
22
|
+
day: "numeric",
|
|
23
|
+
month: "long",
|
|
24
|
+
timeZone: "UTC",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const CIVIL_DAY = new Intl.DateTimeFormat(undefined, {
|
|
28
|
+
weekday: "short",
|
|
29
|
+
day: "numeric",
|
|
30
|
+
month: "long",
|
|
31
|
+
timeZone: "UTC",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* "Wed 10 March", for a civil date `YYYY-MM-DD`. A date with no hours is the
|
|
36
|
+
* same day wherever the reader is, so it is never read as an instant.
|
|
37
|
+
*/
|
|
38
|
+
export function formatCivilDay(date: string): string {
|
|
39
|
+
return CIVIL_DAY.format(new Date(`${date}T00:00:00Z`));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A span as the kit is handed it: when it starts, when it ends, whether it has hours. */
|
|
43
|
+
export type CalendarSpan = Pick<CalendarEventData, "start" | "end" | "allDay">;
|
|
44
|
+
|
|
20
45
|
/** "Wed 10 June, 10:00 – 11:00", or the day alone when it runs all of it. */
|
|
21
|
-
export function formatEventWhen(event:
|
|
46
|
+
export function formatEventWhen(event: CalendarSpan): string {
|
|
22
47
|
const start = new Date(event.start);
|
|
23
48
|
const end = new Date(event.end);
|
|
24
49
|
const day = DAY.format(start);
|
|
@@ -27,3 +52,18 @@ export function formatEventWhen(event: CalendarEventData): string {
|
|
|
27
52
|
if (sameDay) return `${day}, ${CLOCK.format(start)} – ${CLOCK.format(end)}`;
|
|
28
53
|
return `${day}, ${CLOCK.format(start)} – ${DAY.format(end)}, ${CLOCK.format(end)}`;
|
|
29
54
|
}
|
|
55
|
+
|
|
56
|
+
/** "Thursday 11 June", for a civil date `YYYY-MM-DD` read on no clock at all. */
|
|
57
|
+
export function formatDayLabel(date: string): string {
|
|
58
|
+
return LONG_DAY.format(new Date(`${date}T00:00:00Z`));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** "10:00", for a clock time `HH:MM` already on the right clock. */
|
|
62
|
+
export function formatClock(time: string): string {
|
|
63
|
+
return CLOCK.format(new Date(`1970-01-01T${time}:00`));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "10:00", for an instant, on this device's clock. */
|
|
67
|
+
export function formatInstantClock(iso: string): string {
|
|
68
|
+
return CLOCK.format(new Date(iso));
|
|
69
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { buildBugReportContext, buildGitHubIssueUrl } from "./bug-report";
|
|
2
|
+
|
|
3
|
+
/** A prefilled issue for an invitation answer or read that did not land. */
|
|
4
|
+
export function calendarReportHref(what: string): string {
|
|
5
|
+
return buildGitHubIssueUrl(
|
|
6
|
+
buildBugReportContext({
|
|
7
|
+
title: `Calendar invitation: ${what}`,
|
|
8
|
+
errorMessage: what,
|
|
9
|
+
}),
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a suggestion's state asks of its card, and what the day it lands on
|
|
3
|
+
* already holds (#1277).
|
|
4
|
+
*
|
|
5
|
+
* The card is drawn off these answers alone, so a state mapped wrong is a card
|
|
6
|
+
* that asks for an answer the server will refuse — a button that can only fail.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import {
|
|
11
|
+
type CalendarSuggestion,
|
|
12
|
+
clashesOver,
|
|
13
|
+
inviteStanding,
|
|
14
|
+
slotOffersOn,
|
|
15
|
+
toCalendarInvite,
|
|
16
|
+
toEventSuggestion,
|
|
17
|
+
} from "./calendar-suggestion";
|
|
18
|
+
|
|
19
|
+
const suggestion = (
|
|
20
|
+
over: Partial<CalendarSuggestion> = {},
|
|
21
|
+
): CalendarSuggestion => ({
|
|
22
|
+
suggestionId: "sug-1",
|
|
23
|
+
accountConfigId: "cfg-1",
|
|
24
|
+
messageId: "msg-1",
|
|
25
|
+
bodyPartId: "part-1",
|
|
26
|
+
icalUid: "uid-1",
|
|
27
|
+
sequence: 0,
|
|
28
|
+
method: "Request",
|
|
29
|
+
source: "IcalendarPart",
|
|
30
|
+
state: "Pending",
|
|
31
|
+
summary: "Quarterly review",
|
|
32
|
+
dtStart: "2026-09-01T08:00:00+00:00",
|
|
33
|
+
dtEnd: "2026-09-01T09:00:00+00:00",
|
|
34
|
+
allDay: false,
|
|
35
|
+
location: "",
|
|
36
|
+
organizer: "organizer@example.test",
|
|
37
|
+
zoneCertainty: "Explicit",
|
|
38
|
+
acceptedCalendarObjectId: "",
|
|
39
|
+
createdAt: 0,
|
|
40
|
+
updatedAt: 0,
|
|
41
|
+
...over,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const context = {
|
|
45
|
+
threadId: "thr-1",
|
|
46
|
+
threadSubject: "Quarterly review",
|
|
47
|
+
senderName: "Organizer",
|
|
48
|
+
calendarId: "cal-1",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("inviteStanding", () => {
|
|
52
|
+
it("asks for an answer only while the suggestion is pending", () => {
|
|
53
|
+
assert.deepEqual(inviteStanding(suggestion()), {
|
|
54
|
+
state: "pending",
|
|
55
|
+
rsvp: "noReply",
|
|
56
|
+
});
|
|
57
|
+
assert.deepEqual(inviteStanding(suggestion({ state: "Accepted" })), {
|
|
58
|
+
state: "answered",
|
|
59
|
+
rsvp: "accepted",
|
|
60
|
+
});
|
|
61
|
+
assert.deepEqual(inviteStanding(suggestion({ state: "Declined" })), {
|
|
62
|
+
state: "answered",
|
|
63
|
+
rsvp: "declined",
|
|
64
|
+
});
|
|
65
|
+
assert.deepEqual(inviteStanding(suggestion({ state: "Superseded" })), {
|
|
66
|
+
state: "superseded",
|
|
67
|
+
rsvp: "noReply",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("draws no card for one the reader waved away", () => {
|
|
72
|
+
assert.equal(inviteStanding(suggestion({ state: "Dismissed" })), undefined);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("asks about a cancellation only until it is taken or kept", () => {
|
|
76
|
+
assert.deepEqual(inviteStanding(suggestion({ method: "Cancel" })), {
|
|
77
|
+
state: "cancelled",
|
|
78
|
+
rsvp: "noReply",
|
|
79
|
+
});
|
|
80
|
+
assert.equal(
|
|
81
|
+
inviteStanding(suggestion({ method: "Cancel", state: "Accepted" })),
|
|
82
|
+
undefined,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("toCalendarInvite", () => {
|
|
88
|
+
it("names the organiser, and the sender when the invitation names none", () => {
|
|
89
|
+
assert.equal(
|
|
90
|
+
toCalendarInvite(suggestion(), "pending", context).organizerName,
|
|
91
|
+
"organizer@example.test",
|
|
92
|
+
);
|
|
93
|
+
assert.equal(
|
|
94
|
+
toCalendarInvite(suggestion({ organizer: "" }), "pending", context)
|
|
95
|
+
.organizerName,
|
|
96
|
+
"Organizer",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("gives an untitled event a title rather than a blank heading", () => {
|
|
101
|
+
const invite = toCalendarInvite(
|
|
102
|
+
suggestion({ summary: "" }),
|
|
103
|
+
"pending",
|
|
104
|
+
context,
|
|
105
|
+
);
|
|
106
|
+
assert.equal(invite.proposed.title, "Untitled event");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("toEventSuggestion", () => {
|
|
111
|
+
it("says so when the zone the time was read on could not be resolved", () => {
|
|
112
|
+
const reading = toEventSuggestion(
|
|
113
|
+
suggestion({ zoneCertainty: "Ambiguous", source: "TextHeuristic" }),
|
|
114
|
+
{ ...context, sender: "Organizer" },
|
|
115
|
+
);
|
|
116
|
+
assert.match(reading.ambiguity, /hours out/);
|
|
117
|
+
assert.equal(reading.zoneCertainty, "ambiguous");
|
|
118
|
+
assert.ok(reading.confidence < 1);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("clashesOver", () => {
|
|
123
|
+
it("names every busy stretch the span runs into and nothing beside it", () => {
|
|
124
|
+
const clashes = clashesOver(
|
|
125
|
+
{ start: "2026-09-01T08:00:00+00:00", end: "2026-09-01T09:00:00+00:00" },
|
|
126
|
+
[
|
|
127
|
+
{
|
|
128
|
+
start: "2026-09-01T07:00:00+00:00",
|
|
129
|
+
end: "2026-09-01T08:00:00+00:00",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
start: "2026-09-01T08:30:00+00:00",
|
|
133
|
+
end: "2026-09-01T09:30:00+00:00",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
start: "2026-09-01T09:00:00+00:00",
|
|
137
|
+
end: "2026-09-01T10:00:00+00:00",
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
);
|
|
141
|
+
assert.equal(clashes.length, 1);
|
|
142
|
+
assert.match(clashes[0].label, /^Busy /);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("slotOffersOn", () => {
|
|
147
|
+
it("offers half-hours out of the free time only", () => {
|
|
148
|
+
const date = "2026-09-01";
|
|
149
|
+
const offers = slotOffersOn(date, []);
|
|
150
|
+
assert.ok(offers.length > 0);
|
|
151
|
+
assert.ok(offers.length <= 6);
|
|
152
|
+
for (const offer of offers) {
|
|
153
|
+
assert.equal(offer.date, date);
|
|
154
|
+
const [h1, m1] = offer.startTime.split(":").map(Number);
|
|
155
|
+
const [h2, m2] = offer.endTime.split(":").map(Number);
|
|
156
|
+
assert.equal(h2 * 60 + m2 - (h1 * 60 + m1), 30);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("offers nothing on a day booked from end to end", () => {
|
|
161
|
+
const offers = slotOffersOn("2026-09-01", [
|
|
162
|
+
{
|
|
163
|
+
start: new Date("2026-08-31T12:00:00").toISOString(),
|
|
164
|
+
end: new Date("2026-09-02T12:00:00").toISOString(),
|
|
165
|
+
},
|
|
166
|
+
]);
|
|
167
|
+
assert.deepEqual(offers, []);
|
|
168
|
+
});
|
|
169
|
+
});
|