@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 +1 -1
- package/src/components/calendar/CalendarEventPane.stories.tsx +51 -0
- package/src/components/calendar/CalendarEventPane.tsx +79 -14
- package/src/components/calendar/OpenCalendarEvent.stories.tsx +128 -10
- package/src/components/calendar/OpenCalendarEvent.tsx +116 -2
- package/src/components/calendar/calendar-story-fixtures.ts +1 -0
- package/src/components/compose/ComposeForm.tsx +14 -0
- package/src/components/compose/mail-sync-off-refusal.ts +7 -0
- package/src/components/compose/send-from-mail-off-account.render.test.ts +173 -0
- package/src/hooks/calendar/index.ts +4 -0
- package/src/hooks/calendar/instance.test.ts +137 -1
- package/src/hooks/calendar/instance.ts +68 -0
- package/src/hooks/calendar/useCalendarJump.ts +117 -0
- package/src/lib/calendar-report.ts +10 -0
- package/src/lib/calendar-route.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
@@ -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 {
|
|
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
|
+
}
|