@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.
- package/package.json +5 -2
- package/src/calendar.css +4 -0
- package/src/components/agenda-composer.render.test.ts +196 -0
- package/src/components/agenda-composer.stories.tsx +225 -0
- package/src/components/agenda-composer.tsx +283 -0
- package/src/components/agenda-flow.render.test.ts +217 -0
- package/src/components/agenda-flow.stories.tsx +215 -0
- package/src/components/agenda-flow.tsx +887 -0
- package/src/components/agenda-panels.render.test.ts +252 -0
- package/src/components/agenda-panels.stories.tsx +207 -0
- package/src/components/agenda-panels.tsx +421 -0
- package/src/components/calendar-event-chip-content.tsx +102 -0
- package/src/components/calendar-event-chip.stories.tsx +5 -7
- package/src/components/calendar-event-chip.tsx +47 -47
- package/src/components/calendar-grid.render.test.ts +309 -0
- package/src/components/calendar-grid.stories.tsx +203 -0
- package/src/components/calendar-grid.tsx +370 -0
- package/src/components/calendar-types.ts +85 -0
- package/src/index.ts +82 -0
- package/src/lib/agenda-time.test.ts +539 -0
- package/src/lib/agenda-time.ts +505 -0
- package/src/lib/calendar-event-shell.ts +55 -0
- package/src/lib/calendar-slot-pick.test.ts +140 -0
- package/src/lib/calendar-slot-pick.ts +65 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The strip's arithmetic, on days written here rather than read from anywhere.
|
|
3
|
+
*
|
|
4
|
+
* Free time and collapsed runs are claims the design makes on screen — "your
|
|
5
|
+
* afternoon is clear", "five days with nothing booked" — so every number is
|
|
6
|
+
* worked out by hand and written down. The suite runs in whatever zone the
|
|
7
|
+
* machine is in, so anything that would answer differently in Los Angeles than
|
|
8
|
+
* in Amsterdam is asserted in more than one.
|
|
9
|
+
*/
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import type {
|
|
13
|
+
CalendarDay,
|
|
14
|
+
CalendarEventData,
|
|
15
|
+
} from "../components/calendar-types.js";
|
|
16
|
+
import {
|
|
17
|
+
addDays,
|
|
18
|
+
buildAgendaRows,
|
|
19
|
+
buildCalendarDay,
|
|
20
|
+
busyMinutesOf,
|
|
21
|
+
busySpansOn,
|
|
22
|
+
clashesWith,
|
|
23
|
+
conflictsOf,
|
|
24
|
+
datesBetween,
|
|
25
|
+
formatMinute,
|
|
26
|
+
formatRunLabel,
|
|
27
|
+
formatShortDay,
|
|
28
|
+
formatSpan,
|
|
29
|
+
freeAhead,
|
|
30
|
+
freeStretchesOn,
|
|
31
|
+
groupOverlapping,
|
|
32
|
+
isClearDay,
|
|
33
|
+
isEmptyDay,
|
|
34
|
+
minuteOfDay,
|
|
35
|
+
monthLabel,
|
|
36
|
+
readNextUp,
|
|
37
|
+
shortMonthLabel,
|
|
38
|
+
wallSpanOn,
|
|
39
|
+
weekdayLongLabel,
|
|
40
|
+
weekdayShortLabel,
|
|
41
|
+
} from "./agenda-time.js";
|
|
42
|
+
|
|
43
|
+
const HOME_ZONE = "Europe/Amsterdam";
|
|
44
|
+
const TODAY = "2026-06-10";
|
|
45
|
+
|
|
46
|
+
/** Every offset the fixtures print, so instants never depend on the machine. */
|
|
47
|
+
const OFFSET = "+02:00";
|
|
48
|
+
|
|
49
|
+
function event(
|
|
50
|
+
id: string,
|
|
51
|
+
date: string,
|
|
52
|
+
from: string,
|
|
53
|
+
to: string,
|
|
54
|
+
extra: Partial<CalendarEventData> = {},
|
|
55
|
+
): CalendarEventData {
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
calendarId: "c1",
|
|
59
|
+
title: id,
|
|
60
|
+
start: `${date}T${from}:00${OFFSET}`,
|
|
61
|
+
end: `${date}T${to}:00${OFFSET}`,
|
|
62
|
+
allDay: false,
|
|
63
|
+
location: "",
|
|
64
|
+
notes: "",
|
|
65
|
+
attendees: [],
|
|
66
|
+
myRsvp: "accepted",
|
|
67
|
+
threadId: "",
|
|
68
|
+
threadSubject: "",
|
|
69
|
+
timeZone: HOME_ZONE,
|
|
70
|
+
zoneCertainty: "explicit",
|
|
71
|
+
recurrenceRule: "",
|
|
72
|
+
seriesId: "",
|
|
73
|
+
seriesException: false,
|
|
74
|
+
status: "confirmed",
|
|
75
|
+
...extra,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function banner(id: string, from: string, to: string): CalendarEventData {
|
|
80
|
+
return {
|
|
81
|
+
...event(id, from, "00", "00"),
|
|
82
|
+
start: from,
|
|
83
|
+
end: to,
|
|
84
|
+
allDay: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wednesday the 10th: a morning pile-up, lunch, an afternoon gap, one late
|
|
90
|
+
* meeting. Thursday carries a banner and nothing on the clock. Friday through
|
|
91
|
+
* Tuesday are empty. The 17th has one event again.
|
|
92
|
+
*/
|
|
93
|
+
const events: CalendarEventData[] = [
|
|
94
|
+
event("evt_standup", TODAY, "09:00", "09:15"),
|
|
95
|
+
event("evt_roadmap", TODAY, "10:00", "11:30"),
|
|
96
|
+
event("evt_incident", TODAY, "10:30", "12:00"),
|
|
97
|
+
event("evt_1to1", TODAY, "11:00", "11:20"),
|
|
98
|
+
event("evt_lunch", TODAY, "12:00", "13:15", { location: "Toscanini" }),
|
|
99
|
+
event("evt_retro", TODAY, "16:00", "17:00", {
|
|
100
|
+
recurrenceRule: "Every Wednesday",
|
|
101
|
+
threadId: "thr_retro",
|
|
102
|
+
}),
|
|
103
|
+
banner("evt_conference", "2026-06-11", "2026-06-12"),
|
|
104
|
+
event("evt_offsite", "2026-06-17", "14:00", "15:00"),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
function dayOn(date: string, source = events): CalendarDay {
|
|
108
|
+
return buildCalendarDay(date, source, TODAY);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const days = datesBetween(TODAY, "2026-06-18").map((date) => dayOn(date));
|
|
112
|
+
|
|
113
|
+
function clocks(
|
|
114
|
+
stretches: { startMinute: number; endMinute: number }[],
|
|
115
|
+
): string[] {
|
|
116
|
+
return stretches.map(
|
|
117
|
+
(stretch) =>
|
|
118
|
+
`${formatMinute(stretch.startMinute)}–${formatMinute(stretch.endMinute)}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The zones the machine might be in, asserted so a body cannot pass silently. */
|
|
123
|
+
const ZONES = [
|
|
124
|
+
{ name: "UTC", hourAtNoonUtc: 12 },
|
|
125
|
+
{ name: "Pacific/Kiritimati", hourAtNoonUtc: 2 },
|
|
126
|
+
{ name: "America/Los_Angeles", hourAtNoonUtc: 5 },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
function inEveryZone(body: () => void): void {
|
|
130
|
+
const before = process.env.TZ;
|
|
131
|
+
try {
|
|
132
|
+
for (const zone of ZONES) {
|
|
133
|
+
process.env.TZ = zone.name;
|
|
134
|
+
assert.equal(
|
|
135
|
+
new Date("2026-06-11T12:00:00Z").getHours(),
|
|
136
|
+
zone.hourAtNoonUtc,
|
|
137
|
+
`the machine never moved to ${zone.name}`,
|
|
138
|
+
);
|
|
139
|
+
body();
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
if (before === undefined) delete process.env.TZ;
|
|
143
|
+
else process.env.TZ = before;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe("buildCalendarDay", () => {
|
|
148
|
+
it("sorts the clock and keeps the banners apart from it", () => {
|
|
149
|
+
const day = dayOn(TODAY);
|
|
150
|
+
assert.deepEqual(
|
|
151
|
+
day.timed.map((item) => item.id),
|
|
152
|
+
[
|
|
153
|
+
"evt_standup",
|
|
154
|
+
"evt_roadmap",
|
|
155
|
+
"evt_incident",
|
|
156
|
+
"evt_1to1",
|
|
157
|
+
"evt_lunch",
|
|
158
|
+
"evt_retro",
|
|
159
|
+
],
|
|
160
|
+
);
|
|
161
|
+
assert.deepEqual(day.allDay, []);
|
|
162
|
+
assert.equal(day.dayNumber, 10);
|
|
163
|
+
assert.equal(day.isToday, true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("carries an all-day range on every day it covers but the last", () => {
|
|
167
|
+
assert.deepEqual(
|
|
168
|
+
dayOn("2026-06-11").allDay.map((item) => item.id),
|
|
169
|
+
["evt_conference"],
|
|
170
|
+
);
|
|
171
|
+
assert.deepEqual(dayOn("2026-06-12").allDay, []);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("counts overlapping events as the clock time they cover, once", () => {
|
|
175
|
+
const day = dayOn(TODAY);
|
|
176
|
+
assert.equal(day.timed.length, 6);
|
|
177
|
+
assert.equal(day.busyMinutes, 15 + 195 + 60);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("names the pile-up and leaves a clean day empty", () => {
|
|
181
|
+
assert.deepEqual(dayOn(TODAY).conflicts, [
|
|
182
|
+
["evt_1to1", "evt_incident", "evt_roadmap"],
|
|
183
|
+
]);
|
|
184
|
+
assert.deepEqual(dayOn("2026-06-17").conflicts, []);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("counts nothing as no minutes and no clashes", () => {
|
|
188
|
+
assert.equal(busyMinutesOf([]), 0);
|
|
189
|
+
assert.deepEqual(conflictsOf([]), []);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("free time", () => {
|
|
194
|
+
it("measures the gaps the busiest day still leaves open", () => {
|
|
195
|
+
assert.deepEqual(clocks(freeStretchesOn(dayOn(TODAY))), [
|
|
196
|
+
"13:15–16:00",
|
|
197
|
+
"17:00–22:00",
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("ignores a gap too short to be free time", () => {
|
|
202
|
+
const packed = dayOn("2026-06-18", [
|
|
203
|
+
event("evt_a", "2026-06-18", "08:00", "12:00"),
|
|
204
|
+
event("evt_b", "2026-06-18", "13:00", "22:00"),
|
|
205
|
+
]);
|
|
206
|
+
assert.deepEqual(freeStretchesOn(packed), []);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("treats a day with nothing timed on it as free all day", () => {
|
|
210
|
+
const [whole] = freeStretchesOn(dayOn("2026-06-11"));
|
|
211
|
+
assert.equal(whole.wholeDay, true);
|
|
212
|
+
assert.deepEqual(clocks([whole]), ["08:00–22:00"]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The editor writes one date, so 23:00–01:00 comes back as a span that ends
|
|
217
|
+
* before it starts. It is still one evening taken off the day, never two
|
|
218
|
+
* bands over the same morning.
|
|
219
|
+
*/
|
|
220
|
+
it("gives one band for an event whose end is before its start", () => {
|
|
221
|
+
const day = dayOn("2026-06-13", [
|
|
222
|
+
event("evt_late", "2026-06-13", "23:00", "01:00"),
|
|
223
|
+
]);
|
|
224
|
+
assert.deepEqual(clocks(freeStretchesOn(day)), ["08:00–22:00"]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("clips a stretch to the window the day is measured inside", () => {
|
|
228
|
+
const day = dayOn("2026-06-13", [
|
|
229
|
+
event("evt_late", "2026-06-13", "22:30", "23:00"),
|
|
230
|
+
]);
|
|
231
|
+
assert.deepEqual(clocks(freeStretchesOn(day)), ["08:00–22:00"]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("merges a pile-up into the hours it actually covers", () => {
|
|
235
|
+
assert.deepEqual(busySpansOn(dayOn(TODAY)), [
|
|
236
|
+
{ from: 9 * 60, to: 9 * 60 + 15 },
|
|
237
|
+
{ from: 10 * 60, to: 13 * 60 + 15 },
|
|
238
|
+
{ from: 16 * 60, to: 17 * 60 },
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("says whether a day is clear of the clock, banners aside", () => {
|
|
243
|
+
assert.equal(isClearDay(dayOn("2026-06-11")), true);
|
|
244
|
+
assert.equal(isEmptyDay(dayOn("2026-06-11")), false);
|
|
245
|
+
assert.equal(isEmptyDay(dayOn("2026-06-14")), true);
|
|
246
|
+
assert.equal(isClearDay(dayOn(TODAY)), false);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("buildAgendaRows", () => {
|
|
251
|
+
const rows = buildAgendaRows(days, [TODAY]);
|
|
252
|
+
|
|
253
|
+
it("collapses a run of empty days into one row", () => {
|
|
254
|
+
const run = rows.find((row) => row.kind === "run");
|
|
255
|
+
assert.ok(run && run.kind === "run");
|
|
256
|
+
assert.equal(run.from, "2026-06-12");
|
|
257
|
+
assert.equal(run.to, "2026-06-16");
|
|
258
|
+
assert.equal(run.days, 5);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("keeps a day carrying only an all-day banner as its own row", () => {
|
|
262
|
+
assert.ok(
|
|
263
|
+
rows.some((row) => row.kind === "day" && row.day.date === "2026-06-11"),
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("never collapses a day it was told to keep", () => {
|
|
268
|
+
const kept = buildAgendaRows(days, ["2026-06-14"]);
|
|
269
|
+
assert.ok(
|
|
270
|
+
kept.some((row) => row.kind === "day" && row.day.date === "2026-06-14"),
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("collapses a run that reaches the end of the strip", () => {
|
|
275
|
+
const trailing = buildAgendaRows(
|
|
276
|
+
datesBetween("2026-06-12", "2026-06-16").map((date) => dayOn(date)),
|
|
277
|
+
[],
|
|
278
|
+
);
|
|
279
|
+
assert.deepEqual(
|
|
280
|
+
trailing.map((row) => row.kind),
|
|
281
|
+
["run"],
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("leaves a single empty day as a day rather than a run of one", () => {
|
|
286
|
+
const single = buildAgendaRows(
|
|
287
|
+
[dayOn("2026-06-16"), dayOn("2026-06-17")],
|
|
288
|
+
[],
|
|
289
|
+
);
|
|
290
|
+
assert.deepEqual(
|
|
291
|
+
single.map((row) => row.kind),
|
|
292
|
+
["day", "day"],
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("groupOverlapping", () => {
|
|
298
|
+
it("keeps the pile-up together and leaves the rest alone", () => {
|
|
299
|
+
assert.deepEqual(
|
|
300
|
+
groupOverlapping(dayOn(TODAY).timed).map((group) => group.length),
|
|
301
|
+
[1, 3, 1, 1],
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("readNextUp", () => {
|
|
307
|
+
const nextUp = readNextUp(days, `${TODAY}T09:05:00${OFFSET}`);
|
|
308
|
+
|
|
309
|
+
it("names what is running, what is next and how long until it", () => {
|
|
310
|
+
assert.deepEqual(
|
|
311
|
+
nextUp.running.map((item) => item.id),
|
|
312
|
+
["evt_standup"],
|
|
313
|
+
);
|
|
314
|
+
assert.equal(nextUp.next?.id, "evt_roadmap");
|
|
315
|
+
assert.equal(nextUp.minutesUntilNext, 55);
|
|
316
|
+
assert.equal(nextUp.after?.id, "evt_incident");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("counts what is left of today, including what is running", () => {
|
|
320
|
+
assert.equal(nextUp.restOfDay, 6);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("reaches past the pile-up to the gap after lunch", () => {
|
|
324
|
+
assert.equal(nextUp.free?.startMinute, 13 * 60 + 15);
|
|
325
|
+
assert.equal(nextUp.free?.endMinute, 16 * 60);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("says nothing is next once the day is done", () => {
|
|
329
|
+
const done = readNextUp(days, "2026-06-18T21:00:00+02:00");
|
|
330
|
+
assert.equal(done.next, undefined);
|
|
331
|
+
assert.equal(done.minutesUntilNext, 0);
|
|
332
|
+
assert.equal(done.restOfDay, 0);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("freeAhead", () => {
|
|
337
|
+
it("clips today's first stretch to start no earlier than now", () => {
|
|
338
|
+
const [first] = freeAhead(days, `${TODAY}T14:00:00${OFFSET}`, 1);
|
|
339
|
+
assert.deepEqual(clocks([first]), ["14:00–16:00"]);
|
|
340
|
+
assert.equal(first.wholeDay, false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("skips a stretch that now has already eaten", () => {
|
|
344
|
+
const [first] = freeAhead(days, `${TODAY}T15:00:00${OFFSET}`, 1);
|
|
345
|
+
assert.deepEqual(clocks([first]), ["17:00–22:00"]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("looks past today and stops at the limit it was given", () => {
|
|
349
|
+
const found = freeAhead(days, "2026-06-11T09:00:00+02:00", 2);
|
|
350
|
+
assert.deepEqual(
|
|
351
|
+
found.map((stretch) => stretch.date),
|
|
352
|
+
["2026-06-11", "2026-06-12"],
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("returns everything it found when the strip runs out first", () => {
|
|
357
|
+
assert.deepEqual(freeAhead([], `${TODAY}T09:00:00${OFFSET}`, 3), []);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("clashesWith", () => {
|
|
362
|
+
const source = dayOn(TODAY).timed;
|
|
363
|
+
|
|
364
|
+
it("names what a candidate runs into", () => {
|
|
365
|
+
assert.deepEqual(
|
|
366
|
+
clashesWith(
|
|
367
|
+
{
|
|
368
|
+
start: `${TODAY}T11:00:00${OFFSET}`,
|
|
369
|
+
end: `${TODAY}T11:30:00${OFFSET}`,
|
|
370
|
+
},
|
|
371
|
+
source,
|
|
372
|
+
).map((item) => item.id),
|
|
373
|
+
["evt_roadmap", "evt_incident", "evt_1to1"],
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("lets one span end where the next begins", () => {
|
|
378
|
+
assert.deepEqual(
|
|
379
|
+
clashesWith(
|
|
380
|
+
{
|
|
381
|
+
start: `${TODAY}T09:15:00${OFFSET}`,
|
|
382
|
+
end: `${TODAY}T09:45:00${OFFSET}`,
|
|
383
|
+
},
|
|
384
|
+
source,
|
|
385
|
+
),
|
|
386
|
+
[],
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("skips an all-day banner and a declined invitation", () => {
|
|
391
|
+
const declined = event("evt_declined", TODAY, "14:00", "15:00", {
|
|
392
|
+
myRsvp: "declined",
|
|
393
|
+
});
|
|
394
|
+
const candidate = {
|
|
395
|
+
start: `${TODAY}T14:15:00${OFFSET}`,
|
|
396
|
+
end: `${TODAY}T14:45:00${OFFSET}`,
|
|
397
|
+
};
|
|
398
|
+
assert.deepEqual(clashesWith(candidate, [declined]), []);
|
|
399
|
+
assert.deepEqual(
|
|
400
|
+
clashesWith(candidate, [banner("evt_banner", TODAY, "2026-06-12")]),
|
|
401
|
+
[],
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("keeps a span it was told to ignore out of its own verdict", () => {
|
|
406
|
+
const candidate = source[1];
|
|
407
|
+
assert.ok(
|
|
408
|
+
clashesWith(candidate, source).some((item) => item.id === candidate.id),
|
|
409
|
+
);
|
|
410
|
+
assert.deepEqual(
|
|
411
|
+
clashesWith(candidate, source, { ignoreIds: [candidate.id] }).map(
|
|
412
|
+
(item) => item.id,
|
|
413
|
+
),
|
|
414
|
+
["evt_incident", "evt_1to1"],
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("answers the same in every machine zone, off the offsets it is given", () => {
|
|
419
|
+
const abroad = {
|
|
420
|
+
...event("evt_abroad", TODAY, "00", "00"),
|
|
421
|
+
start: `${TODAY}T17:30:00+05:30`,
|
|
422
|
+
end: `${TODAY}T18:30:00+05:30`,
|
|
423
|
+
};
|
|
424
|
+
const home = {
|
|
425
|
+
start: `${TODAY}T14:30:00${OFFSET}`,
|
|
426
|
+
end: `${TODAY}T15:30:00${OFFSET}`,
|
|
427
|
+
};
|
|
428
|
+
const later = {
|
|
429
|
+
start: `${TODAY}T15:00:00${OFFSET}`,
|
|
430
|
+
end: `${TODAY}T16:00:00${OFFSET}`,
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
inEveryZone(() => {
|
|
434
|
+
assert.deepEqual(
|
|
435
|
+
clashesWith(home, [abroad]).map((item) => item.id),
|
|
436
|
+
["evt_abroad"],
|
|
437
|
+
);
|
|
438
|
+
assert.deepEqual(clashesWith(later, [abroad]), []);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe("a clock the mail never named", () => {
|
|
444
|
+
const printed = {
|
|
445
|
+
start: "2026-06-17T16:00:00+02:00",
|
|
446
|
+
end: "2026-06-17T17:00:00+02:00",
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
it("reads the printed hour on the zone that was picked, wherever the machine is", () => {
|
|
450
|
+
inEveryZone(() => {
|
|
451
|
+
assert.deepEqual(wallSpanOn(printed, "Europe/Lisbon", HOME_ZONE), {
|
|
452
|
+
start: "2026-06-17T17:00:00+02:00",
|
|
453
|
+
end: "2026-06-17T18:00:00+02:00",
|
|
454
|
+
});
|
|
455
|
+
assert.deepEqual(wallSpanOn(printed, HOME_ZONE, HOME_ZONE), printed);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("keeps whole-hour arithmetic away from the zones that are not on it", () => {
|
|
460
|
+
assert.equal(
|
|
461
|
+
wallSpanOn(printed, "Asia/Kathmandu", HOME_ZONE).start,
|
|
462
|
+
"2026-06-17T12:15:00+02:00",
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("carries the printed length over a spring-forward instead of losing an hour", () => {
|
|
467
|
+
const booked = wallSpanOn(
|
|
468
|
+
{ start: "2026-03-29T01:30:00+00:00", end: "2026-03-29T03:30:00+00:00" },
|
|
469
|
+
"Europe/Lisbon",
|
|
470
|
+
HOME_ZONE,
|
|
471
|
+
);
|
|
472
|
+
assert.deepEqual(booked, {
|
|
473
|
+
start: "2026-03-29T03:30:00+02:00",
|
|
474
|
+
end: "2026-03-29T05:30:00+02:00",
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("leaves an all-day value alone, having no clock to read", () => {
|
|
479
|
+
assert.deepEqual(
|
|
480
|
+
wallSpanOn(
|
|
481
|
+
{ start: "2026-06-19", end: "2026-06-23" },
|
|
482
|
+
"Europe/Lisbon",
|
|
483
|
+
HOME_ZONE,
|
|
484
|
+
),
|
|
485
|
+
{ start: "2026-06-19", end: "2026-06-23" },
|
|
486
|
+
);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
describe("labels and arithmetic", () => {
|
|
491
|
+
it("says hours and minutes the way a person would", () => {
|
|
492
|
+
assert.equal(formatSpan(45), "45m");
|
|
493
|
+
assert.equal(formatSpan(120), "2h");
|
|
494
|
+
assert.equal(formatSpan(285), "4h 45m");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("clamps a clock to the day it belongs to", () => {
|
|
498
|
+
assert.equal(formatMinute(-30), "00:00");
|
|
499
|
+
assert.equal(formatMinute(9 * 60 + 5), "09:05");
|
|
500
|
+
assert.equal(formatMinute(30 * 60), "24:00");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("reads the minute out of an ISO string without a zone conversion", () => {
|
|
504
|
+
inEveryZone(() => {
|
|
505
|
+
assert.equal(minuteOfDay(`${TODAY}T09:15:00${OFFSET}`), 9 * 60 + 15);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("walks dates across a month boundary", () => {
|
|
510
|
+
assert.equal(addDays("2026-06-30", 1), "2026-07-01");
|
|
511
|
+
assert.equal(addDays("2026-06-01", -1), "2026-05-31");
|
|
512
|
+
assert.deepEqual(datesBetween("2026-06-30", "2026-07-02"), [
|
|
513
|
+
"2026-06-30",
|
|
514
|
+
"2026-07-01",
|
|
515
|
+
"2026-07-02",
|
|
516
|
+
]);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("collapses the month when both ends of a run share one", () => {
|
|
520
|
+
assert.equal(
|
|
521
|
+
formatRunLabel("2026-06-12", "2026-06-16"),
|
|
522
|
+
"Fri 12 – Tue 16 Jun",
|
|
523
|
+
);
|
|
524
|
+
assert.equal(
|
|
525
|
+
formatRunLabel("2026-06-29", "2026-07-02"),
|
|
526
|
+
"Mon 29 Jun – Thu 2 Jul",
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("names a day the same in every machine zone", () => {
|
|
531
|
+
inEveryZone(() => {
|
|
532
|
+
assert.equal(monthLabel(TODAY), "June 2026");
|
|
533
|
+
assert.equal(shortMonthLabel(TODAY), "Jun");
|
|
534
|
+
assert.equal(formatShortDay(TODAY), "Wed 10 Jun");
|
|
535
|
+
assert.equal(weekdayLongLabel(TODAY), "Wednesday");
|
|
536
|
+
assert.equal(weekdayShortLabel(TODAY), "Wed");
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
});
|