@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.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.stories.tsx +115 -0
  3. package/src/components/calendar/CalendarComposePane.tsx +94 -0
  4. package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
  5. package/src/components/calendar/CalendarEventPane.stories.tsx +110 -0
  6. package/src/components/calendar/CalendarEventPane.tsx +90 -0
  7. package/src/components/calendar/CalendarShell.tsx +83 -0
  8. package/src/components/calendar/CalendarViewPlaceholder.tsx +37 -0
  9. package/src/components/calendar/CalendarWorkspace.stories.tsx +219 -0
  10. package/src/components/calendar/CalendarWorkspace.tsx +192 -0
  11. package/src/components/calendar/OpenCalendarEvent.tsx +221 -0
  12. package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
  13. package/src/components/calendar/calendar-workspace.render.test.ts +193 -0
  14. package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
  15. package/src/components/mail/MailSidebarAdapter.tsx +17 -0
  16. package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
  17. package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
  18. package/src/hooks/calendar/draft.test.ts +241 -0
  19. package/src/hooks/calendar/draft.ts +168 -0
  20. package/src/hooks/calendar/index.ts +59 -0
  21. package/src/hooks/calendar/instance.ts +136 -0
  22. package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
  23. package/src/hooks/calendar/recurrence-rule.ts +133 -0
  24. package/src/hooks/calendar/resource.test.ts +98 -0
  25. package/src/hooks/calendar/resource.ts +74 -0
  26. package/src/hooks/calendar/useCalendarEvent.ts +46 -0
  27. package/src/hooks/calendar/useCalendarEvents.ts +124 -0
  28. package/src/hooks/calendar/useCalendarWrites.ts +174 -0
  29. package/src/hooks/calendar/useCalendars.ts +43 -0
  30. package/src/hooks/calendar/window.test.ts +148 -0
  31. package/src/hooks/calendar/window.ts +163 -0
  32. package/src/hooks/useCalendarData.ts +120 -0
  33. package/src/lib/calendar-density.ts +34 -0
  34. package/src/lib/calendar-format.ts +29 -0
  35. package/src/lib/calendar-route.test.ts +210 -0
  36. package/src/lib/calendar-route.ts +187 -0
  37. package/src/lib/search-params.test.ts +77 -0
  38. package/src/lib/search-params.ts +59 -0
  39. package/src/routeTree.gen.ts +199 -0
  40. package/src/router.tsx +5 -0
  41. package/src/routes/calendar/$view.$date/$calendarObjectId/$recurrenceId.tsx +26 -0
  42. package/src/routes/calendar/$view.$date/$calendarObjectId/index.tsx +18 -0
  43. package/src/routes/calendar/$view.$date/$calendarObjectId.tsx +15 -0
  44. package/src/routes/calendar/$view.$date/index.tsx +25 -0
  45. package/src/routes/calendar/$view.$date/new.tsx +20 -0
  46. package/src/routes/calendar/$view.$date.tsx +128 -0
  47. package/src/routes/calendar/-calendar-tree.test.ts +267 -0
  48. package/src/routes/calendar/index.tsx +27 -0
  49. package/src/routes/calendar.tsx +98 -0
  50. package/src/routing/calendar-fragment.test.ts +66 -0
  51. package/src/routing/calendar.ts +150 -0
  52. package/src/routing/fragment.ts +19 -0
  53. package/src/routing/index.ts +10 -0
  54. package/src/routing/nav-selection.ts +2 -0
  55. 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
+ }