@openbrf/display 0.0.0-stage → 0.1.1

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.
Files changed (69) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSE-EXCEPTION.md +28 -0
  3. package/README.md +126 -2
  4. package/dist/contract.d.ts +244 -0
  5. package/dist/contract.d.ts.map +1 -0
  6. package/dist/contract.js +54 -0
  7. package/dist/contract.js.map +1 -0
  8. package/dist/fetch-loop.d.ts +87 -0
  9. package/dist/fetch-loop.d.ts.map +1 -0
  10. package/dist/fetch-loop.js +351 -0
  11. package/dist/fetch-loop.js.map +1 -0
  12. package/dist/fit.d.ts +44 -0
  13. package/dist/fit.d.ts.map +1 -0
  14. package/dist/fit.js +257 -0
  15. package/dist/fit.js.map +1 -0
  16. package/dist/index.d.ts +23 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +18 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/parse.d.ts +39 -0
  21. package/dist/parse.d.ts.map +1 -0
  22. package/dist/parse.js +494 -0
  23. package/dist/parse.js.map +1 -0
  24. package/dist/renderer.d.ts +85 -0
  25. package/dist/renderer.d.ts.map +1 -0
  26. package/dist/renderer.js +476 -0
  27. package/dist/renderer.js.map +1 -0
  28. package/dist/rotation.d.ts +129 -0
  29. package/dist/rotation.d.ts.map +1 -0
  30. package/dist/rotation.js +335 -0
  31. package/dist/rotation.js.map +1 -0
  32. package/dist/slides.d.ts +41 -0
  33. package/dist/slides.d.ts.map +1 -0
  34. package/dist/slides.js +341 -0
  35. package/dist/slides.js.map +1 -0
  36. package/dist/styles.d.ts +10 -0
  37. package/dist/styles.d.ts.map +1 -0
  38. package/dist/styles.js +107 -0
  39. package/dist/styles.js.map +1 -0
  40. package/dist/text.d.ts +16 -0
  41. package/dist/text.d.ts.map +1 -0
  42. package/dist/text.js +62 -0
  43. package/dist/text.js.map +1 -0
  44. package/dist/time.d.ts +39 -0
  45. package/dist/time.d.ts.map +1 -0
  46. package/dist/time.js +94 -0
  47. package/dist/time.js.map +1 -0
  48. package/dist/translate.d.ts +18 -0
  49. package/dist/translate.d.ts.map +1 -0
  50. package/dist/translate.js +35 -0
  51. package/dist/translate.js.map +1 -0
  52. package/dist/words.d.ts +41 -0
  53. package/dist/words.d.ts.map +1 -0
  54. package/dist/words.js +73 -0
  55. package/dist/words.js.map +1 -0
  56. package/package.json +58 -3
  57. package/src/contract.ts +279 -0
  58. package/src/fetch-loop.ts +472 -0
  59. package/src/fit.ts +348 -0
  60. package/src/index.ts +77 -0
  61. package/src/parse.ts +605 -0
  62. package/src/renderer.ts +708 -0
  63. package/src/rotation.ts +515 -0
  64. package/src/slides.ts +595 -0
  65. package/src/styles.ts +113 -0
  66. package/src/text.ts +65 -0
  67. package/src/time.ts +147 -0
  68. package/src/translate.ts +56 -0
  69. package/src/words.ts +91 -0
package/src/parse.ts ADDED
@@ -0,0 +1,605 @@
1
+ import {
2
+ SCREEN_CONTENT_LIMITS as LIMITS,
3
+ SCREEN_CONTENT_VERSION,
4
+ SLIDE_ID_PATTERN,
5
+ type AddonDoor,
6
+ type AgendaItem,
7
+ type AvailabilityResource,
8
+ type BusyInterval,
9
+ type EventsOccurrence,
10
+ type IsoWeekday,
11
+ type PictureRef,
12
+ type ScreenContent,
13
+ type ScreenLayout,
14
+ type ScreenSchedule,
15
+ type ScreenScheduleWindow,
16
+ type Slide,
17
+ type SlideBody,
18
+ type SlideSource,
19
+ } from "./contract.ts";
20
+ import { isTimeZone } from "./time.ts";
21
+
22
+ /** Why a document was refused. */
23
+ export type ScreenContentErrorReason = "version-unknown" | "document-invalid";
24
+
25
+ /**
26
+ * A document the player cannot show at all.
27
+ *
28
+ * To the fetch loop it is a failed fetch, as the contract says of a document
29
+ * with a version the player does not know.
30
+ */
31
+ export class ScreenContentError extends Error {
32
+ override readonly name = "ScreenContentError";
33
+
34
+ constructor(
35
+ readonly reason: ScreenContentErrorReason,
36
+ message: string,
37
+ ) {
38
+ super(message);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Reads a screen content document by the player's rules, and hands back only
44
+ * what the contract names.
45
+ *
46
+ * - **Objects are open.** A field the contract does not name is ignored, and is
47
+ * absent from the result.
48
+ * - **A slide whose `body.kind` is unknown is skipped**, so that a kind added
49
+ * later in version 1 does not stop an older player.
50
+ * - **A malformed slide is skipped too**: a slide of a known kind with a field
51
+ * of the wrong type, a text over its limit, a count out of bounds, a `source`
52
+ * that does not match its body, or an `id` an earlier slide already has. The
53
+ * rest of the rotation is shown without it.
54
+ * - **A `version` other than 1, or none, is refused** with the reason
55
+ * `version-unknown`.
56
+ * - **A document whose own fields are malformed is refused** with the reason
57
+ * `document-invalid`: the screen id, the instants, the locale, the time zone,
58
+ * the release channel, the layout, the schedule, or a rotation that is not a
59
+ * list of at most 100.
60
+ *
61
+ * `refreshSeconds` is held to 30 to 60 rather than refused, because the
62
+ * contract says the player fetches again within a minute whatever the document
63
+ * says.
64
+ */
65
+ export function parseScreenContent(value: unknown): ScreenContent {
66
+ if (!isRecord(value)) {
67
+ throw invalid("The document is not a JSON object.");
68
+ }
69
+ if (value["version"] !== SCREEN_CONTENT_VERSION) {
70
+ throw new ScreenContentError(
71
+ "version-unknown",
72
+ `The document's version is not ${SCREEN_CONTENT_VERSION}.`,
73
+ );
74
+ }
75
+ const screenId = value["screenId"];
76
+ if (typeof screenId !== "string" || screenId.length === 0) {
77
+ throw invalid("screenId is not a string.");
78
+ }
79
+ const generatedAt = value["generatedAt"];
80
+ if (!isInstant(generatedAt)) {
81
+ throw invalid("generatedAt is not an instant.");
82
+ }
83
+ const refreshSeconds = value["refreshSeconds"];
84
+ if (
85
+ typeof refreshSeconds !== "number" ||
86
+ !Number.isFinite(refreshSeconds) ||
87
+ refreshSeconds <= 0
88
+ ) {
89
+ throw invalid("refreshSeconds is not a number of seconds.");
90
+ }
91
+ const locale = value["locale"];
92
+ if (locale !== "sv" && locale !== "en") {
93
+ throw invalid("locale is not sv or en.");
94
+ }
95
+ const timeZone = value["timeZone"];
96
+ if (typeof timeZone !== "string" || !isTimeZone(timeZone)) {
97
+ throw invalid("timeZone is not a time zone.");
98
+ }
99
+ const releaseChannel = value["releaseChannel"];
100
+ if (releaseChannel !== "stable" && releaseChannel !== "beta") {
101
+ throw invalid("releaseChannel is not stable or beta.");
102
+ }
103
+ const layout = parseLayout(value["layout"]);
104
+ const schedule = parseSchedule(value["schedule"]);
105
+ const rotation = value["rotation"];
106
+ if (!Array.isArray(rotation) || rotation.length > LIMITS.rotation) {
107
+ throw invalid(`rotation is not a list of at most ${LIMITS.rotation}.`);
108
+ }
109
+ const slides: Slide[] = [];
110
+ const ids = new Set<string>();
111
+ for (const candidate of rotation) {
112
+ const slide = parseSlide(candidate);
113
+ if (slide !== null && !ids.has(slide.id)) {
114
+ ids.add(slide.id);
115
+ slides.push(slide);
116
+ }
117
+ }
118
+ return {
119
+ version: SCREEN_CONTENT_VERSION,
120
+ screenId,
121
+ generatedAt,
122
+ refreshSeconds: Math.min(
123
+ LIMITS.refreshSecondsMax,
124
+ Math.max(LIMITS.refreshSecondsMin, Math.round(refreshSeconds)),
125
+ ),
126
+ locale,
127
+ timeZone,
128
+ releaseChannel,
129
+ layout,
130
+ schedule,
131
+ rotation: slides,
132
+ };
133
+ }
134
+
135
+ function invalid(message: string): ScreenContentError {
136
+ return new ScreenContentError("document-invalid", message);
137
+ }
138
+
139
+ function parseLayout(value: unknown): ScreenLayout {
140
+ if (!isRecord(value)) {
141
+ throw invalid("layout is not an object.");
142
+ }
143
+ const { orientation, header, theme } = value;
144
+ if (
145
+ orientation !== 0 &&
146
+ orientation !== 90 &&
147
+ orientation !== 180 &&
148
+ orientation !== 270
149
+ ) {
150
+ throw invalid("layout.orientation is not 0, 90, 180 or 270.");
151
+ }
152
+ if (header !== "none" && header !== "clock") {
153
+ throw invalid("layout.header is not none or clock.");
154
+ }
155
+ if (theme !== "light" && theme !== "dark") {
156
+ throw invalid("layout.theme is not light or dark.");
157
+ }
158
+ return { orientation, header, theme };
159
+ }
160
+
161
+ const LOCAL_TIME = /^(?:(?:[01]\d|2[0-3]):[0-5]\d|24:00)$/;
162
+
163
+ function parseSchedule(value: unknown): ScreenSchedule {
164
+ if (!isRecord(value)) {
165
+ throw invalid("schedule is not an object.");
166
+ }
167
+ const on = value["on"];
168
+ if (!Array.isArray(on) || on.length > LIMITS.scheduleWindows) {
169
+ throw invalid(
170
+ `schedule.on is not a list of at most ${LIMITS.scheduleWindows}.`,
171
+ );
172
+ }
173
+ return {
174
+ on: on.map((window): ScreenScheduleWindow => {
175
+ if (!isRecord(window)) {
176
+ throw invalid("A schedule window is not an object.");
177
+ }
178
+ const { days, from, to } = window;
179
+ if (
180
+ !Array.isArray(days) ||
181
+ !days.every(
182
+ (day): day is IsoWeekday =>
183
+ typeof day === "number" &&
184
+ Number.isInteger(day) &&
185
+ day >= 1 &&
186
+ day <= 7,
187
+ )
188
+ ) {
189
+ throw invalid("A schedule window's days are not ISO weekdays.");
190
+ }
191
+ if (
192
+ typeof from !== "string" ||
193
+ typeof to !== "string" ||
194
+ !LOCAL_TIME.test(from) ||
195
+ !LOCAL_TIME.test(to) ||
196
+ from >= to
197
+ ) {
198
+ throw invalid(
199
+ "A schedule window's from and to are not times in order.",
200
+ );
201
+ }
202
+ return { days: [...days], from, to };
203
+ }),
204
+ };
205
+ }
206
+
207
+ const SOURCE_OF_KIND: Readonly<Record<SlideBody["kind"], SlideSource>> = {
208
+ news: "news",
209
+ events: "events",
210
+ meeting: "meeting",
211
+ availability: "availability",
212
+ picture: "picture",
213
+ text: "addon",
214
+ table: "addon",
215
+ doors: "addon",
216
+ };
217
+
218
+ function parseSlide(value: unknown): Slide | null {
219
+ if (!isRecord(value)) {
220
+ return null;
221
+ }
222
+ const { id, seconds, until, source, addon, body } = value;
223
+ if (typeof id !== "string" || !SLIDE_ID_PATTERN.test(id)) {
224
+ return null;
225
+ }
226
+ if (
227
+ typeof seconds !== "number" ||
228
+ !Number.isInteger(seconds) ||
229
+ seconds < LIMITS.slideSecondsMin ||
230
+ seconds > LIMITS.slideSecondsMax
231
+ ) {
232
+ return null;
233
+ }
234
+ if (until !== null && !isInstant(until)) {
235
+ return null;
236
+ }
237
+ const parsed = parseBody(body);
238
+ if (parsed === null) {
239
+ return null;
240
+ }
241
+ const sourceOfBody = SOURCE_OF_KIND[parsed.kind];
242
+ if (source !== sourceOfBody) {
243
+ return null;
244
+ }
245
+ let addonId: string | null = null;
246
+ if (sourceOfBody === "addon") {
247
+ if (typeof addon !== "string" || addon.length === 0) {
248
+ return null;
249
+ }
250
+ addonId = addon;
251
+ } else if (addon !== null) {
252
+ return null;
253
+ }
254
+ return {
255
+ id,
256
+ seconds,
257
+ until,
258
+ source: sourceOfBody,
259
+ addon: addonId,
260
+ body: parsed,
261
+ };
262
+ }
263
+
264
+ function parseBody(value: unknown): SlideBody | null {
265
+ if (!isRecord(value)) {
266
+ return null;
267
+ }
268
+ switch (value["kind"]) {
269
+ case "news":
270
+ return parseNews(value);
271
+ case "events":
272
+ return parseEvents(value);
273
+ case "meeting":
274
+ return parseMeeting(value);
275
+ case "availability":
276
+ return parseAvailability(value);
277
+ case "picture":
278
+ return parsePictureBody(value);
279
+ case "text":
280
+ return parseText(value);
281
+ case "table":
282
+ return parseTable(value);
283
+ case "doors":
284
+ return parseDoors(value);
285
+ default:
286
+ return null;
287
+ }
288
+ }
289
+
290
+ function parseNews(value: Record<string, unknown>): SlideBody | null {
291
+ const { title, paragraphs, picture, publishedAt } = value;
292
+ if (!isText(title, LIMITS.title) || !isInstant(publishedAt)) {
293
+ return null;
294
+ }
295
+ if (!isTextList(paragraphs, LIMITS.paragraphs)) {
296
+ return null;
297
+ }
298
+ const ref = picture === null ? null : parsePictureRef(picture);
299
+ if (ref === undefined) {
300
+ return null;
301
+ }
302
+ return {
303
+ kind: "news",
304
+ title,
305
+ paragraphs: [...paragraphs],
306
+ picture: ref,
307
+ publishedAt,
308
+ };
309
+ }
310
+
311
+ function parseEvents(value: Record<string, unknown>): SlideBody | null {
312
+ const list = value["occurrences"];
313
+ if (
314
+ !Array.isArray(list) ||
315
+ list.length < LIMITS.occurrencesMin ||
316
+ list.length > LIMITS.occurrencesMax
317
+ ) {
318
+ return null;
319
+ }
320
+ const occurrences: EventsOccurrence[] = [];
321
+ for (const item of list) {
322
+ if (!isRecord(item)) {
323
+ return null;
324
+ }
325
+ const {
326
+ title,
327
+ description,
328
+ location,
329
+ startsAt,
330
+ endsAt,
331
+ calledOff,
332
+ placesLeft,
333
+ } = item;
334
+ if (
335
+ !isText(title, LIMITS.title) ||
336
+ !isTextOrNull(description, LIMITS.description) ||
337
+ !isTextOrNull(location, LIMITS.location) ||
338
+ !isInstant(startsAt) ||
339
+ !isInstant(endsAt) ||
340
+ typeof calledOff !== "boolean" ||
341
+ !(placesLeft === null || isCount(placesLeft))
342
+ ) {
343
+ return null;
344
+ }
345
+ occurrences.push({
346
+ title,
347
+ description,
348
+ location,
349
+ startsAt,
350
+ endsAt,
351
+ calledOff,
352
+ placesLeft,
353
+ });
354
+ }
355
+ return { kind: "events", occurrences };
356
+ }
357
+
358
+ function parseMeeting(value: Record<string, unknown>): SlideBody | null {
359
+ const { meetingKind, startsAt, place, digitalParticipation, agenda } = value;
360
+ if (
361
+ (meetingKind !== "ordinary" && meetingKind !== "extraordinary") ||
362
+ !isInstant(startsAt) ||
363
+ !isText(place, LIMITS.place) ||
364
+ !isTextOrNull(digitalParticipation, LIMITS.digitalParticipation) ||
365
+ !Array.isArray(agenda) ||
366
+ agenda.length > LIMITS.agendaItems
367
+ ) {
368
+ return null;
369
+ }
370
+ const items: AgendaItem[] = [];
371
+ for (const item of agenda) {
372
+ if (!isRecord(item)) {
373
+ return null;
374
+ }
375
+ const { position, title } = item;
376
+ if (
377
+ typeof position !== "number" ||
378
+ !Number.isInteger(position) ||
379
+ !isText(title, LIMITS.agendaTitle)
380
+ ) {
381
+ return null;
382
+ }
383
+ items.push({ position, title });
384
+ }
385
+ return {
386
+ kind: "meeting",
387
+ meetingKind,
388
+ startsAt,
389
+ place,
390
+ digitalParticipation,
391
+ agenda: items,
392
+ };
393
+ }
394
+
395
+ const MINUTES_IN_A_DAY = 24 * 60;
396
+
397
+ function parseAvailability(value: Record<string, unknown>): SlideBody | null {
398
+ const { from, to, resources } = value;
399
+ if (!isInstant(from) || !isInstant(to)) {
400
+ return null;
401
+ }
402
+ const span = Date.parse(to) - Date.parse(from);
403
+ if (span <= 0 || span > LIMITS.availabilityDays * 24 * 60 * 60 * 1000) {
404
+ return null;
405
+ }
406
+ if (
407
+ !Array.isArray(resources) ||
408
+ resources.length < LIMITS.resourcesMin ||
409
+ resources.length > LIMITS.resourcesMax
410
+ ) {
411
+ return null;
412
+ }
413
+ const parsed: AvailabilityResource[] = [];
414
+ for (const resource of resources) {
415
+ if (!isRecord(resource)) {
416
+ return null;
417
+ }
418
+ const { name, mode, slotMinutes, opensAtMinute, closesAtMinute, busy } =
419
+ resource;
420
+ if (
421
+ !isText(name, LIMITS.resourceName) ||
422
+ (mode !== "timeSlots" && mode !== "wholeDay" && mode !== "dateRange") ||
423
+ !isMinutesOrNull(slotMinutes) ||
424
+ !isMinutesOrNull(opensAtMinute) ||
425
+ !isMinutesOrNull(closesAtMinute) ||
426
+ !Array.isArray(busy)
427
+ ) {
428
+ return null;
429
+ }
430
+ const intervals: BusyInterval[] = [];
431
+ for (const interval of busy) {
432
+ if (!isRecord(interval)) {
433
+ return null;
434
+ }
435
+ const { start, end } = interval;
436
+ if (
437
+ !isInstant(start) ||
438
+ !isInstant(end) ||
439
+ Date.parse(start) >= Date.parse(end)
440
+ ) {
441
+ return null;
442
+ }
443
+ intervals.push({ start, end });
444
+ }
445
+ parsed.push({
446
+ name,
447
+ mode,
448
+ slotMinutes,
449
+ opensAtMinute,
450
+ closesAtMinute,
451
+ busy: intervals,
452
+ });
453
+ }
454
+ return { kind: "availability", from, to, resources: parsed };
455
+ }
456
+
457
+ function parsePictureBody(value: Record<string, unknown>): SlideBody | null {
458
+ const picture = parsePictureRef(value["picture"]);
459
+ const caption = value["caption"];
460
+ if (picture === undefined || !isTextOrNull(caption, LIMITS.caption)) {
461
+ return null;
462
+ }
463
+ return { kind: "picture", picture, caption };
464
+ }
465
+
466
+ function parseText(value: Record<string, unknown>): SlideBody | null {
467
+ const { heading, body } = value;
468
+ if (typeof heading !== "string" || !isTextList(body, LIMITS.addonTextBody)) {
469
+ return null;
470
+ }
471
+ return { kind: "text", heading, body: [...body] };
472
+ }
473
+
474
+ function parseTable(value: Record<string, unknown>): SlideBody | null {
475
+ const { heading, columns, rows } = value;
476
+ if (
477
+ typeof heading !== "string" ||
478
+ !isStringList(columns) ||
479
+ columns.length === 0 ||
480
+ columns.length > LIMITS.tableColumns ||
481
+ !Array.isArray(rows) ||
482
+ rows.length > LIMITS.tableRows ||
483
+ !rows.every(
484
+ (row): row is string[] =>
485
+ isStringList(row) && row.length === columns.length,
486
+ )
487
+ ) {
488
+ return null;
489
+ }
490
+ return {
491
+ kind: "table",
492
+ heading,
493
+ columns: [...columns],
494
+ rows: rows.map((row) => [...row]),
495
+ };
496
+ }
497
+
498
+ function parseDoors(value: Record<string, unknown>): SlideBody | null {
499
+ const { heading, doors } = value;
500
+ if (
501
+ typeof heading !== "string" ||
502
+ !Array.isArray(doors) ||
503
+ doors.length > LIMITS.doors
504
+ ) {
505
+ return null;
506
+ }
507
+ const parsed: AddonDoor[] = [];
508
+ for (const door of doors) {
509
+ if (!isRecord(door)) {
510
+ return null;
511
+ }
512
+ const { floor, apartment, names } = door;
513
+ if (!(
514
+ floor === null ||
515
+ (typeof floor === "number" && Number.isInteger(floor))
516
+ )) {
517
+ return null;
518
+ }
519
+ if (typeof apartment !== "string" || !isStringList(names)) {
520
+ return null;
521
+ }
522
+ parsed.push({ floor, apartment, names: [...names] });
523
+ }
524
+ return { kind: "doors", heading, doors: parsed };
525
+ }
526
+
527
+ /** The picture, or undefined when it is not one. */
528
+ function parsePictureRef(value: unknown): PictureRef | undefined {
529
+ if (!isRecord(value)) {
530
+ return undefined;
531
+ }
532
+ const { mediaFileId, contentType, byteSize, version, alt } = value;
533
+ if (
534
+ typeof mediaFileId !== "string" ||
535
+ mediaFileId.length === 0 ||
536
+ typeof contentType !== "string" ||
537
+ !isCount(byteSize) ||
538
+ typeof version !== "string" ||
539
+ !isText(alt, LIMITS.alt)
540
+ ) {
541
+ return undefined;
542
+ }
543
+ return { mediaFileId, contentType, byteSize, version, alt };
544
+ }
545
+
546
+ function isRecord(value: unknown): value is Record<string, unknown> {
547
+ return typeof value === "object" && value !== null && !Array.isArray(value);
548
+ }
549
+
550
+ const INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
551
+
552
+ /** Whether a value is an instant as the contract writes one, and a real one. */
553
+ function isInstant(value: unknown): value is string {
554
+ if (typeof value !== "string" || !INSTANT.test(value)) {
555
+ return false;
556
+ }
557
+ const time = Date.parse(value);
558
+ return (
559
+ Number.isFinite(time) &&
560
+ new Date(time).toISOString() === `${value.slice(0, -1)}.000Z`
561
+ );
562
+ }
563
+
564
+ /** The number of characters (code points) in a text. */
565
+ function characters(text: string): number {
566
+ let count = 0;
567
+ for (const _ of text) {
568
+ count += 1;
569
+ }
570
+ return count;
571
+ }
572
+
573
+ function isText(value: unknown, max: number): value is string {
574
+ return typeof value === "string" && characters(value) <= max;
575
+ }
576
+
577
+ function isTextOrNull(value: unknown, max: number): value is string | null {
578
+ return value === null || isText(value, max);
579
+ }
580
+
581
+ function isStringList(value: unknown): value is string[] {
582
+ return (
583
+ Array.isArray(value) && value.every((item) => typeof item === "string")
584
+ );
585
+ }
586
+
587
+ /** A list of texts at most `max` characters together. */
588
+ function isTextList(value: unknown, max: number): value is string[] {
589
+ if (!isStringList(value)) {
590
+ return false;
591
+ }
592
+ let total = 0;
593
+ for (const item of value) {
594
+ total += characters(item);
595
+ }
596
+ return total <= max;
597
+ }
598
+
599
+ function isCount(value: unknown): value is number {
600
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
601
+ }
602
+
603
+ function isMinutesOrNull(value: unknown): value is number | null {
604
+ return value === null || (isCount(value) && value <= MINUTES_IN_A_DAY);
605
+ }