@remit/ui 0.0.154 → 0.0.156

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,217 @@
1
+ /**
2
+ * The rules the strip makes on screen: what a day costs in rows, where free
3
+ * time is drawn, which days collapse into a sentence, and what a pile-up says
4
+ * about itself. Every one of them is a claim the design argues for, so it is
5
+ * asserted off the rendered markup rather than off the arithmetic underneath.
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 { buildCalendarDay } from "../lib/agenda-time.js";
13
+ import { AgendaFlow, type AgendaFlowProps } from "./agenda-flow.js";
14
+ import type {
15
+ CalendarDescriptor,
16
+ CalendarEventData,
17
+ } from "./calendar-types.js";
18
+
19
+ const TODAY = "2026-06-10";
20
+ const OFFSET = "+02:00";
21
+
22
+ const calendars: CalendarDescriptor[] = [
23
+ {
24
+ id: "c1",
25
+ accountId: "a1",
26
+ accountLabel: "Work",
27
+ name: "Northwind",
28
+ color: "cal-3",
29
+ },
30
+ ];
31
+
32
+ function event(
33
+ id: string,
34
+ date: string,
35
+ from: string,
36
+ to: string,
37
+ extra: Partial<CalendarEventData> = {},
38
+ ): CalendarEventData {
39
+ return {
40
+ id,
41
+ calendarId: "c1",
42
+ title: id,
43
+ start: `${date}T${from}:00${OFFSET}`,
44
+ end: `${date}T${to}:00${OFFSET}`,
45
+ allDay: false,
46
+ location: "",
47
+ notes: "",
48
+ attendees: [],
49
+ myRsvp: "accepted",
50
+ threadId: "",
51
+ threadSubject: "",
52
+ timeZone: "Europe/Amsterdam",
53
+ zoneCertainty: "explicit",
54
+ recurrenceRule: "",
55
+ seriesId: "",
56
+ seriesException: false,
57
+ status: "confirmed",
58
+ ...extra,
59
+ };
60
+ }
61
+
62
+ const roadmap = event("evt_roadmap", TODAY, "10:00", "11:30", {
63
+ title: "Q3 roadmap review",
64
+ location: "Kaap",
65
+ attendees: [
66
+ {
67
+ name: "Anna",
68
+ email: "anna@example.test",
69
+ rsvp: "accepted",
70
+ role: "organizer",
71
+ },
72
+ ],
73
+ });
74
+ const incident = event("evt_incident", TODAY, "10:30", "12:00", {
75
+ title: "Incident review",
76
+ });
77
+ const banner: CalendarEventData = {
78
+ ...event("evt_conference", "2026-06-11", "00:00", "00:00"),
79
+ title: "Devcon",
80
+ start: "2026-06-11",
81
+ end: "2026-06-12",
82
+ allDay: true,
83
+ };
84
+
85
+ const events = [roadmap, incident, banner];
86
+
87
+ const dates = [
88
+ TODAY,
89
+ "2026-06-11",
90
+ "2026-06-12",
91
+ "2026-06-13",
92
+ "2026-06-14",
93
+ "2026-06-15",
94
+ "2026-06-16",
95
+ ];
96
+
97
+ const days = dates.map((date) => buildCalendarDay(date, events, TODAY));
98
+
99
+ const base: AgendaFlowProps = {
100
+ days,
101
+ calendars,
102
+ density: "pills",
103
+ today: TODAY,
104
+ focusDate: TODAY,
105
+ selectedEventId: "",
106
+ onSelectEvent: () => {},
107
+ onPickSlot: () => {},
108
+ onZoomDay: () => {},
109
+ onReachStart: () => {},
110
+ onReachEnd: () => {},
111
+ onVisibleDayChange: () => {},
112
+ };
113
+
114
+ /** React writes a marker between adjacent text nodes; a reader sees one sentence. */
115
+ const words = (html: string) => html.replaceAll("<!-- -->", "");
116
+
117
+ const render = (props: Partial<AgendaFlowProps> = {}) =>
118
+ renderToString(createElement(AgendaFlow, { ...base, ...props }));
119
+
120
+ describe("AgendaFlow", () => {
121
+ it("draws the days in the order it was handed them", () => {
122
+ const html = render();
123
+ assert.ok(
124
+ html.indexOf("Q3 roadmap review") < html.indexOf("Devcon"),
125
+ "the 10th is drawn before the 11th",
126
+ );
127
+ });
128
+
129
+ it("names the free stretch the day still leaves open", () => {
130
+ const html = words(render());
131
+ assert.match(html, /10h free/);
132
+ assert.match(html, /12:00 – 22:00/);
133
+ });
134
+
135
+ it("says nothing is booked rather than drawing an empty day", () => {
136
+ assert.match(render(), /Free all day/);
137
+ });
138
+
139
+ it("collapses a run of empty days into one sentence", () => {
140
+ const html = words(render());
141
+ assert.match(html, /Fri 12 – Tue 16 Jun/);
142
+ assert.match(html, /5 days with nothing booked/);
143
+ });
144
+
145
+ it("keeps the day it was focused on out of a run", () => {
146
+ const html = words(
147
+ render({ focusDate: "2026-06-14", today: "2026-06-14" }),
148
+ );
149
+ assert.doesNotMatch(html, /5 days with nothing booked/);
150
+ });
151
+
152
+ it("names a pile-up and offers the grid for it", () => {
153
+ const html = words(render());
154
+ assert.match(html, /2 at once · 10:00 – 12:00/);
155
+ assert.match(html, /Open the grid/);
156
+ });
157
+
158
+ it("marks the day that is today", () => {
159
+ assert.match(render(), /Today/);
160
+ assert.match(render(), /Wednesday/);
161
+ });
162
+
163
+ it("counts the day rather than restating its rows", () => {
164
+ assert.match(render(), /2 events · 2h booked · 1 clash/);
165
+ });
166
+
167
+ it("carries the calendar's hue onto every event", () => {
168
+ assert.match(render(), /bg-cal-3-soft/);
169
+ });
170
+
171
+ it("falls back to one hue for a calendar it was told nothing about", () => {
172
+ assert.match(render({ calendars: [] }), /bg-cal-1-soft/);
173
+ });
174
+
175
+ it("draws a banner as an all-day line", () => {
176
+ assert.match(render(), /All day/);
177
+ });
178
+
179
+ it("says which event is selected rather than only colouring it", () => {
180
+ assert.match(
181
+ render({ selectedEventId: "evt_roadmap" }),
182
+ /aria-pressed="true"/,
183
+ );
184
+ });
185
+
186
+ it("shows where and who only at the detail reading", () => {
187
+ assert.doesNotMatch(render(), /Kaap/);
188
+ assert.match(words(render({ density: "detail" })), /Northwind · Kaap/);
189
+ });
190
+
191
+ it("gives every dot a name at the glance reading", () => {
192
+ const html = render({ density: "dots" });
193
+ assert.match(html, /aria-label="Q3 roadmap review"/);
194
+ assert.doesNotMatch(html, /2 at once/);
195
+ });
196
+
197
+ it("names what a glance day is worth in a few characters", () => {
198
+ const html = words(render({ density: "dots" }));
199
+ assert.match(html, /10h free/);
200
+ assert.match(html, /clear/);
201
+ });
202
+
203
+ it("gives every control a visible focus ring", () => {
204
+ assert.match(render(), /focus-visible:ring-ring/);
205
+ });
206
+
207
+ it("grows every hit target when the surface is touched", () => {
208
+ assert.match(render({ touch: true }), /min-h-12/);
209
+ });
210
+
211
+ it("renders whatever the owner leads today with", () => {
212
+ const html = render({
213
+ todayLead: createElement("p", null, "Next up in 30m"),
214
+ });
215
+ assert.match(html, /Next up in 30m/);
216
+ });
217
+ });
@@ -0,0 +1,215 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { buildCalendarDay, datesBetween } from "../lib/agenda-time.js";
4
+ import { AgendaFlow } from "./agenda-flow.js";
5
+ import type {
6
+ CalendarDescriptor,
7
+ CalendarEventData,
8
+ } from "./calendar-types.js";
9
+
10
+ /**
11
+ * The strip spends its pixels on what is on the day rather than on the hours
12
+ * the day contains. Every story here is a day the argument has to survive: a
13
+ * pile-up, a day with nothing but a banner, and a week nobody booked.
14
+ */
15
+ const meta: Meta<typeof AgendaFlow> = {
16
+ title: "Calendar/Agenda flow",
17
+ component: AgendaFlow,
18
+ parameters: { layout: "fullscreen" },
19
+ decorators: [
20
+ (Story) => (
21
+ <div className="flex h-[38rem] flex-col border border-line bg-surface">
22
+ <Story />
23
+ </div>
24
+ ),
25
+ ],
26
+ };
27
+ export default meta;
28
+
29
+ type Story = StoryObj<typeof AgendaFlow>;
30
+
31
+ const TODAY = "2026-06-10";
32
+ const OFFSET = "+02:00";
33
+
34
+ const calendars: CalendarDescriptor[] = [
35
+ {
36
+ id: "work",
37
+ accountId: "a1",
38
+ accountLabel: "Work",
39
+ name: "Northwind",
40
+ color: "cal-1",
41
+ },
42
+ {
43
+ id: "oncall",
44
+ accountId: "a1",
45
+ accountLabel: "Work",
46
+ name: "On-call",
47
+ color: "cal-4",
48
+ },
49
+ {
50
+ id: "personal",
51
+ accountId: "a2",
52
+ accountLabel: "Personal",
53
+ name: "Family",
54
+ color: "cal-3",
55
+ },
56
+ ];
57
+
58
+ function event(
59
+ id: string,
60
+ title: string,
61
+ calendarId: string,
62
+ date: string,
63
+ from: string,
64
+ to: string,
65
+ extra: Partial<CalendarEventData> = {},
66
+ ): CalendarEventData {
67
+ return {
68
+ id,
69
+ calendarId,
70
+ title,
71
+ start: `${date}T${from}:00${OFFSET}`,
72
+ end: `${date}T${to}:00${OFFSET}`,
73
+ allDay: false,
74
+ location: "",
75
+ notes: "",
76
+ attendees: [],
77
+ myRsvp: "accepted",
78
+ threadId: "",
79
+ threadSubject: "",
80
+ timeZone: "Europe/Amsterdam",
81
+ zoneCertainty: "explicit",
82
+ recurrenceRule: "",
83
+ seriesId: "",
84
+ seriesException: false,
85
+ status: "confirmed",
86
+ ...extra,
87
+ };
88
+ }
89
+
90
+ const events: CalendarEventData[] = [
91
+ event("evt_standup", "Standup", "work", TODAY, "09:00", "09:15", {
92
+ recurrenceRule: "Every weekday",
93
+ }),
94
+ event("evt_roadmap", "Q3 roadmap review", "work", TODAY, "10:00", "11:30", {
95
+ location: "Kaap",
96
+ threadId: "thr_roadmap",
97
+ attendees: [
98
+ {
99
+ name: "Anna Vos",
100
+ email: "anna@example.test",
101
+ rsvp: "accepted",
102
+ role: "organizer",
103
+ },
104
+ {
105
+ name: "Bram Peters",
106
+ email: "bram@example.test",
107
+ rsvp: "noReply",
108
+ role: "attendee",
109
+ },
110
+ ],
111
+ }),
112
+ event("evt_incident", "Incident review", "oncall", TODAY, "10:30", "12:00"),
113
+ event("evt_1to1", "1:1 with Anna", "work", TODAY, "11:00", "11:20"),
114
+ event("evt_lunch", "Lunch with Jane", "personal", TODAY, "12:30", "13:30", {
115
+ location: "Toscanini",
116
+ }),
117
+ event("evt_retro", "Retro", "work", TODAY, "16:00", "17:00", {
118
+ status: "tentative",
119
+ }),
120
+ event("evt_dentist", "Dentist", "personal", "2026-06-11", "14:00", "14:45", {
121
+ myRsvp: "declined",
122
+ }),
123
+ event("evt_call", "Lisbon call", "work", "2026-06-11", "17:00", "18:00", {
124
+ zoneCertainty: "ambiguous",
125
+ timeZone: "",
126
+ }),
127
+ {
128
+ ...event("evt_devcon", "Devcon", "work", "2026-06-12", "00:00", "00:00"),
129
+ start: "2026-06-12",
130
+ end: "2026-06-13",
131
+ allDay: true,
132
+ },
133
+ event("evt_offsite", "Offsite", "work", "2026-06-22", "09:00", "17:00"),
134
+ ];
135
+
136
+ const days = datesBetween("2026-06-08", "2026-06-24").map((date) =>
137
+ buildCalendarDay(date, events, TODAY),
138
+ );
139
+
140
+ const base = {
141
+ days,
142
+ calendars,
143
+ today: TODAY,
144
+ focusDate: TODAY,
145
+ selectedEventId: "",
146
+ onSelectEvent: () => {},
147
+ onPickSlot: () => {},
148
+ onZoomDay: () => {},
149
+ onReachStart: () => {},
150
+ onReachEnd: () => {},
151
+ onVisibleDayChange: () => {},
152
+ };
153
+
154
+ /** The default reading: one row an event, free time drawn between them. */
155
+ export const Rows: Story = {
156
+ args: { ...base, density: "pills" },
157
+ };
158
+
159
+ /** Where, who and which calendar, for a day you are actually working through. */
160
+ export const Detail: Story = {
161
+ args: { ...base, density: "detail" },
162
+ };
163
+
164
+ /** A month at a glance: colour, load and one word a day. */
165
+ export const Dots: Story = {
166
+ args: { ...base, density: "dots" },
167
+ };
168
+
169
+ /** A day with nothing on the clock says so instead of showing whitespace. */
170
+ export const ClearDay: Story = {
171
+ args: { ...base, density: "pills", focusDate: "2026-06-13" },
172
+ };
173
+
174
+ /** Nine days nobody booked, as one sentence rather than nine screens. */
175
+ export const EmptyRun: Story = {
176
+ args: { ...base, density: "pills", focusDate: "2026-06-18" },
177
+ };
178
+
179
+ /** The selection is a state of the row, not a colour laid over it. */
180
+ export const Selected: Story = {
181
+ args: { ...base, density: "detail", selectedEventId: "evt_roadmap" },
182
+ };
183
+
184
+ /** Every hit target grows where a finger has to find it. */
185
+ export const Touch: Story = {
186
+ args: { ...base, density: "pills", touch: true },
187
+ };
188
+
189
+ /** What is next, landed on with today and scrolled away with it. */
190
+ export const WithTodayLead: Story = {
191
+ args: {
192
+ ...base,
193
+ density: "pills",
194
+ todayLead: (
195
+ <p className="border-b border-line bg-surface-sunken px-row-inset py-2 text-xs text-fg-muted">
196
+ Next up · Q3 roadmap review in 30m
197
+ </p>
198
+ ),
199
+ },
200
+ };
201
+
202
+ /** Selecting a row is the only thing the strip owns; the owner holds the rest. */
203
+ export const Interactive: Story = {
204
+ render: () => {
205
+ const [selected, setSelected] = useState("");
206
+ return (
207
+ <AgendaFlow
208
+ {...base}
209
+ density="detail"
210
+ selectedEventId={selected}
211
+ onSelectEvent={setSelected}
212
+ />
213
+ );
214
+ },
215
+ };