@remit/web-client 0.0.301 → 0.0.303

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.301",
3
+ "version": "0.0.303",
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": {
@@ -104,6 +104,57 @@ export const NotOnThisWeek: Story = {
104
104
  args: { event: undefined, calendar: undefined },
105
105
  };
106
106
 
107
+ /** Looking for the day an event the view does not hold falls on. */
108
+ export const Finding: Story = {
109
+ args: { event: undefined, calendar: undefined, absence: { kind: "Finding" } },
110
+ };
111
+
112
+ /** The address names a resource none of the reader's calendars holds. */
113
+ export const Deleted: Story = {
114
+ args: { event: undefined, calendar: undefined, absence: { kind: "Deleted" } },
115
+ };
116
+
117
+ /** A series that falls on no day in its first year or the coming one. */
118
+ export const NoOccurrenceFound: Story = {
119
+ args: {
120
+ event: undefined,
121
+ calendar: undefined,
122
+ absence: { kind: "NoOccurrenceFound" },
123
+ },
124
+ };
125
+
126
+ /** The session ended during the look-up; the way back in is on the pane. */
127
+ export const SignedOut: Story = {
128
+ args: {
129
+ event: undefined,
130
+ calendar: undefined,
131
+ absence: {
132
+ kind: "SignedOut",
133
+ signIn: (
134
+ <button
135
+ type="button"
136
+ className="rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg"
137
+ >
138
+ Sign in again
139
+ </button>
140
+ ),
141
+ },
142
+ },
143
+ };
144
+
145
+ /** The look-up failed, with the server's reason and a way to report it. */
146
+ export const LookupFailed: Story = {
147
+ args: {
148
+ event: undefined,
149
+ calendar: undefined,
150
+ absence: {
151
+ kind: "Failed",
152
+ reason: "Internal server error (HTTP 500)",
153
+ reportHref: "https://github.com/remit-mail/reader/issues/new",
154
+ },
155
+ },
156
+ };
157
+
107
158
  /**
108
159
  * The event was replaced somewhere else — over CalDAV, or in another tab —
109
160
  * between it being read and the write going out. Nothing was overwritten, and
@@ -1,8 +1,61 @@
1
1
  import type { CalendarDescriptor, CalendarEventData } from "@remit/ui";
2
2
  import { EventDetail } from "@remit/ui";
3
3
  import { AlertCircle, CalendarDays } from "lucide-react";
4
+ import type { ReactNode } from "react";
4
5
  import { formatEventWhen } from "@/lib/calendar-format";
5
6
 
7
+ export type CalendarEventAbsence =
8
+ | { kind: "NotInView" }
9
+ | { kind: "Finding" }
10
+ | { kind: "Deleted" }
11
+ | { kind: "NoOccurrenceFound" }
12
+ | { kind: "SignedOut"; signIn: ReactNode }
13
+ | { kind: "Failed"; reason: string; reportHref: string };
14
+
15
+ const NOT_IN_VIEW: CalendarEventAbsence = { kind: "NotInView" };
16
+
17
+ const LINK =
18
+ "rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg outline-none hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring";
19
+
20
+ function absenceText(absence: CalendarEventAbsence): {
21
+ title: string;
22
+ detail: string;
23
+ } {
24
+ switch (absence.kind) {
25
+ case "Finding":
26
+ return {
27
+ title: "Finding the event",
28
+ detail: "Looking for the day it falls on.",
29
+ };
30
+ case "Deleted":
31
+ return {
32
+ title: "This event was deleted",
33
+ detail: "None of your calendars holds it any more.",
34
+ };
35
+ case "NoOccurrenceFound":
36
+ return {
37
+ title: "No occurrence in its first year or the coming year",
38
+ detail:
39
+ "Its repeat rule, or the days taken out of it, leave it no day in either.",
40
+ };
41
+ case "SignedOut":
42
+ return {
43
+ title: "Your session has ended",
44
+ detail: "Sign in again to open this event.",
45
+ };
46
+ case "Failed":
47
+ return {
48
+ title: "The event couldn't be looked up",
49
+ detail: absence.reason,
50
+ };
51
+ case "NotInView":
52
+ return {
53
+ title: "That event isn't on this week",
54
+ detail: "The address names an event the calendar doesn't have here.",
55
+ };
56
+ }
57
+ }
58
+
6
59
  /**
7
60
  * The event the address has open.
8
61
  *
@@ -17,6 +70,7 @@ export interface CalendarEventPaneProps {
17
70
  calendar: CalendarDescriptor | undefined;
18
71
  /** Whether the address names one occurrence rather than the series. */
19
72
  isOccurrence: boolean;
73
+ absence?: CalendarEventAbsence;
20
74
  /**
21
75
  * A write that did not happen, stated where the reader is looking. Empty
22
76
  * when the last one did.
@@ -31,28 +85,39 @@ export function CalendarEventPane({
31
85
  event,
32
86
  calendar,
33
87
  isOccurrence,
88
+ absence = NOT_IN_VIEW,
34
89
  problem = "",
35
90
  onEdit,
36
91
  onDelete,
37
92
  onClose,
38
93
  }: CalendarEventPaneProps) {
39
94
  if (!event || !calendar) {
95
+ const { title, detail } = absenceText(absence);
96
+ const loud = absence.kind === "Failed" || absence.kind === "SignedOut";
40
97
  return (
41
- <div className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center">
98
+ <div
99
+ role={loud ? "alert" : undefined}
100
+ className="flex h-full flex-col items-center justify-center gap-2 bg-surface p-8 text-center"
101
+ >
42
102
  <CalendarDays className="size-8 text-fg-subtle" aria-hidden="true" />
43
- <p className="text-sm font-medium text-fg">
44
- That event isn't on this week
45
- </p>
46
- <p className="max-w-xs text-sm text-fg-muted">
47
- The address names an event the calendar doesn't have here.
48
- </p>
49
- <button
50
- type="button"
51
- onClick={onClose}
52
- className="rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg outline-none hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring"
53
- >
54
- Back to the calendar
55
- </button>
103
+ <p className="text-sm font-medium text-fg">{title}</p>
104
+ <p className="max-w-xs break-words text-sm text-fg-muted">{detail}</p>
105
+ <div className="flex flex-wrap items-center justify-center gap-2">
106
+ {absence.kind === "SignedOut" && absence.signIn}
107
+ {absence.kind === "Failed" && (
108
+ <a
109
+ href={absence.reportHref}
110
+ target="_blank"
111
+ rel="noreferrer"
112
+ className={LINK}
113
+ >
114
+ Report an issue
115
+ </a>
116
+ )}
117
+ <button type="button" onClick={onClose} className={LINK}>
118
+ Back to the calendar
119
+ </button>
120
+ </div>
56
121
  </div>
57
122
  );
58
123
  }
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { expect, userEvent, within } from "storybook/test";
3
+ import { useOpenCalendarEvent } from "@/routing";
3
4
  import {
4
5
  calendars,
5
6
  fortnight,
@@ -44,6 +45,17 @@ const answering = (
44
45
  };
45
46
  };
46
47
 
48
+ /** The pane with its props read off the address, as the app's routes hand them. */
49
+ function FromAddress() {
50
+ const opened = useOpenCalendarEvent();
51
+ return (
52
+ <OpenCalendarEvent
53
+ calendarObjectId={opened?.calendarObjectId ?? ""}
54
+ recurrenceId={opened?.recurrenceId}
55
+ />
56
+ );
57
+ }
58
+
47
59
  function Opened({
48
60
  calendarObjectId,
49
61
  recurrenceId,
@@ -59,16 +71,7 @@ function Opened({
59
71
  : `${STORY_WEEK}/${calendarObjectId}/${recurrenceId}`;
60
72
  return (
61
73
  <div className="h-dvh max-w-xl border-l border-line bg-canvas">
62
- <CalendarStory
63
- entry={address}
64
- server={server}
65
- pane={
66
- <OpenCalendarEvent
67
- calendarObjectId={calendarObjectId}
68
- recurrenceId={recurrenceId}
69
- />
70
- }
71
- />
74
+ <CalendarStory entry={address} server={server} pane={<FromAddress />} />
72
75
  </div>
73
76
  );
74
77
  }
@@ -130,6 +133,121 @@ export const RecurringScopePrompt: Story = {
130
133
  },
131
134
  };
132
135
 
136
+ /**
137
+ * A series opened by its plain address lands on an occurrence, so an edit asks
138
+ * which occurrences it means exactly as it does from one.
139
+ */
140
+ export const SeriesFromItsPlainAddress: Story = {
141
+ args: { calendarObjectId: STANDUP_OBJECT },
142
+ play: async ({ canvasElement }) => {
143
+ const canvas = within(canvasElement);
144
+ await expect(
145
+ await canvas.findByText("One occurrence of a repeating event"),
146
+ ).toBeVisible();
147
+ await userEvent.click(await canvas.findByRole("button", { name: "Edit" }));
148
+ await expect(
149
+ await canvas.findByRole("button", { name: /The whole series/ }),
150
+ ).toBeVisible();
151
+ },
152
+ };
153
+
154
+ /** A series that no longer falls on any day says so instead of drawing nothing. */
155
+ export const SeriesWithNoOccurrences: Story = {
156
+ args: {
157
+ calendarObjectId: STANDUP_OBJECT,
158
+ server: answering((request) =>
159
+ new URL(request.url).pathname.endsWith("/calendar-events")
160
+ ? json({ items: [] })
161
+ : undefined,
162
+ ),
163
+ },
164
+ play: async ({ canvasElement }) => {
165
+ const canvas = within(canvasElement);
166
+ await expect(
167
+ await canvas.findByText(
168
+ "No occurrence in its first year or the coming year",
169
+ ),
170
+ ).toBeVisible();
171
+ },
172
+ };
173
+
174
+ /**
175
+ * A week the standup is not on, and the look-up for it answered by `lookUp`:
176
+ * the listing for the one resource, and the resource read with no calendar.
177
+ */
178
+ const lookingUp =
179
+ (lookUp: (url: URL) => Response): CalendarServer =>
180
+ (request) => {
181
+ const url = new URL(request.url);
182
+ if (url.searchParams.has("calendarObjectId")) return lookUp(url);
183
+ if (url.pathname.endsWith("/calendar-events"))
184
+ return json({
185
+ items: instancesWithin(
186
+ url,
187
+ fortnight.filter(
188
+ (instance) => instance.calendarObjectId !== STANDUP_OBJECT,
189
+ ),
190
+ ),
191
+ });
192
+ if (
193
+ url.pathname.includes(STANDUP_OBJECT) &&
194
+ !url.searchParams.has("calendarId")
195
+ )
196
+ return lookUp(url);
197
+ return answering()(request);
198
+ };
199
+
200
+ /** A resource none of the reader's calendars holds any more. */
201
+ export const SeriesDeleted: Story = {
202
+ args: {
203
+ calendarObjectId: STANDUP_OBJECT,
204
+ server: lookingUp((url) =>
205
+ url.pathname.endsWith("/calendar-events")
206
+ ? json({ items: [] })
207
+ : json({ message: "no event on this account" }, 404),
208
+ ),
209
+ },
210
+ play: async ({ canvasElement }) => {
211
+ const canvas = within(canvasElement);
212
+ await expect(
213
+ await canvas.findByText("This event was deleted"),
214
+ ).toBeVisible();
215
+ },
216
+ };
217
+
218
+ /** A failed look-up says why and offers a report, never "not on this week". */
219
+ export const LookupFailed: Story = {
220
+ args: {
221
+ calendarObjectId: STANDUP_OBJECT,
222
+ server: lookingUp(() =>
223
+ json({ message: "The calendar store is unavailable." }, 500),
224
+ ),
225
+ },
226
+ play: async ({ canvasElement }) => {
227
+ const canvas = within(canvasElement);
228
+ await expect(
229
+ await canvas.findByText("The event couldn't be looked up"),
230
+ ).toBeVisible();
231
+ await expect(
232
+ canvas.getByRole("link", { name: "Report an issue" }),
233
+ ).toBeVisible();
234
+ },
235
+ };
236
+
237
+ /** A session that ended during the look-up is said as such. */
238
+ export const SignedOutDuringLookup: Story = {
239
+ args: {
240
+ calendarObjectId: STANDUP_OBJECT,
241
+ server: lookingUp(() => json({ message: "Session expired." }, 401)),
242
+ },
243
+ play: async ({ canvasElement }) => {
244
+ const canvas = within(canvasElement);
245
+ await expect(
246
+ await canvas.findByText("Your session has ended"),
247
+ ).toBeVisible();
248
+ },
249
+ };
250
+
133
251
  /**
134
252
  * Somebody replaced the event between it being read and the delete going out —
135
253
  * over CalDAV, or in another tab. Nothing was removed, and the pane says so
@@ -3,10 +3,16 @@ import {
3
3
  type RecurrenceScope,
4
4
  RecurrenceScopePrompt,
5
5
  } from "@remit/ui";
6
+ import { Navigate } from "@tanstack/react-router";
6
7
  import { useState } from "react";
8
+ import { useAuthProvider } from "@/auth/provider";
7
9
  import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
8
- import { CalendarEventPane } from "@/components/calendar/CalendarEventPane";
9
10
  import {
11
+ type CalendarEventAbsence,
12
+ CalendarEventPane,
13
+ } from "@/components/calendar/CalendarEventPane";
14
+ import {
15
+ type CalendarJumpSearch,
10
16
  type CalendarWriteOutcome,
11
17
  calendarInstanceId,
12
18
  deviceTimeZone,
@@ -17,17 +23,21 @@ import {
17
23
  patchFromDrafts,
18
24
  rruleFromIcalData,
19
25
  type ScopedWrite,
26
+ seriesOccurrenceOf,
20
27
  storedAnchorZone,
21
28
  textFromIcalData,
22
29
  textFromRrule,
23
30
  UNZONED_CALENDAR,
24
31
  useCalendarEvent,
32
+ useCalendarJump,
25
33
  useCalendars,
26
34
  useCalendarWrites,
27
35
  useDraftClashes,
28
36
  } from "@/hooks/calendar";
29
37
  import { useCalendarData } from "@/hooks/useCalendarData";
30
38
  import { formatEventWhen } from "@/lib/calendar-format";
39
+ import { calendarEventReportHref } from "@/lib/calendar-report";
40
+ import type { CalendarSearch } from "@/lib/calendar-route";
31
41
  import { useCalendarAddress, useCalendarNavigation } from "@/routing";
32
42
 
33
43
  /**
@@ -55,12 +65,54 @@ const CONFLICT =
55
65
 
56
66
  type Intent = "edit" | "delete";
57
67
 
68
+ function SignInAgain() {
69
+ const { Account } = useAuthProvider();
70
+ return (
71
+ <Account>
72
+ {({ signOut }) => (
73
+ <button
74
+ type="button"
75
+ onClick={() => signOut()}
76
+ className="rounded-md border border-line px-2.5 py-1 text-sm font-medium text-fg outline-none hover:bg-surface-sunken focus-visible:ring-2 focus-visible:ring-ring"
77
+ >
78
+ Sign in again
79
+ </button>
80
+ )}
81
+ </Account>
82
+ );
83
+ }
84
+
85
+ const absenceOf = (
86
+ search: CalendarJumpSearch,
87
+ loading: boolean,
88
+ ): CalendarEventAbsence => {
89
+ switch (search.kind) {
90
+ case "Deleted":
91
+ case "NoOccurrenceFound":
92
+ return { kind: search.kind };
93
+ case "SignedOut":
94
+ return { kind: "SignedOut", signIn: <SignInAgain /> };
95
+ case "Failed":
96
+ return {
97
+ kind: "Failed",
98
+ reason: search.reason,
99
+ reportHref: calendarEventReportHref(
100
+ `opening an event failed: ${search.reason}`,
101
+ ),
102
+ };
103
+ case "Searching":
104
+ return { kind: "Finding" };
105
+ default:
106
+ return { kind: loading ? "Finding" : "NotInView" };
107
+ }
108
+ };
109
+
58
110
  export function OpenCalendarEvent({
59
111
  calendarObjectId,
60
112
  recurrenceId,
61
113
  }: OpenCalendarEventProps) {
62
114
  const { view, date, calendarIds } = useCalendarAddress();
63
- const { events } = useCalendarData({ view, date, calendarIds });
115
+ const { events, isLoading } = useCalendarData({ view, date, calendarIds });
64
116
  const { calendars, timeZoneByCalendarId } = useCalendars();
65
117
  const { closeEvent } = useCalendarNavigation();
66
118
  const { updateEvent, deleteEvent, isWriting } = useCalendarWrites();
@@ -69,6 +121,14 @@ export function OpenCalendarEvent({
69
121
  (candidate) =>
70
122
  candidate.id === calendarInstanceId(calendarObjectId, recurrenceId ?? ""),
71
123
  );
124
+ const seriesOnly = recurrenceId === undefined && event === undefined;
125
+ const occurrence = seriesOnly
126
+ ? seriesOccurrenceOf(events, calendarObjectId, new Date().toISOString())
127
+ : undefined;
128
+ const search = useCalendarJump(
129
+ calendarObjectId,
130
+ seriesOnly && !isLoading && occurrence === undefined,
131
+ );
72
132
  const calendar = calendars.find(
73
133
  (candidate) => candidate.id === event?.calendarId,
74
134
  );
@@ -175,6 +235,59 @@ export function OpenCalendarEvent({
175
235
  );
176
236
  };
177
237
 
238
+ if (occurrence) {
239
+ return (
240
+ <Navigate
241
+ to="/calendar/$view/$date/$calendarObjectId/$recurrenceId"
242
+ params={{
243
+ view,
244
+ date,
245
+ calendarObjectId,
246
+ recurrenceId: occurrence.recurrenceId,
247
+ }}
248
+ search={true}
249
+ hash={true}
250
+ replace
251
+ />
252
+ );
253
+ }
254
+
255
+ const jump = search.kind === "Found" ? search.jump : undefined;
256
+ const hidden =
257
+ jump !== undefined &&
258
+ calendarIds.length > 0 &&
259
+ !calendarIds.includes(jump.calendarId);
260
+ if (jump && (jump.date !== date || hidden)) {
261
+ const shown = hidden
262
+ ? (previous: CalendarSearch) => ({
263
+ ...previous,
264
+ calendarId: [...calendarIds, jump.calendarId],
265
+ })
266
+ : true;
267
+ return jump.recurrenceId === "" ? (
268
+ <Navigate
269
+ to="/calendar/$view/$date/$calendarObjectId"
270
+ params={{ view, date: jump.date, calendarObjectId }}
271
+ search={shown}
272
+ hash={true}
273
+ replace
274
+ />
275
+ ) : (
276
+ <Navigate
277
+ to="/calendar/$view/$date/$calendarObjectId/$recurrenceId"
278
+ params={{
279
+ view,
280
+ date: jump.date,
281
+ calendarObjectId,
282
+ recurrenceId: jump.recurrenceId,
283
+ }}
284
+ search={shown}
285
+ hash={true}
286
+ replace
287
+ />
288
+ );
289
+ }
290
+
178
291
  if (asking !== undefined && event) {
179
292
  return (
180
293
  <div className="flex h-full flex-col justify-center bg-surface px-row-inset">
@@ -228,6 +341,7 @@ export function OpenCalendarEvent({
228
341
  }
229
342
  calendar={calendar}
230
343
  isOccurrence={recurrenceId !== undefined}
344
+ absence={absenceOf(search, seriesOnly && isLoading)}
231
345
  problem={problem}
232
346
  onEdit={
233
347
  writable
@@ -106,6 +106,7 @@ export const standupResource = {
106
106
  calendarObjectId: STANDUP_OBJECT,
107
107
  resourceName: "standup.ics",
108
108
  icalUid: "uid-standup",
109
+ dtStart: "2026-06-10T09:15:00+02:00",
109
110
  icalData: [
110
111
  "BEGIN:VCALENDAR",
111
112
  "BEGIN:VEVENT",
@@ -64,6 +64,7 @@ import type {
64
64
  import { AddressField } from "./AddressField";
65
65
  import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
66
66
  import { composeSpellcheck } from "./compose-spellcheck.js";
67
+ import { mailSyncOffReason } from "./mail-sync-off-refusal.js";
67
68
  import {
68
69
  buildQuotedBlock,
69
70
  outgoingBody,
@@ -910,6 +911,13 @@ export const ComposeForm = ({
910
911
  const selectedAccountMissingSmtp = selectedAccount
911
912
  ? accountIsMissingSmtp(selectedAccount)
912
913
  : false;
914
+ const [mailOffRefusal, setMailOffRefusal] = useState<
915
+ { accountId: string; reason: string } | undefined
916
+ >();
917
+ const selectedAccountMailOffReason =
918
+ mailOffRefusal && mailOffRefusal.accountId === selectedAccountId
919
+ ? mailOffRefusal.reason
920
+ : undefined;
913
921
 
914
922
  // An account that has never been to the language setting falls back to what
915
923
  // the browser already knows the user reads, which is an ordered answer.
@@ -942,6 +950,9 @@ export const ComposeForm = ({
942
950
  if (!selectedAccountId) {
943
951
  return { status: "blocked", reason: "Choose an account to send from." };
944
952
  }
953
+ if (selectedAccountMailOffReason) {
954
+ return { status: "blocked", reason: selectedAccountMailOffReason };
955
+ }
945
956
  if (selectedAccountMissingSmtp) {
946
957
  return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
947
958
  }
@@ -972,6 +983,7 @@ export const ComposeForm = ({
972
983
  [
973
984
  isSending,
974
985
  selectedAccountId,
986
+ selectedAccountMailOffReason,
975
987
  selectedAccountMissingSmtp,
976
988
  quoteIsLoading,
977
989
  quoteSourceIsLoading,
@@ -1116,6 +1128,8 @@ export const ComposeForm = ({
1116
1128
  path: { outboxMessageId: messageId },
1117
1129
  })
1118
1130
  .catch((error: unknown) => {
1131
+ const reason = mailSyncOffReason(error);
1132
+ if (reason) setMailOffRefusal({ accountId, reason });
1119
1133
  pushError({
1120
1134
  title: "Couldn't send message",
1121
1135
  detail:
@@ -0,0 +1,7 @@
1
+ import { codedApiErrorBody } from "@/lib/api";
2
+ import { formatErrorDetail } from "../ui/error-banners.js";
3
+
4
+ export const mailSyncOffReason = (error: unknown): string | undefined => {
5
+ if (codedApiErrorBody(error)?.code !== "mail_sync_off") return undefined;
6
+ return formatErrorDetail(error);
7
+ };
@@ -0,0 +1,173 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import type {
4
+ RemitImapAccountResponse,
5
+ RemitImapDescribeMessageResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import {
8
+ type AnyRouter,
9
+ createMemoryHistory,
10
+ createRootRoute,
11
+ createRoute,
12
+ createRouter,
13
+ RouterContextProvider,
14
+ } from "@tanstack/react-router";
15
+ import { createElement, useState } from "react";
16
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
17
+ import { type HttpMock, mockFetch } from "../../test-support/http";
18
+ import { ComposeForm } from "./ComposeForm";
19
+ import { ComposeProvider } from "./ComposeProvider";
20
+
21
+ const ACCOUNT_ID = "acc-1181";
22
+ const OUTBOX_MESSAGE_ID = "ob-1181";
23
+ const SERVER_REFUSAL =
24
+ "Mail is turned off for this account, so it cannot send. Turn Mail back on for this account in Settings to send from it.";
25
+
26
+ const sourceMessage = {
27
+ message: { messageId: "msg-1" },
28
+ envelope: {
29
+ subject: "Lunch",
30
+ messageIdValue: "<m1@example.com>",
31
+ from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
32
+ replyTo: [],
33
+ to: [],
34
+ cc: [],
35
+ },
36
+ references: [],
37
+ bodyParts: [],
38
+ } as unknown as RemitImapDescribeMessageResponse;
39
+
40
+ const outboxEntry = {
41
+ attachments: [],
42
+ outboxMessageId: OUTBOX_MESSAGE_ID,
43
+ accountId: ACCOUNT_ID,
44
+ fromAddress: "me@example.com",
45
+ toAddresses: ["them@example.com"],
46
+ ccAddresses: [],
47
+ bccAddresses: [],
48
+ references: [],
49
+ subject: "Re: Lunch",
50
+ textBody: "yes",
51
+ status: "draft",
52
+ };
53
+
54
+ let harness: DomHarness | undefined;
55
+ let http: HttpMock | undefined;
56
+ let closed = 0;
57
+
58
+ afterEach(() => {
59
+ harness?.close();
60
+ harness = undefined;
61
+ http?.restore();
62
+ http = undefined;
63
+ closed = 0;
64
+ });
65
+
66
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
67
+
68
+ const rootRoute = createRootRoute();
69
+ const mailboxRoute = createRoute({
70
+ getParentRoute: () => rootRoute,
71
+ path: "/mail/$mailboxId",
72
+ validateSearch: (search: Record<string, unknown>) => search,
73
+ });
74
+
75
+ const testRouter = (): AnyRouter =>
76
+ createRouter({
77
+ routeTree: rootRoute.addChildren([mailboxRoute]),
78
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
79
+ }) as unknown as AnyRouter;
80
+
81
+ const mailSyncOff = (): Response =>
82
+ new Response(
83
+ JSON.stringify({
84
+ status: 400,
85
+ code: "mail_sync_off",
86
+ message: SERVER_REFUSAL,
87
+ details: { accountId: ACCOUNT_ID },
88
+ }),
89
+ { status: 400, headers: { "content-type": "application/json" } },
90
+ );
91
+
92
+ const mount = async (options: {
93
+ syncedServices: string[];
94
+ serverRefusesSend: boolean;
95
+ }): Promise<void> => {
96
+ const account = {
97
+ accountId: ACCOUNT_ID,
98
+ email: "me@example.com",
99
+ smtpEnabled: true,
100
+ syncedServices: options.syncedServices,
101
+ } as unknown as RemitImapAccountResponse;
102
+
103
+ http = mockFetch(async (call) => {
104
+ if (call.path.endsWith("/config")) return { accounts: [account] };
105
+ if (call.path.endsWith("/send") && options.serverRefusesSend) {
106
+ return mailSyncOff();
107
+ }
108
+ return outboxEntry;
109
+ });
110
+
111
+ const Opened = () => {
112
+ const [draftId, setDraftId] = useState<string | undefined>(
113
+ OUTBOX_MESSAGE_ID,
114
+ );
115
+ return createElement(ComposeForm, {
116
+ mode: "reply",
117
+ account,
118
+ sourceMessage,
119
+ outboxMessageId: draftId,
120
+ onDraftCreated: setDraftId,
121
+ onClose: () => {
122
+ closed += 1;
123
+ },
124
+ });
125
+ };
126
+
127
+ harness = createDomHarness();
128
+ harness.renderApp(
129
+ createElement(RouterContextProvider, {
130
+ router: testRouter(),
131
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
132
+ children: createElement(ComposeProvider, null, createElement(Opened)),
133
+ }),
134
+ );
135
+ await harness.flush();
136
+ await harness.wait(50);
137
+ };
138
+
139
+ const pressSend = async (): Promise<void> => {
140
+ const button = harness?.byText("button", "Send");
141
+ if (!button) throw new Error("the compose send button is not mounted");
142
+ harness?.click(button);
143
+ await harness?.flush();
144
+ await harness?.wait(100);
145
+ };
146
+
147
+ const sends = (): number =>
148
+ (http?.calls ?? []).filter((call) => call.path.endsWith("/send")).length;
149
+
150
+ describe("sending from an account with mail off (#1181)", () => {
151
+ it("shows the server's refusal in the composer and keeps it open", async () => {
152
+ await mount({ syncedServices: ["Calendar"], serverRefusesSend: true });
153
+
154
+ await pressSend();
155
+
156
+ assert.equal(sends(), 1);
157
+ assert.equal(closed, 0);
158
+ assert.ok((harness?.text() ?? "").includes(SERVER_REFUSAL));
159
+ });
160
+
161
+ it("holds Send blocked on that refusal instead of asking the server again", async () => {
162
+ await mount({ syncedServices: ["Calendar"], serverRefusesSend: true });
163
+
164
+ await pressSend();
165
+ await pressSend();
166
+
167
+ assert.equal(sends(), 1);
168
+ assert.equal(
169
+ harness?.byText("button", "Send")?.getAttribute("title"),
170
+ SERVER_REFUSAL,
171
+ );
172
+ });
173
+ });
@@ -39,10 +39,13 @@ export {
39
39
  export {
40
40
  anchorZoneFor,
41
41
  type CalendarInstanceRef,
42
+ type CalendarJump,
42
43
  calendarInstanceId,
44
+ calendarJumpOf,
43
45
  deviceTimeZone,
44
46
  isDrawnInstance,
45
47
  readCalendarInstanceId,
48
+ seriesOccurrenceOf,
46
49
  toCalendarDescriptor,
47
50
  toCalendarEventData,
48
51
  UNZONED_CALENDAR,
@@ -82,6 +85,7 @@ export {
82
85
  type CalendarFreeBusyResult,
83
86
  useCalendarFreeBusyWeeks,
84
87
  } from "./useCalendarFreeBusy";
88
+ export { type CalendarJumpSearch, useCalendarJump } from "./useCalendarJump";
85
89
  export {
86
90
  type CalendarSuggestionAnswers,
87
91
  type CalendarSuggestionsResult,
@@ -2,7 +2,12 @@
2
2
  import assert from "node:assert/strict";
3
3
  import { describe, it } from "node:test";
4
4
  import type { RemitImapCalendarEventInstance } from "@remit/api-http-client/types.gen.ts";
5
- import { anchorZoneFor, toCalendarEventData } from "./instance";
5
+ import {
6
+ anchorZoneFor,
7
+ calendarJumpOf,
8
+ seriesOccurrenceOf,
9
+ toCalendarEventData,
10
+ } from "./instance";
6
11
 
7
12
  const instance = (
8
13
  over: Partial<RemitImapCalendarEventInstance>,
@@ -68,3 +73,134 @@ describe("the zone a write anchors an event in", () => {
68
73
  }
69
74
  });
70
75
  });
76
+
77
+ describe("the occurrence a series opens at from its plain address", () => {
78
+ const weekly = ["06", "13", "20"].map((day) =>
79
+ toCalendarEventData(
80
+ instance({
81
+ recurrenceId: `203403${day}T090000Z`,
82
+ hasRecurrence: true,
83
+ allDay: false,
84
+ start: `2034-03-${day}T09:00:00+00:00`,
85
+ end: `2034-03-${day}T10:00:00+00:00`,
86
+ }),
87
+ "UTC",
88
+ "UTC",
89
+ ),
90
+ );
91
+ const shuffled = [...weekly].reverse();
92
+
93
+ it("is the next one still to finish", () => {
94
+ assert.deepEqual(
95
+ seriesOccurrenceOf(shuffled, "obj_1", "2034-03-13T09:30:00Z"),
96
+ { calendarObjectId: "obj_1", recurrenceId: "20340313T090000Z" },
97
+ );
98
+ });
99
+
100
+ it("is the first in the window once every one has passed", () => {
101
+ assert.deepEqual(
102
+ seriesOccurrenceOf(shuffled, "obj_1", "2034-04-01T00:00:00Z"),
103
+ { calendarObjectId: "obj_1", recurrenceId: "20340306T090000Z" },
104
+ );
105
+ });
106
+
107
+ it("is nothing for an event that does not recur or is not in the window", () => {
108
+ const oneOff = toCalendarEventData(
109
+ instance({ calendarObjectId: "obj_2" }),
110
+ "UTC",
111
+ "UTC",
112
+ );
113
+ assert.equal(
114
+ seriesOccurrenceOf(
115
+ [oneOff, ...shuffled],
116
+ "obj_2",
117
+ "2034-03-01T00:00:00Z",
118
+ ),
119
+ undefined,
120
+ );
121
+ assert.equal(
122
+ seriesOccurrenceOf(shuffled, "obj_3", "2034-03-01T00:00:00Z"),
123
+ undefined,
124
+ );
125
+ });
126
+ });
127
+
128
+ describe("the day a view moves to for an event it does not hold", () => {
129
+ const weekly = ["06", "13", "20"].map((day) =>
130
+ instance({
131
+ recurrenceId: `2034-03-${day}T01:00:00Z`,
132
+ hasRecurrence: true,
133
+ allDay: false,
134
+ start: `2034-03-${day}T01:00:00+00:00`,
135
+ end: `2034-03-${day}T02:00:00+00:00`,
136
+ }),
137
+ );
138
+ const shuffled = [...weekly].reverse();
139
+
140
+ it("is the day of the next occurrence still to finish, on the device's clock", () => {
141
+ assert.deepEqual(
142
+ calendarJumpOf(
143
+ shuffled,
144
+ "obj_1",
145
+ "2034-03-10T00:00:00Z",
146
+ "America/New_York",
147
+ ),
148
+ {
149
+ calendarObjectId: "obj_1",
150
+ recurrenceId: "2034-03-13T01:00:00Z",
151
+ calendarId: "cal_work",
152
+ date: "2034-03-12",
153
+ },
154
+ );
155
+ });
156
+
157
+ it("is the day of the first occurrence once every one has passed", () => {
158
+ assert.deepEqual(
159
+ calendarJumpOf(shuffled, "obj_1", "2035-01-01T00:00:00Z", "UTC"),
160
+ {
161
+ calendarObjectId: "obj_1",
162
+ recurrenceId: "2034-03-06T01:00:00Z",
163
+ calendarId: "cal_work",
164
+ date: "2034-03-06",
165
+ },
166
+ );
167
+ });
168
+
169
+ it("skips a cancelled occurrence", () => {
170
+ const [first, ...rest] = weekly;
171
+ if (!first) throw new Error("no first occurrence");
172
+ assert.equal(
173
+ calendarJumpOf(
174
+ [{ ...first, status: "Cancelled" }, ...rest],
175
+ "obj_1",
176
+ "2034-03-01T00:00:00Z",
177
+ "UTC",
178
+ )?.date,
179
+ "2034-03-13",
180
+ );
181
+ });
182
+
183
+ it("keeps a one-off event unaddressed by an occurrence", () => {
184
+ assert.deepEqual(
185
+ calendarJumpOf(
186
+ [instance({ calendarObjectId: "obj_2" })],
187
+ "obj_2",
188
+ "2034-01-01T00:00:00Z",
189
+ "Pacific/Pago_Pago",
190
+ ),
191
+ {
192
+ calendarObjectId: "obj_2",
193
+ recurrenceId: "",
194
+ calendarId: "cal_work",
195
+ date: "2034-03-01",
196
+ },
197
+ );
198
+ });
199
+
200
+ it("is nothing when the series has no occurrence", () => {
201
+ assert.equal(
202
+ calendarJumpOf(shuffled, "obj_3", "2034-03-01T00:00:00Z", "UTC"),
203
+ undefined,
204
+ );
205
+ });
206
+ });
@@ -86,6 +86,74 @@ export function readCalendarInstanceId(id: string): CalendarInstanceRef {
86
86
  };
87
87
  }
88
88
 
89
+ const nextOrFirst = (
90
+ events: readonly CalendarEventData[],
91
+ now: string,
92
+ ): CalendarEventData | undefined => {
93
+ const ordered = [...events].sort(
94
+ (a, b) => Date.parse(a.start) - Date.parse(b.start),
95
+ );
96
+ return (
97
+ ordered.find((event) => Date.parse(event.end) > Date.parse(now)) ??
98
+ ordered[0]
99
+ );
100
+ };
101
+
102
+ /**
103
+ * The occurrence a series opens at when the address names only the series: the
104
+ * next one still to finish, or the first in the window where none is left.
105
+ */
106
+ export function seriesOccurrenceOf(
107
+ events: readonly CalendarEventData[],
108
+ calendarObjectId: string,
109
+ now: string,
110
+ ): CalendarInstanceRef | undefined {
111
+ const picked = nextOrFirst(
112
+ events.filter((event) => {
113
+ const ref = readCalendarInstanceId(event.id);
114
+ return (
115
+ ref.calendarObjectId === calendarObjectId && ref.recurrenceId !== ""
116
+ );
117
+ }),
118
+ now,
119
+ );
120
+ return picked ? readCalendarInstanceId(picked.id) : undefined;
121
+ }
122
+
123
+ export interface CalendarJump extends CalendarInstanceRef {
124
+ calendarId: string;
125
+ /** On the clock the calendar is drawn in. */
126
+ date: string;
127
+ }
128
+
129
+ /**
130
+ * Where a view has to move to show an event it does not hold: the day of its
131
+ * next occurrence still to finish, or of its first where none is left.
132
+ */
133
+ export function calendarJumpOf(
134
+ instances: readonly RemitImapCalendarEventInstance[],
135
+ calendarObjectId: string,
136
+ now: string,
137
+ clockZone: string,
138
+ ): CalendarJump | undefined {
139
+ const picked = nextOrFirst(
140
+ instances
141
+ .filter(
142
+ (instance) =>
143
+ instance.calendarObjectId === calendarObjectId &&
144
+ isDrawnInstance(instance),
145
+ )
146
+ .map((instance) => toCalendarEventData(instance, "", clockZone)),
147
+ now,
148
+ );
149
+ if (!picked) return undefined;
150
+ return {
151
+ ...readCalendarInstanceId(picked.id),
152
+ calendarId: picked.calendarId,
153
+ date: picked.start.slice(0, 10),
154
+ };
155
+ }
156
+
89
157
  export function toCalendarDescriptor(
90
158
  calendar: RemitImapCalendarResponse,
91
159
  ): CalendarDescriptor {
@@ -0,0 +1,117 @@
1
+ import {
2
+ calendarEventDetailOperationsGetCalendarEventOptions,
3
+ calendarEventOperationsListCalendarEventsOptions,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import { useQuery } from "@tanstack/react-query";
6
+ import { useState } from "react";
7
+ import { getErrorStatus, softErrorStatuses } from "@/lib/error-classifier";
8
+ import { type CalendarJump, calendarJumpOf, deviceTimeZone } from "./instance";
9
+
10
+ export type CalendarJumpSearch =
11
+ | { kind: "Idle" }
12
+ | { kind: "Searching" }
13
+ | { kind: "Found"; jump: CalendarJump }
14
+ | { kind: "Deleted" }
15
+ | { kind: "NoOccurrenceFound" }
16
+ | { kind: "SignedOut" }
17
+ | { kind: "Failed"; reason: string };
18
+
19
+ const WINDOW_MS = 365 * 24 * 60 * 60_000;
20
+ const DAY_MS = 24 * 60 * 60_000;
21
+
22
+ const LISTING_META = softErrorStatuses(401);
23
+ const RESOURCE_META = softErrorStatuses(401, 404);
24
+
25
+ const instant = (ms: number): string =>
26
+ new Date(ms).toISOString().replace(/\.\d{3}Z$/, "+00:00");
27
+
28
+ const reasonOf = (error: unknown): string => {
29
+ const message =
30
+ error instanceof Error && error.message !== ""
31
+ ? error.message
32
+ : "no reason given";
33
+ const status = getErrorStatus(error);
34
+ return status === undefined ? message : `${message} (HTTP ${status})`;
35
+ };
36
+
37
+ /**
38
+ * Finds the day an event falls on when the view on screen does not hold it:
39
+ * its occurrences in the coming year first, and failing those, the year from
40
+ * the resource's own start — so an event that has ended opens at its first.
41
+ */
42
+ export function useCalendarJump(
43
+ calendarObjectId: string,
44
+ enabled: boolean,
45
+ ): CalendarJumpSearch {
46
+ const [now] = useState(() => Date.now());
47
+ const clock = deviceTimeZone();
48
+ const nowIso = new Date(now).toISOString();
49
+
50
+ const ahead = useQuery({
51
+ ...calendarEventOperationsListCalendarEventsOptions({
52
+ query: {
53
+ from: instant(now),
54
+ to: instant(now + WINDOW_MS),
55
+ calendarObjectId,
56
+ },
57
+ }),
58
+ meta: LISTING_META,
59
+ retry: false,
60
+ enabled,
61
+ });
62
+ const upcoming = ahead.data
63
+ ? calendarJumpOf(ahead.data.items, calendarObjectId, nowIso, clock)
64
+ : undefined;
65
+
66
+ const lookup = useQuery({
67
+ ...calendarEventDetailOperationsGetCalendarEventOptions({
68
+ path: { calendarObjectId },
69
+ }),
70
+ meta: RESOURCE_META,
71
+ retry: false,
72
+ enabled: enabled && ahead.data !== undefined && upcoming === undefined,
73
+ });
74
+
75
+ const started = Date.parse(lookup.data?.dtStart ?? "");
76
+ const unreadableStart = lookup.data !== undefined && Number.isNaN(started);
77
+ const firstFrom = Number.isNaN(started) ? 0 : started - DAY_MS;
78
+ const first = useQuery({
79
+ ...calendarEventOperationsListCalendarEventsOptions({
80
+ query: {
81
+ from: instant(firstFrom),
82
+ to: instant(firstFrom + WINDOW_MS),
83
+ calendarObjectId,
84
+ },
85
+ }),
86
+ meta: LISTING_META,
87
+ retry: false,
88
+ enabled: enabled && lookup.data !== undefined && !unreadableStart,
89
+ });
90
+ const earliest = first.data
91
+ ? calendarJumpOf(first.data.items, calendarObjectId, nowIso, clock)
92
+ : undefined;
93
+
94
+ if (!enabled) return { kind: "Idle" };
95
+ if (upcoming) return { kind: "Found", jump: upcoming };
96
+ if (earliest) return { kind: "Found", jump: earliest };
97
+
98
+ const deleted = getErrorStatus(lookup.error) === 404;
99
+ const failure = [
100
+ ahead.error,
101
+ deleted ? null : lookup.error,
102
+ first.error,
103
+ ].find((error) => error !== null);
104
+ if (failure !== undefined) {
105
+ return getErrorStatus(failure) === 401
106
+ ? { kind: "SignedOut" }
107
+ : { kind: "Failed", reason: reasonOf(failure) };
108
+ }
109
+ if (deleted) return { kind: "Deleted" };
110
+ if (unreadableStart)
111
+ return {
112
+ kind: "Failed",
113
+ reason: `the stored event names no readable start (${lookup.data?.dtStart ?? ""})`,
114
+ };
115
+ if (first.data !== undefined) return { kind: "NoOccurrenceFound" };
116
+ return { kind: "Searching" };
117
+ }
@@ -9,3 +9,13 @@ export function calendarReportHref(what: string): string {
9
9
  }),
10
10
  );
11
11
  }
12
+
13
+ /** A prefilled issue for an event the calendar could not look up. */
14
+ export function calendarEventReportHref(what: string): string {
15
+ return buildGitHubIssueUrl(
16
+ buildBugReportContext({
17
+ title: `Calendar event: ${what}`,
18
+ errorMessage: what,
19
+ }),
20
+ );
21
+ }
@@ -193,3 +193,5 @@ export const calendarSearchSchema = z.object({
193
193
  .preprocess(readCalendarIds, z.array(calendarIdSchema))
194
194
  .transform((ids) => (ids.length === 0 ? undefined : ids)),
195
195
  });
196
+
197
+ export type CalendarSearch = z.infer<typeof calendarSearchSchema>;