@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/src/index.ts CHANGED
@@ -11,6 +11,29 @@ export {
11
11
  type EnvelopeAddress,
12
12
  } from "./components/address-display.js";
13
13
  export { AddressTag, type AddressTagProps } from "./components/address-tag.js";
14
+ export {
15
+ AgendaComposer,
16
+ type AgendaComposerProps,
17
+ AgendaPhraseField,
18
+ type AgendaPhraseFieldProps,
19
+ PhraseReading,
20
+ } from "./components/agenda-composer.js";
21
+ export {
22
+ type AgendaDensity,
23
+ AgendaFlow,
24
+ type AgendaFlowProps,
25
+ type AgendaScrollTarget,
26
+ } from "./components/agenda-flow.js";
27
+ export {
28
+ AgendaDensityControl,
29
+ type AgendaDensityControlProps,
30
+ FreeTimeList,
31
+ type FreeTimeListProps,
32
+ NextUpCard,
33
+ type NextUpCardProps,
34
+ PositionMap,
35
+ type PositionMapProps,
36
+ } from "./components/agenda-panels.js";
14
37
  export {
15
38
  AppPasswordHint,
16
39
  type AppPasswordHintProps,
@@ -137,14 +160,20 @@ export {
137
160
  segmentClassName,
138
161
  } from "./components/calendar-toolbar.js";
139
162
  export {
163
+ type AgendaParse,
140
164
  type CalendarAttendee,
141
165
  type CalendarColorId,
166
+ type CalendarDay,
142
167
  type CalendarDescriptor,
143
168
  type CalendarEventData,
169
+ type CalendarSlotPick,
144
170
  type CalendarViewId,
171
+ type ChoicePicks,
145
172
  calendarColorIds,
146
173
  type EventDraft,
147
174
  type EventSuggestion,
175
+ type PhraseChoice,
176
+ type PhraseChoiceOption,
148
177
  type RecurrenceScope,
149
178
  type RsvpState,
150
179
  type ZoneCertainty,
@@ -797,6 +826,41 @@ export {
797
826
  sanitizeAdoptedHtml,
798
827
  sanitizeQuotedHtml,
799
828
  } from "./lib/adopted-html.js";
829
+ export {
830
+ type AgendaRow,
831
+ addDays,
832
+ type BusySpan,
833
+ buildAgendaRows,
834
+ buildCalendarDay,
835
+ busyMinutesOf,
836
+ busySpansOn,
837
+ type ClashOptions,
838
+ clashesWith,
839
+ conflictsOf,
840
+ DAY_END_MINUTE,
841
+ DAY_START_MINUTE,
842
+ datesBetween,
843
+ FREE_MINUTES,
844
+ type FreeStretch,
845
+ formatMinute,
846
+ formatRunLabel,
847
+ formatShortDay,
848
+ formatSpan,
849
+ freeAhead,
850
+ freeStretchesOn,
851
+ groupOverlapping,
852
+ isClearDay,
853
+ isEmptyDay,
854
+ minuteOfDay,
855
+ monthLabel,
856
+ type NextUp,
857
+ readNextUp,
858
+ shortMonthLabel,
859
+ type WallSpan,
860
+ wallSpanOn,
861
+ weekdayLongLabel,
862
+ weekdayShortLabel,
863
+ } from "./lib/agenda-time.js";
800
864
  export {
801
865
  DEFAULT_ATTACHMENT_FILENAME,
802
866
  formatByteSize,
@@ -0,0 +1,539 @@
1
+ /**
2
+ * The strip's arithmetic, on days written here rather than read from anywhere.
3
+ *
4
+ * Free time and collapsed runs are claims the design makes on screen — "your
5
+ * afternoon is clear", "five days with nothing booked" — so every number is
6
+ * worked out by hand and written down. The suite runs in whatever zone the
7
+ * machine is in, so anything that would answer differently in Los Angeles than
8
+ * in Amsterdam is asserted in more than one.
9
+ */
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "node:test";
12
+ import type {
13
+ CalendarDay,
14
+ CalendarEventData,
15
+ } from "../components/calendar-types.js";
16
+ import {
17
+ addDays,
18
+ buildAgendaRows,
19
+ buildCalendarDay,
20
+ busyMinutesOf,
21
+ busySpansOn,
22
+ clashesWith,
23
+ conflictsOf,
24
+ datesBetween,
25
+ formatMinute,
26
+ formatRunLabel,
27
+ formatShortDay,
28
+ formatSpan,
29
+ freeAhead,
30
+ freeStretchesOn,
31
+ groupOverlapping,
32
+ isClearDay,
33
+ isEmptyDay,
34
+ minuteOfDay,
35
+ monthLabel,
36
+ readNextUp,
37
+ shortMonthLabel,
38
+ wallSpanOn,
39
+ weekdayLongLabel,
40
+ weekdayShortLabel,
41
+ } from "./agenda-time.js";
42
+
43
+ const HOME_ZONE = "Europe/Amsterdam";
44
+ const TODAY = "2026-06-10";
45
+
46
+ /** Every offset the fixtures print, so instants never depend on the machine. */
47
+ const OFFSET = "+02:00";
48
+
49
+ function event(
50
+ id: string,
51
+ date: string,
52
+ from: string,
53
+ to: string,
54
+ extra: Partial<CalendarEventData> = {},
55
+ ): CalendarEventData {
56
+ return {
57
+ id,
58
+ calendarId: "c1",
59
+ title: id,
60
+ start: `${date}T${from}:00${OFFSET}`,
61
+ end: `${date}T${to}:00${OFFSET}`,
62
+ allDay: false,
63
+ location: "",
64
+ notes: "",
65
+ attendees: [],
66
+ myRsvp: "accepted",
67
+ threadId: "",
68
+ threadSubject: "",
69
+ timeZone: HOME_ZONE,
70
+ zoneCertainty: "explicit",
71
+ recurrenceRule: "",
72
+ seriesId: "",
73
+ seriesException: false,
74
+ status: "confirmed",
75
+ ...extra,
76
+ };
77
+ }
78
+
79
+ function banner(id: string, from: string, to: string): CalendarEventData {
80
+ return {
81
+ ...event(id, from, "00", "00"),
82
+ start: from,
83
+ end: to,
84
+ allDay: true,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Wednesday the 10th: a morning pile-up, lunch, an afternoon gap, one late
90
+ * meeting. Thursday carries a banner and nothing on the clock. Friday through
91
+ * Tuesday are empty. The 17th has one event again.
92
+ */
93
+ const events: CalendarEventData[] = [
94
+ event("evt_standup", TODAY, "09:00", "09:15"),
95
+ event("evt_roadmap", TODAY, "10:00", "11:30"),
96
+ event("evt_incident", TODAY, "10:30", "12:00"),
97
+ event("evt_1to1", TODAY, "11:00", "11:20"),
98
+ event("evt_lunch", TODAY, "12:00", "13:15", { location: "Toscanini" }),
99
+ event("evt_retro", TODAY, "16:00", "17:00", {
100
+ recurrenceRule: "Every Wednesday",
101
+ threadId: "thr_retro",
102
+ }),
103
+ banner("evt_conference", "2026-06-11", "2026-06-12"),
104
+ event("evt_offsite", "2026-06-17", "14:00", "15:00"),
105
+ ];
106
+
107
+ function dayOn(date: string, source = events): CalendarDay {
108
+ return buildCalendarDay(date, source, TODAY);
109
+ }
110
+
111
+ const days = datesBetween(TODAY, "2026-06-18").map((date) => dayOn(date));
112
+
113
+ function clocks(
114
+ stretches: { startMinute: number; endMinute: number }[],
115
+ ): string[] {
116
+ return stretches.map(
117
+ (stretch) =>
118
+ `${formatMinute(stretch.startMinute)}–${formatMinute(stretch.endMinute)}`,
119
+ );
120
+ }
121
+
122
+ /** The zones the machine might be in, asserted so a body cannot pass silently. */
123
+ const ZONES = [
124
+ { name: "UTC", hourAtNoonUtc: 12 },
125
+ { name: "Pacific/Kiritimati", hourAtNoonUtc: 2 },
126
+ { name: "America/Los_Angeles", hourAtNoonUtc: 5 },
127
+ ];
128
+
129
+ function inEveryZone(body: () => void): void {
130
+ const before = process.env.TZ;
131
+ try {
132
+ for (const zone of ZONES) {
133
+ process.env.TZ = zone.name;
134
+ assert.equal(
135
+ new Date("2026-06-11T12:00:00Z").getHours(),
136
+ zone.hourAtNoonUtc,
137
+ `the machine never moved to ${zone.name}`,
138
+ );
139
+ body();
140
+ }
141
+ } finally {
142
+ if (before === undefined) delete process.env.TZ;
143
+ else process.env.TZ = before;
144
+ }
145
+ }
146
+
147
+ describe("buildCalendarDay", () => {
148
+ it("sorts the clock and keeps the banners apart from it", () => {
149
+ const day = dayOn(TODAY);
150
+ assert.deepEqual(
151
+ day.timed.map((item) => item.id),
152
+ [
153
+ "evt_standup",
154
+ "evt_roadmap",
155
+ "evt_incident",
156
+ "evt_1to1",
157
+ "evt_lunch",
158
+ "evt_retro",
159
+ ],
160
+ );
161
+ assert.deepEqual(day.allDay, []);
162
+ assert.equal(day.dayNumber, 10);
163
+ assert.equal(day.isToday, true);
164
+ });
165
+
166
+ it("carries an all-day range on every day it covers but the last", () => {
167
+ assert.deepEqual(
168
+ dayOn("2026-06-11").allDay.map((item) => item.id),
169
+ ["evt_conference"],
170
+ );
171
+ assert.deepEqual(dayOn("2026-06-12").allDay, []);
172
+ });
173
+
174
+ it("counts overlapping events as the clock time they cover, once", () => {
175
+ const day = dayOn(TODAY);
176
+ assert.equal(day.timed.length, 6);
177
+ assert.equal(day.busyMinutes, 15 + 195 + 60);
178
+ });
179
+
180
+ it("names the pile-up and leaves a clean day empty", () => {
181
+ assert.deepEqual(dayOn(TODAY).conflicts, [
182
+ ["evt_1to1", "evt_incident", "evt_roadmap"],
183
+ ]);
184
+ assert.deepEqual(dayOn("2026-06-17").conflicts, []);
185
+ });
186
+
187
+ it("counts nothing as no minutes and no clashes", () => {
188
+ assert.equal(busyMinutesOf([]), 0);
189
+ assert.deepEqual(conflictsOf([]), []);
190
+ });
191
+ });
192
+
193
+ describe("free time", () => {
194
+ it("measures the gaps the busiest day still leaves open", () => {
195
+ assert.deepEqual(clocks(freeStretchesOn(dayOn(TODAY))), [
196
+ "13:15–16:00",
197
+ "17:00–22:00",
198
+ ]);
199
+ });
200
+
201
+ it("ignores a gap too short to be free time", () => {
202
+ const packed = dayOn("2026-06-18", [
203
+ event("evt_a", "2026-06-18", "08:00", "12:00"),
204
+ event("evt_b", "2026-06-18", "13:00", "22:00"),
205
+ ]);
206
+ assert.deepEqual(freeStretchesOn(packed), []);
207
+ });
208
+
209
+ it("treats a day with nothing timed on it as free all day", () => {
210
+ const [whole] = freeStretchesOn(dayOn("2026-06-11"));
211
+ assert.equal(whole.wholeDay, true);
212
+ assert.deepEqual(clocks([whole]), ["08:00–22:00"]);
213
+ });
214
+
215
+ /**
216
+ * The editor writes one date, so 23:00–01:00 comes back as a span that ends
217
+ * before it starts. It is still one evening taken off the day, never two
218
+ * bands over the same morning.
219
+ */
220
+ it("gives one band for an event whose end is before its start", () => {
221
+ const day = dayOn("2026-06-13", [
222
+ event("evt_late", "2026-06-13", "23:00", "01:00"),
223
+ ]);
224
+ assert.deepEqual(clocks(freeStretchesOn(day)), ["08:00–22:00"]);
225
+ });
226
+
227
+ it("clips a stretch to the window the day is measured inside", () => {
228
+ const day = dayOn("2026-06-13", [
229
+ event("evt_late", "2026-06-13", "22:30", "23:00"),
230
+ ]);
231
+ assert.deepEqual(clocks(freeStretchesOn(day)), ["08:00–22:00"]);
232
+ });
233
+
234
+ it("merges a pile-up into the hours it actually covers", () => {
235
+ assert.deepEqual(busySpansOn(dayOn(TODAY)), [
236
+ { from: 9 * 60, to: 9 * 60 + 15 },
237
+ { from: 10 * 60, to: 13 * 60 + 15 },
238
+ { from: 16 * 60, to: 17 * 60 },
239
+ ]);
240
+ });
241
+
242
+ it("says whether a day is clear of the clock, banners aside", () => {
243
+ assert.equal(isClearDay(dayOn("2026-06-11")), true);
244
+ assert.equal(isEmptyDay(dayOn("2026-06-11")), false);
245
+ assert.equal(isEmptyDay(dayOn("2026-06-14")), true);
246
+ assert.equal(isClearDay(dayOn(TODAY)), false);
247
+ });
248
+ });
249
+
250
+ describe("buildAgendaRows", () => {
251
+ const rows = buildAgendaRows(days, [TODAY]);
252
+
253
+ it("collapses a run of empty days into one row", () => {
254
+ const run = rows.find((row) => row.kind === "run");
255
+ assert.ok(run && run.kind === "run");
256
+ assert.equal(run.from, "2026-06-12");
257
+ assert.equal(run.to, "2026-06-16");
258
+ assert.equal(run.days, 5);
259
+ });
260
+
261
+ it("keeps a day carrying only an all-day banner as its own row", () => {
262
+ assert.ok(
263
+ rows.some((row) => row.kind === "day" && row.day.date === "2026-06-11"),
264
+ );
265
+ });
266
+
267
+ it("never collapses a day it was told to keep", () => {
268
+ const kept = buildAgendaRows(days, ["2026-06-14"]);
269
+ assert.ok(
270
+ kept.some((row) => row.kind === "day" && row.day.date === "2026-06-14"),
271
+ );
272
+ });
273
+
274
+ it("collapses a run that reaches the end of the strip", () => {
275
+ const trailing = buildAgendaRows(
276
+ datesBetween("2026-06-12", "2026-06-16").map((date) => dayOn(date)),
277
+ [],
278
+ );
279
+ assert.deepEqual(
280
+ trailing.map((row) => row.kind),
281
+ ["run"],
282
+ );
283
+ });
284
+
285
+ it("leaves a single empty day as a day rather than a run of one", () => {
286
+ const single = buildAgendaRows(
287
+ [dayOn("2026-06-16"), dayOn("2026-06-17")],
288
+ [],
289
+ );
290
+ assert.deepEqual(
291
+ single.map((row) => row.kind),
292
+ ["day", "day"],
293
+ );
294
+ });
295
+ });
296
+
297
+ describe("groupOverlapping", () => {
298
+ it("keeps the pile-up together and leaves the rest alone", () => {
299
+ assert.deepEqual(
300
+ groupOverlapping(dayOn(TODAY).timed).map((group) => group.length),
301
+ [1, 3, 1, 1],
302
+ );
303
+ });
304
+ });
305
+
306
+ describe("readNextUp", () => {
307
+ const nextUp = readNextUp(days, `${TODAY}T09:05:00${OFFSET}`);
308
+
309
+ it("names what is running, what is next and how long until it", () => {
310
+ assert.deepEqual(
311
+ nextUp.running.map((item) => item.id),
312
+ ["evt_standup"],
313
+ );
314
+ assert.equal(nextUp.next?.id, "evt_roadmap");
315
+ assert.equal(nextUp.minutesUntilNext, 55);
316
+ assert.equal(nextUp.after?.id, "evt_incident");
317
+ });
318
+
319
+ it("counts what is left of today, including what is running", () => {
320
+ assert.equal(nextUp.restOfDay, 6);
321
+ });
322
+
323
+ it("reaches past the pile-up to the gap after lunch", () => {
324
+ assert.equal(nextUp.free?.startMinute, 13 * 60 + 15);
325
+ assert.equal(nextUp.free?.endMinute, 16 * 60);
326
+ });
327
+
328
+ it("says nothing is next once the day is done", () => {
329
+ const done = readNextUp(days, "2026-06-18T21:00:00+02:00");
330
+ assert.equal(done.next, undefined);
331
+ assert.equal(done.minutesUntilNext, 0);
332
+ assert.equal(done.restOfDay, 0);
333
+ });
334
+ });
335
+
336
+ describe("freeAhead", () => {
337
+ it("clips today's first stretch to start no earlier than now", () => {
338
+ const [first] = freeAhead(days, `${TODAY}T14:00:00${OFFSET}`, 1);
339
+ assert.deepEqual(clocks([first]), ["14:00–16:00"]);
340
+ assert.equal(first.wholeDay, false);
341
+ });
342
+
343
+ it("skips a stretch that now has already eaten", () => {
344
+ const [first] = freeAhead(days, `${TODAY}T15:00:00${OFFSET}`, 1);
345
+ assert.deepEqual(clocks([first]), ["17:00–22:00"]);
346
+ });
347
+
348
+ it("looks past today and stops at the limit it was given", () => {
349
+ const found = freeAhead(days, "2026-06-11T09:00:00+02:00", 2);
350
+ assert.deepEqual(
351
+ found.map((stretch) => stretch.date),
352
+ ["2026-06-11", "2026-06-12"],
353
+ );
354
+ });
355
+
356
+ it("returns everything it found when the strip runs out first", () => {
357
+ assert.deepEqual(freeAhead([], `${TODAY}T09:00:00${OFFSET}`, 3), []);
358
+ });
359
+ });
360
+
361
+ describe("clashesWith", () => {
362
+ const source = dayOn(TODAY).timed;
363
+
364
+ it("names what a candidate runs into", () => {
365
+ assert.deepEqual(
366
+ clashesWith(
367
+ {
368
+ start: `${TODAY}T11:00:00${OFFSET}`,
369
+ end: `${TODAY}T11:30:00${OFFSET}`,
370
+ },
371
+ source,
372
+ ).map((item) => item.id),
373
+ ["evt_roadmap", "evt_incident", "evt_1to1"],
374
+ );
375
+ });
376
+
377
+ it("lets one span end where the next begins", () => {
378
+ assert.deepEqual(
379
+ clashesWith(
380
+ {
381
+ start: `${TODAY}T09:15:00${OFFSET}`,
382
+ end: `${TODAY}T09:45:00${OFFSET}`,
383
+ },
384
+ source,
385
+ ),
386
+ [],
387
+ );
388
+ });
389
+
390
+ it("skips an all-day banner and a declined invitation", () => {
391
+ const declined = event("evt_declined", TODAY, "14:00", "15:00", {
392
+ myRsvp: "declined",
393
+ });
394
+ const candidate = {
395
+ start: `${TODAY}T14:15:00${OFFSET}`,
396
+ end: `${TODAY}T14:45:00${OFFSET}`,
397
+ };
398
+ assert.deepEqual(clashesWith(candidate, [declined]), []);
399
+ assert.deepEqual(
400
+ clashesWith(candidate, [banner("evt_banner", TODAY, "2026-06-12")]),
401
+ [],
402
+ );
403
+ });
404
+
405
+ it("keeps a span it was told to ignore out of its own verdict", () => {
406
+ const candidate = source[1];
407
+ assert.ok(
408
+ clashesWith(candidate, source).some((item) => item.id === candidate.id),
409
+ );
410
+ assert.deepEqual(
411
+ clashesWith(candidate, source, { ignoreIds: [candidate.id] }).map(
412
+ (item) => item.id,
413
+ ),
414
+ ["evt_incident", "evt_1to1"],
415
+ );
416
+ });
417
+
418
+ it("answers the same in every machine zone, off the offsets it is given", () => {
419
+ const abroad = {
420
+ ...event("evt_abroad", TODAY, "00", "00"),
421
+ start: `${TODAY}T17:30:00+05:30`,
422
+ end: `${TODAY}T18:30:00+05:30`,
423
+ };
424
+ const home = {
425
+ start: `${TODAY}T14:30:00${OFFSET}`,
426
+ end: `${TODAY}T15:30:00${OFFSET}`,
427
+ };
428
+ const later = {
429
+ start: `${TODAY}T15:00:00${OFFSET}`,
430
+ end: `${TODAY}T16:00:00${OFFSET}`,
431
+ };
432
+
433
+ inEveryZone(() => {
434
+ assert.deepEqual(
435
+ clashesWith(home, [abroad]).map((item) => item.id),
436
+ ["evt_abroad"],
437
+ );
438
+ assert.deepEqual(clashesWith(later, [abroad]), []);
439
+ });
440
+ });
441
+ });
442
+
443
+ describe("a clock the mail never named", () => {
444
+ const printed = {
445
+ start: "2026-06-17T16:00:00+02:00",
446
+ end: "2026-06-17T17:00:00+02:00",
447
+ };
448
+
449
+ it("reads the printed hour on the zone that was picked, wherever the machine is", () => {
450
+ inEveryZone(() => {
451
+ assert.deepEqual(wallSpanOn(printed, "Europe/Lisbon", HOME_ZONE), {
452
+ start: "2026-06-17T17:00:00+02:00",
453
+ end: "2026-06-17T18:00:00+02:00",
454
+ });
455
+ assert.deepEqual(wallSpanOn(printed, HOME_ZONE, HOME_ZONE), printed);
456
+ });
457
+ });
458
+
459
+ it("keeps whole-hour arithmetic away from the zones that are not on it", () => {
460
+ assert.equal(
461
+ wallSpanOn(printed, "Asia/Kathmandu", HOME_ZONE).start,
462
+ "2026-06-17T12:15:00+02:00",
463
+ );
464
+ });
465
+
466
+ it("carries the printed length over a spring-forward instead of losing an hour", () => {
467
+ const booked = wallSpanOn(
468
+ { start: "2026-03-29T01:30:00+00:00", end: "2026-03-29T03:30:00+00:00" },
469
+ "Europe/Lisbon",
470
+ HOME_ZONE,
471
+ );
472
+ assert.deepEqual(booked, {
473
+ start: "2026-03-29T03:30:00+02:00",
474
+ end: "2026-03-29T05:30:00+02:00",
475
+ });
476
+ });
477
+
478
+ it("leaves an all-day value alone, having no clock to read", () => {
479
+ assert.deepEqual(
480
+ wallSpanOn(
481
+ { start: "2026-06-19", end: "2026-06-23" },
482
+ "Europe/Lisbon",
483
+ HOME_ZONE,
484
+ ),
485
+ { start: "2026-06-19", end: "2026-06-23" },
486
+ );
487
+ });
488
+ });
489
+
490
+ describe("labels and arithmetic", () => {
491
+ it("says hours and minutes the way a person would", () => {
492
+ assert.equal(formatSpan(45), "45m");
493
+ assert.equal(formatSpan(120), "2h");
494
+ assert.equal(formatSpan(285), "4h 45m");
495
+ });
496
+
497
+ it("clamps a clock to the day it belongs to", () => {
498
+ assert.equal(formatMinute(-30), "00:00");
499
+ assert.equal(formatMinute(9 * 60 + 5), "09:05");
500
+ assert.equal(formatMinute(30 * 60), "24:00");
501
+ });
502
+
503
+ it("reads the minute out of an ISO string without a zone conversion", () => {
504
+ inEveryZone(() => {
505
+ assert.equal(minuteOfDay(`${TODAY}T09:15:00${OFFSET}`), 9 * 60 + 15);
506
+ });
507
+ });
508
+
509
+ it("walks dates across a month boundary", () => {
510
+ assert.equal(addDays("2026-06-30", 1), "2026-07-01");
511
+ assert.equal(addDays("2026-06-01", -1), "2026-05-31");
512
+ assert.deepEqual(datesBetween("2026-06-30", "2026-07-02"), [
513
+ "2026-06-30",
514
+ "2026-07-01",
515
+ "2026-07-02",
516
+ ]);
517
+ });
518
+
519
+ it("collapses the month when both ends of a run share one", () => {
520
+ assert.equal(
521
+ formatRunLabel("2026-06-12", "2026-06-16"),
522
+ "Fri 12 – Tue 16 Jun",
523
+ );
524
+ assert.equal(
525
+ formatRunLabel("2026-06-29", "2026-07-02"),
526
+ "Mon 29 Jun – Thu 2 Jul",
527
+ );
528
+ });
529
+
530
+ it("names a day the same in every machine zone", () => {
531
+ inEveryZone(() => {
532
+ assert.equal(monthLabel(TODAY), "June 2026");
533
+ assert.equal(shortMonthLabel(TODAY), "Jun");
534
+ assert.equal(formatShortDay(TODAY), "Wed 10 Jun");
535
+ assert.equal(weekdayLongLabel(TODAY), "Wednesday");
536
+ assert.equal(weekdayShortLabel(TODAY), "Wed");
537
+ });
538
+ });
539
+ });