@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
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A suggestion the server read out of a message, as the calendar kit draws it.
|
|
3
|
+
*
|
|
4
|
+
* The API carries projections of the invitation and nothing about who is
|
|
5
|
+
* looking at it, so what the reader is asked — whether the card still wants an
|
|
6
|
+
* answer, what the span runs into, which half-hours are open — is decided here,
|
|
7
|
+
* in one place, rather than in each surface that shows a card.
|
|
8
|
+
*/
|
|
9
|
+
import type {
|
|
10
|
+
RemitImapCalendarFreeBusySpan,
|
|
11
|
+
RemitImapCalendarSuggestionResponse,
|
|
12
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
13
|
+
import {
|
|
14
|
+
type CalendarClash,
|
|
15
|
+
type CalendarEventData,
|
|
16
|
+
type CalendarInvite,
|
|
17
|
+
type CalendarInviteState,
|
|
18
|
+
type CalendarSlotPick,
|
|
19
|
+
type EventSuggestion,
|
|
20
|
+
formatMinute,
|
|
21
|
+
freeStretchesFromSpans,
|
|
22
|
+
type RsvpState,
|
|
23
|
+
type ZoneCertainty,
|
|
24
|
+
} from "@remit/ui";
|
|
25
|
+
import { addDays, busySpansByDate } from "@/hooks/calendar";
|
|
26
|
+
import {
|
|
27
|
+
formatCivilDay,
|
|
28
|
+
formatClock,
|
|
29
|
+
formatDayLabel,
|
|
30
|
+
formatEventWhen,
|
|
31
|
+
formatInstantClock,
|
|
32
|
+
} from "./calendar-format";
|
|
33
|
+
import { isoDate } from "./calendar-route";
|
|
34
|
+
|
|
35
|
+
export type CalendarSuggestion = RemitImapCalendarSuggestionResponse;
|
|
36
|
+
|
|
37
|
+
/** Half an hour is what an offer of another time is worth. */
|
|
38
|
+
export const OFFER_MINUTES = 30;
|
|
39
|
+
|
|
40
|
+
/** More than a handful of offers is a timetable, not a reply. */
|
|
41
|
+
const OFFER_LIMIT = 6;
|
|
42
|
+
|
|
43
|
+
const ZONES: Record<CalendarSuggestion["zoneCertainty"], ZoneCertainty> = {
|
|
44
|
+
Local: "local",
|
|
45
|
+
Explicit: "explicit",
|
|
46
|
+
Ambiguous: "ambiguous",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const AMBIGUOUS_ZONE =
|
|
50
|
+
"The invitation named a time zone nothing could resolve, so this time may be hours out.";
|
|
51
|
+
|
|
52
|
+
/** Where the card stands, or nothing when there is no card left to draw. */
|
|
53
|
+
export interface InviteStanding {
|
|
54
|
+
state: CalendarInviteState;
|
|
55
|
+
rsvp: RsvpState;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What a suggestion's state means for its card.
|
|
60
|
+
*
|
|
61
|
+
* A dismissed card was waved away and is gone. A cancellation asks for an
|
|
62
|
+
* answer only while it is pending: once taken or kept, the event it named is
|
|
63
|
+
* settled and there is nothing left to say beside the message.
|
|
64
|
+
*/
|
|
65
|
+
export function inviteStanding(
|
|
66
|
+
suggestion: CalendarSuggestion,
|
|
67
|
+
): InviteStanding | undefined {
|
|
68
|
+
if (suggestion.state === "Dismissed") return undefined;
|
|
69
|
+
if (suggestion.method === "Cancel")
|
|
70
|
+
return suggestion.state === "Pending"
|
|
71
|
+
? { state: "cancelled", rsvp: "noReply" }
|
|
72
|
+
: undefined;
|
|
73
|
+
if (suggestion.state === "Superseded")
|
|
74
|
+
return { state: "superseded", rsvp: "noReply" };
|
|
75
|
+
if (suggestion.state === "Accepted")
|
|
76
|
+
return { state: "answered", rsvp: "accepted" };
|
|
77
|
+
if (suggestion.state === "Declined")
|
|
78
|
+
return { state: "answered", rsvp: "declined" };
|
|
79
|
+
if (suggestion.state === "Tentative")
|
|
80
|
+
return { state: "answered", rsvp: "tentative" };
|
|
81
|
+
return { state: "pending", rsvp: "noReply" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const isFromInvitation = (suggestion: CalendarSuggestion): boolean =>
|
|
85
|
+
suggestion.source !== "TextHeuristic";
|
|
86
|
+
|
|
87
|
+
/** The day a suggestion lands on, on the clock this device reads. */
|
|
88
|
+
export function suggestionDate(suggestion: CalendarSuggestion): string {
|
|
89
|
+
if (suggestion.allDay) return suggestion.dtStart.slice(0, 10);
|
|
90
|
+
return isoDate(new Date(suggestion.dtStart));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const suggestionTitle = (suggestion: CalendarSuggestion): string =>
|
|
94
|
+
suggestion.summary === "" ? "Untitled event" : suggestion.summary;
|
|
95
|
+
|
|
96
|
+
export function suggestionWhen(suggestion: CalendarSuggestion): string {
|
|
97
|
+
if (suggestion.allDay) {
|
|
98
|
+
const first = suggestion.dtStart.slice(0, 10);
|
|
99
|
+
const end = suggestion.dtEnd.slice(0, 10);
|
|
100
|
+
const last = end > first ? addDays(end, -1) : first;
|
|
101
|
+
return last === first
|
|
102
|
+
? `${formatCivilDay(first)}, all day`
|
|
103
|
+
: `${formatCivilDay(first)} – ${formatCivilDay(last)}, all day`;
|
|
104
|
+
}
|
|
105
|
+
return formatEventWhen({
|
|
106
|
+
start: suggestion.dtStart,
|
|
107
|
+
end: suggestion.dtEnd,
|
|
108
|
+
allDay: suggestion.allDay,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface InviteContext {
|
|
113
|
+
threadId: string;
|
|
114
|
+
threadSubject: string;
|
|
115
|
+
/** Who the mail came from, for an invitation that names no organiser. */
|
|
116
|
+
senderName: string;
|
|
117
|
+
calendarId: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function proposedEvent(
|
|
121
|
+
suggestion: CalendarSuggestion,
|
|
122
|
+
context: InviteContext,
|
|
123
|
+
): CalendarEventData {
|
|
124
|
+
return {
|
|
125
|
+
id: suggestion.suggestionId,
|
|
126
|
+
calendarId: context.calendarId,
|
|
127
|
+
title: suggestionTitle(suggestion),
|
|
128
|
+
start: suggestion.dtStart,
|
|
129
|
+
end: suggestion.dtEnd,
|
|
130
|
+
allDay: suggestion.allDay,
|
|
131
|
+
location: suggestion.location,
|
|
132
|
+
notes: "",
|
|
133
|
+
attendees: [],
|
|
134
|
+
myRsvp: "noReply",
|
|
135
|
+
threadId: context.threadId,
|
|
136
|
+
threadSubject: context.threadSubject,
|
|
137
|
+
timeZone: "",
|
|
138
|
+
zoneCertainty: ZONES[suggestion.zoneCertainty],
|
|
139
|
+
recurrenceRule: "",
|
|
140
|
+
seriesId: "",
|
|
141
|
+
seriesException: false,
|
|
142
|
+
status: "confirmed",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function toCalendarInvite(
|
|
147
|
+
suggestion: CalendarSuggestion,
|
|
148
|
+
state: CalendarInviteState,
|
|
149
|
+
context: InviteContext,
|
|
150
|
+
): CalendarInvite {
|
|
151
|
+
const organizer =
|
|
152
|
+
suggestion.organizer === "" ? context.senderName : suggestion.organizer;
|
|
153
|
+
return {
|
|
154
|
+
id: suggestion.suggestionId,
|
|
155
|
+
threadId: context.threadId,
|
|
156
|
+
proposed: proposedEvent(suggestion, context),
|
|
157
|
+
organizerName: organizer === "" ? "The sender" : organizer,
|
|
158
|
+
organizerEmail: suggestion.organizer,
|
|
159
|
+
method: "ics",
|
|
160
|
+
evidence: "",
|
|
161
|
+
state,
|
|
162
|
+
sequence: suggestion.sequence,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function toEventSuggestion(
|
|
167
|
+
suggestion: CalendarSuggestion,
|
|
168
|
+
context: Omit<InviteContext, "senderName"> & { sender: string },
|
|
169
|
+
): EventSuggestion {
|
|
170
|
+
return {
|
|
171
|
+
id: suggestion.suggestionId,
|
|
172
|
+
title: suggestionTitle(suggestion),
|
|
173
|
+
start: suggestion.dtStart,
|
|
174
|
+
end: suggestion.dtEnd,
|
|
175
|
+
allDay: suggestion.allDay,
|
|
176
|
+
location: suggestion.location,
|
|
177
|
+
threadId: context.threadId,
|
|
178
|
+
threadSubject: context.threadSubject,
|
|
179
|
+
sender: context.sender,
|
|
180
|
+
senderAddress: suggestion.organizer,
|
|
181
|
+
confidence: isFromInvitation(suggestion) ? 1 : 0.6,
|
|
182
|
+
ambiguity: suggestion.zoneCertainty === "Ambiguous" ? AMBIGUOUS_ZONE : "",
|
|
183
|
+
suggestedCalendarId: context.calendarId,
|
|
184
|
+
timeZone: "",
|
|
185
|
+
zoneCertainty: ZONES[suggestion.zoneCertainty],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const overlaps = (
|
|
190
|
+
span: { start: string; end: string },
|
|
191
|
+
busy: RemitImapCalendarFreeBusySpan,
|
|
192
|
+
): boolean =>
|
|
193
|
+
Date.parse(busy.start) < Date.parse(span.end) &&
|
|
194
|
+
Date.parse(busy.end) > Date.parse(span.start);
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* What is already booked over a span. Busy time is merged across every
|
|
198
|
+
* calendar on the server and carries no titles, so a clash names the hours it
|
|
199
|
+
* takes rather than the meeting in them.
|
|
200
|
+
*/
|
|
201
|
+
export function clashesOver(
|
|
202
|
+
span: { start: string; end: string },
|
|
203
|
+
busy: readonly RemitImapCalendarFreeBusySpan[],
|
|
204
|
+
): CalendarClash[] {
|
|
205
|
+
return busy
|
|
206
|
+
.filter((entry) => overlaps(span, entry))
|
|
207
|
+
.map((entry) => ({
|
|
208
|
+
id: `${entry.start}/${entry.end}`,
|
|
209
|
+
label: `Busy ${formatInstantClock(entry.start)} – ${formatInstantClock(entry.end)}`,
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Half-hours still open on a day, cut out of the busy time the server merged. */
|
|
214
|
+
export function slotOffersOn(
|
|
215
|
+
date: string,
|
|
216
|
+
busy: readonly RemitImapCalendarFreeBusySpan[],
|
|
217
|
+
): CalendarSlotPick[] {
|
|
218
|
+
const spans = busySpansByDate([date], busy).get(date) ?? [];
|
|
219
|
+
const offers: CalendarSlotPick[] = [];
|
|
220
|
+
for (const stretch of freeStretchesFromSpans(date, spans, OFFER_MINUTES)) {
|
|
221
|
+
for (
|
|
222
|
+
let minute = stretch.startMinute;
|
|
223
|
+
minute + OFFER_MINUTES <= stretch.endMinute &&
|
|
224
|
+
offers.length < OFFER_LIMIT;
|
|
225
|
+
minute += OFFER_MINUTES
|
|
226
|
+
) {
|
|
227
|
+
offers.push({
|
|
228
|
+
date,
|
|
229
|
+
endDate: date,
|
|
230
|
+
startTime: formatMinute(minute),
|
|
231
|
+
endTime: formatMinute(minute + OFFER_MINUTES),
|
|
232
|
+
allDay: false,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return offers;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The picked half-hours as a line a reply can carry. */
|
|
240
|
+
export function slotsAsText(
|
|
241
|
+
date: string,
|
|
242
|
+
slots: readonly CalendarSlotPick[],
|
|
243
|
+
picked: readonly string[],
|
|
244
|
+
): string {
|
|
245
|
+
const times = slots
|
|
246
|
+
.filter((slot) => picked.includes(slot.startTime))
|
|
247
|
+
.map(
|
|
248
|
+
(slot) => `${formatClock(slot.startTime)} – ${formatClock(slot.endTime)}`,
|
|
249
|
+
);
|
|
250
|
+
return `${formatDayLabel(date)}: ${times.join(", ")}`;
|
|
251
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An all-day invitation read west of the zone it was sent from (#1277 review).
|
|
3
|
+
*
|
|
4
|
+
* Midnight in the organiser's zone is the previous evening in New York, so a
|
|
5
|
+
* date read as an instant lands a day early. The zone is set before the module
|
|
6
|
+
* loads, because a formatter settles its zone when it is built.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
|
|
11
|
+
process.env.TZ = "America/New_York";
|
|
12
|
+
const { suggestionDate, suggestionWhen } = await import(
|
|
13
|
+
"./calendar-suggestion"
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const allDay = {
|
|
17
|
+
suggestionId: "sug-1",
|
|
18
|
+
accountConfigId: "cfg-1",
|
|
19
|
+
messageId: "msg-1",
|
|
20
|
+
bodyPartId: "part-1",
|
|
21
|
+
icalUid: "uid-1",
|
|
22
|
+
sequence: 0,
|
|
23
|
+
method: "Request",
|
|
24
|
+
source: "IcalendarPart",
|
|
25
|
+
state: "Pending",
|
|
26
|
+
summary: "Offsite",
|
|
27
|
+
dtStart: "2027-03-10T00:00:00+00:00",
|
|
28
|
+
dtEnd: "2027-03-11T00:00:00+00:00",
|
|
29
|
+
allDay: true,
|
|
30
|
+
location: "",
|
|
31
|
+
organizer: "organizer@example.test",
|
|
32
|
+
zoneCertainty: "Explicit",
|
|
33
|
+
acceptedCalendarObjectId: "",
|
|
34
|
+
createdAt: 0,
|
|
35
|
+
updatedAt: 0,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
describe("an all-day invitation, west of where it was sent", () => {
|
|
39
|
+
it("names the day it was sent for, not the evening before", () => {
|
|
40
|
+
const when = suggestionWhen(allDay);
|
|
41
|
+
assert.match(when, /Wed/);
|
|
42
|
+
assert.match(when, /\b10\b/);
|
|
43
|
+
assert.match(when, /all day$/);
|
|
44
|
+
assert.doesNotMatch(when, /Tue|\b9\b/);
|
|
45
|
+
assert.equal(suggestionDate(allDay), "2027-03-10");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("names the last day of a multi-day one, not the exclusive end", () => {
|
|
49
|
+
const when = suggestionWhen({
|
|
50
|
+
...allDay,
|
|
51
|
+
dtEnd: "2027-03-13T00:00:00+00:00",
|
|
52
|
+
});
|
|
53
|
+
assert.match(when, /Wed.*\b10\b.*–.*Fri.*\b12\b/);
|
|
54
|
+
assert.doesNotMatch(when, /Sat|\b13\b/);
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/lib/starred-rows.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Deduping over the accumulated pages rather than inside one keeps a
|
|
10
10
|
* conversation single even when its copies straddle a page boundary — a single
|
|
11
|
-
* page cannot know what earlier pages already showed. The first row wins
|
|
12
|
-
*
|
|
11
|
+
* page cannot know what earlier pages already showed. The first row wins: the
|
|
12
|
+
* newest under a plain listing, and under a search the one the server ranked
|
|
13
|
+
* highest, so a sender's own older message beats a newer reply that mentions
|
|
14
|
+
* them.
|
|
13
15
|
*
|
|
14
16
|
* Generic over anything naming its thread, so the same collapse runs over the
|
|
15
17
|
* API rows and over the merged display rows a text search produces — two
|
|
@@ -25,6 +25,7 @@ import { AgendaView } from "@/components/calendar/AgendaView";
|
|
|
25
25
|
import { CalendarComposeSeedProvider } from "@/components/calendar/CalendarComposeSeed";
|
|
26
26
|
import { CalendarShell } from "@/components/calendar/CalendarShell";
|
|
27
27
|
import { CalendarWorkspace } from "@/components/calendar/CalendarWorkspace";
|
|
28
|
+
import { PendingSuggestions } from "@/components/calendar/PendingSuggestions";
|
|
28
29
|
import { calendarInstanceId, deviceTimeZone } from "@/hooks/calendar";
|
|
29
30
|
import { useCalendarData } from "@/hooks/useCalendarData";
|
|
30
31
|
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
@@ -122,6 +123,7 @@ function CalendarView({ isPhone }: { isPhone: boolean }) {
|
|
|
122
123
|
events={events}
|
|
123
124
|
colorByCalendarId={colorByCalendarId}
|
|
124
125
|
agenda={<AgendaView density={density} onPickSlot={pickSlot} />}
|
|
126
|
+
waiting={<PendingSuggestions />}
|
|
125
127
|
isLoading={isLoading}
|
|
126
128
|
error={error}
|
|
127
129
|
onRetry={retry}
|
package/src/routing/calendar.ts
CHANGED
|
@@ -165,3 +165,32 @@ export function useCalendarNavigation(): CalendarNavigation {
|
|
|
165
165
|
[goTo, navigate, retainPanels, view, date],
|
|
166
166
|
);
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Leave the mail for one event on the calendar, on the day it falls, so the
|
|
171
|
+
* reader lands where the grid already shows what is around it. The panes are
|
|
172
|
+
* not carried: the calendar is a different surface, as the nav treats it.
|
|
173
|
+
*/
|
|
174
|
+
export function useOpenEventOnCalendar(): (
|
|
175
|
+
date: string,
|
|
176
|
+
calendarObjectId: string,
|
|
177
|
+
recurrenceId: string,
|
|
178
|
+
) => void {
|
|
179
|
+
const navigate = useNavigate();
|
|
180
|
+
return useCallback(
|
|
181
|
+
(date: string, calendarObjectId: string, recurrenceId: string) => {
|
|
182
|
+
if (recurrenceId === "") {
|
|
183
|
+
navigate({
|
|
184
|
+
to: "/calendar/$view/$date/$calendarObjectId",
|
|
185
|
+
params: { view: "day", date, calendarObjectId },
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
navigate({
|
|
190
|
+
to: "/calendar/$view/$date/$calendarObjectId/$recurrenceId",
|
|
191
|
+
params: { view: "day", date, calendarObjectId, recurrenceId },
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
[navigate],
|
|
195
|
+
);
|
|
196
|
+
}
|
package/src/routing/index.ts
CHANGED