@remit/web-client 0.0.230 → 0.0.232
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/CalendarComposePane.stories.tsx +115 -0
- package/src/components/calendar/CalendarComposePane.tsx +94 -0
- package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +110 -0
- package/src/components/calendar/CalendarEventPane.tsx +90 -0
- package/src/components/calendar/CalendarShell.tsx +83 -0
- package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
- package/src/components/calendar/CalendarWorkspace.stories.tsx +219 -0
- package/src/components/calendar/CalendarWorkspace.tsx +192 -0
- package/src/components/calendar/OpenCalendarEvent.tsx +221 -0
- package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
- package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
- package/src/components/mail/MailSidebarAdapter.tsx +17 -0
- package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
- package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
- package/src/hooks/calendar/draft.test.ts +241 -0
- package/src/hooks/calendar/draft.ts +168 -0
- package/src/hooks/calendar/index.ts +59 -0
- package/src/hooks/calendar/instance.ts +136 -0
- package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
- package/src/hooks/calendar/recurrence-rule.ts +133 -0
- package/src/hooks/calendar/resource.test.ts +98 -0
- package/src/hooks/calendar/resource.ts +74 -0
- package/src/hooks/calendar/useCalendarEvent.ts +46 -0
- package/src/hooks/calendar/useCalendarEvents.ts +124 -0
- package/src/hooks/calendar/useCalendarWrites.ts +174 -0
- package/src/hooks/calendar/useCalendars.ts +43 -0
- package/src/hooks/calendar/window.test.ts +148 -0
- package/src/hooks/calendar/window.ts +163 -0
- package/src/hooks/useCalendarData.ts +120 -0
- package/src/lib/calendar-density.ts +34 -0
- package/src/lib/calendar-format.ts +29 -0
- package/src/lib/calendar-route.test.ts +210 -0
- package/src/lib/calendar-route.ts +187 -0
- package/src/lib/search-params.test.ts +77 -0
- package/src/lib/search-params.ts +59 -0
- package/src/routeTree.gen.ts +199 -0
- package/src/router.tsx +5 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
- package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
- package/src/routes/calendar/$view.$date/index.tsx +25 -0
- package/src/routes/calendar/$view.$date/new.tsx +20 -0
- package/src/routes/calendar/$view.$date.tsx +128 -0
- package/src/routes/calendar/-calendar-tree.test.ts +267 -0
- package/src/routes/calendar/index.tsx +27 -0
- package/src/routes/calendar.tsx +98 -0
- package/src/routing/calendar-fragment.test.ts +66 -0
- package/src/routing/calendar.ts +150 -0
- package/src/routing/fragment.ts +19 -0
- package/src/routing/index.ts +10 -0
- package/src/routing/nav-selection.ts +2 -0
- package/src/test-support/http.ts +8 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CalendarColorId,
|
|
3
|
+
type CalendarEventData,
|
|
4
|
+
type CalendarViewId,
|
|
5
|
+
type Density,
|
|
6
|
+
NavSidebar,
|
|
7
|
+
} from "@remit/ui";
|
|
8
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
import { CalendarWorkspace } from "./CalendarWorkspace";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The calendar pane the `/calendar/{view}/{date}` route mounts, at every state
|
|
14
|
+
* the shipping shell can be in.
|
|
15
|
+
*
|
|
16
|
+
* It is presentational: the toolbar's moves leave through callbacks and the
|
|
17
|
+
* route turns each one into an address, so a story can drive them with local
|
|
18
|
+
* state and get the same surface the app renders. What the stories are for is
|
|
19
|
+
* what the markup cannot answer — how a week reads once a day is full, and
|
|
20
|
+
* whether a zoom that is not drawn yet reads as unfinished rather than empty.
|
|
21
|
+
*/
|
|
22
|
+
const TIME_ZONE = "Europe/Amsterdam";
|
|
23
|
+
const DATE = "2026-06-10";
|
|
24
|
+
const NOW = `${DATE}T09:30:00+02:00`;
|
|
25
|
+
|
|
26
|
+
const WORK = "cal_work";
|
|
27
|
+
const TEAM = "cal_team";
|
|
28
|
+
const HOME = "cal_home";
|
|
29
|
+
|
|
30
|
+
const colorByCalendarId: Record<string, CalendarColorId> = {
|
|
31
|
+
[WORK]: "cal-1",
|
|
32
|
+
[TEAM]: "cal-6",
|
|
33
|
+
[HOME]: "cal-4",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const template: CalendarEventData = {
|
|
37
|
+
id: "",
|
|
38
|
+
calendarId: WORK,
|
|
39
|
+
title: "",
|
|
40
|
+
start: "",
|
|
41
|
+
end: "",
|
|
42
|
+
allDay: false,
|
|
43
|
+
location: "",
|
|
44
|
+
notes: "",
|
|
45
|
+
attendees: [],
|
|
46
|
+
myRsvp: "accepted",
|
|
47
|
+
threadId: "",
|
|
48
|
+
threadSubject: "",
|
|
49
|
+
timeZone: TIME_ZONE,
|
|
50
|
+
zoneCertainty: "explicit",
|
|
51
|
+
recurrenceRule: "",
|
|
52
|
+
seriesId: "",
|
|
53
|
+
seriesException: false,
|
|
54
|
+
status: "confirmed",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const at = (
|
|
58
|
+
id: string,
|
|
59
|
+
title: string,
|
|
60
|
+
day: string,
|
|
61
|
+
from: string,
|
|
62
|
+
to: string,
|
|
63
|
+
over: Partial<CalendarEventData> = {},
|
|
64
|
+
): CalendarEventData => ({
|
|
65
|
+
...template,
|
|
66
|
+
...over,
|
|
67
|
+
id,
|
|
68
|
+
title,
|
|
69
|
+
start: `2026-06-${day}T${from}:00+02:00`,
|
|
70
|
+
end: `2026-06-${day}T${to}:00+02:00`,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const week: CalendarEventData[] = [
|
|
74
|
+
at("standup-mon", "Standup", "08", "09:15", "09:30", {
|
|
75
|
+
recurrenceRule: "Every weekday, 09:15",
|
|
76
|
+
seriesId: "ser_standup",
|
|
77
|
+
}),
|
|
78
|
+
at("roadmap", "Roadmap review", "08", "11:00", "12:00", {
|
|
79
|
+
calendarId: TEAM,
|
|
80
|
+
}),
|
|
81
|
+
at("standup-tue", "Standup", "09", "09:15", "09:30", {
|
|
82
|
+
recurrenceRule: "Every weekday, 09:15",
|
|
83
|
+
seriesId: "ser_standup",
|
|
84
|
+
}),
|
|
85
|
+
at("supplier", "Supplier call", "09", "14:00", "15:00", {
|
|
86
|
+
calendarId: TEAM,
|
|
87
|
+
zoneCertainty: "ambiguous",
|
|
88
|
+
}),
|
|
89
|
+
at("standup-wed", "Standup", "10", "09:15", "09:30", {
|
|
90
|
+
recurrenceRule: "Every weekday, 09:15",
|
|
91
|
+
seriesId: "ser_standup",
|
|
92
|
+
}),
|
|
93
|
+
at("design", "Design review", "10", "10:00", "12:00", {
|
|
94
|
+
calendarId: TEAM,
|
|
95
|
+
}),
|
|
96
|
+
at("dentist", "Dentist", "10", "11:00", "12:00", {
|
|
97
|
+
calendarId: HOME,
|
|
98
|
+
location: "Kerkstraat 4",
|
|
99
|
+
}),
|
|
100
|
+
at("one-to-one", "One to one", "11", "15:00", "16:00"),
|
|
101
|
+
at("retro", "Retro", "12", "16:00", "17:00", { calendarId: TEAM }),
|
|
102
|
+
at("climbing", "Climbing", "13", "10:00", "12:00", { calendarId: HOME }),
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
/** The shell drives view and date through the router; a story drives its own. */
|
|
106
|
+
function Workspace({
|
|
107
|
+
initialView,
|
|
108
|
+
events,
|
|
109
|
+
isLoading,
|
|
110
|
+
error,
|
|
111
|
+
}: {
|
|
112
|
+
initialView: CalendarViewId;
|
|
113
|
+
events: CalendarEventData[];
|
|
114
|
+
isLoading?: boolean;
|
|
115
|
+
error?: unknown;
|
|
116
|
+
}) {
|
|
117
|
+
const [view, setView] = useState<CalendarViewId>(initialView);
|
|
118
|
+
const [density, setDensity] = useState<Density>("comfortable");
|
|
119
|
+
const [selected, setSelected] = useState("");
|
|
120
|
+
return (
|
|
121
|
+
<CalendarWorkspace
|
|
122
|
+
view={view}
|
|
123
|
+
date={DATE}
|
|
124
|
+
events={events}
|
|
125
|
+
colorByCalendarId={colorByCalendarId}
|
|
126
|
+
isLoading={isLoading}
|
|
127
|
+
error={error}
|
|
128
|
+
onRetry={() => undefined}
|
|
129
|
+
density={density}
|
|
130
|
+
selectedEventId={selected}
|
|
131
|
+
timeZone={TIME_ZONE}
|
|
132
|
+
now={NOW}
|
|
133
|
+
onChangeView={setView}
|
|
134
|
+
onToday={() => undefined}
|
|
135
|
+
onStep={() => undefined}
|
|
136
|
+
onChangeDensity={setDensity}
|
|
137
|
+
onSelectEvent={setSelected}
|
|
138
|
+
onPickSlot={() => undefined}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const meta: Meta<typeof Workspace> = {
|
|
144
|
+
title: "App/Calendar/Workspace",
|
|
145
|
+
component: Workspace,
|
|
146
|
+
parameters: { layout: "fullscreen" },
|
|
147
|
+
args: { initialView: "week", events: week },
|
|
148
|
+
render: (args) => (
|
|
149
|
+
<div className="h-dvh bg-canvas">
|
|
150
|
+
<Workspace {...args} />
|
|
151
|
+
</div>
|
|
152
|
+
),
|
|
153
|
+
};
|
|
154
|
+
export default meta;
|
|
155
|
+
|
|
156
|
+
type Story = StoryObj<typeof Workspace>;
|
|
157
|
+
|
|
158
|
+
export const Week: Story = {};
|
|
159
|
+
|
|
160
|
+
export const Day: Story = { args: { initialView: "day" } };
|
|
161
|
+
|
|
162
|
+
/** Nothing scheduled is still a grid, never a "nothing here" surface. */
|
|
163
|
+
export const EmptyWeek: Story = { args: { events: [] } };
|
|
164
|
+
|
|
165
|
+
/** The first read of a week, before the server has answered. */
|
|
166
|
+
export const Loading: Story = { args: { events: [], isLoading: true } };
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The read was refused. This is the state an empty grid must never be confused
|
|
170
|
+
* with: a week drawn blank because the request failed reads as a week with
|
|
171
|
+
* nothing in it, and the reader plans their day around it.
|
|
172
|
+
*/
|
|
173
|
+
export const CouldNotLoad: Story = {
|
|
174
|
+
args: {
|
|
175
|
+
events: [],
|
|
176
|
+
error: new Error("The window has to be shorter than a year."),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The three zooms that are named but not drawn yet. Each says what it is
|
|
182
|
+
* waiting on, so the route is addressable without pretending to be finished.
|
|
183
|
+
*/
|
|
184
|
+
export const YearNotBuiltYet: Story = { args: { initialView: "year" } };
|
|
185
|
+
|
|
186
|
+
export const MonthNotBuiltYet: Story = { args: { initialView: "month" } };
|
|
187
|
+
|
|
188
|
+
export const AgendaNotBuiltYet: Story = { args: { initialView: "agenda" } };
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The calendar beside the nav it is reached from. The entry sits under the
|
|
192
|
+
* daily brief, which is where the shipping sidebar puts it.
|
|
193
|
+
*/
|
|
194
|
+
export const WithTheNav: Story = {
|
|
195
|
+
render: (args) => (
|
|
196
|
+
<div className="flex h-dvh bg-canvas">
|
|
197
|
+
<div className="w-64 shrink-0">
|
|
198
|
+
<NavSidebar
|
|
199
|
+
accounts={[
|
|
200
|
+
{
|
|
201
|
+
id: "acct_work",
|
|
202
|
+
label: "Work",
|
|
203
|
+
email: "work@example.invalid",
|
|
204
|
+
mailboxes: [
|
|
205
|
+
{ id: "mb_inbox", name: "Inbox", role: "inbox", unseen: 3 },
|
|
206
|
+
{ id: "mb_sent", name: "Sent", role: "sent" },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
]}
|
|
210
|
+
selectedNavId="calendar"
|
|
211
|
+
calendarNav="shown"
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
<div className="min-w-0 flex-1">
|
|
215
|
+
<Workspace {...args} />
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
),
|
|
219
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CalendarColorId,
|
|
3
|
+
CalendarDateNav,
|
|
4
|
+
type CalendarEventData,
|
|
5
|
+
CalendarGrid,
|
|
6
|
+
type CalendarSlotPick,
|
|
7
|
+
type CalendarViewId,
|
|
8
|
+
CalendarViewSwitch,
|
|
9
|
+
type Density,
|
|
10
|
+
segmentClassName,
|
|
11
|
+
} from "@remit/ui";
|
|
12
|
+
import { Loader2 } from "lucide-react";
|
|
13
|
+
import { useId, useState } from "react";
|
|
14
|
+
import { CalendarViewPlaceholder } from "@/components/calendar/CalendarViewPlaceholder";
|
|
15
|
+
import { NavMenuButton } from "@/components/mail/NavMenuButton";
|
|
16
|
+
import { ErrorState } from "@/components/ui/ErrorState";
|
|
17
|
+
import { calendarViewMountsGrid } from "@/lib/calendar-route";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The calendar's own pane: the toolbar that moves it and whatever the current
|
|
21
|
+
* zoom draws.
|
|
22
|
+
*
|
|
23
|
+
* Presentational, like everything in `@remit/ui` it is built from. It holds no
|
|
24
|
+
* events, reads no clock and knows no router: prev, next, today and the zoom
|
|
25
|
+
* switch all leave through callbacks, and the route turns each one into an
|
|
26
|
+
* address. That is what keeps "which view" and "which day" facts of the URL
|
|
27
|
+
* rather than of this component's state.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const DENSITY_OPTIONS: { value: Density; label: string }[] = [
|
|
31
|
+
{ value: "comfortable", label: "Detail" },
|
|
32
|
+
{ value: "compact", label: "Glance" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export interface CalendarWorkspaceProps {
|
|
36
|
+
view: CalendarViewId;
|
|
37
|
+
/** The day the view is centred on, `YYYY-MM-DD`. */
|
|
38
|
+
date: string;
|
|
39
|
+
events: CalendarEventData[];
|
|
40
|
+
colorByCalendarId: Record<string, CalendarColorId>;
|
|
41
|
+
/** The first read of this window is still out; the grid has nothing yet. */
|
|
42
|
+
isLoading?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* A refusal the calendar states in place of the grid. A week drawn empty
|
|
45
|
+
* because the read failed is indistinguishable from a week with nothing in
|
|
46
|
+
* it, which is the one thing this must never look like.
|
|
47
|
+
*/
|
|
48
|
+
error?: unknown;
|
|
49
|
+
onRetry?: () => void;
|
|
50
|
+
density: Density;
|
|
51
|
+
/** The event the reading pane has open, empty for none. */
|
|
52
|
+
selectedEventId: string;
|
|
53
|
+
timeZone: string;
|
|
54
|
+
/** The instant the grid calls now. */
|
|
55
|
+
now: string;
|
|
56
|
+
onChangeView: (view: CalendarViewId) => void;
|
|
57
|
+
onToday: () => void;
|
|
58
|
+
onChangeDensity: (density: Density) => void;
|
|
59
|
+
onSelectEvent: (eventId: string) => void;
|
|
60
|
+
onPickSlot: (pick: CalendarSlotPick) => void;
|
|
61
|
+
onStep: (direction: -1 | 1) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The range the toolbar names before the grid has measured one, and the whole
|
|
66
|
+
* answer at a zoom that draws no grid.
|
|
67
|
+
*/
|
|
68
|
+
function fallbackRangeTitle(view: CalendarViewId, date: string): string {
|
|
69
|
+
const instant = new Date(`${date}T00:00:00`);
|
|
70
|
+
if (view === "year")
|
|
71
|
+
return new Intl.DateTimeFormat(undefined, { year: "numeric" }).format(
|
|
72
|
+
instant,
|
|
73
|
+
);
|
|
74
|
+
if (view === "day")
|
|
75
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
76
|
+
weekday: "short",
|
|
77
|
+
day: "numeric",
|
|
78
|
+
month: "long",
|
|
79
|
+
year: "numeric",
|
|
80
|
+
}).format(instant);
|
|
81
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
82
|
+
month: "long",
|
|
83
|
+
year: "numeric",
|
|
84
|
+
}).format(instant);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function CalendarWorkspace({
|
|
88
|
+
view,
|
|
89
|
+
date,
|
|
90
|
+
events,
|
|
91
|
+
colorByCalendarId,
|
|
92
|
+
isLoading = false,
|
|
93
|
+
error,
|
|
94
|
+
onRetry,
|
|
95
|
+
density,
|
|
96
|
+
selectedEventId,
|
|
97
|
+
timeZone,
|
|
98
|
+
now,
|
|
99
|
+
onChangeView,
|
|
100
|
+
onToday,
|
|
101
|
+
onChangeDensity,
|
|
102
|
+
onSelectEvent,
|
|
103
|
+
onPickSlot,
|
|
104
|
+
onStep,
|
|
105
|
+
}: CalendarWorkspaceProps) {
|
|
106
|
+
const densityGroup = useId();
|
|
107
|
+
// The grid computes the range it drew, which is the only honest title for a
|
|
108
|
+
// week — it knows where the week starts. Held against the address it was
|
|
109
|
+
// measured for, so leaving a view does not carry its title into the next one.
|
|
110
|
+
const [measured, setMeasured] = useState({ key: "", title: "" });
|
|
111
|
+
const addressKey = `${view}/${date}`;
|
|
112
|
+
const title =
|
|
113
|
+
measured.key === addressKey
|
|
114
|
+
? measured.title
|
|
115
|
+
: fallbackRangeTitle(view, date);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex h-full min-h-0 flex-col bg-surface">
|
|
119
|
+
<div className="flex h-pane-header shrink-0 items-center gap-2 border-b border-line px-2">
|
|
120
|
+
{/* Below the nav pane the sidebar is a slide-over with nothing to open
|
|
121
|
+
it: the calendar is a whole surface of its own, so without this
|
|
122
|
+
the reader has no way back to their mail. */}
|
|
123
|
+
<NavMenuButton />
|
|
124
|
+
<CalendarDateNav
|
|
125
|
+
title={title}
|
|
126
|
+
onPrev={() => onStep(-1)}
|
|
127
|
+
onNext={() => onStep(1)}
|
|
128
|
+
onToday={onToday}
|
|
129
|
+
>
|
|
130
|
+
<fieldset className="inline-flex items-center gap-0.5">
|
|
131
|
+
<legend className="sr-only">Calendar density</legend>
|
|
132
|
+
{DENSITY_OPTIONS.map((option) => (
|
|
133
|
+
<label
|
|
134
|
+
key={option.value}
|
|
135
|
+
className={`${segmentClassName(density === option.value)} h-7 text-xs`}
|
|
136
|
+
>
|
|
137
|
+
<input
|
|
138
|
+
type="radio"
|
|
139
|
+
name={densityGroup}
|
|
140
|
+
value={option.value}
|
|
141
|
+
checked={density === option.value}
|
|
142
|
+
onChange={() => onChangeDensity(option.value)}
|
|
143
|
+
className="sr-only"
|
|
144
|
+
/>
|
|
145
|
+
{option.label}
|
|
146
|
+
</label>
|
|
147
|
+
))}
|
|
148
|
+
</fieldset>
|
|
149
|
+
<CalendarViewSwitch value={view} onChange={onChangeView} />
|
|
150
|
+
</CalendarDateNav>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="min-h-0 flex-1">
|
|
154
|
+
{error !== undefined && error !== null ? (
|
|
155
|
+
<div className="flex h-full items-center justify-center">
|
|
156
|
+
<ErrorState
|
|
157
|
+
title="Couldn't load this week"
|
|
158
|
+
error={error}
|
|
159
|
+
onRetry={onRetry}
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
) : isLoading ? (
|
|
163
|
+
<div
|
|
164
|
+
role="status"
|
|
165
|
+
aria-label="Loading the calendar"
|
|
166
|
+
className="flex h-full items-center justify-center bg-surface"
|
|
167
|
+
>
|
|
168
|
+
<Loader2 className="size-6 animate-spin text-fg-subtle" />
|
|
169
|
+
</div>
|
|
170
|
+
) : calendarViewMountsGrid(view) ? (
|
|
171
|
+
<CalendarGrid
|
|
172
|
+
view={view}
|
|
173
|
+
date={date}
|
|
174
|
+
events={events}
|
|
175
|
+
colorByCalendarId={colorByCalendarId}
|
|
176
|
+
density={density}
|
|
177
|
+
selectedEventId={selectedEventId}
|
|
178
|
+
timeZone={timeZone}
|
|
179
|
+
now={now}
|
|
180
|
+
onSelectEvent={onSelectEvent}
|
|
181
|
+
onPickSlot={onPickSlot}
|
|
182
|
+
onRangeChange={(measuredTitle) =>
|
|
183
|
+
setMeasured({ key: addressKey, title: measuredTitle })
|
|
184
|
+
}
|
|
185
|
+
/>
|
|
186
|
+
) : (
|
|
187
|
+
<CalendarViewPlaceholder view={view} />
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type EventDraft,
|
|
3
|
+
type RecurrenceScope,
|
|
4
|
+
RecurrenceScopePrompt,
|
|
5
|
+
} from "@remit/ui";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { CalendarComposePane } from "@/components/calendar/CalendarComposePane";
|
|
8
|
+
import { CalendarEventPane } from "@/components/calendar/CalendarEventPane";
|
|
9
|
+
import {
|
|
10
|
+
type CalendarWriteOutcome,
|
|
11
|
+
calendarInstanceId,
|
|
12
|
+
draftFromEvent,
|
|
13
|
+
patchFromDrafts,
|
|
14
|
+
rruleFromIcalData,
|
|
15
|
+
type ScopedWrite,
|
|
16
|
+
textFromIcalData,
|
|
17
|
+
textFromRrule,
|
|
18
|
+
UNZONED_CALENDAR,
|
|
19
|
+
useCalendarEvent,
|
|
20
|
+
useCalendars,
|
|
21
|
+
useCalendarWrites,
|
|
22
|
+
} from "@/hooks/calendar";
|
|
23
|
+
import { useCalendarData } from "@/hooks/useCalendarData";
|
|
24
|
+
import { formatEventWhen } from "@/lib/calendar-format";
|
|
25
|
+
import { useCalendarAddress, useCalendarNavigation } from "@/routing";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The event the address names, resolved — and everything a reader can do to it.
|
|
29
|
+
*
|
|
30
|
+
* The two event routes differ only in what the address says, so the lookup, the
|
|
31
|
+
* scope question and the way back live here once rather than in each of them.
|
|
32
|
+
*
|
|
33
|
+
* An edit or a delete on a repeating event asks which occurrences it means
|
|
34
|
+
* before the form opens, not on the way out: which instances it reaches changes
|
|
35
|
+
* what the change is, so it is settled while it can still be answered.
|
|
36
|
+
*
|
|
37
|
+
* Every write carries the etag of the version on screen. A 412 means somebody
|
|
38
|
+
* replaced the event in between, and it is said out loud rather than resolved
|
|
39
|
+
* by overwriting them.
|
|
40
|
+
*/
|
|
41
|
+
export interface OpenCalendarEventProps {
|
|
42
|
+
calendarObjectId: string;
|
|
43
|
+
/** One occurrence of a series, absent on the series itself. */
|
|
44
|
+
recurrenceId?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CONFLICT =
|
|
48
|
+
"This event changed somewhere else — over CalDAV, or in another tab. Nothing was saved. Close it and open it again to see the version that's stored now.";
|
|
49
|
+
|
|
50
|
+
type Intent = "edit" | "delete";
|
|
51
|
+
|
|
52
|
+
export function OpenCalendarEvent({
|
|
53
|
+
calendarObjectId,
|
|
54
|
+
recurrenceId,
|
|
55
|
+
}: OpenCalendarEventProps) {
|
|
56
|
+
const { view, date, calendarIds } = useCalendarAddress();
|
|
57
|
+
const { events } = useCalendarData({ view, date, calendarIds });
|
|
58
|
+
const { calendars, timeZoneByCalendarId } = useCalendars();
|
|
59
|
+
const { closeEvent } = useCalendarNavigation();
|
|
60
|
+
const { updateEvent, deleteEvent, isWriting } = useCalendarWrites();
|
|
61
|
+
|
|
62
|
+
const event = events.find(
|
|
63
|
+
(candidate) =>
|
|
64
|
+
candidate.id === calendarInstanceId(calendarObjectId, recurrenceId ?? ""),
|
|
65
|
+
);
|
|
66
|
+
const calendar = calendars.find(
|
|
67
|
+
(candidate) => candidate.id === event?.calendarId,
|
|
68
|
+
);
|
|
69
|
+
const { resource } = useCalendarEvent(
|
|
70
|
+
calendarObjectId,
|
|
71
|
+
event?.calendarId ?? "",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const [asking, setAsking] = useState<Intent | undefined>(undefined);
|
|
75
|
+
const [editing, setEditing] = useState<
|
|
76
|
+
{ before: EventDraft; scope: RecurrenceScope | undefined } | undefined
|
|
77
|
+
>(undefined);
|
|
78
|
+
const [draft, setDraft] = useState<EventDraft | undefined>(undefined);
|
|
79
|
+
const [problem, setProblem] = useState("");
|
|
80
|
+
|
|
81
|
+
const stored = resource ? textFromIcalData(resource.icalData) : undefined;
|
|
82
|
+
const rrule = resource ? rruleFromIcalData(resource.icalData) : "";
|
|
83
|
+
const startTime = event && !event.allDay ? event.start.slice(11, 16) : "";
|
|
84
|
+
const repeat =
|
|
85
|
+
rrule === "" ? "" : (textFromRrule(rrule, startTime) ?? "Repeats");
|
|
86
|
+
|
|
87
|
+
// Scoping needs the occurrence to anchor at, so it is offered only where the
|
|
88
|
+
// address names one. A series opened by itself has one honest answer, which
|
|
89
|
+
// is the whole of it.
|
|
90
|
+
const canScope =
|
|
91
|
+
event !== undefined &&
|
|
92
|
+
event.seriesId !== "" &&
|
|
93
|
+
recurrenceId !== undefined &&
|
|
94
|
+
recurrenceId !== "";
|
|
95
|
+
// Until the resource is read there is no etag, and a write with no etag is a
|
|
96
|
+
// write that can silently overwrite somebody.
|
|
97
|
+
const writable = event !== undefined && resource !== undefined;
|
|
98
|
+
|
|
99
|
+
const settle = (outcome: CalendarWriteOutcome, done: () => void) => {
|
|
100
|
+
if (outcome.kind === "written") {
|
|
101
|
+
setProblem("");
|
|
102
|
+
done();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
setProblem(outcome.kind === "conflict" ? CONFLICT : outcome.message);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const write = (scope: RecurrenceScope | undefined): ScopedWrite => ({
|
|
109
|
+
calendarObjectId,
|
|
110
|
+
calendarId: event?.calendarId ?? "",
|
|
111
|
+
recurrenceId: recurrenceId ?? "",
|
|
112
|
+
scope,
|
|
113
|
+
etag: resource?.etag ?? "",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const startEdit = (scope: RecurrenceScope | undefined) => {
|
|
117
|
+
if (!event) return;
|
|
118
|
+
const before = draftFromEvent(event, {
|
|
119
|
+
repeat,
|
|
120
|
+
location: stored?.location ?? "",
|
|
121
|
+
notes: stored?.description ?? "",
|
|
122
|
+
});
|
|
123
|
+
setAsking(undefined);
|
|
124
|
+
setEditing({ before, scope });
|
|
125
|
+
setDraft(before);
|
|
126
|
+
setProblem("");
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const removeEvent = (scope: RecurrenceScope | undefined) => {
|
|
130
|
+
setAsking(undefined);
|
|
131
|
+
void deleteEvent(write(scope)).then((outcome) =>
|
|
132
|
+
settle(outcome, closeEvent),
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const saveEdit = () => {
|
|
137
|
+
if (!editing || !draft) return;
|
|
138
|
+
const patch = patchFromDrafts(
|
|
139
|
+
editing.before,
|
|
140
|
+
draft,
|
|
141
|
+
timeZoneByCalendarId[draft.calendarId] ?? UNZONED_CALENDAR,
|
|
142
|
+
);
|
|
143
|
+
if (!patch.ok) {
|
|
144
|
+
setProblem(patch.problem);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
void updateEvent(write(editing.scope), patch.patch).then((outcome) =>
|
|
148
|
+
settle(outcome, () => {
|
|
149
|
+
setEditing(undefined);
|
|
150
|
+
setDraft(undefined);
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (asking !== undefined && event) {
|
|
156
|
+
return (
|
|
157
|
+
<div className="flex h-full flex-col justify-center bg-surface px-row-inset">
|
|
158
|
+
<RecurrenceScopePrompt
|
|
159
|
+
className="mx-auto w-full max-w-md"
|
|
160
|
+
title={event.title}
|
|
161
|
+
ruleText={repeat === "" ? "It repeats" : repeat}
|
|
162
|
+
instanceText={formatEventWhen(event)}
|
|
163
|
+
onChoose={asking === "edit" ? startEdit : removeEvent}
|
|
164
|
+
onCancel={() => setAsking(undefined)}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (editing && draft) {
|
|
171
|
+
return (
|
|
172
|
+
<CalendarComposePane
|
|
173
|
+
title="Edit event"
|
|
174
|
+
subtitle={
|
|
175
|
+
editing.scope === "this" ? "This occurrence only" : draft.date
|
|
176
|
+
}
|
|
177
|
+
calendars={calendars}
|
|
178
|
+
draft={draft}
|
|
179
|
+
onChange={setDraft}
|
|
180
|
+
problem={problem}
|
|
181
|
+
saveLabel="Save"
|
|
182
|
+
isSaving={isWriting}
|
|
183
|
+
repeatEditable={editing.scope === undefined || editing.scope === "all"}
|
|
184
|
+
calendarEditable={false}
|
|
185
|
+
onSave={saveEdit}
|
|
186
|
+
onCancel={() => {
|
|
187
|
+
setEditing(undefined);
|
|
188
|
+
setDraft(undefined);
|
|
189
|
+
setProblem("");
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<CalendarEventPane
|
|
197
|
+
event={
|
|
198
|
+
event && {
|
|
199
|
+
...event,
|
|
200
|
+
location: stored?.location ?? "",
|
|
201
|
+
notes: stored?.description ?? "",
|
|
202
|
+
recurrenceRule: repeat,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
calendar={calendar}
|
|
206
|
+
isOccurrence={recurrenceId !== undefined}
|
|
207
|
+
problem={problem}
|
|
208
|
+
onEdit={
|
|
209
|
+
writable
|
|
210
|
+
? () => (canScope ? setAsking("edit") : startEdit(undefined))
|
|
211
|
+
: undefined
|
|
212
|
+
}
|
|
213
|
+
onDelete={
|
|
214
|
+
writable
|
|
215
|
+
? () => (canScope ? setAsking("delete") : removeEvent(undefined))
|
|
216
|
+
: undefined
|
|
217
|
+
}
|
|
218
|
+
onClose={closeEvent}
|
|
219
|
+
/>
|
|
220
|
+
);
|
|
221
|
+
}
|