@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.
- package/package.json +1 -1
- package/src/components/calendar/AgendaStrip.stories.tsx +263 -0
- package/src/components/calendar/AgendaStrip.tsx +145 -0
- package/src/components/calendar/AgendaView.tsx +222 -0
- package/src/components/calendar/CalendarComposePane.stories.tsx +101 -11
- package/src/components/calendar/CalendarComposePane.tsx +83 -23
- package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +30 -1
- package/src/components/calendar/CalendarEventPane.tsx +28 -5
- package/src/components/calendar/CalendarViewPlaceholder.tsx +3 -4
- package/src/components/calendar/CalendarWorkspace.stories.tsx +34 -1
- package/src/components/calendar/CalendarWorkspace.tsx +71 -23
- package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
- package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
- package/src/components/calendar/agenda-address.render.test.ts +389 -0
- package/src/components/calendar/agenda-strip.render.test.ts +241 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +13 -6
- package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
- package/src/hooks/calendar/agenda-window.test.ts +235 -0
- package/src/hooks/calendar/agenda-window.ts +183 -0
- package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
- package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
- package/src/hooks/calendar/draft.test.ts +241 -0
- package/src/hooks/calendar/draft.ts +168 -0
- package/src/hooks/calendar/index.ts +88 -0
- package/src/hooks/calendar/instance.ts +136 -0
- package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
- package/src/hooks/calendar/recurrence-rule.ts +133 -0
- package/src/hooks/calendar/resource.test.ts +98 -0
- package/src/hooks/calendar/resource.ts +74 -0
- package/src/hooks/calendar/selection.ts +107 -0
- package/src/hooks/calendar/useCalendarEvent.ts +46 -0
- package/src/hooks/calendar/useCalendarEvents.ts +195 -0
- package/src/hooks/calendar/useCalendarFreeBusy.ts +80 -0
- package/src/hooks/calendar/useCalendarWrites.ts +174 -0
- package/src/hooks/calendar/useCalendars.ts +43 -0
- package/src/hooks/calendar/window.test.ts +148 -0
- package/src/hooks/calendar/window.ts +168 -0
- package/src/hooks/useCalendarData.ts +62 -40
- package/src/lib/calendar-density.ts +13 -1
- package/src/lib/calendar-route.test.ts +20 -1
- package/src/lib/calendar-route.ts +21 -7
- package/src/routes/calendar/$view.$date/new.tsx +2 -2
- package/src/routes/calendar/$view.$date.tsx +51 -15
- package/src/routing/calendar.ts +32 -4
- package/src/test-support/http.ts +8 -0
- 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
|
+
});
|