@remit/web-client 0.0.232 → 0.0.233
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/AgendaStrip.stories.tsx +263 -0
- package/src/components/calendar/AgendaStrip.tsx +145 -0
- package/src/components/calendar/AgendaView.tsx +222 -0
- package/src/components/calendar/CalendarViewPlaceholder.tsx +3 -4
- package/src/components/calendar/CalendarWorkspace.stories.tsx +12 -1
- package/src/components/calendar/CalendarWorkspace.tsx +56 -38
- package/src/components/calendar/agenda-address.render.test.ts +389 -0
- package/src/components/calendar/agenda-strip.render.test.ts +241 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +13 -6
- package/src/hooks/calendar/agenda-window.test.ts +235 -0
- package/src/hooks/calendar/agenda-window.ts +183 -0
- package/src/hooks/calendar/index.ts +29 -0
- package/src/hooks/calendar/selection.ts +107 -0
- package/src/hooks/calendar/useCalendarEvents.ts +74 -3
- package/src/hooks/calendar/useCalendarFreeBusy.ts +80 -0
- package/src/hooks/calendar/window.ts +8 -3
- package/src/hooks/useCalendarData.ts +27 -53
- package/src/lib/calendar-density.ts +13 -1
- package/src/lib/calendar-route.test.ts +20 -1
- package/src/lib/calendar-route.ts +14 -4
- package/src/routes/calendar/$view.$date.tsx +9 -1
- package/src/routing/calendar.ts +17 -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.233",
|
|
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": {
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildCalendarDay,
|
|
3
|
+
type CalendarDescriptor,
|
|
4
|
+
type CalendarEventData,
|
|
5
|
+
type Density,
|
|
6
|
+
datesBetween,
|
|
7
|
+
freeStretchesOn,
|
|
8
|
+
} from "@remit/ui";
|
|
9
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import { freeStretchesByDate } from "@/hooks/calendar";
|
|
12
|
+
import { AgendaStrip } from "./AgendaStrip";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The agenda at every state the shipping strip can be in.
|
|
16
|
+
*
|
|
17
|
+
* It is presentational — the days it holds and the day it is anchored to are
|
|
18
|
+
* the address's, and `AgendaView` turns each move here into one — so a story
|
|
19
|
+
* drives it with local state and gets the surface the app renders. What the
|
|
20
|
+
* stories answer is what the markup cannot: whether a fortnight with two things
|
|
21
|
+
* in it reads as a fortnight rather than as a bug, and whether "your Thursday
|
|
22
|
+
* afternoon is clear" survives being said about a calendar that is switched off.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const TODAY = "2026-06-10";
|
|
26
|
+
const OFFSET = "+02:00";
|
|
27
|
+
const NOW = `${TODAY}T09:30:00${OFFSET}`;
|
|
28
|
+
|
|
29
|
+
const WORK = "cal_work";
|
|
30
|
+
const TEAM = "cal_team";
|
|
31
|
+
const HOME = "cal_home";
|
|
32
|
+
|
|
33
|
+
const calendars: CalendarDescriptor[] = [
|
|
34
|
+
{
|
|
35
|
+
id: WORK,
|
|
36
|
+
accountId: "acct_work",
|
|
37
|
+
accountLabel: "Work",
|
|
38
|
+
name: "Northwind",
|
|
39
|
+
color: "cal-1",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: TEAM,
|
|
43
|
+
accountId: "acct_work",
|
|
44
|
+
accountLabel: "Work",
|
|
45
|
+
name: "Platform",
|
|
46
|
+
color: "cal-6",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: HOME,
|
|
50
|
+
accountId: "acct_home",
|
|
51
|
+
accountLabel: "Personal",
|
|
52
|
+
name: "Home",
|
|
53
|
+
color: "cal-4",
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const template: CalendarEventData = {
|
|
58
|
+
id: "",
|
|
59
|
+
calendarId: WORK,
|
|
60
|
+
title: "",
|
|
61
|
+
start: "",
|
|
62
|
+
end: "",
|
|
63
|
+
allDay: false,
|
|
64
|
+
location: "",
|
|
65
|
+
notes: "",
|
|
66
|
+
attendees: [],
|
|
67
|
+
myRsvp: "accepted",
|
|
68
|
+
threadId: "",
|
|
69
|
+
threadSubject: "",
|
|
70
|
+
timeZone: "Europe/Amsterdam",
|
|
71
|
+
zoneCertainty: "explicit",
|
|
72
|
+
recurrenceRule: "",
|
|
73
|
+
seriesId: "",
|
|
74
|
+
seriesException: false,
|
|
75
|
+
status: "confirmed",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const at = (
|
|
79
|
+
id: string,
|
|
80
|
+
title: string,
|
|
81
|
+
date: string,
|
|
82
|
+
from: string,
|
|
83
|
+
to: string,
|
|
84
|
+
over: Partial<CalendarEventData> = {},
|
|
85
|
+
): CalendarEventData => ({
|
|
86
|
+
...template,
|
|
87
|
+
...over,
|
|
88
|
+
id,
|
|
89
|
+
title,
|
|
90
|
+
start: `${date}T${from}:00${OFFSET}`,
|
|
91
|
+
end: `${date}T${to}:00${OFFSET}`,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A fortnight that is mostly empty, which is what a real fortnight looks like.
|
|
96
|
+
* Two busy days, a pile-up on one of them, and a long quiet run in the middle:
|
|
97
|
+
* the shape the strip exists to draw and a time grid spends five screens on.
|
|
98
|
+
*/
|
|
99
|
+
const fortnight: CalendarEventData[] = [
|
|
100
|
+
at("standup", "Standup", TODAY, "09:15", "09:30", {
|
|
101
|
+
recurrenceRule: "Repeats",
|
|
102
|
+
seriesId: "ser_standup",
|
|
103
|
+
}),
|
|
104
|
+
at("roadmap", "Q3 roadmap review", TODAY, "10:00", "11:30", {
|
|
105
|
+
calendarId: TEAM,
|
|
106
|
+
location: "Kaap",
|
|
107
|
+
}),
|
|
108
|
+
at("incident", "Incident review", TODAY, "10:30", "12:00"),
|
|
109
|
+
at("dentist", "Dentist", "2026-06-11", "11:00", "12:00", {
|
|
110
|
+
calendarId: HOME,
|
|
111
|
+
location: "Kerkstraat 4",
|
|
112
|
+
}),
|
|
113
|
+
at("retro", "Retro", "2026-06-18", "16:00", "17:00", { calendarId: TEAM }),
|
|
114
|
+
at("climbing", "Climbing", "2026-06-20", "10:00", "12:00", {
|
|
115
|
+
calendarId: HOME,
|
|
116
|
+
}),
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One day with an hour free in it and nothing else: what Detail is for, and the
|
|
121
|
+
* reading a phone gets when the reader is actually in a day.
|
|
122
|
+
*/
|
|
123
|
+
const denseDay: CalendarEventData[] = [
|
|
124
|
+
at("s", "Standup", TODAY, "09:00", "09:15"),
|
|
125
|
+
at("d1", "Design review", TODAY, "09:30", "11:00", { calendarId: TEAM }),
|
|
126
|
+
at("d2", "Supplier call", TODAY, "11:00", "12:00", { location: "Meet" }),
|
|
127
|
+
at("d3", "One to one", TODAY, "13:30", "14:00", { location: "Room 2" }),
|
|
128
|
+
at("d4", "Hiring panel", TODAY, "14:00", "15:30", { calendarId: TEAM }),
|
|
129
|
+
at("d5", "Board prep", TODAY, "15:30", "17:00"),
|
|
130
|
+
at("d6", "School run", TODAY, "17:00", "18:00", { calendarId: HOME }),
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const dates = datesBetween("2026-06-01", "2026-06-24");
|
|
134
|
+
|
|
135
|
+
const pendingDates = (
|
|
136
|
+
loading: "none" | "all" | "week",
|
|
137
|
+
): ReadonlySet<string> => {
|
|
138
|
+
if (loading === "all") return new Set(dates);
|
|
139
|
+
if (loading === "week")
|
|
140
|
+
return new Set(datesBetween("2026-06-15", "2026-06-21"));
|
|
141
|
+
return new Set();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** The reader drives density; everything else is the address's in the app. */
|
|
145
|
+
function Strip({
|
|
146
|
+
events,
|
|
147
|
+
initialDensity,
|
|
148
|
+
loading,
|
|
149
|
+
error,
|
|
150
|
+
busy,
|
|
151
|
+
}: {
|
|
152
|
+
events: CalendarEventData[];
|
|
153
|
+
initialDensity: Density;
|
|
154
|
+
/** Which days have not answered: "all", "none", or one week of them. */
|
|
155
|
+
loading: "none" | "all" | "week";
|
|
156
|
+
error?: unknown;
|
|
157
|
+
/** Busy time the strip is not drawing, as `/calendar-free-busy` returns it. */
|
|
158
|
+
busy: { start: string; end: string }[];
|
|
159
|
+
}) {
|
|
160
|
+
const [density] = useState<Density>(initialDensity);
|
|
161
|
+
const [selected, setSelected] = useState("");
|
|
162
|
+
const days = dates.map((date) => buildCalendarDay(date, events, TODAY));
|
|
163
|
+
const measured = freeStretchesByDate(dates, busy);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<AgendaStrip
|
|
167
|
+
days={days}
|
|
168
|
+
calendars={calendars}
|
|
169
|
+
density={density}
|
|
170
|
+
today={TODAY}
|
|
171
|
+
anchorDate={TODAY}
|
|
172
|
+
now={NOW}
|
|
173
|
+
selectedEventId={selected}
|
|
174
|
+
freeOn={(day) =>
|
|
175
|
+
busy.length === 0
|
|
176
|
+
? freeStretchesOn(day)
|
|
177
|
+
: (measured.get(day.date) ?? freeStretchesOn(day))
|
|
178
|
+
}
|
|
179
|
+
loadingDates={pendingDates(loading)}
|
|
180
|
+
error={error}
|
|
181
|
+
onRetry={() => undefined}
|
|
182
|
+
onSelectEvent={setSelected}
|
|
183
|
+
onPickSlot={() => undefined}
|
|
184
|
+
onZoomDay={() => undefined}
|
|
185
|
+
onGoToDate={() => undefined}
|
|
186
|
+
onReachStart={() => undefined}
|
|
187
|
+
onReachEnd={() => undefined}
|
|
188
|
+
onVisibleDayChange={() => undefined}
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const meta: Meta<typeof Strip> = {
|
|
194
|
+
title: "App/Calendar/Agenda",
|
|
195
|
+
component: Strip,
|
|
196
|
+
parameters: { layout: "fullscreen" },
|
|
197
|
+
args: {
|
|
198
|
+
events: fortnight,
|
|
199
|
+
initialDensity: "comfortable",
|
|
200
|
+
loading: "none",
|
|
201
|
+
busy: [],
|
|
202
|
+
},
|
|
203
|
+
render: (args) => (
|
|
204
|
+
<div className="flex h-dvh flex-col bg-canvas">
|
|
205
|
+
<Strip {...args} />
|
|
206
|
+
</div>
|
|
207
|
+
),
|
|
208
|
+
};
|
|
209
|
+
export default meta;
|
|
210
|
+
|
|
211
|
+
type Story = StoryObj<typeof Strip>;
|
|
212
|
+
|
|
213
|
+
/** A fortnight: two busy days, a pile-up, and the quiet in between named. */
|
|
214
|
+
export const PopulatedFortnight: Story = {};
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Before any week has answered. Every day is a skeleton on the date it will
|
|
218
|
+
* occupy, so nothing jumps when the answers land — and, crucially, this does
|
|
219
|
+
* not read as a fortnight with nothing booked.
|
|
220
|
+
*/
|
|
221
|
+
export const Loading: Story = { args: { loading: "all", events: [] } };
|
|
222
|
+
|
|
223
|
+
/** One week still in flight, with the answered days around it drawn. */
|
|
224
|
+
export const OneWeekStillLoading: Story = { args: { loading: "week" } };
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Nothing booked for weeks. The claim this view makes is that one sentence is a
|
|
228
|
+
* better answer to "am I free" than a fortnight of blank screens.
|
|
229
|
+
*/
|
|
230
|
+
export const EmptyRunCollapsed: Story = { args: { events: [] } };
|
|
231
|
+
|
|
232
|
+
/** One day, back to back, at the reading that spends a row on each. */
|
|
233
|
+
export const DenseDay: Story = { args: { events: denseDay } };
|
|
234
|
+
|
|
235
|
+
/** The same dense day at Glance, where a title still has to be readable. */
|
|
236
|
+
export const DenseDayAtAGlance: Story = {
|
|
237
|
+
args: { events: denseDay, initialDensity: "compact" },
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The reader has unticked the calendar those meetings are on, so the strip
|
|
242
|
+
* draws nothing on the 11th. They are booked all the same, and the free bands
|
|
243
|
+
* come from the merged busy spans rather than from the rows on screen.
|
|
244
|
+
*/
|
|
245
|
+
export const BusyOnACalendarNotDrawn: Story = {
|
|
246
|
+
args: {
|
|
247
|
+
events: [],
|
|
248
|
+
busy: [
|
|
249
|
+
{
|
|
250
|
+
start: `2026-06-11T09:00:00${OFFSET}`,
|
|
251
|
+
end: `2026-06-11T16:00:00${OFFSET}`,
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/** A read that was refused. An empty strip would say the opposite of this. */
|
|
258
|
+
export const CouldNotLoad: Story = {
|
|
259
|
+
args: {
|
|
260
|
+
events: [],
|
|
261
|
+
error: new Error("The window has to be shorter than a year."),
|
|
262
|
+
},
|
|
263
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AgendaFlow,
|
|
3
|
+
type AgendaScrollTarget,
|
|
4
|
+
type CalendarDay,
|
|
5
|
+
type CalendarDescriptor,
|
|
6
|
+
type CalendarSlotPick,
|
|
7
|
+
type Density,
|
|
8
|
+
NextUpCard,
|
|
9
|
+
readNextUp,
|
|
10
|
+
} from "@remit/ui";
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { ErrorState } from "@/components/ui/ErrorState";
|
|
13
|
+
import type { FreeLookup } from "@/hooks/calendar";
|
|
14
|
+
import { agendaDensityOf } from "@/lib/calendar-density";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The agenda, drawn.
|
|
18
|
+
*
|
|
19
|
+
* Presentational, like the rest of the calendar pane: it takes the days it was
|
|
20
|
+
* given, says which one is under the header, and asks for more at either end.
|
|
21
|
+
* What it never does is decide which days those are — the address owns that, and
|
|
22
|
+
* `AgendaView` turns each answer here into one.
|
|
23
|
+
*
|
|
24
|
+
* "What is next" leads today rather than sitting in a rail of its own. It is
|
|
25
|
+
* the question a strip is for, and putting it above today means the reader
|
|
26
|
+
* lands on it and scrolls past it, instead of reading it in a column that is
|
|
27
|
+
* still there when they are three weeks away.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface AgendaStripProps {
|
|
31
|
+
/** Contiguous and ascending. */
|
|
32
|
+
days: CalendarDay[];
|
|
33
|
+
calendars: readonly CalendarDescriptor[];
|
|
34
|
+
/** The device preference the whole calendar reads. */
|
|
35
|
+
density: Density;
|
|
36
|
+
today: string;
|
|
37
|
+
/** The day the address names; never collapsed into a run. */
|
|
38
|
+
anchorDate: string;
|
|
39
|
+
/** The instant "what is next" is measured from. */
|
|
40
|
+
now: string;
|
|
41
|
+
selectedEventId: string;
|
|
42
|
+
/** What is still open on a day, measured off merged busy time. */
|
|
43
|
+
freeOn: FreeLookup;
|
|
44
|
+
/**
|
|
45
|
+
* Days whose week has not answered yet. They draw as skeletons: a day
|
|
46
|
+
* nobody has heard back about and a day with nothing on it are otherwise
|
|
47
|
+
* the same picture, and the strip would be claiming a free fortnight it has
|
|
48
|
+
* no grounds for.
|
|
49
|
+
*/
|
|
50
|
+
loadingDates: ReadonlySet<string>;
|
|
51
|
+
/**
|
|
52
|
+
* A refusal stated in place of the strip. A diary drawn empty because the
|
|
53
|
+
* read failed is indistinguishable from a diary with nothing in it, which is
|
|
54
|
+
* the one thing this must never look like.
|
|
55
|
+
*/
|
|
56
|
+
error?: unknown;
|
|
57
|
+
onRetry?: () => void;
|
|
58
|
+
scrollTarget?: AgendaScrollTarget;
|
|
59
|
+
onSelectEvent: (eventId: string) => void;
|
|
60
|
+
onPickSlot: (pick: CalendarSlotPick) => void;
|
|
61
|
+
/** Drops out of the list into the day grid. */
|
|
62
|
+
onZoomDay: (date: string) => void;
|
|
63
|
+
onGoToDate: (date: string) => void;
|
|
64
|
+
onReachStart: () => void;
|
|
65
|
+
onReachEnd: () => void;
|
|
66
|
+
/** The day under the sticky header, which the address follows. */
|
|
67
|
+
onVisibleDayChange: (date: string) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function AgendaStrip({
|
|
71
|
+
days,
|
|
72
|
+
calendars,
|
|
73
|
+
density,
|
|
74
|
+
today,
|
|
75
|
+
anchorDate,
|
|
76
|
+
now,
|
|
77
|
+
selectedEventId,
|
|
78
|
+
freeOn,
|
|
79
|
+
loadingDates,
|
|
80
|
+
error,
|
|
81
|
+
onRetry,
|
|
82
|
+
scrollTarget,
|
|
83
|
+
onSelectEvent,
|
|
84
|
+
onPickSlot,
|
|
85
|
+
onZoomDay,
|
|
86
|
+
onGoToDate,
|
|
87
|
+
onReachStart,
|
|
88
|
+
onReachEnd,
|
|
89
|
+
onVisibleDayChange,
|
|
90
|
+
}: AgendaStripProps) {
|
|
91
|
+
const nextUp = useMemo(() => readNextUp(days, now), [days, now]);
|
|
92
|
+
/*
|
|
93
|
+
* "What is next" is an answer about days that have arrived. Drawn over a
|
|
94
|
+
* today whose week is still out it reads "Nothing else booked", which is a
|
|
95
|
+
* claim rather than a delay.
|
|
96
|
+
*/
|
|
97
|
+
const settledToday = !loadingDates.has(today);
|
|
98
|
+
|
|
99
|
+
if (error !== undefined && error !== null)
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex h-full items-center justify-center bg-surface">
|
|
102
|
+
<ErrorState
|
|
103
|
+
title="Couldn't load these days"
|
|
104
|
+
error={error}
|
|
105
|
+
onRetry={onRetry}
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
/* No spinner over the whole strip. Days already fetched stay put while their
|
|
111
|
+
neighbours arrive, and a day still outstanding says so where it will be
|
|
112
|
+
drawn — which is also what stops the rows jumping when it lands. */
|
|
113
|
+
return (
|
|
114
|
+
<AgendaFlow
|
|
115
|
+
days={days}
|
|
116
|
+
calendars={calendars}
|
|
117
|
+
density={agendaDensityOf(density)}
|
|
118
|
+
today={today}
|
|
119
|
+
focusDate={anchorDate}
|
|
120
|
+
selectedEventId={selectedEventId}
|
|
121
|
+
freeOn={freeOn}
|
|
122
|
+
loadingDates={loadingDates}
|
|
123
|
+
scrollTarget={scrollTarget}
|
|
124
|
+
onSelectEvent={onSelectEvent}
|
|
125
|
+
onPickSlot={onPickSlot}
|
|
126
|
+
onZoomDay={onZoomDay}
|
|
127
|
+
onReachStart={onReachStart}
|
|
128
|
+
onReachEnd={onReachEnd}
|
|
129
|
+
onVisibleDayChange={onVisibleDayChange}
|
|
130
|
+
todayLead={
|
|
131
|
+
settledToday ? (
|
|
132
|
+
<div className="border-b border-line bg-surface-sunken p-3">
|
|
133
|
+
<NextUpCard
|
|
134
|
+
nextUp={nextUp}
|
|
135
|
+
calendars={calendars}
|
|
136
|
+
today={today}
|
|
137
|
+
onSelectEvent={onSelectEvent}
|
|
138
|
+
onGoTo={onGoToDate}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
) : undefined
|
|
142
|
+
}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgendaScrollTarget,
|
|
3
|
+
buildCalendarDay,
|
|
4
|
+
type CalendarDay,
|
|
5
|
+
type CalendarSlotPick,
|
|
6
|
+
type Density,
|
|
7
|
+
freeStretchesOn,
|
|
8
|
+
} from "@remit/ui";
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import { AgendaStrip } from "@/components/calendar/AgendaStrip";
|
|
11
|
+
import {
|
|
12
|
+
type AgendaRange,
|
|
13
|
+
calendarInstanceId,
|
|
14
|
+
datesInRange,
|
|
15
|
+
extendRangeEnd,
|
|
16
|
+
extendRangeStart,
|
|
17
|
+
freeStretchesByDate,
|
|
18
|
+
rangeAround,
|
|
19
|
+
rangeCovering,
|
|
20
|
+
readCalendarInstanceId,
|
|
21
|
+
useCalendarEventWeeks,
|
|
22
|
+
useCalendarFreeBusyWeeks,
|
|
23
|
+
useCalendarSelection,
|
|
24
|
+
useDrawnEvents,
|
|
25
|
+
weekKeyOf,
|
|
26
|
+
weekWindowsOver,
|
|
27
|
+
} from "@/hooks/calendar";
|
|
28
|
+
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
29
|
+
import { isoDate } from "@/lib/calendar-route";
|
|
30
|
+
import {
|
|
31
|
+
useCalendarAddress,
|
|
32
|
+
useCalendarNavigation,
|
|
33
|
+
useOpenCalendarEvent,
|
|
34
|
+
} from "@/routing";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The agenda, bound to the address.
|
|
38
|
+
*
|
|
39
|
+
* The strip is the same continuous run of time the grid draws, at a zoom that
|
|
40
|
+
* does not spend pixels on empty hours. Two facts of the address drive it and
|
|
41
|
+
* nothing else does: the day it opened on, which the strip lands on, and the
|
|
42
|
+
* calendars ticked in the query, which narrow what it draws. Switching to the
|
|
43
|
+
* week grid and back changes neither, which is what "the grid is a zoom level
|
|
44
|
+
* you drop into and leave" means in practice.
|
|
45
|
+
*
|
|
46
|
+
* The days it holds are fetched a week at a time, on the grid's own cache keys.
|
|
47
|
+
* That is what makes the range able to grow without limit — a single read may
|
|
48
|
+
* not cover more than a year, and one request that widened every time the
|
|
49
|
+
* reader reached an end would eventually be refused and replace the strip with
|
|
50
|
+
* the refusal — and it is what makes the two zooms share everything they have
|
|
51
|
+
* already read.
|
|
52
|
+
*
|
|
53
|
+
* Scrolling writes the day back to the address, debounced and by `replace`.
|
|
54
|
+
* Debounced because a flick past a fortnight is one move rather than fourteen,
|
|
55
|
+
* and `replace` because scrolling is not somewhere a reader went — Back belongs
|
|
56
|
+
* to the screen they came from, not to the row they scrolled past.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/** Long enough that a flick lands before the address moves. */
|
|
60
|
+
export const ANCHOR_DEBOUNCE_MS = 400;
|
|
61
|
+
|
|
62
|
+
export interface AgendaViewProps {
|
|
63
|
+
/** The device preference the whole calendar reads. */
|
|
64
|
+
density: Density;
|
|
65
|
+
/**
|
|
66
|
+
* An empty slot the reader picked. The composer is seeded by the route, the
|
|
67
|
+
* same way the grid's picks are, so a slot picked off a free band and one
|
|
68
|
+
* dragged on the grid open the same form.
|
|
69
|
+
*/
|
|
70
|
+
onPickSlot: (pick: CalendarSlotPick) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function AgendaView({ density, onPickSlot }: AgendaViewProps) {
|
|
74
|
+
const { date, calendarIds } = useCalendarAddress();
|
|
75
|
+
const { openEvent, setAnchorDate, zoomToDay } = useCalendarNavigation();
|
|
76
|
+
const opened = useOpenCalendarEvent();
|
|
77
|
+
|
|
78
|
+
// One reading of the clock per mount. A strip that re-decided what "today"
|
|
79
|
+
// meant on every render would move the day it is anchored to under a reader
|
|
80
|
+
// who had not touched it.
|
|
81
|
+
const [clock] = useState(() => new Date());
|
|
82
|
+
const today = useMemo(() => isoDate(clock), [clock]);
|
|
83
|
+
const now = useMemo(() => clock.toISOString(), [clock]);
|
|
84
|
+
|
|
85
|
+
const [range, setRange] = useState<AgendaRange>(() => rangeAround(date));
|
|
86
|
+
const [scrollTarget, setScrollTarget] = useState<AgendaScrollTarget>();
|
|
87
|
+
const [visibleDate, setVisibleDate] = useState(date);
|
|
88
|
+
const jumps = useRef(0);
|
|
89
|
+
/*
|
|
90
|
+
* The day this view last put in the address. A date arriving that matches it
|
|
91
|
+
* is our own write coming back, and landing the strip on that would fight the
|
|
92
|
+
* scroll that caused it.
|
|
93
|
+
*/
|
|
94
|
+
const written = useRef(date);
|
|
95
|
+
|
|
96
|
+
const land = useCallback((day: string) => {
|
|
97
|
+
written.current = day;
|
|
98
|
+
setVisibleDate(day);
|
|
99
|
+
setRange((previous) => rangeCovering(previous, day));
|
|
100
|
+
jumps.current += 1;
|
|
101
|
+
setScrollTarget({ date: day, token: jumps.current });
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (date === written.current) return;
|
|
106
|
+
land(date);
|
|
107
|
+
}, [date, land]);
|
|
108
|
+
|
|
109
|
+
const anchor = useDebouncedValue(visibleDate, ANCHOR_DEBOUNCE_MS);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (anchor === written.current) return;
|
|
112
|
+
written.current = anchor;
|
|
113
|
+
setAnchorDate(anchor);
|
|
114
|
+
}, [anchor, setAnchorDate]);
|
|
115
|
+
|
|
116
|
+
const dates = useMemo(() => datesInRange(range), [range]);
|
|
117
|
+
const windows = useMemo(() => weekWindowsOver(dates), [dates]);
|
|
118
|
+
|
|
119
|
+
const {
|
|
120
|
+
calendars,
|
|
121
|
+
timeZoneByCalendarId,
|
|
122
|
+
shown,
|
|
123
|
+
narrowed,
|
|
124
|
+
resolved,
|
|
125
|
+
isLoading: loadingCalendars,
|
|
126
|
+
} = useCalendarSelection(calendarIds);
|
|
127
|
+
|
|
128
|
+
const events = useCalendarEventWeeks(windows, narrowed, resolved);
|
|
129
|
+
const busy = useCalendarFreeBusyWeeks(windows);
|
|
130
|
+
const drawn = useDrawnEvents(events.instances, timeZoneByCalendarId, shown);
|
|
131
|
+
|
|
132
|
+
const days = useMemo(
|
|
133
|
+
() => dates.map((day) => buildCalendarDay(day, drawn, today)),
|
|
134
|
+
[dates, drawn, today],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
/*
|
|
138
|
+
* A day whose week has not answered yet, or whose calendars are not known.
|
|
139
|
+
* It draws as a skeleton rather than as a day with nothing on it, because
|
|
140
|
+
* those two pictures are otherwise the same one and only one of them is true.
|
|
141
|
+
*/
|
|
142
|
+
const loadingDates = useMemo(() => {
|
|
143
|
+
const pending = new Set<string>();
|
|
144
|
+
if (loadingCalendars || !resolved) return new Set(dates);
|
|
145
|
+
for (const day of dates) {
|
|
146
|
+
if (events.pendingWindows.has(weekKeyOf(day))) pending.add(day);
|
|
147
|
+
}
|
|
148
|
+
return pending;
|
|
149
|
+
}, [dates, events.pendingWindows, loadingCalendars, resolved]);
|
|
150
|
+
|
|
151
|
+
/*
|
|
152
|
+
* Free time comes from the merged busy spans, which cover every calendar the
|
|
153
|
+
* reader holds rather than the ones the strip is drawing. A week whose spans
|
|
154
|
+
* have not landed falls back to the gaps between its own rows: calling an
|
|
155
|
+
* afternoon clear on the strength of nothing at all is the one mistake this
|
|
156
|
+
* view exists to avoid.
|
|
157
|
+
*/
|
|
158
|
+
const freeByDate = useMemo(
|
|
159
|
+
() => freeStretchesByDate(dates, busy.spans),
|
|
160
|
+
[dates, busy.spans],
|
|
161
|
+
);
|
|
162
|
+
const freeOn = useCallback(
|
|
163
|
+
(day: CalendarDay) =>
|
|
164
|
+
busy.pendingWindows.has(weekKeyOf(day.date))
|
|
165
|
+
? freeStretchesOn(day)
|
|
166
|
+
: (freeByDate.get(day.date) ?? freeStretchesOn(day)),
|
|
167
|
+
[freeByDate, busy.pendingWindows],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const goToDate = useCallback(
|
|
171
|
+
(day: string) => {
|
|
172
|
+
land(day);
|
|
173
|
+
setAnchorDate(day);
|
|
174
|
+
},
|
|
175
|
+
[land, setAnchorDate],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const selectEvent = useCallback(
|
|
179
|
+
(eventId: string) => {
|
|
180
|
+
const instance = readCalendarInstanceId(eventId);
|
|
181
|
+
openEvent(instance.calendarObjectId, instance.recurrenceId);
|
|
182
|
+
},
|
|
183
|
+
[openEvent],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
/** Whichever read was refused is the one a retry re-sends. */
|
|
187
|
+
const retry = useCallback(() => {
|
|
188
|
+
events.refetch();
|
|
189
|
+
busy.refetch();
|
|
190
|
+
}, [events.refetch, busy.refetch]);
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<AgendaStrip
|
|
194
|
+
days={days}
|
|
195
|
+
calendars={calendars}
|
|
196
|
+
density={density}
|
|
197
|
+
today={today}
|
|
198
|
+
anchorDate={date}
|
|
199
|
+
now={now}
|
|
200
|
+
selectedEventId={
|
|
201
|
+
opened
|
|
202
|
+
? calendarInstanceId(
|
|
203
|
+
opened.calendarObjectId,
|
|
204
|
+
opened.recurrenceId ?? "",
|
|
205
|
+
)
|
|
206
|
+
: ""
|
|
207
|
+
}
|
|
208
|
+
freeOn={freeOn}
|
|
209
|
+
loadingDates={loadingDates}
|
|
210
|
+
error={events.error ?? busy.error}
|
|
211
|
+
onRetry={retry}
|
|
212
|
+
scrollTarget={scrollTarget}
|
|
213
|
+
onSelectEvent={selectEvent}
|
|
214
|
+
onPickSlot={onPickSlot}
|
|
215
|
+
onZoomDay={zoomToDay}
|
|
216
|
+
onGoToDate={goToDate}
|
|
217
|
+
onReachStart={() => setRange(extendRangeStart)}
|
|
218
|
+
onReachEnd={() => setRange(extendRangeEnd)}
|
|
219
|
+
onVisibleDayChange={setVisibleDate}
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -4,14 +4,13 @@ import { CalendarRange } from "lucide-react";
|
|
|
4
4
|
/**
|
|
5
5
|
* A zoom level that is named but not drawn yet.
|
|
6
6
|
*
|
|
7
|
-
* The ladder is five steps and
|
|
7
|
+
* The ladder is five steps and two of them arrive later, so the route stays
|
|
8
8
|
* addressable and says what it is waiting on. A view that silently rendered
|
|
9
9
|
* nothing would be indistinguishable from a week with nothing in it.
|
|
10
10
|
*/
|
|
11
11
|
const WAITING_ON: Record<string, string> = {
|
|
12
12
|
year: "The year grid arrives with the rest of the zoom ladder.",
|
|
13
13
|
month: "The month grid arrives with the rest of the zoom ladder.",
|
|
14
|
-
agenda: "The agenda arrives with the scrolling day strip.",
|
|
15
14
|
};
|
|
16
15
|
|
|
17
16
|
export interface CalendarViewPlaceholderProps {
|
|
@@ -29,8 +28,8 @@ export function CalendarViewPlaceholder({
|
|
|
29
28
|
<CalendarRange className="size-8 text-fg-subtle" aria-hidden="true" />
|
|
30
29
|
<p className="text-sm font-medium text-fg">Not built yet</p>
|
|
31
30
|
<p className="max-w-xs text-sm text-fg-muted">
|
|
32
|
-
{WAITING_ON[view] ?? "This view arrives in a later stage."} Week
|
|
33
|
-
work now.
|
|
31
|
+
{WAITING_ON[view] ?? "This view arrives in a later stage."} Week, Day
|
|
32
|
+
and Agenda work now.
|
|
34
33
|
</p>
|
|
35
34
|
</div>
|
|
36
35
|
);
|
|
@@ -18,6 +18,9 @@ import { CalendarWorkspace } from "./CalendarWorkspace";
|
|
|
18
18
|
* state and get the same surface the app renders. What the stories are for is
|
|
19
19
|
* what the markup cannot answer — how a week reads once a day is full, and
|
|
20
20
|
* whether a zoom that is not drawn yet reads as unfinished rather than empty.
|
|
21
|
+
*
|
|
22
|
+
* The strip is a slot rather than something this pane builds, so it is storied
|
|
23
|
+
* under App/Calendar/Agenda and stood in for here.
|
|
21
24
|
*/
|
|
22
25
|
const TIME_ZONE = "Europe/Amsterdam";
|
|
23
26
|
const DATE = "2026-06-10";
|
|
@@ -123,6 +126,13 @@ function Workspace({
|
|
|
123
126
|
date={DATE}
|
|
124
127
|
events={events}
|
|
125
128
|
colorByCalendarId={colorByCalendarId}
|
|
129
|
+
// The strip loads its own days and is storied on its own; here it
|
|
130
|
+
// stands in for whatever the route mounts at that zoom.
|
|
131
|
+
agenda={
|
|
132
|
+
<div className="p-4 text-sm text-fg-muted">
|
|
133
|
+
The strip, which App/Calendar/Agenda has to itself.
|
|
134
|
+
</div>
|
|
135
|
+
}
|
|
126
136
|
isLoading={isLoading}
|
|
127
137
|
error={error}
|
|
128
138
|
onRetry={() => undefined}
|
|
@@ -185,7 +195,8 @@ export const YearNotBuiltYet: Story = { args: { initialView: "year" } };
|
|
|
185
195
|
|
|
186
196
|
export const MonthNotBuiltYet: Story = { args: { initialView: "month" } };
|
|
187
197
|
|
|
188
|
-
|
|
198
|
+
/** The zoom that mounts the strip instead of the grid. */
|
|
199
|
+
export const Agenda: Story = { args: { initialView: "agenda" } };
|
|
189
200
|
|
|
190
201
|
/**
|
|
191
202
|
* The calendar beside the nav it is reached from. The entry sits under the
|