@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,505 @@
1
+ /**
2
+ * The arithmetic the agenda needs and a grid does not.
3
+ *
4
+ * A time grid renders empty hours and lets the reader find the gaps. A list has
5
+ * to name them, so free time here is computed rather than left as whitespace:
6
+ * the stretches inside a day, the runs of days with nothing on them at all, and
7
+ * the answer to "what is the next thing". Every function is pure and reads its
8
+ * clock from a parameter, so a story and a test give the same answer.
9
+ */
10
+ import { Temporal } from "temporal-polyfill";
11
+ import type {
12
+ CalendarDay,
13
+ CalendarEventData,
14
+ } from "../components/calendar-types.js";
15
+
16
+ /** The window a free stretch is measured inside. Nobody wants "free 02:00–07:00". */
17
+ export const DAY_START_MINUTE = 8 * 60;
18
+ export const DAY_END_MINUTE = 22 * 60;
19
+
20
+ /** Under this a gap is the walk between two rooms, not free time. */
21
+ export const FREE_MINUTES = 90;
22
+
23
+ export function minuteOfDay(iso: string): number {
24
+ return Number(iso.slice(11, 13)) * 60 + Number(iso.slice(14, 16));
25
+ }
26
+
27
+ export function formatMinute(minute: number): string {
28
+ const clamped = Math.max(0, Math.min(24 * 60, Math.round(minute)));
29
+ return `${String(Math.floor(clamped / 60)).padStart(2, "0")}:${String(
30
+ clamped % 60,
31
+ ).padStart(2, "0")}`;
32
+ }
33
+
34
+ /** "4h 45m", "2h", "45m". */
35
+ export function formatSpan(minutes: number): string {
36
+ const whole = Math.round(minutes);
37
+ if (whole < 60) return `${whole}m`;
38
+ const hours = Math.floor(whole / 60);
39
+ const rest = whole % 60;
40
+ return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
41
+ }
42
+
43
+ export function addDays(date: string, days: number): string {
44
+ const cursor = new Date(`${date}T00:00:00Z`);
45
+ cursor.setUTCDate(cursor.getUTCDate() + days);
46
+ return cursor.toISOString().slice(0, 10);
47
+ }
48
+
49
+ export function datesBetween(from: string, to: string): string[] {
50
+ const dates: string[] = [];
51
+ for (let cursor = from; cursor <= to; cursor = addDays(cursor, 1))
52
+ dates.push(cursor);
53
+ return dates;
54
+ }
55
+
56
+ export function monthLabel(date: string): string {
57
+ const [year, month, day] = date.split("-").map(Number);
58
+ return new Date(year, month - 1, day).toLocaleDateString("en-GB", {
59
+ month: "long",
60
+ year: "numeric",
61
+ });
62
+ }
63
+
64
+ export function shortMonthLabel(date: string): string {
65
+ const [year, month, day] = date.split("-").map(Number);
66
+ return new Date(year, month - 1, day).toLocaleDateString("en-GB", {
67
+ month: "short",
68
+ });
69
+ }
70
+
71
+ /** "Thu 11 Jun". */
72
+ export function formatShortDay(date: string): string {
73
+ const [year, month, day] = date.split("-").map(Number);
74
+ return new Date(year, month - 1, day).toLocaleDateString("en-GB", {
75
+ weekday: "short",
76
+ day: "numeric",
77
+ month: "short",
78
+ });
79
+ }
80
+
81
+ export function weekdayLongLabel(date: string): string {
82
+ const [year, month, day] = date.split("-").map(Number);
83
+ return new Date(year, month - 1, day).toLocaleDateString("en-GB", {
84
+ weekday: "long",
85
+ });
86
+ }
87
+
88
+ /** "Sat 20 – Thu 25 June", collapsing the month when both ends share one. */
89
+ export function formatRunLabel(from: string, to: string): string {
90
+ const [fromYear, fromMonth, fromDay] = from.split("-").map(Number);
91
+ const [toYear, toMonth, toDay] = to.split("-").map(Number);
92
+ const first = new Date(fromYear, fromMonth - 1, fromDay);
93
+ const last = new Date(toYear, toMonth - 1, toDay);
94
+ const opening = first.toLocaleDateString("en-GB", {
95
+ weekday: "short",
96
+ day: "numeric",
97
+ ...(fromMonth === toMonth ? {} : { month: "short" }),
98
+ });
99
+ const closing = last.toLocaleDateString("en-GB", {
100
+ weekday: "short",
101
+ day: "numeric",
102
+ month: "short",
103
+ });
104
+ return `${opening} – ${closing}`;
105
+ }
106
+
107
+ export function weekdayShortLabel(date: string): string {
108
+ const [year, month, day] = date.split("-").map(Number);
109
+ return new Date(year, month - 1, day).toLocaleDateString("en-GB", {
110
+ weekday: "short",
111
+ });
112
+ }
113
+
114
+ function instantOf(iso: string): number {
115
+ return new Date(iso).getTime();
116
+ }
117
+
118
+ /** An all-day range ends on the morning after its last day. */
119
+ function coversDay(event: CalendarEventData, date: string): boolean {
120
+ if (!event.allDay) return event.start.slice(0, 10) === date;
121
+ return date >= event.start.slice(0, 10) && date < event.end.slice(0, 10);
122
+ }
123
+
124
+ function overlaps(a: CalendarEventData, b: CalendarEventData): boolean {
125
+ return (
126
+ instantOf(a.start) < instantOf(b.end) &&
127
+ instantOf(b.start) < instantOf(a.end)
128
+ );
129
+ }
130
+
131
+ /** Clock time covered by at least one of these, counted once. */
132
+ export function busyMinutesOf(timed: readonly CalendarEventData[]): number {
133
+ const spans = timed
134
+ .map((event) => [instantOf(event.start), instantOf(event.end)] as const)
135
+ .sort((a, b) => a[0] - b[0]);
136
+ let covered = 0;
137
+ let openFrom = 0;
138
+ let openTo = 0;
139
+ for (const [from, to] of spans) {
140
+ if (from > openTo) {
141
+ covered += openTo - openFrom;
142
+ openFrom = from;
143
+ openTo = to;
144
+ continue;
145
+ }
146
+ openTo = Math.max(openTo, to);
147
+ }
148
+ covered += openTo - openFrom;
149
+ return Math.round(covered / 60_000);
150
+ }
151
+
152
+ /** Every event that runs into another, grouped around the one it collides with. */
153
+ export function conflictsOf(timed: readonly CalendarEventData[]): string[][] {
154
+ const groups: string[][] = [];
155
+ for (const anchor of timed) {
156
+ const group = timed
157
+ .filter((other) => other.id === anchor.id || overlaps(anchor, other))
158
+ .map((event) => event.id)
159
+ .sort();
160
+ if (group.length < 2) continue;
161
+ const key = group.join("|");
162
+ if (!groups.some((existing) => existing.join("|") === key))
163
+ groups.push(group);
164
+ }
165
+ return groups.filter(
166
+ (group) =>
167
+ !groups.some(
168
+ (other) => other !== group && group.every((id) => other.includes(id)),
169
+ ),
170
+ );
171
+ }
172
+
173
+ /** One day assembled out of a flat event list — the shape every surface takes. */
174
+ export function buildCalendarDay(
175
+ date: string,
176
+ events: readonly CalendarEventData[],
177
+ today: string,
178
+ ): CalendarDay {
179
+ const onDay = events.filter((event) => coversDay(event, date));
180
+ const timed = onDay
181
+ .filter((event) => !event.allDay)
182
+ .sort((a, b) => instantOf(a.start) - instantOf(b.start));
183
+ return {
184
+ date,
185
+ weekdayLabel: weekdayShortLabel(date),
186
+ dayNumber: Number(date.slice(8)),
187
+ isToday: date === today,
188
+ timed,
189
+ allDay: onDay.filter((event) => event.allDay),
190
+ busyMinutes: busyMinutesOf(timed),
191
+ conflicts: conflictsOf(timed),
192
+ };
193
+ }
194
+
195
+ export function isEmptyDay(day: CalendarDay): boolean {
196
+ return day.timed.length === 0 && day.allDay.length === 0;
197
+ }
198
+
199
+ /** Nothing on the clock, whatever banners the day carries. */
200
+ export function isClearDay(day: CalendarDay): boolean {
201
+ return day.timed.length === 0;
202
+ }
203
+
204
+ export interface FreeStretch {
205
+ date: string;
206
+ startMinute: number;
207
+ endMinute: number;
208
+ minutes: number;
209
+ /** The day has no timed events at all, so the stretch is the whole day. */
210
+ wholeDay: boolean;
211
+ }
212
+
213
+ export interface BusySpan {
214
+ from: number;
215
+ to: number;
216
+ }
217
+
218
+ /**
219
+ * The minutes of the day that are actually covered. Four meetings stacked on
220
+ * each other take one hour off a day, not four, and every summary in this
221
+ * option is measured off that rather than off a row count.
222
+ */
223
+ export function busySpansOn(day: CalendarDay): BusySpan[] {
224
+ const spans = day.timed
225
+ .map((event) => ({
226
+ from: minuteOfDay(event.start),
227
+ to: minuteOfDay(event.end),
228
+ }))
229
+ .sort((a, b) => a.from - b.from);
230
+
231
+ const merged: BusySpan[] = [];
232
+ for (const span of spans) {
233
+ const last = merged[merged.length - 1];
234
+ if (last && span.from <= last.to) {
235
+ last.to = Math.max(last.to, span.to);
236
+ continue;
237
+ }
238
+ merged.push({ ...span });
239
+ }
240
+ return merged;
241
+ }
242
+
243
+ function insideWindow(minute: number): number {
244
+ return Math.min(Math.max(minute, DAY_START_MINUTE), DAY_END_MINUTE);
245
+ }
246
+
247
+ /**
248
+ * The gaps between those spans, inside the window worth calling a day.
249
+ *
250
+ * Both ends of every span are pulled into the window before they are measured,
251
+ * and the cursor only ever moves forward, so an event running past the window
252
+ * cannot stretch a band past it either and a span that ends before it starts —
253
+ * an overnight event, which the editor writes onto one date — cannot leave the
254
+ * cursor where the tail would emit a second band over the first.
255
+ */
256
+ export function freeStretchesOn(
257
+ day: CalendarDay,
258
+ minMinutes = FREE_MINUTES,
259
+ ): FreeStretch[] {
260
+ if (day.timed.length === 0)
261
+ return [
262
+ {
263
+ date: day.date,
264
+ startMinute: DAY_START_MINUTE,
265
+ endMinute: DAY_END_MINUTE,
266
+ minutes: DAY_END_MINUTE - DAY_START_MINUTE,
267
+ wholeDay: true,
268
+ },
269
+ ];
270
+
271
+ const merged = busySpansOn(day);
272
+ const stretches: FreeStretch[] = [];
273
+ let cursor = DAY_START_MINUTE;
274
+ for (const span of merged) {
275
+ const from = insideWindow(span.from);
276
+ const to = insideWindow(span.to);
277
+ if (from - cursor >= minMinutes)
278
+ stretches.push({
279
+ date: day.date,
280
+ startMinute: cursor,
281
+ endMinute: from,
282
+ minutes: from - cursor,
283
+ wholeDay: false,
284
+ });
285
+ cursor = Math.max(cursor, from, to);
286
+ }
287
+ if (DAY_END_MINUTE - cursor >= minMinutes)
288
+ stretches.push({
289
+ date: day.date,
290
+ startMinute: cursor,
291
+ endMinute: DAY_END_MINUTE,
292
+ minutes: DAY_END_MINUTE - cursor,
293
+ wholeDay: false,
294
+ });
295
+ return stretches;
296
+ }
297
+
298
+ export interface ClashOptions {
299
+ /** Spans that are not a clash: the candidate itself, or one already answered. */
300
+ ignoreIds?: readonly string[];
301
+ }
302
+
303
+ export interface WallSpan {
304
+ start: string;
305
+ end: string;
306
+ }
307
+
308
+ /**
309
+ * The hours a mail printed, read as times on `sourceZone` and written again on
310
+ * `displayZone` — the clock this calendar stores and draws.
311
+ *
312
+ * A zoneless reading carries an offset it has no right to: 16:00 sits in the
313
+ * fixture on +02:00 because something had to be written there. Once the reader
314
+ * says which clock the mail meant, the wall times are what survive and the
315
+ * instants are recomputed from them, so picking Lisbon moves the event an hour
316
+ * rather than relabelling it. Both zones are arguments; neither is read from
317
+ * the environment.
318
+ *
319
+ * Only the start is converted; the end is the start plus the span the mail
320
+ * printed. Converting both ends independently silently rewrites the length of
321
+ * anything that straddles a transition — an hour of a two-hour meeting is lost
322
+ * across a spring-forward and an hour is invented across a fall-back.
323
+ *
324
+ * A wall time the source zone skips moves forward to the hour that replaced it,
325
+ * and one it repeats takes the first of the two, which is what `compatible`
326
+ * disambiguation means. An all-day value has no clock to read and passes
327
+ * through untouched.
328
+ */
329
+ export function wallSpanOn(
330
+ span: WallSpan,
331
+ sourceZone: string,
332
+ displayZone: string,
333
+ ): WallSpan {
334
+ if (!span.start.includes("T") || !span.end.includes("T"))
335
+ return { start: span.start, end: span.end };
336
+ const printedStart = Temporal.PlainDateTime.from(span.start.slice(0, 19));
337
+ const printedEnd = Temporal.PlainDateTime.from(span.end.slice(0, 19));
338
+ const start = printedStart
339
+ .toZonedDateTime(sourceZone)
340
+ .withTimeZone(displayZone);
341
+ const end = start.add(
342
+ printedStart.until(printedEnd, {
343
+ largestUnit: "minute",
344
+ }),
345
+ );
346
+ return {
347
+ start: start.toString({ timeZoneName: "never" }),
348
+ end: end.toString({ timeZoneName: "never" }),
349
+ };
350
+ }
351
+
352
+ /**
353
+ * Whatever a candidate span runs into, so the clash is named before the answer.
354
+ *
355
+ * A declined event is not a commitment and an all-day banner is not an hour, so
356
+ * neither clashes. Spans are compared as instants, which is why every ISO the
357
+ * fixtures write carries its offset: two events written in different zones are
358
+ * still measured against the same line.
359
+ */
360
+ export function clashesWith(
361
+ candidate: { start: string; end: string },
362
+ source: readonly CalendarEventData[],
363
+ { ignoreIds = [] }: ClashOptions = {},
364
+ ): CalendarEventData[] {
365
+ const from = Date.parse(candidate.start);
366
+ const to = Date.parse(candidate.end);
367
+ return source.filter((item) => {
368
+ if (item.allDay || item.myRsvp === "declined") return false;
369
+ if (ignoreIds.includes(item.id)) return false;
370
+ return Date.parse(item.start) < to && from < Date.parse(item.end);
371
+ });
372
+ }
373
+
374
+ /**
375
+ * One entry in the strip. A day with something on it stands alone; a run of
376
+ * days with nothing at all becomes a single line, because six empty screens is
377
+ * a worse answer to "am I free" than one sentence saying so.
378
+ */
379
+ export type AgendaRow =
380
+ | { kind: "day"; key: string; day: CalendarDay }
381
+ | { kind: "run"; key: string; from: string; to: string; days: number };
382
+
383
+ export function buildAgendaRows(
384
+ days: CalendarDay[],
385
+ keep: readonly string[],
386
+ ): AgendaRow[] {
387
+ const rows: AgendaRow[] = [];
388
+ let run: CalendarDay[] = [];
389
+
390
+ const flush = () => {
391
+ if (run.length === 0) return;
392
+ if (run.length === 1)
393
+ rows.push({ kind: "day", key: run[0].date, day: run[0] });
394
+ else
395
+ rows.push({
396
+ kind: "run",
397
+ key: `run_${run[0].date}`,
398
+ from: run[0].date,
399
+ to: run[run.length - 1].date,
400
+ days: run.length,
401
+ });
402
+ run = [];
403
+ };
404
+
405
+ for (const day of days) {
406
+ if (isEmptyDay(day) && !keep.includes(day.date)) {
407
+ run.push(day);
408
+ continue;
409
+ }
410
+ flush();
411
+ rows.push({ kind: "day", key: day.date, day });
412
+ }
413
+ flush();
414
+ return rows;
415
+ }
416
+
417
+ /** Events that run into each other, grouped; everything else on its own. */
418
+ export function groupOverlapping(
419
+ timed: CalendarEventData[],
420
+ ): CalendarEventData[][] {
421
+ const groups: CalendarEventData[][] = [];
422
+ for (const event of timed) {
423
+ const group = groups[groups.length - 1];
424
+ const reach = group
425
+ ? Math.max(...group.map((member) => Date.parse(member.end)))
426
+ : 0;
427
+ if (group && Date.parse(event.start) < reach) {
428
+ group.push(event);
429
+ continue;
430
+ }
431
+ groups.push([event]);
432
+ }
433
+ return groups;
434
+ }
435
+
436
+ export interface NextUp {
437
+ /** Happening at `nowIso`. */
438
+ running: CalendarEventData[];
439
+ next: CalendarEventData | undefined;
440
+ minutesUntilNext: number;
441
+ after: CalendarEventData | undefined;
442
+ /** Still to come today, counting what is running. */
443
+ restOfDay: number;
444
+ /** The first stretch worth calling free, clipped to start no earlier than now. */
445
+ free: FreeStretch | undefined;
446
+ }
447
+
448
+ export function readNextUp(days: CalendarDay[], nowIso: string): NextUp {
449
+ const now = Date.parse(nowIso);
450
+ const today = nowIso.slice(0, 10);
451
+ const timed = days
452
+ .flatMap((day) => day.timed)
453
+ .sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
454
+
455
+ const running = timed.filter(
456
+ (event) => Date.parse(event.start) <= now && Date.parse(event.end) > now,
457
+ );
458
+ const ahead = timed.filter((event) => Date.parse(event.start) > now);
459
+ const next = ahead[0];
460
+ const restOfDay =
461
+ running.filter((event) => event.start.slice(0, 10) === today).length +
462
+ ahead.filter((event) => event.start.slice(0, 10) === today).length;
463
+
464
+ return {
465
+ running,
466
+ next,
467
+ minutesUntilNext: next
468
+ ? Math.round((Date.parse(next.start) - now) / 60_000)
469
+ : 0,
470
+ after: ahead[1],
471
+ restOfDay,
472
+ free: freeAhead(days, nowIso, 1)[0],
473
+ };
474
+ }
475
+
476
+ /** The next free stretches across the strip, in order, starting from now. */
477
+ export function freeAhead(
478
+ days: CalendarDay[],
479
+ nowIso: string,
480
+ limit: number,
481
+ ): FreeStretch[] {
482
+ const today = nowIso.slice(0, 10);
483
+ const nowMinute = minuteOfDay(nowIso);
484
+ const found: FreeStretch[] = [];
485
+
486
+ for (const day of days) {
487
+ if (day.date < today) continue;
488
+ for (const stretch of freeStretchesOn(day)) {
489
+ if (day.date > today) {
490
+ found.push(stretch);
491
+ } else {
492
+ const from = Math.max(stretch.startMinute, nowMinute);
493
+ if (stretch.endMinute - from < FREE_MINUTES) continue;
494
+ found.push({
495
+ ...stretch,
496
+ startMinute: from,
497
+ minutes: stretch.endMinute - from,
498
+ wholeDay: stretch.wholeDay && from === stretch.startMinute,
499
+ });
500
+ }
501
+ if (found.length === limit) return found;
502
+ }
503
+ }
504
+ return found;
505
+ }
@@ -0,0 +1,55 @@
1
+ import type { Density } from "../components/app-shell-types.js";
2
+ import type {
3
+ CalendarColorId,
4
+ RsvpState,
5
+ } from "../components/calendar-types.js";
6
+ import { calendarColorClasses } from "./calendar-color.js";
7
+ import { cn } from "./cn.js";
8
+
9
+ export interface CalendarEventShell {
10
+ color: CalendarColorId;
11
+ /**
12
+ * `row` is the horizontal pill of the all-day band and the month grid.
13
+ * `column` is the block that fills its slot in a time grid.
14
+ */
15
+ layout: "row" | "column";
16
+ density: Density;
17
+ /** The reader's own reply. A declined event dims; it never recolours. */
18
+ rsvp: RsvpState;
19
+ status: "confirmed" | "tentative";
20
+ selected: boolean;
21
+ /** Two lines rather than one: a title with something under it. */
22
+ stacked: boolean;
23
+ }
24
+
25
+ /**
26
+ * The coloured body of one event: the hue, the RSVP, the selection, and the box
27
+ * they sit in.
28
+ *
29
+ * `CalendarEventChip` draws this body inside a button of its own.
30
+ * `CalendarGrid` cannot — its engine builds the event's element and takes a
31
+ * class string for it — so there the engine's element *is* the body, styled
32
+ * from here. Neither surface restates the other.
33
+ *
34
+ * How the body is sized is left to the surface: the chip stretches it beside a
35
+ * leading slot, and the grid's engine has already positioned it.
36
+ */
37
+ export function calendarEventBodyClasses(shell: CalendarEventShell): string {
38
+ const hue = calendarColorClasses(shell.color);
39
+ const provisional =
40
+ shell.rsvp === "tentative" || shell.status === "tentative";
41
+ return cn(
42
+ "flex min-w-0 overflow-hidden rounded-sm border-l-2 px-1.5 py-0.5",
43
+ hue.soft,
44
+ hue.text,
45
+ hue.rail,
46
+ shell.layout === "column" || shell.stacked
47
+ ? "flex-col"
48
+ : "items-center gap-1.5",
49
+ provisional && "border-y border-r border-dashed",
50
+ provisional && hue.border,
51
+ shell.rsvp === "declined" && "opacity-60",
52
+ shell.selected && "ring-2 ring-ring",
53
+ shell.density === "compact" ? "text-2xs" : "text-xs",
54
+ );
55
+ }
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ isDraggedSelection,
5
+ pointPick,
6
+ rangePick,
7
+ } from "./calendar-slot-pick.js";
8
+
9
+ describe("pointPick", () => {
10
+ it("drafts an hour from the point that was clicked", () => {
11
+ assert.deepEqual(pointPick("2026-06-10T14:30:00+02:00", false), {
12
+ date: "2026-06-10",
13
+ startTime: "14:30",
14
+ endTime: "15:30",
15
+ allDay: false,
16
+ });
17
+ });
18
+
19
+ it("reads the clock off the string, not off the host's zone", () => {
20
+ /* The same instant, written in two zones. A pick reads the wall clock the
21
+ grid drew, so each keeps its own — a Date would collapse both to the
22
+ runner's zone. */
23
+ assert.equal(
24
+ pointPick("2026-06-10T09:00:00+02:00", false).startTime,
25
+ "09:00",
26
+ );
27
+ assert.equal(pointPick("2026-06-10T07:00:00Z", false).startTime, "07:00");
28
+ });
29
+
30
+ it("carries an all-day point with no clock at all", () => {
31
+ assert.deepEqual(pointPick("2026-06-10", true), {
32
+ date: "2026-06-10",
33
+ startTime: "",
34
+ endTime: "",
35
+ allDay: true,
36
+ });
37
+ });
38
+
39
+ it("rolls a draft that runs past midnight round the clock", () => {
40
+ assert.equal(
41
+ pointPick("2026-06-10T23:30:00+02:00", false).endTime,
42
+ "00:30",
43
+ );
44
+ });
45
+ });
46
+
47
+ describe("rangePick", () => {
48
+ it("keeps both ends of a dragged range", () => {
49
+ assert.deepEqual(
50
+ rangePick(
51
+ "2026-06-10T09:00:00+02:00",
52
+ "2026-06-10T11:30:00+02:00",
53
+ false,
54
+ ),
55
+ {
56
+ date: "2026-06-10",
57
+ startTime: "09:00",
58
+ endTime: "11:30",
59
+ allDay: false,
60
+ },
61
+ );
62
+ });
63
+
64
+ it("drops the clock off an all-day range", () => {
65
+ assert.deepEqual(rangePick("2026-06-10", "2026-06-13", true), {
66
+ date: "2026-06-10",
67
+ startTime: "",
68
+ endTime: "",
69
+ allDay: true,
70
+ });
71
+ });
72
+ });
73
+
74
+ describe("isDraggedSelection", () => {
75
+ const slot = 30;
76
+
77
+ it("calls one slot a click, not a drag", () => {
78
+ assert.equal(
79
+ isDraggedSelection(
80
+ "2026-06-10T09:00:00+02:00",
81
+ "2026-06-10T09:30:00+02:00",
82
+ false,
83
+ slot,
84
+ ),
85
+ false,
86
+ );
87
+ });
88
+
89
+ it("calls anything wider than a slot a drag", () => {
90
+ assert.equal(
91
+ isDraggedSelection(
92
+ "2026-06-10T09:00:00+02:00",
93
+ "2026-06-10T10:00:00+02:00",
94
+ false,
95
+ slot,
96
+ ),
97
+ true,
98
+ );
99
+ });
100
+
101
+ it("measures against the slot in force, so a coarser grid clicks wider", () => {
102
+ assert.equal(
103
+ isDraggedSelection(
104
+ "2026-06-10T09:00:00+02:00",
105
+ "2026-06-10T10:00:00+02:00",
106
+ false,
107
+ 60,
108
+ ),
109
+ false,
110
+ );
111
+ });
112
+
113
+ it("calls a single all-day cell a click", () => {
114
+ assert.equal(
115
+ isDraggedSelection("2026-06-10", "2026-06-11", true, slot),
116
+ false,
117
+ );
118
+ });
119
+
120
+ it("calls two all-day cells a drag", () => {
121
+ assert.equal(
122
+ isDraggedSelection("2026-06-10", "2026-06-12", true, slot),
123
+ true,
124
+ );
125
+ });
126
+
127
+ it("survives a day that changed length under it", () => {
128
+ /* The Amsterdam DST spring forward: 25 to 26 October is 25 hours long, and
129
+ rounding is what keeps that one cell a click. */
130
+ assert.equal(
131
+ isDraggedSelection(
132
+ "2026-10-25T00:00:00+02:00",
133
+ "2026-10-26T00:00:00+01:00",
134
+ true,
135
+ slot,
136
+ ),
137
+ false,
138
+ );
139
+ });
140
+ });