@remit/ui 0.0.158 → 0.0.160

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.158",
3
+ "version": "0.0.160",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -136,6 +136,7 @@ export function AgendaComposer({
136
136
  onCancel={onCancel}
137
137
  saveLabel={saveLabel}
138
138
  repeatEditable={repeatEditable}
139
+ guestsEditable
139
140
  onCustomRepeat={onCustomRepeat}
140
141
  touch={touch}
141
142
  header={
@@ -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 { buildCalendarDay } from "../lib/agenda-time.js";
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
 
@@ -256,6 +274,8 @@ export function AgendaFlow({
256
274
  lookup={lookup}
257
275
  density={density}
258
276
  today={today}
277
+ pending={row.kind === "day" && loadingDates.has(row.day.date)}
278
+ freeOn={freeOn}
259
279
  selectedEventId={selectedEventId}
260
280
  onSelectEvent={onSelectEvent}
261
281
  onPickSlot={onPickSlot}
@@ -280,6 +300,9 @@ interface RowProps {
280
300
  lookup: CalendarLookup;
281
301
  density: AgendaDensity;
282
302
  today: string;
303
+ /** This day's events have not arrived yet. */
304
+ pending: boolean;
305
+ freeOn: (day: CalendarDay) => FreeStretch[];
283
306
  selectedEventId: string;
284
307
  onSelectEvent: (eventId: string) => void;
285
308
  onPickSlot: (pick: CalendarSlotPick) => void;
@@ -294,6 +317,8 @@ function FlowRow({
294
317
  lookup,
295
318
  density,
296
319
  today,
320
+ pending,
321
+ freeOn,
297
322
  selectedEventId,
298
323
  onSelectEvent,
299
324
  onPickSlot,
@@ -315,6 +340,13 @@ function FlowRow({
315
340
  </div>
316
341
  );
317
342
 
343
+ if (pending)
344
+ return (
345
+ <div ref={anchorRef}>
346
+ <PendingDay day={row.day} today={today} />
347
+ </div>
348
+ );
349
+
318
350
  if (density === "dots")
319
351
  return (
320
352
  <div ref={anchorRef}>
@@ -323,6 +355,7 @@ function FlowRow({
323
355
  day={row.day}
324
356
  lookup={lookup}
325
357
  today={today}
358
+ freeOn={freeOn}
326
359
  onSelectEvent={onSelectEvent}
327
360
  onZoomDay={onZoomDay}
328
361
  selectedEventId={selectedEventId}
@@ -338,6 +371,7 @@ function FlowRow({
338
371
  lookup={lookup}
339
372
  density={density}
340
373
  today={today}
374
+ freeOn={freeOn}
341
375
  selectedEventId={selectedEventId}
342
376
  onSelectEvent={onSelectEvent}
343
377
  onPickSlot={onPickSlot}
@@ -393,11 +427,52 @@ function EmptyRun({
393
427
  );
394
428
  }
395
429
 
430
+ /**
431
+ * A day still being fetched. It keeps the date column, so the strip has the
432
+ * shape it will have once the answer lands and nothing jumps under the reader,
433
+ * and it says nothing at all about what is on the day.
434
+ */
435
+ function PendingDay({ day, today }: { day: CalendarDay; today: string }) {
436
+ const isToday = day.date === today;
437
+ return (
438
+ <section
439
+ aria-busy="true"
440
+ data-testid={`agenda-day-pending-${day.date}`}
441
+ className={cn(
442
+ "border-b border-line",
443
+ isToday && "border-l-2 border-l-accent bg-accent-soft/20",
444
+ )}
445
+ >
446
+ <header className="flex h-section-row items-center gap-3 px-row-inset">
447
+ <div className="flex w-16 shrink-0 items-baseline gap-1.5">
448
+ <span
449
+ className={cn(
450
+ "text-lg font-semibold tabular-nums",
451
+ isToday ? "text-accent" : "text-fg",
452
+ )}
453
+ >
454
+ {day.dayNumber}
455
+ </span>
456
+ <span className="text-2xs uppercase tracking-wider text-fg-subtle">
457
+ {day.weekdayLabel}
458
+ </span>
459
+ </div>
460
+ <span className="sr-only">Loading {day.date}</span>
461
+ <span className="h-3 min-w-0 flex-1 animate-pulse rounded-full bg-surface-sunken" />
462
+ </header>
463
+ <div className="flex flex-col gap-1 pb-2">
464
+ <span className="mx-row-inset h-7 animate-pulse rounded-md bg-surface-sunken" />
465
+ </div>
466
+ </section>
467
+ );
468
+ }
469
+
396
470
  /** The month-at-a-glance reading: one line a day, colour and shape only. */
397
471
  function DotsDay({
398
472
  day,
399
473
  lookup,
400
474
  today,
475
+ freeOn,
401
476
  selectedEventId,
402
477
  onSelectEvent,
403
478
  onZoomDay,
@@ -405,6 +480,7 @@ function DotsDay({
405
480
  day: CalendarDay;
406
481
  lookup: CalendarLookup;
407
482
  today: string;
483
+ freeOn: (day: CalendarDay) => FreeStretch[];
408
484
  selectedEventId: string;
409
485
  onSelectEvent: (eventId: string) => void;
410
486
  onZoomDay: (date: string) => void;
@@ -449,16 +525,20 @@ function DotsDay({
449
525
  </div>
450
526
  <BusyBar day={day} className="w-24 shrink-0" />
451
527
  <span className="w-24 shrink-0 text-right text-2xs text-fg-subtle">
452
- {glanceLabel(day)}
528
+ {glanceLabel(day, freeOn)}
453
529
  </span>
454
530
  </div>
455
531
  );
456
532
  }
457
533
 
458
534
  /** What the day is worth saying in eleven characters. */
459
- function glanceLabel(day: CalendarDay): string {
460
- if (day.timed.length === 0) return "clear";
461
- const longest = freeStretchesOn(day).reduce(
535
+ function glanceLabel(
536
+ day: CalendarDay,
537
+ freeOn: (day: CalendarDay) => FreeStretch[],
538
+ ): string {
539
+ const free = freeOn(day);
540
+ if (day.timed.length === 0 && isWholeDayFree(free)) return "clear";
541
+ const longest = free.reduce(
462
542
  (best, stretch) => Math.max(best, stretch.minutes),
463
543
  0,
464
544
  );
@@ -466,11 +546,17 @@ function glanceLabel(day: CalendarDay): string {
466
546
  return `${formatSpan(day.busyMinutes)} booked`;
467
547
  }
468
548
 
549
+ /** Nothing takes an hour out of this day, whoever measured it. */
550
+ function isWholeDayFree(free: readonly FreeStretch[]): boolean {
551
+ return free.length === 1 && free[0].wholeDay;
552
+ }
553
+
469
554
  function DayBlock({
470
555
  day,
471
556
  lookup,
472
557
  density,
473
558
  today,
559
+ freeOn,
474
560
  selectedEventId,
475
561
  onSelectEvent,
476
562
  onPickSlot,
@@ -481,6 +567,7 @@ function DayBlock({
481
567
  lookup: CalendarLookup;
482
568
  density: AgendaDensity;
483
569
  today: string;
570
+ freeOn: (day: CalendarDay) => FreeStretch[];
484
571
  selectedEventId: string;
485
572
  onSelectEvent: (eventId: string) => void;
486
573
  onPickSlot: (pick: CalendarSlotPick) => void;
@@ -489,7 +576,12 @@ function DayBlock({
489
576
  }) {
490
577
  const isToday = day.date === today;
491
578
  const groups = groupOverlapping(day.timed);
492
- const free = freeStretchesOn(day).filter((stretch) => !stretch.wholeDay);
579
+ const measured = freeOn(day);
580
+ const free = measured.filter((stretch) => !stretch.wholeDay);
581
+ /* Nothing on the strip and nothing taking the day out from anywhere else.
582
+ A day whose hours went to a calendar the reader has unticked is not free,
583
+ so it draws its bands rather than the line that says it is clear. */
584
+ const clear = day.timed.length === 0 && isWholeDayFree(measured);
493
585
 
494
586
  const items: {
495
587
  key: string;
@@ -596,7 +688,7 @@ function DayBlock({
596
688
  touch={touch}
597
689
  />
598
690
  ))}
599
- {isClearDay(day) ? (
691
+ {clear ? (
600
692
  <ClearDayLine day={day} onPickSlot={onPickSlot} touch={touch} />
601
693
  ) : (
602
694
  items.map((item) => <div key={item.key}>{item.node}</div>)
@@ -78,11 +78,27 @@ describe("EventEditor", () => {
78
78
  it("reveals the rest on request", () => {
79
79
  const html = render({ expanded: true });
80
80
  assert.match(html, /aria-label="Location"/);
81
- assert.match(html, /aria-label="Guests"/);
82
81
  assert.match(html, /aria-label="Repeat"/);
83
82
  assert.match(html, /Fewer details/);
84
83
  });
85
84
 
85
+ it("takes no guests until a surface says it can store them", () => {
86
+ assert.doesNotMatch(render({ expanded: true }), /aria-label="Guests"/);
87
+ assert.match(
88
+ render({ expanded: true, guestsEditable: true }),
89
+ /aria-label="Guests"/,
90
+ );
91
+ });
92
+
93
+ it("drops the calendar picker where the event cannot be moved", () => {
94
+ assert.match(render({}), /Northwind/);
95
+ assert.doesNotMatch(render({ calendarEditable: false }), /Northwind/);
96
+ assert.doesNotMatch(
97
+ render({ expanded: true, calendarEditable: false }),
98
+ /Northwind/,
99
+ );
100
+ });
101
+
86
102
  it("drops the clock fields for an all-day entry", () => {
87
103
  const html = render({ draft: { ...draft, allDay: true } });
88
104
  assert.doesNotMatch(html, /aria-label="Start time"/);
@@ -64,9 +64,13 @@ const backwards: EventDraft = {
64
64
  function Live({
65
65
  startExpanded,
66
66
  seed: initial = seed,
67
+ guestsEditable,
68
+ calendarEditable,
67
69
  }: {
68
70
  startExpanded: boolean;
69
71
  seed?: EventDraft;
72
+ guestsEditable?: boolean;
73
+ calendarEditable?: boolean;
70
74
  }) {
71
75
  const [draft, setDraft] = useState(initial);
72
76
  const [expanded, setExpanded] = useState(startExpanded);
@@ -78,6 +82,8 @@ function Live({
78
82
  calendars={calendars}
79
83
  expanded={expanded}
80
84
  onToggleExpanded={() => setExpanded((open) => !open)}
85
+ guestsEditable={guestsEditable}
86
+ calendarEditable={calendarEditable}
81
87
  onSave={() => {}}
82
88
  onCancel={() => {}}
83
89
  />
@@ -99,6 +105,24 @@ export const EndBeforeStart: Story = {
99
105
  render: () => <Live startExpanded={false} seed={backwards} />,
100
106
  };
101
107
 
108
+ /**
109
+ * Guests are opt-in. A store with nowhere to put them leaves the field out, so
110
+ * the default form has none: a box that takes names and drops them is worse
111
+ * than no box, because the reader only finds out later.
112
+ */
113
+ export const WithGuests: Story = {
114
+ render: () => <Live startExpanded guestsEditable />,
115
+ };
116
+
117
+ /**
118
+ * Editing an event that already exists. The collection a resource lives in is
119
+ * part of its address, so the calendar is read-only here rather than a picker
120
+ * that changes nothing.
121
+ */
122
+ export const WithoutTheCalendarPicker: Story = {
123
+ render: () => <Live startExpanded calendarEditable={false} />,
124
+ };
125
+
102
126
  /** All day takes the clock fields away, so there is no range left to reject. */
103
127
  export const AllDay: Story = {
104
128
  render: () => (
@@ -276,6 +276,18 @@ export interface EventEditorProps {
276
276
  * a single instance reads the rule back instead of offering to change it.
277
277
  */
278
278
  repeatEditable?: boolean;
279
+ /**
280
+ * Whether guests can be written here. Off unless a surface says otherwise: a
281
+ * box that takes names and drops them is worse than no box, because the
282
+ * reader only finds out later, from an event with nobody on it.
283
+ */
284
+ guestsEditable?: boolean;
285
+ /**
286
+ * Whether the event can be moved to another calendar. An edit cannot: the
287
+ * collection a resource lives in is part of its address, so a picker that
288
+ * changes it and saves nothing is a control that lies.
289
+ */
290
+ calendarEditable?: boolean;
279
291
  /** Opens the custom-rule editor. Absent leaves the derived choices alone. */
280
292
  onCustomRepeat?: () => void;
281
293
  /** Grows the controls for a rail. */
@@ -302,6 +314,8 @@ export function EventEditor({
302
314
  saveLabel = "Add",
303
315
  header,
304
316
  repeatEditable = true,
317
+ guestsEditable = false,
318
+ calendarEditable = true,
305
319
  onCustomRepeat,
306
320
  touch,
307
321
  className,
@@ -363,10 +377,12 @@ export function EventEditor({
363
377
  {header}
364
378
  <EventField label="Title">{title}</EventField>
365
379
  <EventField label="When">{when}</EventField>
366
- <EventField label="Calendar">{calendarPicker}</EventField>
380
+ {calendarEditable && (
381
+ <EventField label="Calendar">{calendarPicker}</EventField>
382
+ )}
367
383
  <EventField label="Repeat">{repeat}</EventField>
368
384
  <EventField label="Location">{location}</EventField>
369
- <EventField label="Guests">{guests}</EventField>
385
+ {guestsEditable && <EventField label="Guests">{guests}</EventField>}
370
386
  <EventField label="Notes">{notes}</EventField>
371
387
  {actions}
372
388
  </div>
@@ -381,7 +397,7 @@ export function EventEditor({
381
397
 
382
398
  {title}
383
399
  {when}
384
- {calendarPicker}
400
+ {calendarEditable && calendarPicker}
385
401
 
386
402
  {onToggleExpanded && (
387
403
  <button
@@ -405,7 +421,7 @@ export function EventEditor({
405
421
  {!folded && (
406
422
  <div className="flex flex-col gap-2 border-t border-line pt-3">
407
423
  {location}
408
- {guests}
424
+ {guestsEditable && guests}
409
425
  {repeat}
410
426
  {notes}
411
427
  </div>
package/src/index.ts CHANGED
@@ -855,6 +855,7 @@ export {
855
855
  formatShortDay,
856
856
  formatSpan,
857
857
  freeAhead,
858
+ freeStretchesFromSpans,
858
859
  freeStretchesOn,
859
860
  groupOverlapping,
860
861
  isClearDay,
@@ -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
 
@@ -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
- const spans = day.timed
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
- .sort((a, b) => a.from - b.from);
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 spans) {
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 those spans, inside the window worth calling a day.
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 freeStretchesOn(
257
- day: CalendarDay,
278
+ export function freeStretchesFromSpans(
279
+ date: string,
280
+ spans: readonly BusySpan[],
258
281
  minMinutes = FREE_MINUTES,
259
282
  ): FreeStretch[] {
260
- if (day.timed.length === 0)
283
+ const busy = mergeBusySpans(spans);
284
+ if (busy.length === 0)
261
285
  return [
262
286
  {
263
- date: day.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 merged) {
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: day.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: day.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[];