@remit/web-client 0.0.231 → 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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/AgendaStrip.stories.tsx +263 -0
  3. package/src/components/calendar/AgendaStrip.tsx +145 -0
  4. package/src/components/calendar/AgendaView.tsx +222 -0
  5. package/src/components/calendar/CalendarComposePane.stories.tsx +101 -11
  6. package/src/components/calendar/CalendarComposePane.tsx +83 -23
  7. package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
  8. package/src/components/calendar/CalendarEventPane.stories.tsx +30 -1
  9. package/src/components/calendar/CalendarEventPane.tsx +28 -5
  10. package/src/components/calendar/CalendarViewPlaceholder.tsx +3 -4
  11. package/src/components/calendar/CalendarWorkspace.stories.tsx +34 -1
  12. package/src/components/calendar/CalendarWorkspace.tsx +71 -23
  13. package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
  14. package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
  15. package/src/components/calendar/agenda-address.render.test.ts +389 -0
  16. package/src/components/calendar/agenda-strip.render.test.ts +241 -0
  17. package/src/components/calendar/calendar-workspace.render.test.ts +13 -6
  18. package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
  19. package/src/hooks/calendar/agenda-window.test.ts +235 -0
  20. package/src/hooks/calendar/agenda-window.ts +183 -0
  21. package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
  22. package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
  23. package/src/hooks/calendar/draft.test.ts +241 -0
  24. package/src/hooks/calendar/draft.ts +168 -0
  25. package/src/hooks/calendar/index.ts +88 -0
  26. package/src/hooks/calendar/instance.ts +136 -0
  27. package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
  28. package/src/hooks/calendar/recurrence-rule.ts +133 -0
  29. package/src/hooks/calendar/resource.test.ts +98 -0
  30. package/src/hooks/calendar/resource.ts +74 -0
  31. package/src/hooks/calendar/selection.ts +107 -0
  32. package/src/hooks/calendar/useCalendarEvent.ts +46 -0
  33. package/src/hooks/calendar/useCalendarEvents.ts +195 -0
  34. package/src/hooks/calendar/useCalendarFreeBusy.ts +80 -0
  35. package/src/hooks/calendar/useCalendarWrites.ts +174 -0
  36. package/src/hooks/calendar/useCalendars.ts +43 -0
  37. package/src/hooks/calendar/window.test.ts +148 -0
  38. package/src/hooks/calendar/window.ts +168 -0
  39. package/src/hooks/useCalendarData.ts +62 -40
  40. package/src/lib/calendar-density.ts +13 -1
  41. package/src/lib/calendar-route.test.ts +20 -1
  42. package/src/lib/calendar-route.ts +21 -7
  43. package/src/routes/calendar/$view.$date/new.tsx +2 -2
  44. package/src/routes/calendar/$view.$date.tsx +51 -15
  45. package/src/routing/calendar.ts +32 -4
  46. package/src/test-support/http.ts +8 -0
  47. package/src/lib/calendar-fixtures.ts +0 -207
@@ -0,0 +1,235 @@
1
+ /**
2
+ * The rolling range, and the busy time it is measured against.
3
+ *
4
+ * The strip never paginates, so the claims here are about continuity: reaching
5
+ * an end grows the run rather than replacing it, a day already held keeps the
6
+ * days either side of it, and a jump opens a window where it landed. The
7
+ * free-time half asserts that busy spans the strip did not draw still take the
8
+ * hours they cover — the rule itself is `@remit/ui`'s, and this only decides
9
+ * which spans a day gets.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import { freeStretchesFromSpans } from "@remit/ui";
14
+ import {
15
+ busySpansByDate,
16
+ datesInRange,
17
+ extendRangeEnd,
18
+ extendRangeStart,
19
+ freeStretchesByDate,
20
+ LEAD_IN,
21
+ LEAD_OUT,
22
+ PAGE,
23
+ rangeAround,
24
+ rangeCovering,
25
+ weekKeyOf,
26
+ weekWindowsOver,
27
+ } from "./agenda-window.js";
28
+ import { calendarWindow, startOfDay } from "./window.js";
29
+
30
+ const DATE = "2026-06-10";
31
+
32
+ /** The offset this machine is on, so a fixture reads the same in any zone. */
33
+ const offsetOn = (date: string): string => startOfDay(date).slice(19);
34
+
35
+ const at = (date: string, time: string): string =>
36
+ `${date}T${time}:00${offsetOn(date)}`;
37
+
38
+ const clocks = (
39
+ stretches: readonly { startMinute: number; endMinute: number }[],
40
+ ): string[] =>
41
+ stretches.map(
42
+ (stretch) =>
43
+ `${Math.floor(stretch.startMinute / 60)}–${Math.floor(stretch.endMinute / 60)}`,
44
+ );
45
+
46
+ describe("the days the strip holds", () => {
47
+ it("opens further ahead than behind, because a diary is read forwards", () => {
48
+ const range = rangeAround(DATE);
49
+ assert.equal(range.from, "2026-05-31");
50
+ assert.equal(range.to, "2026-07-04");
51
+ assert.equal(datesInRange(range).length, LEAD_IN + LEAD_OUT + 1);
52
+ });
53
+
54
+ it("grows the run at whichever end was reached, keeping the other", () => {
55
+ 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
+ });
64
+ assert.equal(
65
+ datesInRange(extendRangeEnd(range)).length,
66
+ datesInRange(range).length + PAGE,
67
+ );
68
+ });
69
+
70
+ it("keeps the run a day is already in, so a scroll refetches nothing", () => {
71
+ const range = rangeAround(DATE);
72
+ for (const day of ["2026-05-31", DATE, "2026-06-28", "2026-07-04"]) {
73
+ assert.equal(rangeCovering(range, day), range, `${day} moved the run`);
74
+ }
75
+ });
76
+
77
+ it("opens a window where a jump landed rather than stretching to it", () => {
78
+ const range = rangeAround(DATE);
79
+ assert.deepEqual(
80
+ rangeCovering(range, "2026-11-02"),
81
+ rangeAround("2026-11-02"),
82
+ );
83
+ assert.deepEqual(
84
+ rangeCovering(range, "2026-01-05"),
85
+ rangeAround("2026-01-05"),
86
+ );
87
+ });
88
+ });
89
+
90
+ /**
91
+ * The range is not a window. A read may not cover more than a year
92
+ * (`CALENDAR_MAX_WINDOW_DAYS`), and the strip's range grows every time the
93
+ * reader reaches an end, so the range names weeks to fetch rather than being
94
+ * one request that eventually gets refused.
95
+ */
96
+ describe("the weeks a range is fetched as", () => {
97
+ it("is the grid's own window for each week, once each", () => {
98
+ const windows = weekWindowsOver(datesInRange(rangeAround(DATE)));
99
+ for (const window of windows) {
100
+ assert.deepEqual(
101
+ window,
102
+ calendarWindow("week", window.from.slice(0, 10)),
103
+ );
104
+ }
105
+ assert.equal(new Set(windows.map((w) => w.from)).size, windows.length);
106
+ });
107
+
108
+ it("covers every day the strip holds and nothing before or after", () => {
109
+ const dates = datesInRange(rangeAround(DATE));
110
+ const held = new Set(weekWindowsOver(dates).map((window) => window.from));
111
+ for (const date of dates) assert.ok(held.has(weekKeyOf(date)));
112
+ });
113
+
114
+ it("names the week a day belongs to, Monday to Monday", () => {
115
+ // 2026-06-10 is a Wednesday; its week opens on the 8th.
116
+ assert.equal(weekKeyOf("2026-06-10"), startOfDay("2026-06-08"));
117
+ assert.equal(weekKeyOf("2026-06-08"), startOfDay("2026-06-08"));
118
+ assert.equal(weekKeyOf("2026-06-14"), startOfDay("2026-06-08"));
119
+ assert.equal(weekKeyOf("2026-06-15"), startOfDay("2026-06-15"));
120
+ });
121
+
122
+ it("asks for one week however far the reader scrolls", () => {
123
+ // Past the server's 366-day ceiling in both directions.
124
+ let range = rangeAround(DATE);
125
+ for (let reach = 0; reach < 30; reach += 1) {
126
+ range = extendRangeEnd(extendRangeStart(range));
127
+ }
128
+ const dates = datesInRange(range);
129
+ assert.ok(dates.length > 366, "the range never grew past the ceiling");
130
+
131
+ for (const window of weekWindowsOver(dates)) {
132
+ const days =
133
+ (Date.parse(window.to) - Date.parse(window.from)) / 86_400_000;
134
+ assert.equal(days, 7, `a window covered ${days} days`);
135
+ }
136
+ });
137
+
138
+ it("grows by adding weeks rather than by widening one", () => {
139
+ const opening = weekWindowsOver(datesInRange(rangeAround(DATE)));
140
+ const wider = weekWindowsOver(
141
+ datesInRange(extendRangeStart(rangeAround(DATE))),
142
+ );
143
+ const kept = new Set(wider.map((window) => window.from));
144
+ for (const window of opening) {
145
+ assert.ok(kept.has(window.from), `${window.from} was given up`);
146
+ }
147
+ assert.ok(wider.length > opening.length);
148
+ });
149
+ });
150
+
151
+ describe("busy time, split into the days it covers", () => {
152
+ const dates = ["2026-06-10", "2026-06-11", "2026-06-12"];
153
+
154
+ it("reads a span as minutes of the day it falls on", () => {
155
+ const byDate = busySpansByDate(dates, [
156
+ { start: at("2026-06-10", "09:00"), end: at("2026-06-10", "10:30") },
157
+ ]);
158
+ assert.deepEqual(byDate.get("2026-06-10"), [{ from: 540, to: 630 }]);
159
+ assert.equal(byDate.get("2026-06-11"), undefined);
160
+ });
161
+
162
+ it("gives a span crossing midnight one entry per day it touches", () => {
163
+ const byDate = busySpansByDate(dates, [
164
+ { start: at("2026-06-10", "22:00"), end: at("2026-06-11", "02:00") },
165
+ ]);
166
+ assert.deepEqual(byDate.get("2026-06-10"), [{ from: 1320, to: 1440 }]);
167
+ assert.deepEqual(byDate.get("2026-06-11"), [{ from: 0, to: 120 }]);
168
+ });
169
+
170
+ it("leaves no entry on the day a span merely ends at midnight of", () => {
171
+ const byDate = busySpansByDate(dates, [
172
+ { start: at("2026-06-10", "22:00"), end: at("2026-06-11", "00:00") },
173
+ ]);
174
+ assert.deepEqual(byDate.get("2026-06-10"), [{ from: 1320, to: 1440 }]);
175
+ assert.equal(byDate.get("2026-06-11"), undefined);
176
+ });
177
+
178
+ it("merges spans that run into each other into the hours they cover", () => {
179
+ const byDate = busySpansByDate(dates, [
180
+ { start: at("2026-06-10", "11:00"), end: at("2026-06-10", "12:00") },
181
+ { start: at("2026-06-10", "09:00"), end: at("2026-06-10", "11:30") },
182
+ ]);
183
+ assert.deepEqual(byDate.get("2026-06-10"), [{ from: 540, to: 720 }]);
184
+ });
185
+
186
+ it("drops a span that is not a stretch of time at all", () => {
187
+ const byDate = busySpansByDate(dates, [
188
+ { start: at("2026-06-10", "11:00"), end: at("2026-06-10", "11:00") },
189
+ { start: "not a date", end: at("2026-06-10", "12:00") },
190
+ ]);
191
+ assert.equal(byDate.size, 0);
192
+ });
193
+
194
+ it("ignores busy time outside the days on screen", () => {
195
+ const byDate = busySpansByDate(dates, [
196
+ { start: at("2026-07-01", "09:00"), end: at("2026-07-01", "10:00") },
197
+ ]);
198
+ assert.equal(byDate.size, 0);
199
+ });
200
+ });
201
+
202
+ describe("free time, off the spans the server merged", () => {
203
+ const dates = ["2026-06-10", "2026-06-11"];
204
+
205
+ it("answers every day the strip holds, including the untouched ones", () => {
206
+ const free = freeStretchesByDate(dates, []);
207
+ assert.deepEqual([...free.keys()], dates);
208
+ assert.equal(free.get("2026-06-11")?.[0].wholeDay, true);
209
+ });
210
+
211
+ /**
212
+ * The reader unticked work, so the strip draws nothing on the 10th. They are
213
+ * still in meetings all morning, and "Free all day" would be the one thing
214
+ * this view must never say.
215
+ */
216
+ it("takes hours out of a day the strip is drawing nothing on", () => {
217
+ const free = freeStretchesByDate(dates, [
218
+ { start: at("2026-06-10", "09:00"), end: at("2026-06-10", "12:00") },
219
+ { start: at("2026-06-10", "13:00"), end: at("2026-06-10", "16:00") },
220
+ ]);
221
+ assert.deepEqual(clocks(free.get("2026-06-10") ?? []), ["16–22"]);
222
+ assert.equal(free.get("2026-06-10")?.[0].wholeDay, false);
223
+ });
224
+
225
+ it("is the rule `@remit/ui` states, applied to those spans", () => {
226
+ const spans = [{ from: 9 * 60, to: 12 * 60 }];
227
+ const free = freeStretchesByDate(dates, [
228
+ { start: at("2026-06-10", "09:00"), end: at("2026-06-10", "12:00") },
229
+ ]);
230
+ assert.deepEqual(
231
+ free.get("2026-06-10"),
232
+ freeStretchesFromSpans("2026-06-10", spans),
233
+ );
234
+ });
235
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The days the strip currently holds, and the arithmetic that grows them.
3
+ *
4
+ * The agenda never paginates. It holds one contiguous run of days around the
5
+ * day the address names and grows it at whichever end the reader reaches, so
6
+ * there are no page boundaries to lose a place at and no request that throws
7
+ * away what is already on screen. The range is the only thing a window is: what
8
+ * to ask `/calendar-events` and `/calendar-free-busy` for falls out of it.
9
+ *
10
+ * Nothing here reads a clock or a router. Every function takes the day it works
11
+ * from, which is what lets the rules be asserted without either.
12
+ */
13
+ import {
14
+ type BusySpan,
15
+ type CalendarDay,
16
+ datesBetween,
17
+ type FreeStretch,
18
+ freeStretchesFromSpans,
19
+ } from "@remit/ui";
20
+ import { isoDate } from "@/lib/calendar-route";
21
+ import { addDays, type CalendarWindow, calendarWindow } from "./window";
22
+
23
+ /**
24
+ * Days held behind and ahead of the day the address opened on. More ahead than
25
+ * behind: a reader lands on a calendar to look forward, and the days behind are
26
+ * there so scrolling back a little does not immediately fetch.
27
+ */
28
+ export const LEAD_IN = 10;
29
+ export const LEAD_OUT = 24;
30
+
31
+ /** What reaching an end adds to it. */
32
+ export const PAGE = 14;
33
+
34
+ export interface AgendaRange {
35
+ /** `YYYY-MM-DD`, inclusive. */
36
+ from: string;
37
+ /** `YYYY-MM-DD`, inclusive. */
38
+ to: string;
39
+ }
40
+
41
+ export const rangeAround = (date: string): AgendaRange => ({
42
+ from: addDays(date, -LEAD_IN),
43
+ to: addDays(date, LEAD_OUT),
44
+ });
45
+
46
+ export const extendRangeStart = (range: AgendaRange): AgendaRange => ({
47
+ ...range,
48
+ from: addDays(range.from, -PAGE),
49
+ });
50
+
51
+ export const extendRangeEnd = (range: AgendaRange): AgendaRange => ({
52
+ ...range,
53
+ to: addDays(range.to, PAGE),
54
+ });
55
+
56
+ /**
57
+ * The range that has a day in it. A day already held keeps the range it is in —
58
+ * identically, so a scroll that moved the address does not throw away the days
59
+ * either side of it and refetch them. A day outside it is a jump, and a jump
60
+ * opens a window around where it landed rather than stretching one across the
61
+ * months in between.
62
+ */
63
+ export function rangeCovering(range: AgendaRange, date: string): AgendaRange {
64
+ if (date >= range.from && date <= range.to) return range;
65
+ return rangeAround(date);
66
+ }
67
+
68
+ /**
69
+ * The week each day of the range is served by, deduplicated and in order.
70
+ *
71
+ * The strip holds more days than one read may ask for — the server refuses a
72
+ * window over a year — so the range says which weeks to fetch rather than being
73
+ * a window itself. Reaching an end then adds a week to this list instead of
74
+ * widening a single request, which is what keeps the days already on screen
75
+ * where they are.
76
+ *
77
+ * The weeks are `calendarWindow("week", …)` exactly, so they are the grid's own
78
+ * cache entries: a reader who drops into the week grid and comes back out draws
79
+ * from what the strip fetched, and a week the grid has already read costs the
80
+ * strip nothing.
81
+ */
82
+ export function weekWindowsOver(dates: readonly string[]): CalendarWindow[] {
83
+ const byStart = new Map<string, CalendarWindow>();
84
+ for (const date of dates) {
85
+ const week = calendarWindow("week", date);
86
+ if (!byStart.has(week.from)) byStart.set(week.from, week);
87
+ }
88
+ return [...byStart.values()];
89
+ }
90
+
91
+ /** The week window a day belongs to, named by its `from`. */
92
+ export const weekKeyOf = (date: string): string =>
93
+ calendarWindow("week", date).from;
94
+
95
+ const MINUTES_IN_DAY = 24 * 60;
96
+
97
+ const minuteOfInstant = (instant: Date): number =>
98
+ instant.getHours() * 60 + instant.getMinutes();
99
+
100
+ /**
101
+ * Busy spans as minutes of the days they cross, on the clock this device is
102
+ * reading. The API answers in UTC instants because a busy stretch is an
103
+ * interval rather than a civil date, so the split into days happens here, where
104
+ * the zone the strip is drawn in is known.
105
+ *
106
+ * A span crossing midnight becomes one entry per day it touches. Each day's
107
+ * entries come back sorted and merged, which is the shape the free-time rule
108
+ * takes.
109
+ */
110
+ export function busySpansByDate(
111
+ dates: readonly string[],
112
+ spans: readonly { start: string; end: string }[],
113
+ ): Map<string, BusySpan[]> {
114
+ const held = new Set(dates);
115
+ const byDate = new Map<string, BusySpan[]>();
116
+
117
+ for (const span of spans) {
118
+ const start = new Date(span.start);
119
+ const end = new Date(span.end);
120
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue;
121
+ if (end.getTime() <= start.getTime()) continue;
122
+
123
+ const firstDate = isoDate(start);
124
+ // The instant before the end, so a span ending at midnight belongs to the
125
+ // day it ran through rather than opening an empty entry on the next one.
126
+ const lastDate = isoDate(new Date(end.getTime() - 1));
127
+ for (let date = firstDate; date <= lastDate; date = addDays(date, 1)) {
128
+ if (!held.has(date)) continue;
129
+ const from = date === firstDate ? minuteOfInstant(start) : 0;
130
+ // Read off the end's own day rather than off the loop: a span ending at
131
+ // midnight belongs to the day it ran through, where its end is the end
132
+ // of that day and not minute zero of the next one.
133
+ const to = isoDate(end) === date ? minuteOfInstant(end) : MINUTES_IN_DAY;
134
+ if (to <= from) continue;
135
+ const day = byDate.get(date);
136
+ if (day) day.push({ from, to });
137
+ else byDate.set(date, [{ from, to }]);
138
+ }
139
+ }
140
+
141
+ for (const [date, day] of byDate) byDate.set(date, mergeSpans(day));
142
+ return byDate;
143
+ }
144
+
145
+ function mergeSpans(spans: BusySpan[]): BusySpan[] {
146
+ const sorted = [...spans].sort((a, b) => a.from - b.from);
147
+ const merged: BusySpan[] = [];
148
+ for (const span of sorted) {
149
+ const last = merged[merged.length - 1];
150
+ if (last && span.from <= last.to) {
151
+ last.to = Math.max(last.to, span.to);
152
+ continue;
153
+ }
154
+ merged.push({ ...span });
155
+ }
156
+ return merged;
157
+ }
158
+
159
+ /**
160
+ * The free time on every day the strip holds, out of the busy spans the server
161
+ * merged across every calendar. It is `freeStretchesFromSpans` and nothing
162
+ * else: the rule lives in `@remit/ui` beside the strip that draws it, so this
163
+ * only decides which spans a day gets.
164
+ */
165
+ export function freeStretchesByDate(
166
+ dates: readonly string[],
167
+ spans: readonly { start: string; end: string }[],
168
+ ): Map<string, FreeStretch[]> {
169
+ const busy = busySpansByDate(dates, spans);
170
+ return new Map(
171
+ dates.map((date) => [
172
+ date,
173
+ freeStretchesFromSpans(date, busy.get(date) ?? []),
174
+ ]),
175
+ );
176
+ }
177
+
178
+ /** The days a range names, in order. */
179
+ export const datesInRange = (range: AgendaRange): string[] =>
180
+ datesBetween(range.from, range.to);
181
+
182
+ /** How the strip asks a day what is still open on it. */
183
+ export type FreeLookup = (day: CalendarDay) => FreeStretch[];
@@ -0,0 +1,265 @@
1
+ /**
2
+ * What the calendar asks the server, and how often.
3
+ *
4
+ * Three things are pinned here, and all three are about the request rather than
5
+ * about the pixels: the window is the screenful the address names, the same
6
+ * week asked for twice is one request, and a step lands on a window that was
7
+ * already warmed. A grid that refetches on every navigation looks identical in
8
+ * a screenshot and is a different product to use.
9
+ *
10
+ * The tick list is the fourth: ticking nothing is every calendar, and an
11
+ * address naming a calendar that is gone shows the rest rather than a week that
12
+ * reads as empty.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import type {
18
+ RemitImapCalendarEventInstance,
19
+ RemitImapCalendarResponse,
20
+ } from "@remit/api-http-client/types.gen.ts";
21
+ import { createElement } from "react";
22
+ import { type CalendarData, useCalendarData } from "@/hooks/useCalendarData";
23
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
24
+ import { type HttpMock, mockFetch } from "../../test-support/http";
25
+
26
+ const WORK = "11111111-1111-4111-8111-111111111111";
27
+ const HOME = "22222222-2222-4222-8222-222222222222";
28
+
29
+ let harness: DomHarness | undefined;
30
+ let http: HttpMock | undefined;
31
+ let data: CalendarData | undefined;
32
+
33
+ afterEach(() => {
34
+ harness?.close();
35
+ harness = undefined;
36
+ http?.restore();
37
+ http = undefined;
38
+ data = undefined;
39
+ });
40
+
41
+ const calendar = (
42
+ calendarId: string,
43
+ displayName: string,
44
+ ): RemitImapCalendarResponse =>
45
+ ({
46
+ calendarId,
47
+ accountConfigId: "cfg-1",
48
+ urlSegment: displayName.toLowerCase(),
49
+ displayName,
50
+ color: "Cal1",
51
+ componentSet: "VeventOnly",
52
+ source: "UserCreated",
53
+ timezone: "Europe/Amsterdam",
54
+ syncSequence: 1,
55
+ createdAt: 0,
56
+ updatedAt: 0,
57
+ }) as RemitImapCalendarResponse;
58
+
59
+ const instance = (
60
+ over: Partial<RemitImapCalendarEventInstance>,
61
+ ): RemitImapCalendarEventInstance =>
62
+ ({
63
+ calendarId: WORK,
64
+ calendarObjectId: "obj-1",
65
+ recurrenceId: "",
66
+ icalUid: "uid-1",
67
+ summary: "Roadmap review",
68
+ start: "2026-06-10T10:00:00+02:00",
69
+ end: "2026-06-10T11:30:00+02:00",
70
+ allDay: false,
71
+ status: "Confirmed",
72
+ transparency: "Opaque",
73
+ zoneCertainty: "Explicit",
74
+ etag: "etag-1",
75
+ hasRecurrence: false,
76
+ ...over,
77
+ }) as RemitImapCalendarEventInstance;
78
+
79
+ function Probe({
80
+ date,
81
+ calendarIds,
82
+ }: {
83
+ date: string;
84
+ calendarIds: readonly string[];
85
+ }) {
86
+ data = useCalendarData({ view: "week", date, calendarIds });
87
+ return null;
88
+ }
89
+
90
+ const mount = async (
91
+ date: string,
92
+ calendarIds: readonly string[],
93
+ instances: RemitImapCalendarEventInstance[] = [],
94
+ calendars: RemitImapCalendarResponse[] = [
95
+ calendar(WORK, "Work"),
96
+ calendar(HOME, "Home"),
97
+ ],
98
+ ) => {
99
+ http = mockFetch((call) =>
100
+ call.path.endsWith("/calendars")
101
+ ? { items: calendars }
102
+ : { items: instances },
103
+ );
104
+ harness = createDomHarness();
105
+ harness.renderApp(createElement(Probe, { date, calendarIds }));
106
+ await harness.flush();
107
+ await harness.wait(20);
108
+ await harness.flush();
109
+ };
110
+
111
+ const rerender = async (date: string, calendarIds: readonly string[]) => {
112
+ harness?.renderApp(createElement(Probe, { date, calendarIds }));
113
+ await harness?.flush();
114
+ await harness?.wait(20);
115
+ await harness?.flush();
116
+ };
117
+
118
+ /** Event listings whose window starts on a given day. */
119
+ const windowsFrom = (day: string): string[] =>
120
+ (http?.to("/calendar-events") ?? [])
121
+ .map((call) => call.url)
122
+ .filter((url) => url.includes(`from=${encodeURIComponent(day)}`));
123
+
124
+ describe("the window the grid asks for", () => {
125
+ it("is the week the address names, not the day", async () => {
126
+ await mount("2026-06-10", []);
127
+ const asked = windowsFrom("2026-06-08");
128
+ assert.equal(asked.length, 1, "the visible week was asked for once");
129
+ assert.ok(
130
+ asked[0].includes(`to=${encodeURIComponent("2026-06-15")}`),
131
+ `the window ends on the next Monday: ${asked[0]}`,
132
+ );
133
+ });
134
+
135
+ it("warms the week either side, so a step is not a wait", async () => {
136
+ await mount("2026-06-10", []);
137
+ assert.equal(windowsFrom("2026-06-01").length, 1);
138
+ assert.equal(windowsFrom("2026-06-15").length, 1);
139
+ });
140
+ });
141
+
142
+ describe("moving around inside a week already read", () => {
143
+ it("asks nothing again", async () => {
144
+ await mount("2026-06-08", []);
145
+ await rerender("2026-06-11", []);
146
+ assert.equal(
147
+ windowsFrom("2026-06-08").length,
148
+ 1,
149
+ "every day of one week shares the window that was already fetched",
150
+ );
151
+ });
152
+
153
+ it("steps onto a week the prefetch already holds", async () => {
154
+ await mount("2026-06-10", []);
155
+ const before = windowsFrom("2026-06-15").length;
156
+ await rerender("2026-06-17", []);
157
+ assert.equal(
158
+ windowsFrom("2026-06-15").length,
159
+ before,
160
+ "the next week was warmed, so stepping onto it fetches nothing",
161
+ );
162
+ });
163
+ });
164
+
165
+ describe("the calendars the address ticked", () => {
166
+ it("names none when the reader has ticked none", async () => {
167
+ await mount("2026-06-10", []);
168
+ assert.ok(
169
+ windowsFrom("2026-06-08").every((url) => !url.includes("calendarId=")),
170
+ "ticking nothing off is every calendar, which the API takes as no filter",
171
+ );
172
+ });
173
+
174
+ it("names the one the address ticked", async () => {
175
+ await mount("2026-06-10", [WORK]);
176
+ const asked = windowsFrom("2026-06-08");
177
+ assert.equal(asked.length, 1);
178
+ assert.ok(asked[0].includes(`calendarId=${WORK}`));
179
+ assert.ok(!asked[0].includes(`calendarId=${HOME}`));
180
+ });
181
+
182
+ it("shows the rest when the address names a calendar that is gone", async () => {
183
+ await mount("2026-06-10", ["33333333-3333-4333-8333-333333333333"]);
184
+ assert.ok(
185
+ windowsFrom("2026-06-08").every((url) => !url.includes("calendarId=")),
186
+ "an address pointing at nothing shows the calendar, not an empty week",
187
+ );
188
+ });
189
+ });
190
+
191
+ /**
192
+ * The zone every write is built in, so it has to be one the server will take.
193
+ *
194
+ * The server checks a TZID against `Intl.supportedValuesOf("timeZone")` — the
195
+ * canonical IANA list, which holds no spelling of UTC at all — and refuses
196
+ * anything absent from it. A collection naming no zone therefore cannot be
197
+ * answered with a substitute, and above all not with the reader's own: a
198
+ * browser on a UTC machine resolves that to "UTC", and every event written
199
+ * from one was refused for a zone nobody had chosen.
200
+ */
201
+ describe("the zone a collection is read on", () => {
202
+ const RESOLVABLE = new Set(Intl.supportedValuesOf("timeZone"));
203
+
204
+ it("is one the server will take, or none at all", async () => {
205
+ await mount(
206
+ "2026-06-10",
207
+ [],
208
+ [],
209
+ [
210
+ {
211
+ ...calendar(WORK, "Work"),
212
+ timezone: "",
213
+ } as RemitImapCalendarResponse,
214
+ ],
215
+ );
216
+ const zone = data?.timeZoneByCalendarId[WORK];
217
+ assert.ok(
218
+ zone === "" || RESOLVABLE.has(zone ?? ""),
219
+ `"${zone}" is neither absent nor a zone the server accepts as a TZID`,
220
+ );
221
+ });
222
+
223
+ it("is the collection's own where it has one", async () => {
224
+ await mount("2026-06-10", []);
225
+ assert.equal(data?.timeZoneByCalendarId[WORK], "Europe/Amsterdam");
226
+ });
227
+ });
228
+
229
+ describe("what the grid is handed", () => {
230
+ it("draws the occurrences the server expanded", async () => {
231
+ await mount(
232
+ "2026-06-10",
233
+ [],
234
+ [
235
+ instance({ calendarObjectId: "obj-1" }),
236
+ instance({
237
+ calendarObjectId: "obj-2",
238
+ recurrenceId: "2026-06-11T07:15:00Z",
239
+ summary: "Standup",
240
+ hasRecurrence: true,
241
+ }),
242
+ ],
243
+ );
244
+ assert.deepEqual(
245
+ data?.events.map((event) => event.id),
246
+ ["obj-1", "obj-2#2026-06-11T07:15:00Z"],
247
+ );
248
+ assert.equal(data?.events[1].title, "Standup");
249
+ });
250
+
251
+ it("leaves a cancelled occurrence off rather than drawing it as confirmed", async () => {
252
+ await mount(
253
+ "2026-06-10",
254
+ [],
255
+ [
256
+ instance({ calendarObjectId: "obj-1" }),
257
+ instance({ calendarObjectId: "obj-3", status: "Cancelled" }),
258
+ ],
259
+ );
260
+ assert.deepEqual(
261
+ data?.events.map((event) => event.id),
262
+ ["obj-1"],
263
+ );
264
+ });
265
+ });