@remit/web-client 0.0.232 → 0.0.233

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * What the agenda puts on screen.
3
+ *
4
+ * Every claim here is one the strip makes to a reader rather than one the
5
+ * arithmetic makes to itself: that it opened on the day the address named, that
6
+ * a week with nothing in it is a sentence rather than seven blank screens, that
7
+ * an afternoon booked on a calendar it is not drawing is not offered as free,
8
+ * and that a read it was refused says so instead of drawing an empty diary.
9
+ */
10
+ import "@remit/test-dom";
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+ import {
14
+ buildCalendarDay,
15
+ type CalendarDescriptor,
16
+ type CalendarEventData,
17
+ datesBetween,
18
+ freeStretchesOn,
19
+ } from "@remit/ui";
20
+ import { createElement, type ReactElement } from "react";
21
+ import { renderToString } from "react-dom/server";
22
+ import { freeStretchesByDate, startOfDay } from "@/hooks/calendar";
23
+ import { AgendaStrip, type AgendaStripProps } from "./AgendaStrip.js";
24
+
25
+ const TODAY = "2026-06-10";
26
+ /**
27
+ * Events are read off the wall time they print, so their offset is arbitrary.
28
+ * A busy span is an instant, so it carries this machine's own offset and the
29
+ * suite answers the same in Los Angeles as in Amsterdam.
30
+ */
31
+ const OFFSET = "+02:00";
32
+ const busyAt = (date: string, time: string): string =>
33
+ `${date}T${time}:00${startOfDay(date).slice(19)}`;
34
+ const NOW = `${TODAY}T09:30:00${OFFSET}`;
35
+ const WORK = "cal_work";
36
+
37
+ const calendars: CalendarDescriptor[] = [
38
+ {
39
+ id: WORK,
40
+ accountId: "acct_work",
41
+ accountLabel: "Work",
42
+ name: "Northwind",
43
+ color: "cal-3",
44
+ },
45
+ ];
46
+
47
+ const event = (
48
+ id: string,
49
+ date: string,
50
+ from: string,
51
+ to: string,
52
+ title = id,
53
+ ): CalendarEventData => ({
54
+ id,
55
+ calendarId: WORK,
56
+ title,
57
+ start: `${date}T${from}:00${OFFSET}`,
58
+ end: `${date}T${to}:00${OFFSET}`,
59
+ allDay: false,
60
+ location: "",
61
+ notes: "",
62
+ attendees: [],
63
+ myRsvp: "accepted",
64
+ threadId: "",
65
+ threadSubject: "",
66
+ timeZone: "Europe/Amsterdam",
67
+ zoneCertainty: "explicit",
68
+ recurrenceRule: "",
69
+ seriesId: "",
70
+ seriesException: false,
71
+ status: "confirmed",
72
+ });
73
+
74
+ const events = [
75
+ event("evt_roadmap", TODAY, "10:00", "11:30", "Q3 roadmap review"),
76
+ event("evt_retro", "2026-06-18", "16:00", "17:00", "Retro"),
77
+ ];
78
+
79
+ const dates = datesBetween("2026-06-08", "2026-06-20");
80
+ const days = dates.map((date) => buildCalendarDay(date, events, TODAY));
81
+
82
+ const noop = () => {};
83
+
84
+ /** Every day answered for, which is what the cases below are about. */
85
+ const NOTHING_PENDING: ReadonlySet<string> = new Set();
86
+
87
+ const base: AgendaStripProps = {
88
+ days,
89
+ calendars,
90
+ density: "comfortable",
91
+ today: TODAY,
92
+ anchorDate: TODAY,
93
+ now: NOW,
94
+ selectedEventId: "",
95
+ // The gaps between the rows: what the strip reads before the merged busy
96
+ // spans land, and what every case here but one is measured against.
97
+ freeOn: freeStretchesOn,
98
+ loadingDates: NOTHING_PENDING,
99
+ onSelectEvent: noop,
100
+ onPickSlot: noop,
101
+ onZoomDay: noop,
102
+ onGoToDate: noop,
103
+ onReachStart: noop,
104
+ onReachEnd: noop,
105
+ onVisibleDayChange: noop,
106
+ };
107
+
108
+ /** React writes a marker between adjacent text nodes; a reader sees one sentence. */
109
+ const words = (html: string) => html.replaceAll("<!-- -->", "");
110
+
111
+ const render = (props: Partial<AgendaStripProps> = {}): string =>
112
+ words(
113
+ renderToString(
114
+ createElement(AgendaStrip, { ...base, ...props }) as ReactElement,
115
+ ),
116
+ );
117
+
118
+ describe("the day the address named", () => {
119
+ it("names it under the sticky header the strip opens on", () => {
120
+ assert.match(render(), /Wednesday/);
121
+ assert.match(render(), />Today</);
122
+ });
123
+
124
+ it("keeps it out of a run, however empty it is", () => {
125
+ const quiet = dates.map((date) => buildCalendarDay(date, [], TODAY));
126
+ const html = render({ days: quiet, anchorDate: "2026-06-16" });
127
+ // The run either side of the 16th, rather than one run over it.
128
+ assert.match(html, /5 days with nothing booked/);
129
+ assert.match(html, /4 days with nothing booked/);
130
+ });
131
+ });
132
+
133
+ describe("a run of days with nothing on them", () => {
134
+ it("collapses into one sentence rather than seven empty screens", () => {
135
+ const html = render();
136
+ assert.match(html, /Thu 11 – Wed 17 Jun/);
137
+ assert.match(html, /7 days with nothing booked/);
138
+ });
139
+ });
140
+
141
+ describe("free time", () => {
142
+ /**
143
+ * The reader has unticked the calendar those meetings are on, so the strip
144
+ * draws nothing on the 11th. They are booked all morning regardless, and
145
+ * "Free all day" is the one answer this view must never give.
146
+ */
147
+ it("comes from the merged busy spans, not from the rows on screen", () => {
148
+ const free = freeStretchesByDate(dates, [
149
+ {
150
+ start: busyAt("2026-06-11", "09:00"),
151
+ end: busyAt("2026-06-11", "16:00"),
152
+ },
153
+ ]);
154
+ const html = render({
155
+ anchorDate: "2026-06-11",
156
+ freeOn: (day) => free.get(day.date) ?? [],
157
+ });
158
+ assert.doesNotMatch(html, /Free all day/);
159
+ assert.match(html, /16:00 – 22:00/);
160
+ });
161
+
162
+ it("names the stretch a booked day still leaves open", () => {
163
+ assert.match(render(), /11:30 – 22:00/);
164
+ });
165
+ });
166
+
167
+ describe("what is next", () => {
168
+ it("leads today, so it is the first thing on screen and the first gone", () => {
169
+ const html = render();
170
+ assert.match(html, /Q3 roadmap review/);
171
+ assert.match(html, /Next · in 30m/);
172
+ });
173
+
174
+ it("says so plainly when the diary is empty ahead", () => {
175
+ const past = [event("evt_done", TODAY, "08:00", "09:00", "Breakfast")];
176
+ const html = render({
177
+ days: dates.map((date) => buildCalendarDay(date, past, TODAY)),
178
+ });
179
+ assert.match(html, /Nothing else booked/);
180
+ });
181
+ });
182
+
183
+ /**
184
+ * One preference for the whole calendar, which the grid already reads. The
185
+ * strip's third reading — a month of coloured dots with no titles on it — is
186
+ * not reachable from Glance on purpose: Glance says how much of a day fits, not
187
+ * whether to say what is on it.
188
+ */
189
+ describe("the density the device holds", () => {
190
+ it("names the calendar an event is on at the detail reading", () => {
191
+ assert.match(render({ density: "comfortable" }), /Northwind/);
192
+ });
193
+
194
+ it("drops that line at a glance, still naming every event", () => {
195
+ const html = render({ density: "compact" });
196
+ assert.match(html, /Q3 roadmap review/);
197
+ assert.doesNotMatch(html, /Northwind/);
198
+ });
199
+ });
200
+
201
+ describe("a read that did not happen", () => {
202
+ it("states the refusal rather than drawing an empty diary", () => {
203
+ const html = render({
204
+ days: [],
205
+ error: new Error("The window has to be shorter than a year."),
206
+ });
207
+ assert.match(html, /load these days/);
208
+ assert.match(html, /shorter than a year/);
209
+ assert.doesNotMatch(html, /days with nothing booked/);
210
+ });
211
+
212
+ /**
213
+ * The bug this pins: a week still in flight has no events, so every day of
214
+ * it built as "nothing booked" and a run of them collapsed into "7 days with
215
+ * nothing booked" — a claim about a diary nobody had read yet.
216
+ */
217
+ it("draws an unanswered day as a skeleton, never as a day with nothing on it", () => {
218
+ const pending = new Set(["2026-06-11", "2026-06-12", "2026-06-13"]);
219
+ const html = render({ loadingDates: pending });
220
+ for (const date of pending) {
221
+ assert.match(html, new RegExp(`agenda-day-pending-${date}`));
222
+ }
223
+ assert.match(html, /aria-busy="true"/);
224
+ // The run that would have swallowed them, had they been read as empty.
225
+ assert.doesNotMatch(html, /Thu 11 – /);
226
+ });
227
+
228
+ it("keeps the days that have answered while their neighbours arrive", () => {
229
+ const html = render({ loadingDates: new Set(["2026-06-11"]) });
230
+ assert.match(html, /Q3 roadmap review/);
231
+ assert.match(html, /agenda-day-pending-2026-06-11/);
232
+ });
233
+
234
+ it("holds back what is next until today has answered", () => {
235
+ assert.match(render(), /Next · in 30m/);
236
+ assert.doesNotMatch(
237
+ render({ loadingDates: new Set([TODAY]) }),
238
+ /Nothing else booked/,
239
+ );
240
+ });
241
+ });
@@ -24,6 +24,9 @@ const DATE = "2026-06-10";
24
24
  const NOW = `${DATE}T09:30:00+02:00`;
25
25
  const CALENDAR = "cal_work";
26
26
 
27
+ /** Stands in for the strip, which loads its own days and is tested on its own. */
28
+ const AGENDA_SLOT = "the strip";
29
+
27
30
  const colorByCalendarId: Record<string, CalendarColorId> = {
28
31
  [CALENDAR]: "cal-1",
29
32
  };
@@ -59,6 +62,7 @@ const workspaceProps = (
59
62
  date: DATE,
60
63
  events,
61
64
  colorByCalendarId,
65
+ agenda: createElement("p", null, AGENDA_SLOT),
62
66
  density: "comfortable",
63
67
  selectedEventId: "",
64
68
  timeZone: TIME_ZONE,
@@ -110,21 +114,24 @@ describe("the zoom the address names", () => {
110
114
  }
111
115
  });
112
116
 
117
+ it("mounts the strip for the agenda, and not the grid", () => {
118
+ const html = render("agenda");
119
+ assert.ok(html.includes(AGENDA_SLOT), "the agenda slot was not mounted");
120
+ assert.equal(html.includes("Not built yet"), false);
121
+ assert.equal(html.includes("Roadmap review"), false);
122
+ });
123
+
113
124
  it("says so for a zoom that is not drawn yet", () => {
114
- for (const view of ["year", "month", "agenda"] as const) {
125
+ for (const view of ["year", "month"] as const) {
115
126
  const html = render(view);
116
127
  assert.ok(
117
128
  html.includes(`calendar-placeholder-${view}`),
118
129
  `${view} rendered no placeholder`,
119
130
  );
120
131
  assert.ok(html.includes("Not built yet"));
121
- assert.ok(html.includes("Week and Day work now."));
132
+ assert.ok(html.includes("Week, Day and Agenda work now."));
122
133
  }
123
134
  });
124
-
125
- it("names the agenda's own stage rather than a generic one", () => {
126
- assert.ok(render("agenda").includes("scrolling day strip"));
127
- });
128
135
  });
129
136
 
130
137
  describe("the toolbar", () => {
@@ -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
+ });