@remit/ui 0.0.155 → 0.0.157

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,252 @@
1
+ /**
2
+ * The readings beside the strip. Each one exists because a list can answer a
3
+ * question a grid answers badly, so what is asserted here is the answer — the
4
+ * sentence about what is next, the open time named as time, the shape of the
5
+ * two months you are somewhere inside of.
6
+ */
7
+ import "@remit/test-dom";
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "node:test";
10
+ import { createElement } from "react";
11
+ import { renderToString } from "react-dom/server";
12
+ import {
13
+ buildCalendarDay,
14
+ type FreeStretch,
15
+ type NextUp,
16
+ readNextUp,
17
+ } from "../lib/agenda-time.js";
18
+ import {
19
+ AgendaDensityControl,
20
+ FreeTimeList,
21
+ NextUpCard,
22
+ type NextUpCardProps,
23
+ PositionMap,
24
+ } from "./agenda-panels.js";
25
+ import type {
26
+ CalendarDescriptor,
27
+ CalendarEventData,
28
+ } from "./calendar-types.js";
29
+
30
+ const TODAY = "2026-06-10";
31
+ const OFFSET = "+02:00";
32
+
33
+ const calendars: CalendarDescriptor[] = [
34
+ {
35
+ id: "c1",
36
+ accountId: "a1",
37
+ accountLabel: "Work",
38
+ name: "Northwind",
39
+ color: "cal-3",
40
+ },
41
+ ];
42
+
43
+ function event(
44
+ id: string,
45
+ title: string,
46
+ date: string,
47
+ from: string,
48
+ to: string,
49
+ extra: Partial<CalendarEventData> = {},
50
+ ): CalendarEventData {
51
+ return {
52
+ id,
53
+ calendarId: "c1",
54
+ title,
55
+ start: `${date}T${from}:00${OFFSET}`,
56
+ end: `${date}T${to}:00${OFFSET}`,
57
+ allDay: false,
58
+ location: "",
59
+ notes: "",
60
+ attendees: [],
61
+ myRsvp: "accepted",
62
+ threadId: "",
63
+ threadSubject: "",
64
+ timeZone: "Europe/Amsterdam",
65
+ zoneCertainty: "explicit",
66
+ recurrenceRule: "",
67
+ seriesId: "",
68
+ seriesException: false,
69
+ status: "confirmed",
70
+ ...extra,
71
+ };
72
+ }
73
+
74
+ const events = [
75
+ event("evt_standup", "Standup", TODAY, "09:00", "09:30"),
76
+ event("evt_roadmap", "Q3 roadmap review", TODAY, "10:00", "11:30", {
77
+ location: "Kaap",
78
+ }),
79
+ event("evt_retro", "Retro", TODAY, "16:00", "17:00"),
80
+ event("evt_offsite", "Offsite", "2026-06-11", "14:00", "15:00"),
81
+ ];
82
+
83
+ const days = [TODAY, "2026-06-11", "2026-06-12"].map((date) =>
84
+ buildCalendarDay(date, events, TODAY),
85
+ );
86
+
87
+ const nextUp: NextUp = readNextUp(days, `${TODAY}T09:15:00${OFFSET}`);
88
+
89
+ /** React writes a marker between adjacent text nodes; a reader sees one sentence. */
90
+ const words = (html: string) => html.replaceAll("<!-- -->", "");
91
+
92
+ const renderNextUp = (props: Partial<NextUpCardProps> = {}) =>
93
+ renderToString(
94
+ createElement(NextUpCard, {
95
+ nextUp,
96
+ calendars,
97
+ today: TODAY,
98
+ onSelectEvent: () => {},
99
+ onGoTo: () => {},
100
+ ...props,
101
+ }),
102
+ );
103
+
104
+ describe("NextUpCard", () => {
105
+ it("says what is running now and until when", () => {
106
+ const html = renderNextUp();
107
+ assert.match(html, /Now/);
108
+ assert.match(html, /Standup/);
109
+ assert.match(html, /until 09:30/);
110
+ });
111
+
112
+ it("names the next thing and how long until it", () => {
113
+ const html = renderNextUp();
114
+ assert.match(html, /Next · in 45m/);
115
+ assert.match(html, /Q3 roadmap review/);
116
+ assert.match(html, /Kaap/);
117
+ });
118
+
119
+ it("names the one after it without giving it a row of its own", () => {
120
+ assert.match(words(renderNextUp()), /then Retro/);
121
+ });
122
+
123
+ it("drops the day prefix for today and keeps it for anything else", () => {
124
+ const html = words(
125
+ renderNextUp({ nextUp: readNextUp(days, `${TODAY}T18:00:00${OFFSET}`) }),
126
+ );
127
+ assert.match(html, /tomorrow · 14:00/);
128
+ });
129
+
130
+ it("offers the next free stretch as somewhere to go", () => {
131
+ assert.match(renderNextUp(), /free/);
132
+ });
133
+
134
+ it("says so plainly when nothing else is booked", () => {
135
+ const html = renderNextUp({
136
+ nextUp: readNextUp(days, "2026-06-12T21:00:00+02:00"),
137
+ });
138
+ assert.match(html, /Nothing else booked\./);
139
+ });
140
+
141
+ it("carries the calendar's hue and falls back when it has none", () => {
142
+ assert.match(renderNextUp(), /bg-cal-3-soft/);
143
+ assert.match(renderNextUp({ calendars: [] }), /bg-cal-1-soft/);
144
+ });
145
+ });
146
+
147
+ describe("AgendaDensityControl", () => {
148
+ const render = (icons?: boolean) =>
149
+ renderToString(
150
+ createElement(AgendaDensityControl, {
151
+ value: "pills" as const,
152
+ onChange: () => {},
153
+ icons,
154
+ }),
155
+ );
156
+
157
+ it("is a labelled group of radios, not a row of unlabelled buttons", () => {
158
+ const html = render();
159
+ assert.match(html, /Agenda density/);
160
+ assert.match(html, /type="radio"/);
161
+ assert.match(html, /checked=""/);
162
+ });
163
+
164
+ it("keeps every step named when the words are replaced by icons", () => {
165
+ const html = render(true);
166
+ assert.match(html, /aria-label="Dots"/);
167
+ assert.match(html, /aria-label="Rows"/);
168
+ assert.match(html, /aria-label="Detail"/);
169
+ });
170
+ });
171
+
172
+ describe("FreeTimeList", () => {
173
+ const stretches: FreeStretch[] = [
174
+ {
175
+ date: TODAY,
176
+ startMinute: 11 * 60 + 30,
177
+ endMinute: 16 * 60,
178
+ minutes: 270,
179
+ wholeDay: false,
180
+ },
181
+ {
182
+ date: "2026-06-12",
183
+ startMinute: 8 * 60,
184
+ endMinute: 22 * 60,
185
+ minutes: 840,
186
+ wholeDay: true,
187
+ },
188
+ ];
189
+
190
+ const render = (list: FreeStretch[]) =>
191
+ renderToString(
192
+ createElement(FreeTimeList, {
193
+ stretches: list,
194
+ today: TODAY,
195
+ onPick: () => {},
196
+ }),
197
+ );
198
+
199
+ it("lists open time as time, with the day it falls on", () => {
200
+ const html = words(render(stretches));
201
+ assert.match(html, /4h 30m/);
202
+ assert.match(html, /today/);
203
+ assert.match(html, /Fri 12 Jun/);
204
+ });
205
+
206
+ it("says the days on screen are full rather than showing nothing", () => {
207
+ assert.match(render([]), /Nothing open in the days on screen\./);
208
+ });
209
+ });
210
+
211
+ describe("PositionMap", () => {
212
+ const render = () =>
213
+ renderToString(
214
+ createElement(PositionMap, {
215
+ anchorDate: TODAY,
216
+ visibleDate: "2026-06-11",
217
+ today: TODAY,
218
+ dayOf: (date: string) => buildCalendarDay(date, events, TODAY),
219
+ onGoTo: () => {},
220
+ }),
221
+ );
222
+
223
+ it("draws the anchor month and the one after it", () => {
224
+ const html = render();
225
+ assert.match(html, /Jun/);
226
+ assert.match(html, /Jul/);
227
+ });
228
+
229
+ it("marks today and where the strip is parked apart from each other", () => {
230
+ const html = render();
231
+ assert.match(html, /text-accent/);
232
+ assert.match(html, /ring-line-strong/);
233
+ });
234
+
235
+ it("shows a day's load and flags the ones that clash", () => {
236
+ const busy = renderToString(
237
+ createElement(PositionMap, {
238
+ anchorDate: TODAY,
239
+ visibleDate: TODAY,
240
+ today: TODAY,
241
+ dayOf: (date: string) =>
242
+ buildCalendarDay(
243
+ date,
244
+ [...events, event("evt_clash", "Clash", TODAY, "10:30", "11:00")],
245
+ TODAY,
246
+ ),
247
+ onGoTo: () => {},
248
+ }),
249
+ );
250
+ assert.match(busy, /bg-warning/);
251
+ });
252
+ });
@@ -0,0 +1,207 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import {
4
+ buildCalendarDay,
5
+ type FreeStretch,
6
+ freeAhead,
7
+ readNextUp,
8
+ } from "../lib/agenda-time.js";
9
+ import {
10
+ AgendaDensityControl,
11
+ FreeTimeList,
12
+ NextUpCard,
13
+ PositionMap,
14
+ } from "./agenda-panels.js";
15
+ import type {
16
+ CalendarDescriptor,
17
+ CalendarEventData,
18
+ } from "./calendar-types.js";
19
+
20
+ /**
21
+ * The readings beside the strip. None of them repeats the list — each answers
22
+ * something the rows cannot answer at a glance.
23
+ */
24
+ const meta: Meta = {
25
+ title: "Calendar/Agenda panels",
26
+ parameters: { layout: "padded" },
27
+ };
28
+ export default meta;
29
+
30
+ type Story = StoryObj;
31
+
32
+ const TODAY = "2026-06-10";
33
+ const NOW = `${TODAY}T09:15:00+02:00`;
34
+ const OFFSET = "+02:00";
35
+
36
+ const calendars: CalendarDescriptor[] = [
37
+ {
38
+ id: "work",
39
+ accountId: "a1",
40
+ accountLabel: "Work",
41
+ name: "Northwind",
42
+ color: "cal-1",
43
+ },
44
+ {
45
+ id: "personal",
46
+ accountId: "a2",
47
+ accountLabel: "Personal",
48
+ name: "Family",
49
+ color: "cal-3",
50
+ },
51
+ ];
52
+
53
+ function event(
54
+ id: string,
55
+ title: string,
56
+ calendarId: string,
57
+ date: string,
58
+ from: string,
59
+ to: string,
60
+ location = "",
61
+ ): CalendarEventData {
62
+ return {
63
+ id,
64
+ calendarId,
65
+ title,
66
+ start: `${date}T${from}:00${OFFSET}`,
67
+ end: `${date}T${to}:00${OFFSET}`,
68
+ allDay: false,
69
+ location,
70
+ notes: "",
71
+ attendees: [],
72
+ myRsvp: "accepted",
73
+ threadId: "",
74
+ threadSubject: "",
75
+ timeZone: "Europe/Amsterdam",
76
+ zoneCertainty: "explicit",
77
+ recurrenceRule: "",
78
+ seriesId: "",
79
+ seriesException: false,
80
+ status: "confirmed",
81
+ };
82
+ }
83
+
84
+ const events = [
85
+ event("evt_standup", "Standup", "work", TODAY, "09:00", "09:30"),
86
+ event(
87
+ "evt_roadmap",
88
+ "Q3 roadmap review",
89
+ "work",
90
+ TODAY,
91
+ "10:00",
92
+ "11:30",
93
+ "Kaap",
94
+ ),
95
+ event("evt_lunch", "Lunch with Jane", "personal", TODAY, "12:30", "13:30"),
96
+ event("evt_retro", "Retro", "work", TODAY, "16:00", "17:00"),
97
+ event("evt_dentist", "Dentist", "personal", "2026-06-11", "14:00", "14:45"),
98
+ ];
99
+
100
+ const dates = [
101
+ "2026-06-10",
102
+ "2026-06-11",
103
+ "2026-06-12",
104
+ "2026-06-13",
105
+ "2026-06-14",
106
+ ];
107
+ const days = dates.map((date) => buildCalendarDay(date, events, TODAY));
108
+ const dayOf = (date: string) => buildCalendarDay(date, events, TODAY);
109
+
110
+ const nextUpProps = {
111
+ calendars,
112
+ today: TODAY,
113
+ onSelectEvent: () => {},
114
+ onGoTo: () => {},
115
+ };
116
+
117
+ /** Something is running and something is coming: both, in one sentence each. */
118
+ export const NextUpRunning: Story = {
119
+ render: () => (
120
+ <NextUpCard
121
+ {...nextUpProps}
122
+ nextUp={readNextUp(days, NOW)}
123
+ className="max-w-80"
124
+ />
125
+ ),
126
+ };
127
+
128
+ /** The end of a day, where "nothing else" is the whole answer. */
129
+ export const NextUpDone: Story = {
130
+ render: () => (
131
+ <NextUpCard
132
+ {...nextUpProps}
133
+ nextUp={readNextUp(days, "2026-06-14T21:00:00+02:00")}
134
+ className="max-w-80"
135
+ />
136
+ ),
137
+ };
138
+
139
+ /** Grown for a rail a thumb reaches. */
140
+ export const NextUpTouch: Story = {
141
+ render: () => (
142
+ <NextUpCard
143
+ {...nextUpProps}
144
+ nextUp={readNextUp(days, NOW)}
145
+ touch
146
+ className="max-w-80"
147
+ />
148
+ ),
149
+ };
150
+
151
+ /** Three readings, not two, and the control never leaves the screen. */
152
+ export const Density: Story = {
153
+ render: () => {
154
+ const [value, setValue] = useState<"dots" | "pills" | "detail">("pills");
155
+ return (
156
+ <div className="flex flex-col gap-4">
157
+ <AgendaDensityControl value={value} onChange={setValue} />
158
+ <AgendaDensityControl value={value} onChange={setValue} icons />
159
+ <AgendaDensityControl value={value} onChange={setValue} touch icons />
160
+ </div>
161
+ );
162
+ },
163
+ };
164
+
165
+ /** Empty time, listed like anything else that is on the calendar. */
166
+ export const OpenTime: Story = {
167
+ render: () => (
168
+ <div className="max-w-80">
169
+ <FreeTimeList
170
+ stretches={freeAhead(days, NOW, 5)}
171
+ today={TODAY}
172
+ onPick={() => {}}
173
+ />
174
+ </div>
175
+ ),
176
+ };
177
+
178
+ /** Nothing open is a sentence, never an empty box. */
179
+ export const NoOpenTime: Story = {
180
+ render: () => (
181
+ <div className="max-w-80">
182
+ <FreeTimeList
183
+ stretches={[] as FreeStretch[]}
184
+ today={TODAY}
185
+ onPick={() => {}}
186
+ />
187
+ </div>
188
+ ),
189
+ };
190
+
191
+ /** A scrollbar with meaning: how full each day is, and where you are parked. */
192
+ export const WhereYouAre: Story = {
193
+ render: () => {
194
+ const [visible, setVisible] = useState("2026-06-11");
195
+ return (
196
+ <div className="max-w-64">
197
+ <PositionMap
198
+ anchorDate={TODAY}
199
+ visibleDate={visible}
200
+ today={TODAY}
201
+ dayOf={dayOf}
202
+ onGoTo={setVisible}
203
+ />
204
+ </div>
205
+ );
206
+ },
207
+ };