@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.
@@ -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 { calendarViewMountsGrid } from "@/lib/calendar-route";
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
- <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}
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
- </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>
203
+ ) : (
204
+ <CalendarViewPlaceholder view={view} />
205
+ )}
206
+ </div>
207
+ )}
190
208
  </div>
191
209
  );
192
210
  }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * The strip and the address, bound together.
3
+ *
4
+ * Three claims, and every one of them is about the URL rather than about
5
+ * pixels: the strip opens on the day the path names, reaching an end asks for
6
+ * more days rather than replacing the ones on screen, and the day the reader
7
+ * scrolls to is written by `replace` — scrolling is not somewhere they went, so
8
+ * Back belongs to the screen they arrived from and not to every row they
9
+ * passed. The last one is the reason this suite drives the real router instead
10
+ * of a callback: a `push` here would bury the way out under a fortnight of
11
+ * 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
+ /** The one element the strip scrolls; jsdom has no layout, only the event. */
205
+ const scroller = (dom: DomHarness): Element => {
206
+ const found = dom.query(".overflow-y-auto");
207
+ if (!found) throw new Error("the strip rendered no scroller");
208
+ return found;
209
+ };
210
+
211
+ const scroll = (dom: DomHarness) => {
212
+ dom.dispatch(scroller(dom), new Event("scroll", { bubbles: false }));
213
+ };
214
+
215
+ describe("the day the address names", () => {
216
+ it("is the day the strip opens on", async () => {
217
+ const { dom } = await mount();
218
+ assert.match(dom.text(), /Wednesday/, "the header names another day");
219
+ });
220
+
221
+ it("asks for the weeks around it, one request each", async () => {
222
+ const { dom } = await mount();
223
+ const asked = eventWindows(dom);
224
+ const expected = weekWindowsOver(datesInRange(rangeAround(DATE)));
225
+ assert.deepEqual(
226
+ asked.map((window) => window.from).sort(),
227
+ expected.map((window) => window.from).sort(),
228
+ );
229
+ for (const window of asked) {
230
+ const days =
231
+ (Date.parse(window.to) - Date.parse(window.from)) / 86_400_000;
232
+ assert.equal(days, 7, "a read covered more than one week");
233
+ }
234
+ });
235
+ });
236
+
237
+ describe("reaching an end of the strip", () => {
238
+ it("asks for more days without giving up the ones on screen", async () => {
239
+ const { dom } = await mount();
240
+ const before = dom.text();
241
+ assert.doesNotMatch(before, /17 May/);
242
+
243
+ scroll(dom);
244
+ await dom.flush();
245
+
246
+ const after = dom.text();
247
+ assert.match(after, /17 May/, "the days before the window did not arrive");
248
+ assert.match(after, /9 Jun/, "the days it opened with were thrown away");
249
+ });
250
+
251
+ it("adds week keys rather than widening the one it holds", async () => {
252
+ const { dom } = await mount();
253
+ const opening = eventWindows(dom).map((window) => window.from);
254
+
255
+ scroll(dom);
256
+ await dom.flush();
257
+
258
+ const after = eventWindows(dom).map((window) => window.from);
259
+ for (const from of opening) {
260
+ assert.ok(after.includes(from), `${from} was given up to fetch more`);
261
+ }
262
+ assert.ok(after.length > opening.length, "no week was added");
263
+ assert.ok(
264
+ after.some((from) => from < opening[0]),
265
+ "nothing reached further back than the week it opened with",
266
+ );
267
+ });
268
+
269
+ it("never grows a single read past what the server will answer", async () => {
270
+ const { dom } = await mount();
271
+ for (let reach = 0; reach < 6; reach += 1) {
272
+ scroll(dom);
273
+ await dom.flush();
274
+ }
275
+ for (const window of eventWindows(dom)) {
276
+ const days =
277
+ (Date.parse(window.to) - Date.parse(window.from)) / 86_400_000;
278
+ assert.equal(days, 7, `a read covered ${days} days`);
279
+ }
280
+ });
281
+ });
282
+
283
+ describe("the day the reader scrolled to", () => {
284
+ it("is written to the path, so a reload comes back to it", async () => {
285
+ const { dom, router } = await mount();
286
+ scroll(dom);
287
+ await dom.wait(600);
288
+ await dom.flush();
289
+
290
+ assert.match(router.state.location.pathname, /^\/calendar\/agenda\//);
291
+ assert.notEqual(
292
+ router.state.location.pathname,
293
+ `/calendar/agenda/${DATE}`,
294
+ "scrolling left the address on the day it opened",
295
+ );
296
+ });
297
+
298
+ it("replaces rather than pushes, so Back is still the way out", async () => {
299
+ const mounted = await mount();
300
+ scroll(mounted.dom);
301
+ await mounted.dom.wait(600);
302
+ await mounted.dom.flush();
303
+
304
+ assert.ok(mounted.replaces > 0, "the address was never written");
305
+ assert.equal(mounted.pushes, 0, "scrolling stacked up history entries");
306
+ });
307
+ });
308
+
309
+ describe("dropping into the grid and back out", () => {
310
+ it("keeps the day and the calendars the reader was looking at", async () => {
311
+ const { dom, router } = await mount();
312
+ await act(async () => {
313
+ await router.navigate({
314
+ to: "/calendar/$view/$date",
315
+ params: { view: "agenda", date: DATE },
316
+ search: { calendarId: [HOME, WORK] },
317
+ });
318
+ });
319
+ await dom.flush();
320
+
321
+ dom.click(dom.byLabel(TO_WEEK));
322
+ await dom.flush();
323
+
324
+ assert.equal(router.state.location.pathname, `/calendar/week/${DATE}`);
325
+ assert.deepEqual(router.state.location.search.calendarId, [HOME, WORK]);
326
+ });
327
+ });
328
+
329
+ /**
330
+ * The layout's own read, bound exactly as the route binds it. Mounted without
331
+ * the strip, so any window in the cache came from here.
332
+ */
333
+ function LayoutOnly() {
334
+ const { view, date, calendarIds } = useCalendarAddress();
335
+ useCalendarData({
336
+ view,
337
+ date,
338
+ calendarIds,
339
+ enabled: !calendarViewMountsAgenda(view),
340
+ });
341
+ return createElement("div", { "data-testid": "layout-only" });
342
+ }
343
+
344
+ describe("the layout's own week, at a zoom that does not draw it", () => {
345
+ const mountLayout = async (view: string): Promise<DomHarness> => {
346
+ const dom = createDomHarness();
347
+ harness = dom;
348
+ seed(dom);
349
+ const router = testRouter(`/calendar/${view}/${DATE}`, LayoutOnly);
350
+ await router.load();
351
+ dom.renderApp(createElement(RouterProvider, { router }));
352
+ await dom.flush();
353
+ return dom;
354
+ };
355
+
356
+ it("reads the week the grid draws", async () => {
357
+ const dom = await mountLayout("week");
358
+ assert.deepEqual(
359
+ eventWindows(dom).map((window) => window.from),
360
+ [calendarWindow("week", DATE).from],
361
+ );
362
+ });
363
+
364
+ /*
365
+ * The strip fetches its own weeks, so this read renders nothing — and the
366
+ * address rewrites on every scroll, so leaving it on would ask again, with
367
+ * its two neighbours prefetched, all the way down the strip.
368
+ */
369
+ it("asks for nothing at all at the agenda zoom", async () => {
370
+ const dom = await mountLayout("agenda");
371
+ assert.deepEqual(eventWindows(dom), []);
372
+ });
373
+ });
374
+
375
+ /** The windows the strip currently has `/calendar-events` open on. */
376
+ const eventWindows = (dom: DomHarness): { from: string; to: string }[] =>
377
+ dom.queryClient
378
+ .getQueryCache()
379
+ .getAll()
380
+ .filter((query) => query.isActive())
381
+ .map(
382
+ (query) =>
383
+ query.queryKey[0] as {
384
+ _id?: string;
385
+ query?: { from: string; to: string };
386
+ },
387
+ )
388
+ .filter((key) => key?._id === "calendarEventOperationsListCalendarEvents")
389
+ .flatMap((key) => (key.query ? [key.query] : []));