@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 +1 -1
- package/src/components/agenda-composer.tsx +1 -0
- package/src/components/agenda-flow.render.test.ts +43 -1
- package/src/components/agenda-flow.tsx +100 -8
- package/src/components/event-editor.render.test.ts +17 -1
- package/src/components/event-editor.stories.tsx +24 -0
- package/src/components/event-editor.tsx +20 -4
- package/src/index.ts +1 -0
- package/src/lib/agenda-time.test.ts +59 -0
- package/src/lib/agenda-time.ts +45 -14
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
|
|
|
@@ -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(
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
@@ -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[];
|