@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.
@@ -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
+ }