@remit/web-client 0.0.302 → 0.0.304
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/compose.stories.tsx +374 -0
- package/src/components/layout/ComposeFab.tsx +2 -15
- package/src/components/layout/mail-shell.stories.tsx +142 -0
- package/src/components/mail/detail-surface.stories.tsx +171 -0
- package/src/components/mail/mail-pickers.stories.tsx +82 -0
- package/src/components/mail/search.stories.tsx +280 -0
- package/src/components/mail/selection-wizard.stories.tsx +160 -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/src/mocks/story-frame/compose-world.ts +191 -0
- package/src/mocks/story-frame/mail-handlers.ts +195 -22
- package/src/mocks/story-frame/mail-world.ts +44 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.304",
|
|
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
|