@remit/web-client 0.0.233 → 0.0.235

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.233",
3
+ "version": "0.0.235",
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": {
@@ -63,6 +63,15 @@ export interface AgendaStripProps {
63
63
  onGoToDate: (date: string) => void;
64
64
  onReachStart: () => void;
65
65
  onReachEnd: () => void;
66
+ /**
67
+ * The run has grown as far as scrolling takes it. The strip says so at that
68
+ * end and offers the next stretch, so a diary too sparse to fill the pane
69
+ * cannot fetch its way across years nobody asked for.
70
+ */
71
+ atStartCap?: boolean;
72
+ atEndCap?: boolean;
73
+ onLoadEarlier?: () => void;
74
+ onLoadLater?: () => void;
66
75
  /** The day under the sticky header, which the address follows. */
67
76
  onVisibleDayChange: (date: string) => void;
68
77
  }
@@ -86,6 +95,10 @@ export function AgendaStrip({
86
95
  onGoToDate,
87
96
  onReachStart,
88
97
  onReachEnd,
98
+ atStartCap,
99
+ atEndCap,
100
+ onLoadEarlier,
101
+ onLoadLater,
89
102
  onVisibleDayChange,
90
103
  }: AgendaStripProps) {
91
104
  const nextUp = useMemo(() => readNextUp(days, now), [days, now]);
@@ -126,6 +139,10 @@ export function AgendaStrip({
126
139
  onZoomDay={onZoomDay}
127
140
  onReachStart={onReachStart}
128
141
  onReachEnd={onReachEnd}
142
+ atStartCap={atStartCap}
143
+ atEndCap={atEndCap}
144
+ onLoadEarlier={onLoadEarlier}
145
+ onLoadLater={onLoadLater}
129
146
  onVisibleDayChange={onVisibleDayChange}
130
147
  todayLead={
131
148
  settledToday ? (
@@ -15,7 +15,11 @@ import {
15
15
  extendRangeEnd,
16
16
  extendRangeStart,
17
17
  freeStretchesByDate,
18
+ liftRangeCeiling,
19
+ liftRangeFloor,
18
20
  rangeAround,
21
+ rangeAtCeiling,
22
+ rangeAtFloor,
19
23
  rangeCovering,
20
24
  readCalendarInstanceId,
21
25
  useCalendarEventWeeks,
@@ -44,16 +48,22 @@ import {
44
48
  * you drop into and leave" means in practice.
45
49
  *
46
50
  * 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.
51
+ * That is what lets the range grow at all — a single read may not cover more
52
+ * than a year, and one request that widened every time the reader reached an
53
+ * end would eventually be refused and replace the strip with the refusal — and
54
+ * it is what makes the two zooms share everything they have already read.
55
+ *
56
+ * It grows on the reader's scroll and on nothing else, a week at a time, and
57
+ * only as far as a year either way. Past that it says so and offers the next
58
+ * stretch: a sparse diary draws shorter than the distance the strip fetches at,
59
+ * so an end measured off the content alone is reached on the first layout pass
60
+ * and never stops being reached.
52
61
  *
53
62
  * Scrolling writes the day back to the address, debounced and by `replace`.
54
63
  * Debounced because a flick past a fortnight is one move rather than fourteen,
55
64
  * 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.
65
+ * to the screen they came from, not to the row they scrolled past. Growing the
66
+ * range writes nothing: days arriving at an end is not the reader moving.
57
67
  */
58
68
 
59
69
  /** Long enough that a flick lands before the address moves. */
@@ -183,6 +193,11 @@ export function AgendaView({ density, onPickSlot }: AgendaViewProps) {
183
193
  [openEvent],
184
194
  );
185
195
 
196
+ const growStart = useCallback(() => setRange(extendRangeStart), []);
197
+ const growEnd = useCallback(() => setRange(extendRangeEnd), []);
198
+ const loadEarlier = useCallback(() => setRange(liftRangeFloor), []);
199
+ const loadLater = useCallback(() => setRange(liftRangeCeiling), []);
200
+
186
201
  /** Whichever read was refused is the one a retry re-sends. */
187
202
  const retry = useCallback(() => {
188
203
  events.refetch();
@@ -214,8 +229,12 @@ export function AgendaView({ density, onPickSlot }: AgendaViewProps) {
214
229
  onPickSlot={onPickSlot}
215
230
  onZoomDay={zoomToDay}
216
231
  onGoToDate={goToDate}
217
- onReachStart={() => setRange(extendRangeStart)}
218
- onReachEnd={() => setRange(extendRangeEnd)}
232
+ onReachStart={growStart}
233
+ onReachEnd={growEnd}
234
+ atStartCap={rangeAtFloor(range)}
235
+ atEndCap={rangeAtCeiling(range)}
236
+ onLoadEarlier={loadEarlier}
237
+ onLoadLater={loadLater}
219
238
  onVisibleDayChange={setVisibleDate}
220
239
  />
221
240
  );
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * The strip and the address, bound together.
3
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.
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
12
  */
13
13
  import assert from "node:assert/strict";
14
14
  import { afterEach, describe, it } from "node:test";
@@ -201,15 +201,49 @@ const mount = async (path = `/calendar/agenda/${DATE}`): Promise<Mounted> => {
201
201
  };
202
202
  };
203
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");
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");
207
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);
208
225
  return found;
209
226
  };
210
227
 
211
- const scroll = (dom: DomHarness) => {
212
- dom.dispatch(scroller(dom), new Event("scroll", { bubbles: false }));
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 }));
213
247
  };
214
248
 
215
249
  describe("the day the address names", () => {
@@ -238,21 +272,35 @@ describe("reaching an end of the strip", () => {
238
272
  it("asks for more days without giving up the ones on screen", async () => {
239
273
  const { dom } = await mount();
240
274
  const before = dom.text();
241
- assert.doesNotMatch(before, /17 May/);
275
+ assert.doesNotMatch(before, /11 Jul/);
242
276
 
243
- scroll(dom);
277
+ scrollDown(dom);
244
278
  await dom.flush();
245
279
 
246
280
  const after = dom.text();
247
- assert.match(after, /17 May/, "the days before the window did not arrive");
281
+ assert.match(after, /11 Jul/, "the days past the window did not arrive");
248
282
  assert.match(after, /9 Jun/, "the days it opened with were thrown away");
249
283
  });
250
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
+
251
297
  it("adds week keys rather than widening the one it holds", async () => {
252
298
  const { dom } = await mount();
253
299
  const opening = eventWindows(dom).map((window) => window.from);
254
300
 
255
- scroll(dom);
301
+ scrollDown(dom);
302
+ await dom.flush();
303
+ scrollUp(dom);
256
304
  await dom.flush();
257
305
 
258
306
  const after = eventWindows(dom).map((window) => window.from);
@@ -269,7 +317,7 @@ describe("reaching an end of the strip", () => {
269
317
  it("never grows a single read past what the server will answer", async () => {
270
318
  const { dom } = await mount();
271
319
  for (let reach = 0; reach < 6; reach += 1) {
272
- scroll(dom);
320
+ scrollDown(dom);
273
321
  await dom.flush();
274
322
  }
275
323
  for (const window of eventWindows(dom)) {
@@ -280,10 +328,50 @@ describe("reaching an end of the strip", () => {
280
328
  });
281
329
  });
282
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
+
283
371
  describe("the day the reader scrolled to", () => {
284
372
  it("is written to the path, so a reload comes back to it", async () => {
285
373
  const { dom, router } = await mount();
286
- scroll(dom);
374
+ scrollDown(dom);
287
375
  await dom.wait(600);
288
376
  await dom.flush();
289
377
 
@@ -297,13 +385,29 @@ describe("the day the reader scrolled to", () => {
297
385
 
298
386
  it("replaces rather than pushes, so Back is still the way out", async () => {
299
387
  const mounted = await mount();
300
- scroll(mounted.dom);
388
+ scrollDown(mounted.dom);
301
389
  await mounted.dom.wait(600);
302
390
  await mounted.dom.flush();
303
391
 
304
392
  assert.ok(mounted.replaces > 0, "the address was never written");
305
393
  assert.equal(mounted.pushes, 0, "scrolling stacked up history entries");
306
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
+ });
307
411
  });
308
412
 
309
413
  describe("dropping into the grid and back out", () => {
@@ -233,6 +233,24 @@ export const DryRunReportPhone: Story = {
233
233
  ),
234
234
  };
235
235
 
236
+ /**
237
+ * A household's worth of rules and senders, on the 1512×827 laptop where this
238
+ * report was found running off the bottom of the screen with "Import n changes"
239
+ * unreachable below it (#1021). The report scrolls inside the card; the
240
+ * verdict summary at the top and the import button at the bottom both stay put.
241
+ */
242
+ export const DryRunReportLongShortViewport: Story = {
243
+ globals: { viewport: { value: "laptopShort" } },
244
+ render: () => (
245
+ <StepDryRunReport
246
+ sections={groupReportSections(
247
+ Array.from({ length: 6 }, () => dryRunReport.items).flat(),
248
+ )}
249
+ fileName={CONFIG_FILE_NAME}
250
+ />
251
+ ),
252
+ };
253
+
236
254
  const HELD =
237
255
  "2 accounts, 14 rules and 31 flagged senders are set up here already.";
238
256
 
@@ -0,0 +1,216 @@
1
+ /**
2
+ * A report longer than the screen still leaves the reader somewhere to press.
3
+ *
4
+ * On a 1512×827 laptop the dry run of a real configuration drew taller than the
5
+ * viewport. The wizard sat in a fixed box with nothing scrolling, so "Import n
6
+ * changes" was rendered below the fold with no way to reach it and the import
7
+ * could not be finished at all (#1021).
8
+ *
9
+ * The shape that fixes it is structural, and structure is what this file holds:
10
+ * the growable part of a step lives in its own scroll box, the title and the
11
+ * footer actions sit outside that box, and the shell is capped to the viewport
12
+ * so nothing can push the footer off it. jsdom has no layout engine, so the
13
+ * pixels are pinned by the "long report, short viewport" Storybook stories
14
+ * instead — what is asserted here is the arrangement those stories depend on,
15
+ * which is the part a refactor silently takes away.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { afterEach, describe, it } from "node:test";
20
+ import { createElement } from "react";
21
+ import type { ReportSection } from "../../lib/config-import";
22
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
23
+ import type { ImportedAccount } from "./steps";
24
+ import { StepCredentialsOverview, StepDryRunReport } from "./steps";
25
+
26
+ let harness: DomHarness | undefined;
27
+
28
+ afterEach(() => {
29
+ harness?.close();
30
+ harness = undefined;
31
+ });
32
+
33
+ const LONG_REPORT: ReportSection[] = [
34
+ {
35
+ id: "accounts",
36
+ title: "Accounts",
37
+ entries: Array.from({ length: 40 }, (_, index) => ({
38
+ id: `accounts-${index}`,
39
+ label: `mailbox-${index}@example.test`,
40
+ verdict: "created" as const,
41
+ })),
42
+ },
43
+ {
44
+ id: "filters",
45
+ title: "Rules & filters",
46
+ entries: Array.from({ length: 40 }, (_, index) => ({
47
+ id: `filters-${index}`,
48
+ label: `Rule ${index}`,
49
+ verdict: "skipped" as const,
50
+ reason: "The folder this rule files into is not here yet.",
51
+ })),
52
+ },
53
+ ];
54
+
55
+ const MANY_ACCOUNTS: ImportedAccount[] = Array.from(
56
+ { length: 30 },
57
+ (_, index) => ({
58
+ accountId: `acct-${index}`,
59
+ address: `mailbox-${index}@example.test`,
60
+ displayName: `Mailbox ${index}`,
61
+ connector: "imap" as const,
62
+ server: "imap.example.test",
63
+ state: "needed" as const,
64
+ }),
65
+ );
66
+
67
+ const classes = (element: Element): string[] =>
68
+ (element.getAttribute("class") ?? "").split(/\s+/).filter(Boolean);
69
+
70
+ const shellOf = (mounted: DomHarness): Element => {
71
+ const body = mounted.query("[data-testid=wizard-body]");
72
+ assert.ok(body, "the step renders a wizard body region");
73
+ const shell = body.closest("div.h-dvh");
74
+ assert.ok(shell, "the wizard body sits inside the shell root");
75
+ return shell;
76
+ };
77
+
78
+ const FOCUSABLE =
79
+ 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
80
+
81
+ describe("a wizard step longer than the viewport (#1021)", () => {
82
+ it("puts the whole report inside the scroll box and the footer outside it", () => {
83
+ harness = createDomHarness({ viewportWidth: 1512 });
84
+ harness.render(
85
+ createElement(StepDryRunReport, {
86
+ sections: LONG_REPORT,
87
+ fileName: "reader-config.example.json",
88
+ }),
89
+ );
90
+
91
+ const body = harness.query("[data-testid=wizard-body]");
92
+ const footer = harness.query("[data-testid=wizard-footer]");
93
+ assert.ok(body && footer);
94
+
95
+ // The rail above the card renders a list item per step, so the count is
96
+ // the assertion: all 80 report rows are inside the scroll box, and a row
97
+ // left outside it would show up as a shortfall here.
98
+ assert.equal(
99
+ body.querySelectorAll("li").length,
100
+ 80,
101
+ "every report row grows inside the scroll box",
102
+ );
103
+
104
+ assert.ok(
105
+ !body.contains(footer),
106
+ "the footer sits outside the scroll box, so the report cannot scroll it away",
107
+ );
108
+ const primary = harness.byText("button", "Import");
109
+ assert.ok(
110
+ footer.contains(primary),
111
+ "the primary action lives in the footer",
112
+ );
113
+ });
114
+
115
+ it("scrolls the body and never the shell", () => {
116
+ harness = createDomHarness({ viewportWidth: 1512 });
117
+ harness.render(
118
+ createElement(StepDryRunReport, {
119
+ sections: LONG_REPORT,
120
+ fileName: "reader-config.example.json",
121
+ }),
122
+ );
123
+
124
+ const body = harness.query("[data-testid=wizard-body]");
125
+ assert.ok(body);
126
+ const bodyClasses = classes(body);
127
+ assert.ok(
128
+ bodyClasses.includes("overflow-y-auto"),
129
+ "the body region is the scrolling element",
130
+ );
131
+ assert.ok(
132
+ bodyClasses.includes("min-h-0"),
133
+ "the body region can shrink below its content, or it never scrolls",
134
+ );
135
+
136
+ const shell = shellOf(harness);
137
+ assert.ok(
138
+ classes(shell).includes("overflow-hidden"),
139
+ "the shell root does not scroll — the footer would ride out of view with it",
140
+ );
141
+ assert.deepEqual(
142
+ harness
143
+ .queryAll('[class*="overflow-y-auto"]')
144
+ .map((element) => element.getAttribute("data-testid")),
145
+ ["wizard-body"],
146
+ "exactly one region scrolls, and it is the one holding the growable content",
147
+ );
148
+ });
149
+
150
+ it("caps the shell at the viewport rather than growing past it", () => {
151
+ harness = createDomHarness({ viewportWidth: 1512 });
152
+ harness.render(
153
+ createElement(StepDryRunReport, {
154
+ sections: LONG_REPORT,
155
+ fileName: "reader-config.example.json",
156
+ }),
157
+ );
158
+
159
+ const shellClasses = classes(shellOf(harness));
160
+ assert.ok(
161
+ shellClasses.includes("h-dvh"),
162
+ "the shell is exactly the viewport, so no content length can extend it",
163
+ );
164
+ assert.ok(
165
+ !shellClasses.some((token) => token.startsWith("min-h-")),
166
+ "a minimum height would let the content push the shell past the fold again",
167
+ );
168
+ });
169
+
170
+ it("keeps the footer action last in the tab order", () => {
171
+ harness = createDomHarness({ viewportWidth: 1512 });
172
+ harness.render(
173
+ createElement(StepDryRunReport, {
174
+ sections: LONG_REPORT,
175
+ fileName: "reader-config.example.json",
176
+ }),
177
+ );
178
+
179
+ const footer = harness.query("[data-testid=wizard-footer]");
180
+ assert.ok(footer);
181
+ const focusable = harness.queryAll(FOCUSABLE);
182
+ assert.ok(focusable.length >= 2, "the step has controls to tab through");
183
+ assert.ok(
184
+ focusable.every((element) => {
185
+ const order = element.getAttribute("tabindex");
186
+ return order === null || Number.parseInt(order, 10) <= 0;
187
+ }),
188
+ "nothing claims a positive tabindex, so DOM order is the tab order",
189
+ );
190
+ const last = focusable[focusable.length - 1];
191
+ assert.ok(
192
+ last && footer.contains(last),
193
+ "tabbing forward through the report ends on the footer action",
194
+ );
195
+ });
196
+
197
+ it("holds the same shape for a long list of accounts needing credentials", () => {
198
+ harness = createDomHarness({ viewportWidth: 1512 });
199
+ harness.render(
200
+ createElement(StepCredentialsOverview, { accounts: MANY_ACCOUNTS }),
201
+ );
202
+
203
+ const body = harness.query("[data-testid=wizard-body]");
204
+ const footer = harness.query("[data-testid=wizard-footer]");
205
+ assert.ok(body && footer);
206
+ assert.equal(
207
+ body.querySelectorAll("li").length,
208
+ MANY_ACCOUNTS.length,
209
+ "the account list grows inside the scroll box",
210
+ );
211
+ assert.ok(
212
+ footer.contains(harness.byText("button", "Finish later")),
213
+ "the way on stays pinned however many accounts the file carried",
214
+ );
215
+ });
216
+ });
@@ -13,19 +13,24 @@ import { describe, it } from "node:test";
13
13
  import { freeStretchesFromSpans } from "@remit/ui";
14
14
  import {
15
15
  busySpansByDate,
16
+ CAP_WEEKS,
16
17
  datesInRange,
17
18
  extendRangeEnd,
18
19
  extendRangeStart,
19
20
  freeStretchesByDate,
20
21
  LEAD_IN,
21
22
  LEAD_OUT,
23
+ liftRangeCeiling,
24
+ liftRangeFloor,
22
25
  PAGE,
23
26
  rangeAround,
27
+ rangeAtCeiling,
28
+ rangeAtFloor,
24
29
  rangeCovering,
25
30
  weekKeyOf,
26
31
  weekWindowsOver,
27
32
  } from "./agenda-window.js";
28
- import { calendarWindow, startOfDay } from "./window.js";
33
+ import { addDays, calendarWindow, startOfDay } from "./window.js";
29
34
 
30
35
  const DATE = "2026-06-10";
31
36
 
@@ -53,14 +58,8 @@ describe("the days the strip holds", () => {
53
58
 
54
59
  it("grows the run at whichever end was reached, keeping the other", () => {
55
60
  const range = rangeAround(DATE);
56
- assert.deepEqual(extendRangeStart(range), {
57
- from: "2026-05-17",
58
- to: range.to,
59
- });
60
- assert.deepEqual(extendRangeEnd(range), {
61
- from: range.from,
62
- to: "2026-07-18",
63
- });
61
+ assert.deepEqual(extendRangeStart(range), { ...range, from: "2026-05-24" });
62
+ assert.deepEqual(extendRangeEnd(range), { ...range, to: "2026-07-11" });
64
63
  assert.equal(
65
64
  datesInRange(extendRangeEnd(range)).length,
66
65
  datesInRange(range).length + PAGE,
@@ -87,6 +86,71 @@ describe("the days the strip holds", () => {
87
86
  });
88
87
  });
89
88
 
89
+ /**
90
+ * The strip is shorter than the distance it fetches at whenever the diary is
91
+ * sparse, so an end measured off the content is reached on the first layout
92
+ * pass and stays reached however many days arrive. What stops that is the
93
+ * strip only growing on a scroll the reader drove; this is the backstop that
94
+ * makes the runaway it caused — a two-event diary walking out to 2032, with the
95
+ * address following it — arithmetically impossible rather than merely unlikely.
96
+ */
97
+ describe("how far the run grows before it asks", () => {
98
+ const CAP_DAYS = CAP_WEEKS * 7;
99
+
100
+ /** Far more reaches than a year of weeks needs, so the cap is what stops it. */
101
+ const reachEnd = (range = rangeAround(DATE), times = 200) => {
102
+ let grown = range;
103
+ for (let reach = 0; reach < times; reach += 1)
104
+ grown = extendRangeEnd(grown);
105
+ return grown;
106
+ };
107
+
108
+ const reachStart = (range = rangeAround(DATE), times = 200) => {
109
+ let grown = range;
110
+ for (let reach = 0; reach < times; reach += 1)
111
+ grown = extendRangeStart(grown);
112
+ return grown;
113
+ };
114
+
115
+ it("stops a year either way, however often an end is reached", () => {
116
+ const range = reachEnd(reachStart());
117
+ assert.equal(range.from, addDays(DATE, -CAP_DAYS));
118
+ assert.equal(range.to, addDays(DATE, CAP_DAYS));
119
+ assert.equal(rangeAtFloor(range), true);
120
+ assert.equal(rangeAtCeiling(range), true);
121
+ });
122
+
123
+ it("hands back the run it was given at the cap, so nothing refetches", () => {
124
+ const capped = reachEnd();
125
+ assert.equal(extendRangeEnd(capped), capped);
126
+ const behind = reachStart();
127
+ assert.equal(extendRangeStart(behind), behind);
128
+ });
129
+
130
+ it("opens the next year where the reader asked, keeping the days held", () => {
131
+ const capped = reachEnd();
132
+ const more = liftRangeCeiling(capped);
133
+ assert.equal(more.from, capped.from);
134
+ assert.equal(more.to, addDays(capped.to, PAGE));
135
+ assert.equal(rangeAtCeiling(more), false);
136
+ assert.equal(rangeAtFloor(more), false);
137
+ });
138
+
139
+ it("does the same behind, without moving the end ahead", () => {
140
+ const capped = reachStart();
141
+ const more = liftRangeFloor(capped);
142
+ assert.equal(more.to, capped.to);
143
+ assert.equal(more.from, addDays(capped.from, -PAGE));
144
+ assert.equal(rangeAtFloor(more), false);
145
+ });
146
+
147
+ it("gives a jump its own year, measured from where it landed", () => {
148
+ const jumped = rangeCovering(reachEnd(), "2028-03-01");
149
+ assert.deepEqual(jumped, rangeAround("2028-03-01"));
150
+ assert.equal(rangeAtCeiling(jumped), false);
151
+ });
152
+ });
153
+
90
154
  /**
91
155
  * The range is not a window. A read may not cover more than a year
92
156
  * (`CALENDAR_MAX_WINDOW_DAYS`), and the strip's range grows every time the
@@ -105,6 +169,15 @@ describe("the weeks a range is fetched as", () => {
105
169
  assert.equal(new Set(windows.map((w) => w.from)).size, windows.length);
106
170
  });
107
171
 
172
+ /**
173
+ * What a strip that has just opened issues, and all it issues: the six weeks
174
+ * its opening run falls in. Anything past those is a week the reader has
175
+ * scrolled to.
176
+ */
177
+ it("is the six weeks the opening run falls in, and no more", () => {
178
+ assert.equal(weekWindowsOver(datesInRange(rangeAround(DATE))).length, 6);
179
+ });
180
+
108
181
  it("covers every day the strip holds and nothing before or after", () => {
109
182
  const dates = datesInRange(rangeAround(DATE));
110
183
  const held = new Set(weekWindowsOver(dates).map((window) => window.from));
@@ -28,30 +28,70 @@ import { addDays, type CalendarWindow, calendarWindow } from "./window";
28
28
  export const LEAD_IN = 10;
29
29
  export const LEAD_OUT = 24;
30
30
 
31
- /** What reaching an end adds to it. */
32
- export const PAGE = 14;
31
+ /** What reaching an end adds to it: the next week, and no more. */
32
+ export const PAGE = 7;
33
+
34
+ /**
35
+ * How far the run grows on the reader's scroll before it stops and asks.
36
+ *
37
+ * A year either way is further than anyone scrolls in one sitting, so the cap
38
+ * costs a reader nothing and bounds the damage of anything that asks for more
39
+ * days without one behind it. The strip is shorter than the reach it fetches at
40
+ * whenever the diary is sparse, and a run that grew on that alone walked out to
41
+ * a decade of empty weeks and took the address with it.
42
+ */
43
+ export const CAP_WEEKS = 52;
44
+ const CAP_DAYS = CAP_WEEKS * 7;
33
45
 
34
46
  export interface AgendaRange {
35
47
  /** `YYYY-MM-DD`, inclusive. */
36
48
  from: string;
37
49
  /** `YYYY-MM-DD`, inclusive. */
38
50
  to: string;
51
+ /** The earliest day scrolling alone may reach. */
52
+ floor: string;
53
+ /** The latest. */
54
+ ceiling: string;
39
55
  }
40
56
 
41
57
  export const rangeAround = (date: string): AgendaRange => ({
42
58
  from: addDays(date, -LEAD_IN),
43
59
  to: addDays(date, LEAD_OUT),
60
+ floor: addDays(date, -CAP_DAYS),
61
+ ceiling: addDays(date, CAP_DAYS),
44
62
  });
45
63
 
46
- export const extendRangeStart = (range: AgendaRange): AgendaRange => ({
47
- ...range,
48
- from: addDays(range.from, -PAGE),
49
- });
64
+ /** The run has grown as far back as it may on its own. */
65
+ export const rangeAtFloor = (range: AgendaRange): boolean =>
66
+ range.from <= range.floor;
50
67
 
51
- export const extendRangeEnd = (range: AgendaRange): AgendaRange => ({
52
- ...range,
53
- to: addDays(range.to, PAGE),
54
- });
68
+ export const rangeAtCeiling = (range: AgendaRange): boolean =>
69
+ range.to >= range.ceiling;
70
+
71
+ const later = (a: string, b: string): string => (a > b ? a : b);
72
+ const earlier = (a: string, b: string): string => (a < b ? a : b);
73
+
74
+ /** At the cap this is the range it was given, identically, so nothing refetches. */
75
+ export const extendRangeStart = (range: AgendaRange): AgendaRange =>
76
+ rangeAtFloor(range)
77
+ ? range
78
+ : { ...range, from: later(addDays(range.from, -PAGE), range.floor) };
79
+
80
+ export const extendRangeEnd = (range: AgendaRange): AgendaRange =>
81
+ rangeAtCeiling(range)
82
+ ? range
83
+ : { ...range, to: earlier(addDays(range.to, PAGE), range.ceiling) };
84
+
85
+ /**
86
+ * The cap lifted, by a reader who said so. Another year opens behind the day
87
+ * the run has reached and the days already held stay where they are, so the
88
+ * strip keeps growing on the scroll from there.
89
+ */
90
+ export const liftRangeFloor = (range: AgendaRange): AgendaRange =>
91
+ extendRangeStart({ ...range, floor: addDays(range.from, -CAP_DAYS) });
92
+
93
+ export const liftRangeCeiling = (range: AgendaRange): AgendaRange =>
94
+ extendRangeEnd({ ...range, ceiling: addDays(range.to, CAP_DAYS) });
55
95
 
56
96
  /**
57
97
  * The range that has a day in it. A day already held keeps the range it is in —
@@ -8,6 +8,7 @@
8
8
  export {
9
9
  type AgendaRange,
10
10
  busySpansByDate,
11
+ CAP_WEEKS,
11
12
  datesInRange,
12
13
  extendRangeEnd,
13
14
  extendRangeStart,
@@ -15,8 +16,12 @@ export {
15
16
  freeStretchesByDate,
16
17
  LEAD_IN,
17
18
  LEAD_OUT,
19
+ liftRangeCeiling,
20
+ liftRangeFloor,
18
21
  PAGE,
19
22
  rangeAround,
23
+ rangeAtCeiling,
24
+ rangeAtFloor,
20
25
  rangeCovering,
21
26
  weekKeyOf,
22
27
  weekWindowsOver,