@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.
Files changed (29) 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
  29. 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
+ });
@@ -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, which
12
- * is the newest under the server's descending order.
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}
@@ -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
+ }
@@ -11,6 +11,7 @@ export {
11
11
  useCalendarNavigation,
12
12
  useIsWritingEvent,
13
13
  useOpenCalendarEvent,
14
+ useOpenEventOnCalendar,
14
15
  } from "./calendar";
15
16
  export {
16
17
  useAdoptComposeDraft,
@@ -40,6 +40,7 @@ export const makeAccount = (
40
40
  username: "alice@example.com",
41
41
  email: "alice@example.com",
42
42
  authType: "password",
43
+ syncedServices: ["Mail"],
43
44
  imapHost: "imap.example.com",
44
45
  imapPort: 993,
45
46
  imapTls: true,