@remit/ui 0.0.159 → 0.0.161
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
CHANGED
|
@@ -9,7 +9,10 @@ import assert from "node:assert/strict";
|
|
|
9
9
|
import { describe, it } from "node:test";
|
|
10
10
|
import { createElement } from "react";
|
|
11
11
|
import { renderToString } from "react-dom/server";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
buildCalendarDay,
|
|
14
|
+
freeStretchesFromSpans,
|
|
15
|
+
} from "../lib/agenda-time.js";
|
|
13
16
|
import { AgendaFlow, type AgendaFlowProps } from "./agenda-flow.js";
|
|
14
17
|
import type {
|
|
15
18
|
CalendarDescriptor,
|
|
@@ -208,6 +211,45 @@ describe("AgendaFlow", () => {
|
|
|
208
211
|
assert.match(render({ touch: true }), /min-h-12/);
|
|
209
212
|
});
|
|
210
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Busy time on a calendar the strip is not drawing still takes the afternoon.
|
|
216
|
+
* Without this, a reader who unticks work reads "Free all day" for a day they
|
|
217
|
+
* are booked solid in.
|
|
218
|
+
*/
|
|
219
|
+
it("draws the free time its owner measured rather than the gaps on screen", () => {
|
|
220
|
+
const html = words(
|
|
221
|
+
render({
|
|
222
|
+
freeOn: (day) =>
|
|
223
|
+
freeStretchesFromSpans(day.date, [{ from: 8 * 60, to: 18 * 60 }]),
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
assert.doesNotMatch(html, /Free all day/);
|
|
227
|
+
assert.match(html, /4h free/);
|
|
228
|
+
assert.match(html, /18:00 – 22:00/);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A day whose events have not arrived has no events, which is the same shape
|
|
233
|
+
* as a day with none. Collapsed into a run it becomes "5 days with nothing
|
|
234
|
+
* booked" — a claim about a diary nobody has read yet.
|
|
235
|
+
*/
|
|
236
|
+
it("draws a day still being fetched as a skeleton, out of every run", () => {
|
|
237
|
+
const html = words(
|
|
238
|
+
render({ loadingDates: new Set(["2026-06-13", "2026-06-14"]) }),
|
|
239
|
+
);
|
|
240
|
+
assert.match(html, /agenda-day-pending-2026-06-13/);
|
|
241
|
+
assert.match(html, /agenda-day-pending-2026-06-14/);
|
|
242
|
+
assert.match(html, /aria-busy="true"/);
|
|
243
|
+
assert.doesNotMatch(html, /5 days with nothing booked/);
|
|
244
|
+
// The days either side of them answered, and still collapse.
|
|
245
|
+
assert.match(html, /2 days with nothing booked/);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("says nothing about what is on a day it has not heard about", () => {
|
|
249
|
+
const html = words(render({ loadingDates: new Set(["2026-06-13"]) }));
|
|
250
|
+
assert.match(html, /Loading 2026-06-13/);
|
|
251
|
+
});
|
|
252
|
+
|
|
211
253
|
it("renders whatever the owner leads today with", () => {
|
|
212
254
|
const html = render({
|
|
213
255
|
todayLead: createElement("p", null, "Next up in 30m"),
|
|
@@ -33,7 +33,6 @@ import {
|
|
|
33
33
|
formatSpan,
|
|
34
34
|
freeStretchesOn,
|
|
35
35
|
groupOverlapping,
|
|
36
|
-
isClearDay,
|
|
37
36
|
minuteOfDay,
|
|
38
37
|
monthLabel,
|
|
39
38
|
weekdayLongLabel,
|
|
@@ -65,6 +64,9 @@ const LONG_FREE_MINUTES = 150;
|
|
|
65
64
|
/** The hue every calendar the caller did not describe falls back to. */
|
|
66
65
|
const FALLBACK_COLOR: CalendarColorId = "cal-1";
|
|
67
66
|
|
|
67
|
+
/** Nothing outstanding, which is what a caller that never fetches has. */
|
|
68
|
+
const EMPTY_DATES: ReadonlySet<string> = new Set();
|
|
69
|
+
|
|
68
70
|
export interface AgendaScrollTarget {
|
|
69
71
|
date: string;
|
|
70
72
|
/** Changes on every request, so asking twice for the same day works. */
|
|
@@ -103,6 +105,20 @@ export interface AgendaFlowProps {
|
|
|
103
105
|
onReachEnd: () => void;
|
|
104
106
|
/** The day under the sticky header, for the position map. */
|
|
105
107
|
onVisibleDayChange: (date: string) => void;
|
|
108
|
+
/**
|
|
109
|
+
* Days whose events have not arrived. They draw as a skeleton and are never
|
|
110
|
+
* collapsed into a run: a day nobody has heard back about is not a day with
|
|
111
|
+
* nothing on it, and "6 days with nothing booked" over an unanswered request
|
|
112
|
+
* is the one sentence this strip must never print.
|
|
113
|
+
*/
|
|
114
|
+
loadingDates?: ReadonlySet<string>;
|
|
115
|
+
/**
|
|
116
|
+
* The free time on a day, where the owner knows more about it than the rows
|
|
117
|
+
* do. Busy time on a calendar the strip is not drawing is still not free, so
|
|
118
|
+
* an owner holding merged busy spans answers this from those. Absent falls
|
|
119
|
+
* back to the gaps between what is on screen.
|
|
120
|
+
*/
|
|
121
|
+
freeOn?: (day: CalendarDay) => FreeStretch[];
|
|
106
122
|
scrollTarget?: AgendaScrollTarget;
|
|
107
123
|
/**
|
|
108
124
|
* Rendered immediately above today, and landed on with it, so what's next is
|
|
@@ -126,6 +142,8 @@ export function AgendaFlow({
|
|
|
126
142
|
onReachStart,
|
|
127
143
|
onReachEnd,
|
|
128
144
|
onVisibleDayChange,
|
|
145
|
+
loadingDates = EMPTY_DATES,
|
|
146
|
+
freeOn = freeStretchesOn,
|
|
129
147
|
scrollTarget,
|
|
130
148
|
todayLead,
|
|
131
149
|
touch,
|
|
@@ -141,7 +159,7 @@ export function AgendaFlow({
|
|
|
141
159
|
const [visibleDate, setVisibleDate] = useState(focusDate);
|
|
142
160
|
|
|
143
161
|
const lookup = useMemo(() => lookupOf(calendars), [calendars]);
|
|
144
|
-
const rows = buildAgendaRows(days, [today, focusDate]);
|
|
162
|
+
const rows = buildAgendaRows(days, [today, focusDate, ...loadingDates]);
|
|
145
163
|
const firstDate = days[0]?.date ?? "";
|
|
146
164
|
const lastDate = days[days.length - 1]?.date ?? "";
|
|
147
165
|
|
|
@@ -230,6 +248,7 @@ export function AgendaFlow({
|
|
|
230
248
|
<div
|
|
231
249
|
ref={scroller}
|
|
232
250
|
onScroll={handleScroll}
|
|
251
|
+
data-testid="agenda-strip"
|
|
233
252
|
className={cn(
|
|
234
253
|
"relative min-h-0 flex-1 overflow-y-auto bg-surface",
|
|
235
254
|
className,
|
|
@@ -256,6 +275,8 @@ export function AgendaFlow({
|
|
|
256
275
|
lookup={lookup}
|
|
257
276
|
density={density}
|
|
258
277
|
today={today}
|
|
278
|
+
pending={row.kind === "day" && loadingDates.has(row.day.date)}
|
|
279
|
+
freeOn={freeOn}
|
|
259
280
|
selectedEventId={selectedEventId}
|
|
260
281
|
onSelectEvent={onSelectEvent}
|
|
261
282
|
onPickSlot={onPickSlot}
|
|
@@ -280,6 +301,9 @@ interface RowProps {
|
|
|
280
301
|
lookup: CalendarLookup;
|
|
281
302
|
density: AgendaDensity;
|
|
282
303
|
today: string;
|
|
304
|
+
/** This day's events have not arrived yet. */
|
|
305
|
+
pending: boolean;
|
|
306
|
+
freeOn: (day: CalendarDay) => FreeStretch[];
|
|
283
307
|
selectedEventId: string;
|
|
284
308
|
onSelectEvent: (eventId: string) => void;
|
|
285
309
|
onPickSlot: (pick: CalendarSlotPick) => void;
|
|
@@ -294,6 +318,8 @@ function FlowRow({
|
|
|
294
318
|
lookup,
|
|
295
319
|
density,
|
|
296
320
|
today,
|
|
321
|
+
pending,
|
|
322
|
+
freeOn,
|
|
297
323
|
selectedEventId,
|
|
298
324
|
onSelectEvent,
|
|
299
325
|
onPickSlot,
|
|
@@ -315,6 +341,13 @@ function FlowRow({
|
|
|
315
341
|
</div>
|
|
316
342
|
);
|
|
317
343
|
|
|
344
|
+
if (pending)
|
|
345
|
+
return (
|
|
346
|
+
<div ref={anchorRef}>
|
|
347
|
+
<PendingDay day={row.day} today={today} />
|
|
348
|
+
</div>
|
|
349
|
+
);
|
|
350
|
+
|
|
318
351
|
if (density === "dots")
|
|
319
352
|
return (
|
|
320
353
|
<div ref={anchorRef}>
|
|
@@ -323,6 +356,7 @@ function FlowRow({
|
|
|
323
356
|
day={row.day}
|
|
324
357
|
lookup={lookup}
|
|
325
358
|
today={today}
|
|
359
|
+
freeOn={freeOn}
|
|
326
360
|
onSelectEvent={onSelectEvent}
|
|
327
361
|
onZoomDay={onZoomDay}
|
|
328
362
|
selectedEventId={selectedEventId}
|
|
@@ -338,6 +372,7 @@ function FlowRow({
|
|
|
338
372
|
lookup={lookup}
|
|
339
373
|
density={density}
|
|
340
374
|
today={today}
|
|
375
|
+
freeOn={freeOn}
|
|
341
376
|
selectedEventId={selectedEventId}
|
|
342
377
|
onSelectEvent={onSelectEvent}
|
|
343
378
|
onPickSlot={onPickSlot}
|
|
@@ -393,11 +428,52 @@ function EmptyRun({
|
|
|
393
428
|
);
|
|
394
429
|
}
|
|
395
430
|
|
|
431
|
+
/**
|
|
432
|
+
* A day still being fetched. It keeps the date column, so the strip has the
|
|
433
|
+
* shape it will have once the answer lands and nothing jumps under the reader,
|
|
434
|
+
* and it says nothing at all about what is on the day.
|
|
435
|
+
*/
|
|
436
|
+
function PendingDay({ day, today }: { day: CalendarDay; today: string }) {
|
|
437
|
+
const isToday = day.date === today;
|
|
438
|
+
return (
|
|
439
|
+
<section
|
|
440
|
+
aria-busy="true"
|
|
441
|
+
data-testid={`agenda-day-pending-${day.date}`}
|
|
442
|
+
className={cn(
|
|
443
|
+
"border-b border-line",
|
|
444
|
+
isToday && "border-l-2 border-l-accent bg-accent-soft/20",
|
|
445
|
+
)}
|
|
446
|
+
>
|
|
447
|
+
<header className="flex h-section-row items-center gap-3 px-row-inset">
|
|
448
|
+
<div className="flex w-16 shrink-0 items-baseline gap-1.5">
|
|
449
|
+
<span
|
|
450
|
+
className={cn(
|
|
451
|
+
"text-lg font-semibold tabular-nums",
|
|
452
|
+
isToday ? "text-accent" : "text-fg",
|
|
453
|
+
)}
|
|
454
|
+
>
|
|
455
|
+
{day.dayNumber}
|
|
456
|
+
</span>
|
|
457
|
+
<span className="text-2xs uppercase tracking-wider text-fg-subtle">
|
|
458
|
+
{day.weekdayLabel}
|
|
459
|
+
</span>
|
|
460
|
+
</div>
|
|
461
|
+
<span className="sr-only">Loading {day.date}</span>
|
|
462
|
+
<span className="h-3 min-w-0 flex-1 animate-pulse rounded-full bg-surface-sunken" />
|
|
463
|
+
</header>
|
|
464
|
+
<div className="flex flex-col gap-1 pb-2">
|
|
465
|
+
<span className="mx-row-inset h-7 animate-pulse rounded-md bg-surface-sunken" />
|
|
466
|
+
</div>
|
|
467
|
+
</section>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
396
471
|
/** The month-at-a-glance reading: one line a day, colour and shape only. */
|
|
397
472
|
function DotsDay({
|
|
398
473
|
day,
|
|
399
474
|
lookup,
|
|
400
475
|
today,
|
|
476
|
+
freeOn,
|
|
401
477
|
selectedEventId,
|
|
402
478
|
onSelectEvent,
|
|
403
479
|
onZoomDay,
|
|
@@ -405,6 +481,7 @@ function DotsDay({
|
|
|
405
481
|
day: CalendarDay;
|
|
406
482
|
lookup: CalendarLookup;
|
|
407
483
|
today: string;
|
|
484
|
+
freeOn: (day: CalendarDay) => FreeStretch[];
|
|
408
485
|
selectedEventId: string;
|
|
409
486
|
onSelectEvent: (eventId: string) => void;
|
|
410
487
|
onZoomDay: (date: string) => void;
|
|
@@ -449,16 +526,20 @@ function DotsDay({
|
|
|
449
526
|
</div>
|
|
450
527
|
<BusyBar day={day} className="w-24 shrink-0" />
|
|
451
528
|
<span className="w-24 shrink-0 text-right text-2xs text-fg-subtle">
|
|
452
|
-
{glanceLabel(day)}
|
|
529
|
+
{glanceLabel(day, freeOn)}
|
|
453
530
|
</span>
|
|
454
531
|
</div>
|
|
455
532
|
);
|
|
456
533
|
}
|
|
457
534
|
|
|
458
535
|
/** What the day is worth saying in eleven characters. */
|
|
459
|
-
function glanceLabel(
|
|
460
|
-
|
|
461
|
-
|
|
536
|
+
function glanceLabel(
|
|
537
|
+
day: CalendarDay,
|
|
538
|
+
freeOn: (day: CalendarDay) => FreeStretch[],
|
|
539
|
+
): string {
|
|
540
|
+
const free = freeOn(day);
|
|
541
|
+
if (day.timed.length === 0 && isWholeDayFree(free)) return "clear";
|
|
542
|
+
const longest = free.reduce(
|
|
462
543
|
(best, stretch) => Math.max(best, stretch.minutes),
|
|
463
544
|
0,
|
|
464
545
|
);
|
|
@@ -466,11 +547,17 @@ function glanceLabel(day: CalendarDay): string {
|
|
|
466
547
|
return `${formatSpan(day.busyMinutes)} booked`;
|
|
467
548
|
}
|
|
468
549
|
|
|
550
|
+
/** Nothing takes an hour out of this day, whoever measured it. */
|
|
551
|
+
function isWholeDayFree(free: readonly FreeStretch[]): boolean {
|
|
552
|
+
return free.length === 1 && free[0].wholeDay;
|
|
553
|
+
}
|
|
554
|
+
|
|
469
555
|
function DayBlock({
|
|
470
556
|
day,
|
|
471
557
|
lookup,
|
|
472
558
|
density,
|
|
473
559
|
today,
|
|
560
|
+
freeOn,
|
|
474
561
|
selectedEventId,
|
|
475
562
|
onSelectEvent,
|
|
476
563
|
onPickSlot,
|
|
@@ -481,6 +568,7 @@ function DayBlock({
|
|
|
481
568
|
lookup: CalendarLookup;
|
|
482
569
|
density: AgendaDensity;
|
|
483
570
|
today: string;
|
|
571
|
+
freeOn: (day: CalendarDay) => FreeStretch[];
|
|
484
572
|
selectedEventId: string;
|
|
485
573
|
onSelectEvent: (eventId: string) => void;
|
|
486
574
|
onPickSlot: (pick: CalendarSlotPick) => void;
|
|
@@ -489,7 +577,12 @@ function DayBlock({
|
|
|
489
577
|
}) {
|
|
490
578
|
const isToday = day.date === today;
|
|
491
579
|
const groups = groupOverlapping(day.timed);
|
|
492
|
-
const
|
|
580
|
+
const measured = freeOn(day);
|
|
581
|
+
const free = measured.filter((stretch) => !stretch.wholeDay);
|
|
582
|
+
/* Nothing on the strip and nothing taking the day out from anywhere else.
|
|
583
|
+
A day whose hours went to a calendar the reader has unticked is not free,
|
|
584
|
+
so it draws its bands rather than the line that says it is clear. */
|
|
585
|
+
const clear = day.timed.length === 0 && isWholeDayFree(measured);
|
|
493
586
|
|
|
494
587
|
const items: {
|
|
495
588
|
key: string;
|
|
@@ -596,7 +689,7 @@ function DayBlock({
|
|
|
596
689
|
touch={touch}
|
|
597
690
|
/>
|
|
598
691
|
))}
|
|
599
|
-
{
|
|
692
|
+
{clear ? (
|
|
600
693
|
<ClearDayLine day={day} onPickSlot={onPickSlot} touch={touch} />
|
|
601
694
|
) : (
|
|
602
695
|
items.map((item) => <div key={item.key}>{item.node}</div>)
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
formatShortDay,
|
|
28
28
|
formatSpan,
|
|
29
29
|
freeAhead,
|
|
30
|
+
freeStretchesFromSpans,
|
|
30
31
|
freeStretchesOn,
|
|
31
32
|
groupOverlapping,
|
|
32
33
|
isClearDay,
|
|
@@ -247,6 +248,64 @@ describe("free time", () => {
|
|
|
247
248
|
});
|
|
248
249
|
});
|
|
249
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The same rule, measured off spans nobody drew. `/calendar-free-busy` merges
|
|
253
|
+
* every calendar the reader holds, so the answer to "am I free" has to come out
|
|
254
|
+
* of that rather than out of the rows on screen — and it has to be the answer
|
|
255
|
+
* the rows would have given, or the strip and the server disagree about one
|
|
256
|
+
* afternoon.
|
|
257
|
+
*/
|
|
258
|
+
describe("freeStretchesFromSpans", () => {
|
|
259
|
+
const spans = (day: CalendarDay) =>
|
|
260
|
+
freeStretchesFromSpans(day.date, busySpansOn(day));
|
|
261
|
+
|
|
262
|
+
it("gives the same answer as the day it was measured off", () => {
|
|
263
|
+
for (const date of [TODAY, "2026-06-11", "2026-06-13"]) {
|
|
264
|
+
const day = dayOn(date);
|
|
265
|
+
assert.deepEqual(clocks(spans(day)), clocks(freeStretchesOn(day)));
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("takes hours out of a day nothing on the strip is booked in", () => {
|
|
270
|
+
const busy = [
|
|
271
|
+
{ from: 9 * 60, to: 12 * 60 },
|
|
272
|
+
{ from: 13 * 60, to: 16 * 60 },
|
|
273
|
+
];
|
|
274
|
+
assert.deepEqual(clocks(freeStretchesFromSpans("2026-06-11", busy)), [
|
|
275
|
+
"16:00–22:00",
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("reads no spans at all as free all day", () => {
|
|
280
|
+
const [whole] = freeStretchesFromSpans("2026-06-11", []);
|
|
281
|
+
assert.equal(whole.wholeDay, true);
|
|
282
|
+
assert.deepEqual(clocks([whole]), ["08:00–22:00"]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The cursor only moves forward, so spans arriving out of order used to emit
|
|
287
|
+
* a band across time that is booked. A caller cannot be asked to remember a
|
|
288
|
+
* precondition whose only symptom is being told an afternoon is free.
|
|
289
|
+
*/
|
|
290
|
+
it("orders and joins the spans it was given, however they arrive", () => {
|
|
291
|
+
const scrambled = [
|
|
292
|
+
{ from: 13 * 60, to: 16 * 60 },
|
|
293
|
+
{ from: 9 * 60, to: 12 * 60 },
|
|
294
|
+
{ from: 11 * 60, to: 13 * 60 },
|
|
295
|
+
];
|
|
296
|
+
assert.deepEqual(clocks(freeStretchesFromSpans("2026-06-11", scrambled)), [
|
|
297
|
+
"16:00–22:00",
|
|
298
|
+
]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("clips a span that runs past the window the day is measured inside", () => {
|
|
302
|
+
assert.deepEqual(
|
|
303
|
+
clocks(freeStretchesFromSpans("2026-06-11", [{ from: 0, to: 9 * 60 }])),
|
|
304
|
+
["09:00–22:00"],
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
250
309
|
describe("buildAgendaRows", () => {
|
|
251
310
|
const rows = buildAgendaRows(days, [TODAY]);
|
|
252
311
|
|
package/src/lib/agenda-time.ts
CHANGED
|
@@ -221,15 +221,25 @@ export interface BusySpan {
|
|
|
221
221
|
* option is measured off that rather than off a row count.
|
|
222
222
|
*/
|
|
223
223
|
export function busySpansOn(day: CalendarDay): BusySpan[] {
|
|
224
|
-
|
|
225
|
-
.map((event) => ({
|
|
224
|
+
return mergeBusySpans(
|
|
225
|
+
day.timed.map((event) => ({
|
|
226
226
|
from: minuteOfDay(event.start),
|
|
227
227
|
to: minuteOfDay(event.end),
|
|
228
|
-
}))
|
|
229
|
-
|
|
228
|
+
})),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
230
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Spans in start order, with anything that runs into its neighbour joined to
|
|
234
|
+
* it. Every reading of busy time goes through this, so a caller never has to
|
|
235
|
+
* know whether the spans it holds were already in order — one meeting split
|
|
236
|
+
* across two calendars and one that ran late are the same shape by the time
|
|
237
|
+
* anything measures free time against them.
|
|
238
|
+
*/
|
|
239
|
+
export function mergeBusySpans(spans: readonly BusySpan[]): BusySpan[] {
|
|
240
|
+
const sorted = [...spans].sort((a, b) => a.from - b.from);
|
|
231
241
|
const merged: BusySpan[] = [];
|
|
232
|
-
for (const span of
|
|
242
|
+
for (const span of sorted) {
|
|
233
243
|
const last = merged[merged.length - 1];
|
|
234
244
|
if (last && span.from <= last.to) {
|
|
235
245
|
last.to = Math.max(last.to, span.to);
|
|
@@ -245,22 +255,36 @@ function insideWindow(minute: number): number {
|
|
|
245
255
|
}
|
|
246
256
|
|
|
247
257
|
/**
|
|
248
|
-
* The gaps between
|
|
258
|
+
* The gaps between merged busy spans, inside the window worth calling a day.
|
|
249
259
|
*
|
|
250
260
|
* Both ends of every span are pulled into the window before they are measured,
|
|
251
261
|
* and the cursor only ever moves forward, so an event running past the window
|
|
252
262
|
* cannot stretch a band past it either and a span that ends before it starts —
|
|
253
263
|
* an overnight event, which the editor writes onto one date — cannot leave the
|
|
254
264
|
* cursor where the tail would emit a second band over the first.
|
|
265
|
+
*
|
|
266
|
+
* Spans rather than a day, because busy time is not always drawn: a calendar
|
|
267
|
+
* the reader has unticked, and every calendar the server merges into
|
|
268
|
+
* `/calendar-free-busy`, still take hours out of a Thursday. One rule answers
|
|
269
|
+
* "when am I free" wherever the busy time was measured, so the strip and the
|
|
270
|
+
* server cannot disagree about the same afternoon.
|
|
271
|
+
*
|
|
272
|
+
* The spans are put in order and joined here rather than being required that
|
|
273
|
+
* way. The cursor only moves forward, so spans handed over out of order would
|
|
274
|
+
* silently emit a band across time that is booked — a caller cannot be asked to
|
|
275
|
+
* remember a precondition whose only symptom is being told an afternoon is free
|
|
276
|
+
* when it is not.
|
|
255
277
|
*/
|
|
256
|
-
export function
|
|
257
|
-
|
|
278
|
+
export function freeStretchesFromSpans(
|
|
279
|
+
date: string,
|
|
280
|
+
spans: readonly BusySpan[],
|
|
258
281
|
minMinutes = FREE_MINUTES,
|
|
259
282
|
): FreeStretch[] {
|
|
260
|
-
|
|
283
|
+
const busy = mergeBusySpans(spans);
|
|
284
|
+
if (busy.length === 0)
|
|
261
285
|
return [
|
|
262
286
|
{
|
|
263
|
-
date
|
|
287
|
+
date,
|
|
264
288
|
startMinute: DAY_START_MINUTE,
|
|
265
289
|
endMinute: DAY_END_MINUTE,
|
|
266
290
|
minutes: DAY_END_MINUTE - DAY_START_MINUTE,
|
|
@@ -268,15 +292,14 @@ export function freeStretchesOn(
|
|
|
268
292
|
},
|
|
269
293
|
];
|
|
270
294
|
|
|
271
|
-
const merged = busySpansOn(day);
|
|
272
295
|
const stretches: FreeStretch[] = [];
|
|
273
296
|
let cursor = DAY_START_MINUTE;
|
|
274
|
-
for (const span of
|
|
297
|
+
for (const span of busy) {
|
|
275
298
|
const from = insideWindow(span.from);
|
|
276
299
|
const to = insideWindow(span.to);
|
|
277
300
|
if (from - cursor >= minMinutes)
|
|
278
301
|
stretches.push({
|
|
279
|
-
date
|
|
302
|
+
date,
|
|
280
303
|
startMinute: cursor,
|
|
281
304
|
endMinute: from,
|
|
282
305
|
minutes: from - cursor,
|
|
@@ -286,7 +309,7 @@ export function freeStretchesOn(
|
|
|
286
309
|
}
|
|
287
310
|
if (DAY_END_MINUTE - cursor >= minMinutes)
|
|
288
311
|
stretches.push({
|
|
289
|
-
date
|
|
312
|
+
date,
|
|
290
313
|
startMinute: cursor,
|
|
291
314
|
endMinute: DAY_END_MINUTE,
|
|
292
315
|
minutes: DAY_END_MINUTE - cursor,
|
|
@@ -295,6 +318,14 @@ export function freeStretchesOn(
|
|
|
295
318
|
return stretches;
|
|
296
319
|
}
|
|
297
320
|
|
|
321
|
+
/** The free time left by the events on a day, which is the same rule. */
|
|
322
|
+
export function freeStretchesOn(
|
|
323
|
+
day: CalendarDay,
|
|
324
|
+
minMinutes = FREE_MINUTES,
|
|
325
|
+
): FreeStretch[] {
|
|
326
|
+
return freeStretchesFromSpans(day.date, busySpansOn(day), minMinutes);
|
|
327
|
+
}
|
|
328
|
+
|
|
298
329
|
export interface ClashOptions {
|
|
299
330
|
/** Spans that are not a clash: the candidate itself, or one already answered. */
|
|
300
331
|
ignoreIds?: readonly string[];
|