@remit/ui 0.0.155 → 0.0.157
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/calendar.css +4 -0
- package/src/components/agenda-composer.render.test.ts +196 -0
- package/src/components/agenda-composer.stories.tsx +225 -0
- package/src/components/agenda-composer.tsx +283 -0
- package/src/components/agenda-flow.render.test.ts +217 -0
- package/src/components/agenda-flow.stories.tsx +215 -0
- package/src/components/agenda-flow.tsx +887 -0
- package/src/components/agenda-panels.render.test.ts +252 -0
- package/src/components/agenda-panels.stories.tsx +207 -0
- package/src/components/agenda-panels.tsx +421 -0
- package/src/components/calendar-event-chip-content.tsx +102 -0
- package/src/components/calendar-event-chip.stories.tsx +5 -7
- package/src/components/calendar-event-chip.tsx +47 -47
- package/src/components/calendar-grid.render.test.ts +309 -0
- package/src/components/calendar-grid.stories.tsx +203 -0
- package/src/components/calendar-grid.tsx +370 -0
- package/src/components/calendar-types.ts +85 -0
- package/src/index.ts +82 -0
- package/src/lib/agenda-time.test.ts +539 -0
- package/src/lib/agenda-time.ts +505 -0
- package/src/lib/calendar-event-shell.ts +55 -0
- package/src/lib/calendar-slot-pick.test.ts +140 -0
- package/src/lib/calendar-slot-pick.ts +65 -0
|
@@ -0,0 +1,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
|
+
});
|