@remit/ui 0.0.155 → 0.0.156
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 +2 -1
- 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.tsx +86 -37
- package/src/components/calendar-types.ts +85 -0
- package/src/index.ts +64 -0
- package/src/lib/agenda-time.test.ts +539 -0
- package/src/lib/agenda-time.ts +505 -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
|
+
}
|