@remit/web-client 0.0.302 → 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/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
|
|
@@ -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
|
+
}
|