@remit/web-client 0.0.232 → 0.0.234
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 +162 -0
- package/src/components/calendar/AgendaView.tsx +241 -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 +493 -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 +308 -0
- package/src/hooks/calendar/agenda-window.ts +223 -0
- package/src/hooks/calendar/index.ts +34 -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
|
@@ -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
|
|
@@ -10,11 +10,14 @@ import {
|
|
|
10
10
|
segmentClassName,
|
|
11
11
|
} from "@remit/ui";
|
|
12
12
|
import { Loader2 } from "lucide-react";
|
|
13
|
-
import { useId, useState } from "react";
|
|
13
|
+
import { type ReactNode, useId, useState } from "react";
|
|
14
14
|
import { CalendarViewPlaceholder } from "@/components/calendar/CalendarViewPlaceholder";
|
|
15
15
|
import { NavMenuButton } from "@/components/mail/NavMenuButton";
|
|
16
16
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
calendarViewMountsAgenda,
|
|
19
|
+
calendarViewMountsGrid,
|
|
20
|
+
} from "@/lib/calendar-route";
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* The calendar's own pane: the toolbar that moves it and whatever the current
|
|
@@ -38,6 +41,12 @@ export interface CalendarWorkspaceProps {
|
|
|
38
41
|
date: string;
|
|
39
42
|
events: CalendarEventData[];
|
|
40
43
|
colorByCalendarId: Record<string, CalendarColorId>;
|
|
44
|
+
/**
|
|
45
|
+
* The strip, at the one zoom that draws it. A slot rather than something
|
|
46
|
+
* built here because it loads its own days over a rolling range, and this
|
|
47
|
+
* pane holds no events and knows no router.
|
|
48
|
+
*/
|
|
49
|
+
agenda: ReactNode;
|
|
41
50
|
/** The first read of this window is still out; the grid has nothing yet. */
|
|
42
51
|
isLoading?: boolean;
|
|
43
52
|
/**
|
|
@@ -89,6 +98,7 @@ export function CalendarWorkspace({
|
|
|
89
98
|
date,
|
|
90
99
|
events,
|
|
91
100
|
colorByCalendarId,
|
|
101
|
+
agenda,
|
|
92
102
|
isLoading = false,
|
|
93
103
|
error,
|
|
94
104
|
onRetry,
|
|
@@ -150,43 +160,51 @@ export function CalendarWorkspace({
|
|
|
150
160
|
</CalendarDateNav>
|
|
151
161
|
</div>
|
|
152
162
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
163
|
+
{/* The strip rolls its own range, so it answers for its own days: the
|
|
164
|
+
week this pane read is not what it is drawing, and gating it on that
|
|
165
|
+
read would blank a strip that has everything it needs. It also
|
|
166
|
+
scrolls itself, so it gets a column to fill rather than a box. */}
|
|
167
|
+
{calendarViewMountsAgenda(view) ? (
|
|
168
|
+
<div className="flex min-h-0 flex-1 flex-col">{agenda}</div>
|
|
169
|
+
) : (
|
|
170
|
+
<div className="min-h-0 flex-1">
|
|
171
|
+
{error !== undefined && error !== null ? (
|
|
172
|
+
<div className="flex h-full items-center justify-center">
|
|
173
|
+
<ErrorState
|
|
174
|
+
title="Couldn't load this week"
|
|
175
|
+
error={error}
|
|
176
|
+
onRetry={onRetry}
|
|
177
|
+
/>
|
|
178
|
+
</div>
|
|
179
|
+
) : isLoading ? (
|
|
180
|
+
<div
|
|
181
|
+
role="status"
|
|
182
|
+
aria-label="Loading the calendar"
|
|
183
|
+
className="flex h-full items-center justify-center bg-surface"
|
|
184
|
+
>
|
|
185
|
+
<Loader2 className="size-6 animate-spin text-fg-subtle" />
|
|
186
|
+
</div>
|
|
187
|
+
) : calendarViewMountsGrid(view) ? (
|
|
188
|
+
<CalendarGrid
|
|
189
|
+
view={view}
|
|
190
|
+
date={date}
|
|
191
|
+
events={events}
|
|
192
|
+
colorByCalendarId={colorByCalendarId}
|
|
193
|
+
density={density}
|
|
194
|
+
selectedEventId={selectedEventId}
|
|
195
|
+
timeZone={timeZone}
|
|
196
|
+
now={now}
|
|
197
|
+
onSelectEvent={onSelectEvent}
|
|
198
|
+
onPickSlot={onPickSlot}
|
|
199
|
+
onRangeChange={(measuredTitle) =>
|
|
200
|
+
setMeasured({ key: addressKey, title: measuredTitle })
|
|
201
|
+
}
|
|
160
202
|
/>
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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>
|
|
203
|
+
) : (
|
|
204
|
+
<CalendarViewPlaceholder view={view} />
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
190
208
|
</div>
|
|
191
209
|
);
|
|
192
210
|
}
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The strip and the address, bound together.
|
|
3
|
+
*
|
|
4
|
+
* Four claims, and every one of them is about the URL rather than about pixels:
|
|
5
|
+
* the strip opens on the day the path names, the reader reaching an end asks
|
|
6
|
+
* for a week more rather than replacing the days on screen, a strip nobody
|
|
7
|
+
* scrolled asks for nothing at all, and the day the reader scrolls to is
|
|
8
|
+
* written by `replace` — scrolling is not somewhere they went, so Back belongs
|
|
9
|
+
* to the screen they arrived from and not to every row they passed. The last
|
|
10
|
+
* one is the reason this suite drives the real router instead of a callback: a
|
|
11
|
+
* `push` here would bury the way out under a fortnight of history entries.
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { afterEach, describe, it } from "node:test";
|
|
15
|
+
import {
|
|
16
|
+
calendarEventOperationsListCalendarEventsQueryKey,
|
|
17
|
+
calendarFreeBusyOperationsListCalendarFreeBusyQueryKey,
|
|
18
|
+
calendarOperationsListCalendarsQueryKey,
|
|
19
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
20
|
+
import {
|
|
21
|
+
type AnyRouter,
|
|
22
|
+
createMemoryHistory,
|
|
23
|
+
createRootRoute,
|
|
24
|
+
createRoute,
|
|
25
|
+
createRouter,
|
|
26
|
+
Outlet,
|
|
27
|
+
RouterProvider,
|
|
28
|
+
} from "@tanstack/react-router";
|
|
29
|
+
import { act, createElement, type ReactNode } from "react";
|
|
30
|
+
import {
|
|
31
|
+
calendarWindow,
|
|
32
|
+
datesInRange,
|
|
33
|
+
extendRangeEnd,
|
|
34
|
+
extendRangeStart,
|
|
35
|
+
rangeAround,
|
|
36
|
+
weekWindowsOver,
|
|
37
|
+
} from "@/hooks/calendar";
|
|
38
|
+
import { useCalendarData } from "@/hooks/useCalendarData";
|
|
39
|
+
import { useCalendarAddress, useCalendarNavigation } from "@/routing";
|
|
40
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
41
|
+
import {
|
|
42
|
+
calendarSearchSchema,
|
|
43
|
+
calendarViewMountsAgenda,
|
|
44
|
+
} from "../../lib/calendar-route";
|
|
45
|
+
import { stringifySearch } from "../../lib/search-params";
|
|
46
|
+
import { AgendaView } from "./AgendaView";
|
|
47
|
+
|
|
48
|
+
const DATE = "2026-06-10";
|
|
49
|
+
const WORK = "cal_work";
|
|
50
|
+
const HOME = "cal_home";
|
|
51
|
+
|
|
52
|
+
const TO_WEEK = "to week";
|
|
53
|
+
|
|
54
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
55
|
+
// `window`.
|
|
56
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
57
|
+
|
|
58
|
+
let harness: DomHarness | undefined;
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
harness?.close();
|
|
62
|
+
harness = undefined;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** The zoom switch's own binding, beside the strip the route mounts. */
|
|
66
|
+
function CalendarLayout() {
|
|
67
|
+
const { goToView } = useCalendarNavigation();
|
|
68
|
+
return createElement(
|
|
69
|
+
"div",
|
|
70
|
+
null,
|
|
71
|
+
createElement(
|
|
72
|
+
"button",
|
|
73
|
+
{
|
|
74
|
+
type: "button",
|
|
75
|
+
"aria-label": TO_WEEK,
|
|
76
|
+
onClick: () => goToView("week"),
|
|
77
|
+
},
|
|
78
|
+
"Week",
|
|
79
|
+
),
|
|
80
|
+
createElement(AgendaView, {
|
|
81
|
+
density: "comfortable",
|
|
82
|
+
onPickSlot: () => undefined,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const testRouter = (
|
|
88
|
+
path: string,
|
|
89
|
+
component: () => ReactNode = CalendarLayout,
|
|
90
|
+
): AnyRouter => {
|
|
91
|
+
const rootRoute = createRootRoute({ component: Outlet });
|
|
92
|
+
const viewRoute = createRoute({
|
|
93
|
+
getParentRoute: () => rootRoute,
|
|
94
|
+
path: "/calendar/$view/$date",
|
|
95
|
+
validateSearch: calendarSearchSchema,
|
|
96
|
+
component,
|
|
97
|
+
});
|
|
98
|
+
// The strip reads whether an event is open, which is a child of this route.
|
|
99
|
+
const eventRoute = createRoute({
|
|
100
|
+
getParentRoute: () => viewRoute,
|
|
101
|
+
path: "$calendarObjectId",
|
|
102
|
+
component: Outlet,
|
|
103
|
+
});
|
|
104
|
+
const instanceRoute = createRoute({
|
|
105
|
+
getParentRoute: () => eventRoute,
|
|
106
|
+
path: "$recurrenceId",
|
|
107
|
+
component: Outlet,
|
|
108
|
+
});
|
|
109
|
+
const composerRoute = createRoute({
|
|
110
|
+
getParentRoute: () => viewRoute,
|
|
111
|
+
path: "new",
|
|
112
|
+
component: Outlet,
|
|
113
|
+
});
|
|
114
|
+
const routeTree = rootRoute.addChildren([
|
|
115
|
+
viewRoute.addChildren([
|
|
116
|
+
eventRoute.addChildren([instanceRoute]),
|
|
117
|
+
composerRoute,
|
|
118
|
+
]),
|
|
119
|
+
]);
|
|
120
|
+
return createRouter({
|
|
121
|
+
routeTree,
|
|
122
|
+
// A list of ticked calendars is repeated params, which is how the app
|
|
123
|
+
// spells one and what a hand-written link says.
|
|
124
|
+
stringifySearch,
|
|
125
|
+
history: createMemoryHistory({ initialEntries: [path] }),
|
|
126
|
+
}) as unknown as AnyRouter;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** How many times a case reaches an end, and so how far the strip can go. */
|
|
130
|
+
const REACHES = 8;
|
|
131
|
+
|
|
132
|
+
/** Every week the strip can reach, given that many pulls at either end. */
|
|
133
|
+
const reachableWeeks = () => {
|
|
134
|
+
let range = rangeAround(DATE);
|
|
135
|
+
for (let reach = 0; reach < REACHES; reach += 1) {
|
|
136
|
+
range = extendRangeEnd(extendRangeStart(range));
|
|
137
|
+
}
|
|
138
|
+
return weekWindowsOver(datesInRange(range));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Every week the strip can ask for, answered from the cache. A week nobody
|
|
143
|
+
* seeded would fire a real request, and what these cases are about is which
|
|
144
|
+
* weeks get asked for, not what comes back.
|
|
145
|
+
*/
|
|
146
|
+
const seed = (dom: DomHarness) => {
|
|
147
|
+
dom.queryClient.setQueryData(calendarOperationsListCalendarsQueryKey(), {
|
|
148
|
+
items: [],
|
|
149
|
+
});
|
|
150
|
+
for (const window of reachableWeeks()) {
|
|
151
|
+
dom.queryClient.setQueryData(
|
|
152
|
+
calendarEventOperationsListCalendarEventsQueryKey({ query: window }),
|
|
153
|
+
{ items: [] },
|
|
154
|
+
);
|
|
155
|
+
dom.queryClient.setQueryData(
|
|
156
|
+
calendarFreeBusyOperationsListCalendarFreeBusyQueryKey({
|
|
157
|
+
query: window,
|
|
158
|
+
}),
|
|
159
|
+
{ items: [] },
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
interface Mounted {
|
|
165
|
+
dom: DomHarness;
|
|
166
|
+
router: AnyRouter;
|
|
167
|
+
pushes: number;
|
|
168
|
+
replaces: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const mount = async (path = `/calendar/agenda/${DATE}`): Promise<Mounted> => {
|
|
172
|
+
const dom = createDomHarness();
|
|
173
|
+
harness = dom;
|
|
174
|
+
seed(dom);
|
|
175
|
+
|
|
176
|
+
const router = testRouter(path);
|
|
177
|
+
const counted = { pushes: 0, replaces: 0 };
|
|
178
|
+
const { push, replace } = router.history;
|
|
179
|
+
router.history.push = (...args: Parameters<typeof push>) => {
|
|
180
|
+
counted.pushes += 1;
|
|
181
|
+
return push(...args);
|
|
182
|
+
};
|
|
183
|
+
router.history.replace = (...args: Parameters<typeof replace>) => {
|
|
184
|
+
counted.replaces += 1;
|
|
185
|
+
return replace(...args);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
await router.load();
|
|
189
|
+
dom.renderApp(createElement(RouterProvider, { router }));
|
|
190
|
+
await dom.flush();
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
dom,
|
|
194
|
+
router,
|
|
195
|
+
get pushes() {
|
|
196
|
+
return counted.pushes;
|
|
197
|
+
},
|
|
198
|
+
get replaces() {
|
|
199
|
+
return counted.replaces;
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The one element the strip scrolls. jsdom has no layout, so it is given the
|
|
206
|
+
* one part of it these cases turn on: an offset that moves and stays where it
|
|
207
|
+
* was put. Everything else about the strip's geometry is zero, which is the
|
|
208
|
+
* sparse diary this suite is about — every end within reach of every other.
|
|
209
|
+
*/
|
|
210
|
+
const stubbed = new WeakSet<Element>();
|
|
211
|
+
|
|
212
|
+
const scroller = (dom: DomHarness): HTMLElement => {
|
|
213
|
+
const found = dom.query<HTMLElement>(".overflow-y-auto");
|
|
214
|
+
if (!found) throw new Error("the strip rendered no scroller");
|
|
215
|
+
if (stubbed.has(found)) return found;
|
|
216
|
+
let top = 0;
|
|
217
|
+
Object.defineProperty(found, "scrollTop", {
|
|
218
|
+
configurable: true,
|
|
219
|
+
get: () => top,
|
|
220
|
+
set: (next: number) => {
|
|
221
|
+
top = Math.max(0, next);
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
stubbed.add(found);
|
|
225
|
+
return found;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/** A reader scrolling: the gesture, where it took the strip, then the event. */
|
|
229
|
+
const scrollBy = (dom: DomHarness, delta: number) => {
|
|
230
|
+
const element = scroller(dom);
|
|
231
|
+
dom.dispatch(element, new Event("wheel", { bubbles: true }));
|
|
232
|
+
element.scrollTop += delta;
|
|
233
|
+
dom.dispatch(element, new Event("scroll", { bubbles: false }));
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const scrollDown = (dom: DomHarness) => scrollBy(dom, 200);
|
|
237
|
+
const scrollUp = (dom: DomHarness) => scrollBy(dom, -400);
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The same movement with nobody behind it: a font swapping under a month of
|
|
241
|
+
* rows, a pane resized, the offset taken back after days were prepended.
|
|
242
|
+
*/
|
|
243
|
+
const settle = (dom: DomHarness) => {
|
|
244
|
+
const element = scroller(dom);
|
|
245
|
+
element.scrollTop += 120;
|
|
246
|
+
dom.dispatch(element, new Event("scroll", { bubbles: false }));
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
describe("the day the address names", () => {
|
|
250
|
+
it("is the day the strip opens on", async () => {
|
|
251
|
+
const { dom } = await mount();
|
|
252
|
+
assert.match(dom.text(), /Wednesday/, "the header names another day");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("asks for the weeks around it, one request each", async () => {
|
|
256
|
+
const { dom } = await mount();
|
|
257
|
+
const asked = eventWindows(dom);
|
|
258
|
+
const expected = weekWindowsOver(datesInRange(rangeAround(DATE)));
|
|
259
|
+
assert.deepEqual(
|
|
260
|
+
asked.map((window) => window.from).sort(),
|
|
261
|
+
expected.map((window) => window.from).sort(),
|
|
262
|
+
);
|
|
263
|
+
for (const window of asked) {
|
|
264
|
+
const days =
|
|
265
|
+
(Date.parse(window.to) - Date.parse(window.from)) / 86_400_000;
|
|
266
|
+
assert.equal(days, 7, "a read covered more than one week");
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("reaching an end of the strip", () => {
|
|
272
|
+
it("asks for more days without giving up the ones on screen", async () => {
|
|
273
|
+
const { dom } = await mount();
|
|
274
|
+
const before = dom.text();
|
|
275
|
+
assert.doesNotMatch(before, /11 Jul/);
|
|
276
|
+
|
|
277
|
+
scrollDown(dom);
|
|
278
|
+
await dom.flush();
|
|
279
|
+
|
|
280
|
+
const after = dom.text();
|
|
281
|
+
assert.match(after, /11 Jul/, "the days past the window did not arrive");
|
|
282
|
+
assert.match(after, /9 Jun/, "the days it opened with were thrown away");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("grows the end the reader went to, a week at a time, and only that one", async () => {
|
|
286
|
+
const { dom } = await mount();
|
|
287
|
+
scrollDown(dom);
|
|
288
|
+
await dom.flush();
|
|
289
|
+
assert.match(dom.text(), /11 Jul/);
|
|
290
|
+
assert.doesNotMatch(dom.text(), /24 May/, "the end nobody went to grew");
|
|
291
|
+
|
|
292
|
+
scrollUp(dom);
|
|
293
|
+
await dom.flush();
|
|
294
|
+
assert.match(dom.text(), /24 May/, "the days behind never arrived");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("adds week keys rather than widening the one it holds", async () => {
|
|
298
|
+
const { dom } = await mount();
|
|
299
|
+
const opening = eventWindows(dom).map((window) => window.from);
|
|
300
|
+
|
|
301
|
+
scrollDown(dom);
|
|
302
|
+
await dom.flush();
|
|
303
|
+
scrollUp(dom);
|
|
304
|
+
await dom.flush();
|
|
305
|
+
|
|
306
|
+
const after = eventWindows(dom).map((window) => window.from);
|
|
307
|
+
for (const from of opening) {
|
|
308
|
+
assert.ok(after.includes(from), `${from} was given up to fetch more`);
|
|
309
|
+
}
|
|
310
|
+
assert.ok(after.length > opening.length, "no week was added");
|
|
311
|
+
assert.ok(
|
|
312
|
+
after.some((from) => from < opening[0]),
|
|
313
|
+
"nothing reached further back than the week it opened with",
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("never grows a single read past what the server will answer", async () => {
|
|
318
|
+
const { dom } = await mount();
|
|
319
|
+
for (let reach = 0; reach < 6; reach += 1) {
|
|
320
|
+
scrollDown(dom);
|
|
321
|
+
await dom.flush();
|
|
322
|
+
}
|
|
323
|
+
for (const window of eventWindows(dom)) {
|
|
324
|
+
const days =
|
|
325
|
+
(Date.parse(window.to) - Date.parse(window.from)) / 86_400_000;
|
|
326
|
+
assert.equal(days, 7, `a read covered ${days} days`);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The bug this suite exists to keep out. A diary with two events in it draws
|
|
333
|
+
* shorter than the distance the strip fetches at, so both ends are reached from
|
|
334
|
+
* the first layout pass, and every extension raised another scroll event to
|
|
335
|
+
* reach them again. Left to run it walked a five-week window out to 2032 and
|
|
336
|
+
* wrote every day of the way into the path.
|
|
337
|
+
*/
|
|
338
|
+
describe("a strip nobody has scrolled", () => {
|
|
339
|
+
it("asks for the weeks it opened with and no others", async () => {
|
|
340
|
+
const { dom } = await mount();
|
|
341
|
+
const opening = eventWindows(dom)
|
|
342
|
+
.map((window) => window.from)
|
|
343
|
+
.sort();
|
|
344
|
+
|
|
345
|
+
for (let pass = 0; pass < 5; pass += 1) {
|
|
346
|
+
settle(dom);
|
|
347
|
+
await dom.flush();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
assert.deepEqual(
|
|
351
|
+
eventWindows(dom)
|
|
352
|
+
.map((window) => window.from)
|
|
353
|
+
.sort(),
|
|
354
|
+
opening,
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("leaves the address on the day it was opened with", async () => {
|
|
359
|
+
const { dom, router } = await mount();
|
|
360
|
+
for (let pass = 0; pass < 5; pass += 1) {
|
|
361
|
+
settle(dom);
|
|
362
|
+
await dom.flush();
|
|
363
|
+
}
|
|
364
|
+
await dom.wait(600);
|
|
365
|
+
await dom.flush();
|
|
366
|
+
|
|
367
|
+
assert.equal(router.state.location.pathname, `/calendar/agenda/${DATE}`);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe("the day the reader scrolled to", () => {
|
|
372
|
+
it("is written to the path, so a reload comes back to it", async () => {
|
|
373
|
+
const { dom, router } = await mount();
|
|
374
|
+
scrollDown(dom);
|
|
375
|
+
await dom.wait(600);
|
|
376
|
+
await dom.flush();
|
|
377
|
+
|
|
378
|
+
assert.match(router.state.location.pathname, /^\/calendar\/agenda\//);
|
|
379
|
+
assert.notEqual(
|
|
380
|
+
router.state.location.pathname,
|
|
381
|
+
`/calendar/agenda/${DATE}`,
|
|
382
|
+
"scrolling left the address on the day it opened",
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("replaces rather than pushes, so Back is still the way out", async () => {
|
|
387
|
+
const mounted = await mount();
|
|
388
|
+
scrollDown(mounted.dom);
|
|
389
|
+
await mounted.dom.wait(600);
|
|
390
|
+
await mounted.dom.flush();
|
|
391
|
+
|
|
392
|
+
assert.ok(mounted.replaces > 0, "the address was never written");
|
|
393
|
+
assert.equal(mounted.pushes, 0, "scrolling stacked up history entries");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("stays there while the days it asked for arrive", async () => {
|
|
397
|
+
const { dom, router } = await mount();
|
|
398
|
+
scrollDown(dom);
|
|
399
|
+
await dom.wait(600);
|
|
400
|
+
await dom.flush();
|
|
401
|
+
const landed = router.state.location.pathname;
|
|
402
|
+
|
|
403
|
+
// The prepend takes the scroll offset back, which is another scroll event.
|
|
404
|
+
// It is not the reader moving, so it writes nothing.
|
|
405
|
+
settle(dom);
|
|
406
|
+
await dom.wait(600);
|
|
407
|
+
await dom.flush();
|
|
408
|
+
|
|
409
|
+
assert.equal(router.state.location.pathname, landed);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe("dropping into the grid and back out", () => {
|
|
414
|
+
it("keeps the day and the calendars the reader was looking at", async () => {
|
|
415
|
+
const { dom, router } = await mount();
|
|
416
|
+
await act(async () => {
|
|
417
|
+
await router.navigate({
|
|
418
|
+
to: "/calendar/$view/$date",
|
|
419
|
+
params: { view: "agenda", date: DATE },
|
|
420
|
+
search: { calendarId: [HOME, WORK] },
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
await dom.flush();
|
|
424
|
+
|
|
425
|
+
dom.click(dom.byLabel(TO_WEEK));
|
|
426
|
+
await dom.flush();
|
|
427
|
+
|
|
428
|
+
assert.equal(router.state.location.pathname, `/calendar/week/${DATE}`);
|
|
429
|
+
assert.deepEqual(router.state.location.search.calendarId, [HOME, WORK]);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The layout's own read, bound exactly as the route binds it. Mounted without
|
|
435
|
+
* the strip, so any window in the cache came from here.
|
|
436
|
+
*/
|
|
437
|
+
function LayoutOnly() {
|
|
438
|
+
const { view, date, calendarIds } = useCalendarAddress();
|
|
439
|
+
useCalendarData({
|
|
440
|
+
view,
|
|
441
|
+
date,
|
|
442
|
+
calendarIds,
|
|
443
|
+
enabled: !calendarViewMountsAgenda(view),
|
|
444
|
+
});
|
|
445
|
+
return createElement("div", { "data-testid": "layout-only" });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
describe("the layout's own week, at a zoom that does not draw it", () => {
|
|
449
|
+
const mountLayout = async (view: string): Promise<DomHarness> => {
|
|
450
|
+
const dom = createDomHarness();
|
|
451
|
+
harness = dom;
|
|
452
|
+
seed(dom);
|
|
453
|
+
const router = testRouter(`/calendar/${view}/${DATE}`, LayoutOnly);
|
|
454
|
+
await router.load();
|
|
455
|
+
dom.renderApp(createElement(RouterProvider, { router }));
|
|
456
|
+
await dom.flush();
|
|
457
|
+
return dom;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
it("reads the week the grid draws", async () => {
|
|
461
|
+
const dom = await mountLayout("week");
|
|
462
|
+
assert.deepEqual(
|
|
463
|
+
eventWindows(dom).map((window) => window.from),
|
|
464
|
+
[calendarWindow("week", DATE).from],
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
/*
|
|
469
|
+
* The strip fetches its own weeks, so this read renders nothing — and the
|
|
470
|
+
* address rewrites on every scroll, so leaving it on would ask again, with
|
|
471
|
+
* its two neighbours prefetched, all the way down the strip.
|
|
472
|
+
*/
|
|
473
|
+
it("asks for nothing at all at the agenda zoom", async () => {
|
|
474
|
+
const dom = await mountLayout("agenda");
|
|
475
|
+
assert.deepEqual(eventWindows(dom), []);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
/** The windows the strip currently has `/calendar-events` open on. */
|
|
480
|
+
const eventWindows = (dom: DomHarness): { from: string; to: string }[] =>
|
|
481
|
+
dom.queryClient
|
|
482
|
+
.getQueryCache()
|
|
483
|
+
.getAll()
|
|
484
|
+
.filter((query) => query.isActive())
|
|
485
|
+
.map(
|
|
486
|
+
(query) =>
|
|
487
|
+
query.queryKey[0] as {
|
|
488
|
+
_id?: string;
|
|
489
|
+
query?: { from: string; to: string };
|
|
490
|
+
},
|
|
491
|
+
)
|
|
492
|
+
.filter((key) => key?._id === "calendarEventOperationsListCalendarEvents")
|
|
493
|
+
.flatMap((key) => (key.query ? [key.query] : []));
|