@bash-app/bash-common 29.67.0 → 29.69.0

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,906 @@
1
+ import { DayOfWeek } from "@prisma/client";
2
+ import { DateTime, Interval } from "luxon";
3
+ import {
4
+ DayOfWeekIdx,
5
+ dayOfWeekIdxToDay,
6
+ dayOfWeekIdxToDayOfWeek,
7
+ dayOfWeekToIdx,
8
+ } from "./generalDateTimeUtils";
9
+ import { roundToNearestDivisor } from "./mathUtils";
10
+
11
+ const PARSE_TIME_REG = /^(\d{1,2}):(\d{2}) ?([APM]{0,2})$/i;
12
+
13
+ // export const LUXON_DATETIME_FORMAT_STANDARD = "MM/dd/yyyy - hh:mm a";
14
+ export const LUXON_DATETIME_FORMAT_STANDARD = "D t";
15
+ export const LUXON_DATETIME_FORMAT_ISO_LIKE = "yyyy-MM-dd hh:mm"; //"D t";
16
+ // export const LUXON_DATE_FORMAT_STANDARD = "yyyy/MM/dd";
17
+ // export const LUXON_DATE_FORMAT_ISO = "yyyy/MM/dd";
18
+ export const LUXON_TIME_FORMAT_AM_PM = "h:mm a";
19
+
20
+ export const MINUTES_PER_DAY = 1440;
21
+
22
+ export function dayOfWeekGetIdx(dayOfWeek: DayOfWeek | DayOfWeekIdx) {
23
+ if (typeof dayOfWeek === "number") {
24
+ return dayOfWeek as DayOfWeekIdx;
25
+ } else if (typeof dayOfWeek === "string") {
26
+ return dayOfWeekToIdx[dayOfWeek as DayOfWeek];
27
+ } else {
28
+ throw new Error(`dayOfWeekToIdx: unhandled case`);
29
+ }
30
+ }
31
+
32
+ export function dateTimeFromDayTime(
33
+ dayOfWeek: DayOfWeek | DayOfWeekIdx,
34
+ hour: number = 0,
35
+ minute: number = 0,
36
+ interpretAsTimezone?: string,
37
+ timezoneToConvertTo?: string
38
+ ): DateTime {
39
+ //use date where first day of month is a known sunday
40
+ let res = DateTime.fromObject(
41
+ {
42
+ year: 2023,
43
+ month: 1,
44
+ // weekday: dayOfWeekGetIdx(dayOfWeek),
45
+ day: dayOfWeekIdxToDay[dayOfWeekGetIdx(dayOfWeek)],
46
+ hour: hour,
47
+ minute: minute,
48
+ },
49
+ {
50
+ zone: interpretAsTimezone,
51
+ }
52
+ );
53
+
54
+ if (timezoneToConvertTo) {
55
+ res = res.setZone(timezoneToConvertTo);
56
+ }
57
+
58
+ return res;
59
+ }
60
+
61
+ export function dateTimeFromDate(
62
+ date: string | Date,
63
+ interpretAsTimezone?: string,
64
+ timezoneToConvertTo?: string
65
+ ) {
66
+ let res =
67
+ typeof date === "string"
68
+ ? DateTime.fromISO(date, { zone: interpretAsTimezone })
69
+ : DateTime.fromJSDate(date, { zone: interpretAsTimezone });
70
+
71
+ if (timezoneToConvertTo) {
72
+ res = res.setZone(timezoneToConvertTo);
73
+ }
74
+
75
+ return res;
76
+ }
77
+
78
+ export type DateTimeTime = Pick<DateTime, "hour" | "minute">;
79
+ export type DateTimeDate = Pick<DateTime, "year" | "month" | "day">;
80
+
81
+ export function dateTimeToTimeComponents(dateTime: DateTime): DateTimeTime {
82
+ return {
83
+ hour: dateTime.hour,
84
+ minute: dateTime.minute,
85
+ };
86
+ }
87
+
88
+ export function dateTimeToDateComponents(dateTime: DateTime): DateTimeDate {
89
+ return {
90
+ year: dateTime.year,
91
+ month: dateTime.month,
92
+ day: dateTime.day,
93
+ };
94
+ }
95
+
96
+ export function dateTimeFromTime(
97
+ time: string | Date,
98
+ interpretAsTimezone?: string,
99
+ timezoneToConvertTo?: string
100
+ ): DateTime | null {
101
+ let res =
102
+ typeof time === "string"
103
+ ? DateTime.fromFormat(time, LUXON_TIME_FORMAT_AM_PM, {
104
+ zone: interpretAsTimezone,
105
+ })
106
+ : DateTime.fromJSDate(time, { zone: interpretAsTimezone });
107
+
108
+ if (!res.isValid) {
109
+ return null;
110
+ }
111
+
112
+ if (timezoneToConvertTo) {
113
+ res = res.setZone(timezoneToConvertTo);
114
+ }
115
+
116
+ return res;
117
+ }
118
+
119
+ export function dateTimeFromComponents(
120
+ date: DateTime | Date | string | null | undefined,
121
+ time: DateTime | Date | string | null | undefined,
122
+ interpretAsTimezone?: string,
123
+ timezoneToConvertTo?: string
124
+ ): DateTime | null {
125
+ let res = date
126
+ ? DateTime.isDateTime(date)
127
+ ? date.setZone(interpretAsTimezone)
128
+ : dateTimeFromDate(date, interpretAsTimezone, timezoneToConvertTo)
129
+ : DateTime.now().setZone(
130
+ timezoneToConvertTo ?? interpretAsTimezone ?? undefined
131
+ );
132
+
133
+ const timeComp = DateTime.isDateTime(time)
134
+ ? time.setZone(interpretAsTimezone)
135
+ : dateTimeFromTime(
136
+ time ?? "12:00 AM",
137
+ interpretAsTimezone,
138
+ timezoneToConvertTo
139
+ );
140
+
141
+ if (!timeComp) {
142
+ return null;
143
+ }
144
+
145
+ res = dateTimeSetTime(res, timeComp);
146
+
147
+ if (timezoneToConvertTo) {
148
+ res = res.setZone(timezoneToConvertTo);
149
+ }
150
+
151
+ return res;
152
+ }
153
+
154
+ export function dateTimeGetDateTimeTIme(dateTime: DateTime) {
155
+ return DateTime.fromObject(
156
+ {
157
+ // day: dayOfWeekIdxToDay[dateTime.weekday as DayOfWeekIdx],
158
+ hour: dateTime.hour,
159
+ minute: dateTime.minute,
160
+ },
161
+ {
162
+ zone: dateTime.zone,
163
+ }
164
+ );
165
+ }
166
+
167
+ export function dateTimeSetTime(
168
+ dateTime: DateTime,
169
+ time: DateTimeTime | DateTime = { hour: 0, minute: 0 }
170
+ ): DateTime {
171
+ const timeComp = DateTime.isDateTime(time)
172
+ ? dateTimeToTimeComponents(time as DateTime)
173
+ : time;
174
+ return dateTime.set(timeComp);
175
+ }
176
+
177
+ export function dateTimeSetDate(
178
+ dateTime: DateTime,
179
+ date: DateTimeDate | DateTime = { year: 2020, month: 1, day: 1 }
180
+ ): DateTime {
181
+ const dateComp = DateTime.isDateTime(date)
182
+ ? dateTimeToDateComponents(date)
183
+ : date;
184
+ return dateTime.set(dateComp);
185
+ }
186
+
187
+ export function dateRangeGetDateTimeTIme(dateRange: LuxonDateRange) {
188
+ return {
189
+ start: dateTimeGetDateTimeTIme(dateRange.start),
190
+ end: dateTimeGetDateTimeTIme(dateRange.end),
191
+ };
192
+ }
193
+
194
+ export function dateTimeFormatTime(dateTime: DateTime) {
195
+ return dateTime.toFormat(LUXON_TIME_FORMAT_AM_PM);
196
+ }
197
+
198
+ export function dateTimeWithinRange(
199
+ dateTime: DateTime,
200
+ dateRange: LuxonDateRange
201
+ ) {
202
+ return Interval.fromDateTimes(dateRange.start, dateRange.end).contains(
203
+ dateTime
204
+ );
205
+ }
206
+
207
+ export function dateTimeRangeIntersection(
208
+ lhs: LuxonDateRange,
209
+ rhs: LuxonDateRange
210
+ ): Interval | null {
211
+ const intervalFirst = Interval.fromDateTimes(lhs.start, lhs.end);
212
+ const intervalSecond = Interval.fromDateTimes(rhs.start, rhs.end);
213
+
214
+ return intervalFirst.intersection(intervalSecond);
215
+ }
216
+
217
+ export function dateRangeWithinDateRange(
218
+ dateRange: LuxonDateRange,
219
+ withinRange: LuxonDateRange
220
+ ) {
221
+ const withinInterval = Interval.fromDateTimes(
222
+ withinRange.start,
223
+ withinRange.end
224
+ );
225
+
226
+ return (
227
+ withinInterval.contains(dateRange.start) &&
228
+ withinInterval.contains(dateRange.end)
229
+ );
230
+ }
231
+
232
+ export function dateTimeRangeToInterval(
233
+ dateTimeRange: LuxonDateRange
234
+ ): Interval {
235
+ return Interval.fromDateTimes(dateTimeRange.start, dateTimeRange.end);
236
+ }
237
+
238
+ export function dateRangeDurationMatch(
239
+ lhs: LuxonDateRange,
240
+ rhs: LuxonDateRange,
241
+ withinSeconds: number = 60
242
+ ) {
243
+ return (
244
+ Math.abs(
245
+ lhs.end.diff(lhs.start).toMillis() - rhs.end.diff(rhs.end).toMillis()
246
+ ) <=
247
+ withinSeconds * 1000
248
+ );
249
+ }
250
+
251
+ export function utcOffsetMinutesToTimezone(utcOffsetMinutes: number) {
252
+ const hours = Math.floor(Math.abs(utcOffsetMinutes) / 60);
253
+ const minutes = Math.abs(utcOffsetMinutes) % 60;
254
+ const sign = utcOffsetMinutes >= 0 ? "+" : "-";
255
+ const offsetString = `UTC${sign}${String(hours).padStart(2, "0")}:${String(
256
+ minutes
257
+ ).padStart(2, "0")}`;
258
+
259
+ return offsetString;
260
+ }
261
+
262
+ export function dateTimeFormatDateRange(
263
+ startDateTimeArg: DateTime | null,
264
+ endDateTimeArg: DateTime | null
265
+ ): string {
266
+ if (startDateTimeArg?.day === endDateTimeArg?.day) {
267
+ return `${startDateTimeArg?.toFormat(
268
+ LUXON_DATETIME_FORMAT_STANDARD
269
+ )} to ${endDateTimeArg?.toFormat(LUXON_TIME_FORMAT_AM_PM)}`;
270
+ }
271
+
272
+ return `${startDateTimeArg?.toFormat(
273
+ LUXON_DATETIME_FORMAT_STANDARD
274
+ )} to ${endDateTimeArg?.toFormat(LUXON_DATETIME_FORMAT_STANDARD)}`;
275
+ }
276
+
277
+ export interface LuxonDateRange {
278
+ start: DateTime;
279
+ end: DateTime;
280
+ }
281
+
282
+ export type LuxonDayOfWeekHours = {
283
+ [key in DayOfWeekIdx]: LuxonDateRange[];
284
+ };
285
+
286
+ export function formatDayOfWeekHours(hours: LuxonDayOfWeekHours): string[] {
287
+ return Object.entries(hours).map(([dayIdx, ranges]) => {
288
+ const dayName =
289
+ dayOfWeekIdxToDayOfWeek[parseInt(dayIdx, 10) as DayOfWeekIdx]; // Convert index to day name
290
+ const formattedRanges = ranges
291
+ .map(
292
+ ({ start, end }) =>
293
+ `${start.toFormat("h:mm a")} – ${end.toFormat("h:mm a")}`
294
+ )
295
+ .join(", ");
296
+ return `${dayName}: ${formattedRanges}`;
297
+ });
298
+ }
299
+
300
+ export function mergeCrossDayHours(hours: LuxonDateRange[]): LuxonDateRange[] {
301
+ if (hours.length === 0) return [];
302
+
303
+ // Sort by start time
304
+ const sortedHours = hours.sort(
305
+ (a, b) => a.start.toMillis() - b.start.toMillis()
306
+ );
307
+ const mergedHours: LuxonDateRange[] = [];
308
+
309
+ for (const range of sortedHours) {
310
+ if (mergedHours.length === 0) {
311
+ mergedHours.push(range);
312
+ continue;
313
+ }
314
+
315
+ const lastMerged = mergedHours[mergedHours.length - 1];
316
+
317
+ // Calculate the time difference between last merged range end and current range start
318
+ const gapInMinutes = range.start.diff(lastMerged.end, "minutes").minutes;
319
+
320
+ // If the gap is 1 minute or less, merge the ranges
321
+ if (gapInMinutes <= 1) {
322
+ lastMerged.end = range.end; // Extend the end time
323
+ } else {
324
+ mergedHours.push(range); // Otherwise, treat it as a new separate range
325
+ }
326
+ }
327
+
328
+ return mergedHours;
329
+ }
330
+
331
+ export function dateTimeHoursByDay(
332
+ hours: LuxonDateRange[]
333
+ ): LuxonDayOfWeekHours {
334
+ return hours.reduce(
335
+ (sofar, curr): LuxonDayOfWeekHours => {
336
+ sofar[curr.start.weekday as DayOfWeekIdx].push(curr);
337
+ sofar[curr.start.weekday as DayOfWeekIdx] = mergeCrossDayHours(
338
+ sofar[curr.start.weekday as DayOfWeekIdx]
339
+ );
340
+ return sofar;
341
+ },
342
+ Object.keys(dayOfWeekIdxToDayOfWeek).reduce(
343
+ (sofar, curr): LuxonDayOfWeekHours => {
344
+ return {
345
+ ...sofar,
346
+ [Number(curr) as DayOfWeekIdx]: [],
347
+ };
348
+ },
349
+ {} as LuxonDayOfWeekHours
350
+ )
351
+ );
352
+ }
353
+
354
+ export function normalizeLuxonInterval(interval: Interval) {
355
+ const setArgs = { year: 0, month: 1, day: 1 } as const;
356
+
357
+ const timezone = interval?.start?.zone || interval?.end?.zone || "local";
358
+
359
+ const originalStart = interval?.start;
360
+ const originalEnd = interval?.end;
361
+
362
+ const start =
363
+ originalStart?.set(setArgs) ??
364
+ DateTime.fromObject(setArgs, { zone: timezone });
365
+
366
+ let end =
367
+ originalEnd?.set(setArgs) ??
368
+ DateTime.fromObject(setArgs, { zone: timezone });
369
+
370
+ if (
371
+ originalEnd &&
372
+ originalStart &&
373
+ originalEnd.hasSame(originalStart, "day") === false
374
+ ) {
375
+ end = end.set({ hour: 23, minute: 59 });
376
+ }
377
+
378
+ return Interval.fromDateTimes(start, end);
379
+ }
380
+
381
+ export function dateRangeIntervalFromWeekdayTime(dateRange: LuxonDateRange) {
382
+ return Interval.fromDateTimes(dateRange.start, dateRange.end);
383
+ }
384
+
385
+ // Helper function to calculate overlaps for a single day's array of hours
386
+ function getOverlappingIntervalsList(
387
+ ranges: LuxonDateRange[],
388
+ inputInterval: Interval
389
+ ): Interval[] {
390
+ // Convert LuxonDateRange objects to Interval objects
391
+ const rangeIntervals = ranges.map(({ start, end }) =>
392
+ Interval.fromDateTimes(start, end)
393
+ );
394
+
395
+ // Find overlapping intervals between the range intervals and the single input interval
396
+ return rangeIntervals
397
+ .map((rangeInterval) => rangeInterval.intersection(inputInterval))
398
+ .filter((interval): interval is Interval => interval !== null);
399
+ }
400
+
401
+ //returns common horus that are overlapping with input Interval on everyday (with valid hours) within weekHours
402
+ export function dateTimeGetOverlappingHoursCommonHelper(
403
+ weekHours: LuxonDayOfWeekHours,
404
+ inputInterval: Interval
405
+ ): LuxonDateRange | null {
406
+ console.log(
407
+ `dateTimeGetOverlappingHoursCommonHelper weekHours: ${JSON.stringify(
408
+ weekHours
409
+ )}, inputInterval: ${JSON.stringify(inputInterval)}`
410
+ );
411
+
412
+ let commonInterval: Interval | null = null;
413
+
414
+ // const inputIntervalNormalized = normalizeLuxonInterval(inputInterval);
415
+
416
+ for (const dayIdx in weekHours) {
417
+ const dayKey = parseInt(dayIdx) as DayOfWeekIdx;
418
+
419
+ // console.log(`dateTimeGetCommonOverlappingHours dayIdx: ${dayIdx}`);
420
+
421
+ if (weekHours[dayKey] == null) {
422
+ continue;
423
+ }
424
+
425
+ // Calculate overlapping intervals for the current day
426
+ const dailyIntervals = getOverlappingIntervalsList(
427
+ weekHours[dayKey],
428
+ inputInterval
429
+ );
430
+
431
+ console.log(
432
+ `dateTimeGetOverlappingHoursCommonHelper dayIdx: ${dayIdx}, dailyIntervals: ${JSON.stringify(
433
+ dailyIntervals
434
+ )}`
435
+ );
436
+
437
+ if (dailyIntervals.length === 0) {
438
+ // No overlap for this day, so no common interval is possible
439
+ continue;
440
+ }
441
+
442
+ // Treat each daily interval separately
443
+ const dailyTimeIntervals = dailyIntervals
444
+ .filter(
445
+ (dailyInterval) =>
446
+ dailyInterval.start !== null && dailyInterval.end !== null
447
+ )
448
+ .map((dailyInterval) => {
449
+ return normalizeLuxonInterval(dailyInterval);
450
+ });
451
+
452
+ // Find the intersection of all daily intervals with the current common interval
453
+ for (const dailyTimeInterval of dailyTimeIntervals) {
454
+ if (commonInterval === null) {
455
+ commonInterval = dailyTimeInterval;
456
+ } else {
457
+ commonInterval = commonInterval.intersection(dailyTimeInterval);
458
+ if (commonInterval === null) {
459
+ // If no common interval remains, return null early
460
+ return null;
461
+ }
462
+ }
463
+ }
464
+ }
465
+
466
+ // console.log(
467
+ // `dateTimeGetCommonOverlappingHours commonInterval: ${JSON.stringify(
468
+ // commonInterval
469
+ // )}`
470
+ // );
471
+
472
+ // Convert the common interval to a LuxonDateRange if it exists
473
+ if (
474
+ commonInterval == null ||
475
+ commonInterval.start == null ||
476
+ commonInterval.end == null
477
+ ) {
478
+ return null;
479
+ }
480
+
481
+ return {
482
+ start: commonInterval.start,
483
+ end: commonInterval.end,
484
+ } as LuxonDateRange;
485
+ }
486
+
487
+ export function dateTimeGetOverlappingHoursCommon(
488
+ weekHours: LuxonDayOfWeekHours,
489
+ inputHours: LuxonDateRange
490
+ ): LuxonDateRange | null {
491
+ const inputInterval = Interval.fromDateTimes(
492
+ inputHours.start,
493
+ inputHours.end
494
+ );
495
+
496
+ return dateTimeGetOverlappingHoursCommonHelper(weekHours, inputInterval);
497
+ }
498
+
499
+ // Returns a list of overlapping hours for any dateRange in weekHours[startDay of inputRange]
500
+ export function dateTimeGetOverlappingHoursContinuousHelper(
501
+ weekHours: LuxonDayOfWeekHours,
502
+ inputInterval: Interval
503
+ ): LuxonDateRange[] | null {
504
+ console.log(
505
+ `dateTimeGetOverlappingHoursContinuousHelper weekHours: ${JSON.stringify(
506
+ weekHours
507
+ )}, inputRange: ${JSON.stringify(inputInterval)}`
508
+ );
509
+
510
+ if (!inputInterval.start) {
511
+ return null;
512
+ }
513
+
514
+ const startDayIdx = inputInterval.start.weekday as keyof LuxonDayOfWeekHours;
515
+
516
+ if (!weekHours[startDayIdx]) {
517
+ return null;
518
+ }
519
+
520
+ // Calculate overlapping intervals for the start day of inputRange
521
+ const overlappingIntervals = getOverlappingIntervalsList(
522
+ weekHours[startDayIdx],
523
+ inputInterval
524
+ );
525
+
526
+ console.log(
527
+ `dateTimeGetOverlappingHoursContinuousHelper startDayIdx: ${startDayIdx}, overlappingIntervals: ${JSON.stringify(
528
+ overlappingIntervals
529
+ )}`
530
+ );
531
+
532
+ // Convert overlapping intervals back to LuxonDateRange[]
533
+ return overlappingIntervals
534
+ .filter((interval) => interval.start !== null && interval.end !== null)
535
+ .map(
536
+ (interval) =>
537
+ ({
538
+ start: interval.start,
539
+ end: interval.end,
540
+ } as LuxonDateRange)
541
+ );
542
+ }
543
+
544
+ export function dateTimeGetOverlappingHoursContinuous(
545
+ weekHours: LuxonDayOfWeekHours,
546
+ inputHours: LuxonDateRange
547
+ ): LuxonDateRange[] | null {
548
+ const inputInterval = Interval.fromDateTimes(
549
+ inputHours.start,
550
+ inputHours.end
551
+ );
552
+
553
+ return dateTimeGetOverlappingHoursContinuousHelper(weekHours, inputInterval);
554
+ }
555
+
556
+ export function filterConflictingIntervals(
557
+ availableHours: LuxonDateRange[],
558
+ blockedHours: LuxonDateRange[]
559
+ ): LuxonDateRange[] {
560
+ // Convert available hours into an array of Intervals
561
+ let remainingIntervals = availableHours.map(({ start, end }) =>
562
+ Interval.fromDateTimes(start, end)
563
+ );
564
+
565
+ // Convert blocked hours into Intervals and subtract them from each available interval
566
+ blockedHours
567
+ .map(({ start, end }) => Interval.fromDateTimes(start, end))
568
+ .forEach((blockedInterval) => {
569
+ remainingIntervals = remainingIntervals.flatMap((availableInterval) =>
570
+ availableInterval.difference(blockedInterval)
571
+ );
572
+ });
573
+
574
+ // Convert the resulting Intervals back to LuxonDateRange[]
575
+ return remainingIntervals.map((interval) => ({
576
+ start: interval.start!,
577
+ end: interval.end!,
578
+ }));
579
+ }
580
+
581
+ export function getConflictingIntervals(
582
+ availableHours: LuxonDateRange[],
583
+ blockedHours: LuxonDateRange[]
584
+ ): LuxonDateRange[] {
585
+ // Convert available hours and blocked hours into arrays of Luxon Intervals
586
+ const availableIntervals = availableHours.map(({ start, end }) =>
587
+ Interval.fromDateTimes(start, end)
588
+ );
589
+ const blockedIntervals = blockedHours.map(({ start, end }) =>
590
+ Interval.fromDateTimes(start, end)
591
+ );
592
+
593
+ // For each available interval, find intersections with each blocked interval
594
+ const conflictingIntervals: Interval[] = [];
595
+ availableIntervals.forEach((availableInterval) => {
596
+ blockedIntervals.forEach((blockedInterval) => {
597
+ const intersection = availableInterval.intersection(blockedInterval);
598
+ if (intersection) {
599
+ conflictingIntervals.push(intersection);
600
+ }
601
+ });
602
+ });
603
+
604
+ // Convert the resulting conflicting Intervals back to LuxonDateRange[]
605
+ return conflictingIntervals.map((interval) => ({
606
+ start: interval.start!,
607
+ end: interval.end!,
608
+ }));
609
+ }
610
+
611
+ export interface TimeSlots {
612
+ start: string[];
613
+ end: string[];
614
+ }
615
+
616
+ // export function generateTimeSlots(
617
+ // filteredHours: LuxonDateRange[],
618
+ // bookedRange: LuxonDateRange,
619
+ // businessHours: LuxonDayOfWeekHours
620
+ // ): TimeSlots {
621
+ // const timeSlotsStart: string[] = [];
622
+ // let timeSlotsEnd: string[] = [];
623
+
624
+ // let currentTime = bookedRange.start;
625
+ // const endTime = bookedRange.end;
626
+
627
+ // while (currentTime < endTime) {
628
+ // const currentDayOfWeek = currentTime.weekday as DayOfWeekIdx;
629
+ // const businessDayHours = businessHours[currentDayOfWeek]?.[0];
630
+
631
+ // if (!businessDayHours) {
632
+ // throw new Error("No business hours found for the current day.");
633
+ // }
634
+
635
+ // const { start: dayStart, end: dayEnd } = businessDayHours;
636
+
637
+ // // Ensure the current time is within today's business hours
638
+ // if (currentTime < dayStart) {
639
+ // currentTime = dayStart;
640
+ // }
641
+
642
+ // // If adding 30 minutes exceeds today's boundary, jump to the next day's start
643
+ // if (currentTime.plus({ minutes: 30 }) > dayEnd) {
644
+ // const nextDayOfWeek = currentTime.plus({ days: 1 })
645
+ // .weekday as DayOfWeekIdx;
646
+ // const nextDayHours = businessHours[nextDayOfWeek]?.[0];
647
+
648
+ // if (!nextDayHours) {
649
+ // throw new Error("No business hours for the next day.");
650
+ // }
651
+
652
+ // currentTime = nextDayHours.start;
653
+ // } else {
654
+ // timeSlotsStart.push(dateTimeFormatTime(currentTime));
655
+ // currentTime = currentTime.plus({ minutes: 30 });
656
+ // }
657
+ // }
658
+
659
+ // // The last slot should match the end time, respecting endDayOfWeek business hours
660
+ // const endDayOfWeek = bookedRange.end.weekday as DayOfWeekIdx;
661
+ // const endDayHours = businessHours[endDayOfWeek]?.[0];
662
+
663
+ // if (!endDayHours) {
664
+ // throw new Error("No business hours for the final day.");
665
+ // }
666
+
667
+ // const finalEndTime =
668
+ // bookedRange.end > endDayHours.end ? endDayHours.end : bookedRange.end;
669
+ // timeSlotsEnd = [...timeSlotsStart.slice(1), dateTimeFormatTime(finalEndTime)];
670
+
671
+ // return {
672
+ // start: timeSlotsStart,
673
+ // end: timeSlotsEnd,
674
+ // } as TimeSlots;
675
+ // }
676
+
677
+ export function generateTimeSlots(filteredHours: LuxonDateRange[]): TimeSlots {
678
+ const timeSlotsStart: string[] = [];
679
+ let timeSlotsEnd: string[] = [];
680
+
681
+ filteredHours.forEach(({ start, end }) => {
682
+ let currentTime = start;
683
+
684
+ while (currentTime < end) {
685
+ timeSlotsStart.push(dateTimeFormatTime(currentTime)); // Add the time in "HH:mm" format
686
+ currentTime = currentTime.plus({ minutes: 30 }); // Increment by 30 minutes
687
+ }
688
+
689
+ const finalTime =
690
+ currentTime.hour == 0 && currentTime.minute == 0
691
+ ? currentTime.set({ hour: 23, minute: 59 })
692
+ : currentTime;
693
+ timeSlotsEnd = [...timeSlotsStart.slice(1), dateTimeFormatTime(finalTime)];
694
+ });
695
+
696
+ return {
697
+ start: timeSlotsStart,
698
+ end: timeSlotsEnd,
699
+ } as TimeSlots;
700
+ }
701
+
702
+ export function dateTimeNormalizeToMWY(
703
+ srcDate: DateTime,
704
+ referenceDate: DateTime
705
+ ): DateTime {
706
+ const srcWeekday = srcDate.weekday;
707
+ const firstDayOfReferenceMonth = DateTime.fromObject(
708
+ {
709
+ year: referenceDate.year,
710
+ month: referenceDate.month,
711
+ day: 1,
712
+ },
713
+ { zone: srcDate.zone }
714
+ );
715
+
716
+ const weekdayOffset = (srcWeekday - firstDayOfReferenceMonth.weekday + 7) % 7;
717
+ const referenceWeekday = firstDayOfReferenceMonth.plus({
718
+ days: weekdayOffset,
719
+ });
720
+
721
+ const referenceWeek = Math.ceil((referenceDate.day - 1 - weekdayOffset) / 7); //ceil instead of floor to ensure week wraps around forward and not backwards
722
+ const normalizedDate = referenceWeekday.plus({ weeks: referenceWeek }).set({
723
+ hour: srcDate.hour,
724
+ minute: srcDate.minute,
725
+ });
726
+
727
+ // console.log(
728
+ // `dateTimeNormalizeToMWY: srcDate: ${JSON.stringify(
729
+ // srcDate
730
+ // )}, referenceDate: ${JSON.stringify(
731
+ // referenceDate
732
+ // )}, referenceDate: ${JSON.stringify(normalizedDate)}`
733
+ // );
734
+
735
+ return normalizedDate;
736
+ }
737
+
738
+ export function dateRangeNormalizeToMWY(
739
+ srcRange: LuxonDateRange,
740
+ referenceRange: LuxonDateRange
741
+ ): LuxonDateRange {
742
+ // Calculate the original duration between start and end
743
+ const originalDuration = srcRange.end.diff(srcRange.start, [
744
+ "weeks",
745
+ "days",
746
+ "hours",
747
+ ]);
748
+
749
+ // Normalize the start date
750
+ const newStart = dateTimeNormalizeToMWY(srcRange.start, referenceRange.start);
751
+
752
+ // Apply the duration to the normalized start to compute the new end
753
+ let newEnd = newStart.plus(originalDuration);
754
+
755
+ // Check if the end date wraps around to the next week
756
+ if (newEnd.weekNumber < newStart.weekNumber) {
757
+ // Adjust to the next week by adding one week
758
+ newEnd = newEnd.plus({ weeks: 1 });
759
+ }
760
+
761
+ const newRange = {
762
+ start: newStart,
763
+ end: newEnd,
764
+ } as LuxonDateRange;
765
+
766
+ // console.log(
767
+ // `dateRangeNormalizeToMWY srcRange: ${JSON.stringify(
768
+ // srcRange
769
+ // )}, referenceRange: ${JSON.stringify(
770
+ // referenceRange
771
+ // )}, newRange: ${JSON.stringify(newRange)}`
772
+ // );
773
+
774
+ return newRange;
775
+ }
776
+
777
+ //need function to extend out businesshours until the date of the reference range
778
+
779
+ //need function to merge overlap[startDate] if they continue onto the next day
780
+
781
+ export function luxonDateRangeSort(dateRanges: LuxonDateRange[]) {
782
+ return dateRanges.sort((a, b) => a.start.toMillis() - b.start.toMillis());
783
+ }
784
+
785
+ export function dateTimeDiffHours(
786
+ startTime: DateTime | null,
787
+ endTime: DateTime | null
788
+ ): number {
789
+ // console.log(
790
+ // `calculateHoursBetweenTimes startTime: ${JSON.stringify(
791
+ // startTime
792
+ // )}, endTime: ${JSON.stringify(endTime)}`
793
+ // );
794
+
795
+ if (startTime == null || endTime == null) {
796
+ return 0;
797
+ }
798
+
799
+ const diff = endTime.diff(startTime, ["minutes", "days"]).toObject();
800
+ const hours = ((diff.minutes ?? 0) + 1) / 60;
801
+ const days = (diff.days ?? 0) + 1;
802
+
803
+ // console.log(`calculateHoursBetweenTimes diff: ${JSON.stringify(diff)}`);
804
+
805
+ return roundToNearestDivisor(hours * days, 4); //round to nearest quarter hour
806
+ }
807
+
808
+ export function dateTimeRangeHours(dateTimeRange: LuxonDateRange) {
809
+ return roundToNearestDivisor(
810
+ dateTimeRangeToInterval(dateTimeRange).toDuration("hours").toObject()
811
+ ?.hours ?? 0,
812
+ 4
813
+ ); //round to nearest quarter hour
814
+ }
815
+
816
+ export function dateTimeRangeCrossesMidnight(range: LuxonDateRange) {
817
+ return (
818
+ range.start.hour > range.end.hour ||
819
+ (range.start.hour === range.end.hour &&
820
+ range.start.minute >= range.end.minute)
821
+ );
822
+ }
823
+
824
+ /**
825
+ * Given an overall range, split it into segments that use the input’s
826
+ * start time as the daily “start” and the input’s end time as the daily “end.”
827
+ *
828
+ * Each segment will run from a day’s daily start to the next day’s daily end.
829
+ *
830
+ * For example, if the overall range is:
831
+ * start: March 5, 2025 11:00 am
832
+ * end: March 9, 2025 9:00 pm
833
+ *
834
+ * then the segments produced are:
835
+ * March 5, 11:00 am – March 6, 9:00 pm
836
+ * March 6, 11:00 am – March 7, 9:00 pm
837
+ * March 7, 11:00 am – March 8, 9:00 pm
838
+ * March 8, 11:00 am – March 9, 9:00 pm
839
+ *
840
+ * (You can then filter out partial segments if you want only full “days”.)
841
+ */
842
+ export function splitRangeByInputTimes(
843
+ range: LuxonDateRange
844
+ ): LuxonDateRange[] {
845
+ const startTime = dateTimeToTimeComponents(range.start);
846
+ const endTime = dateTimeToTimeComponents(range.end);
847
+
848
+ const segments: LuxonDateRange[] = [];
849
+
850
+ // Determine if the range crosses midnight.
851
+ const crossesMidnight = dateTimeRangeCrossesMidnight(range);
852
+
853
+ // Set the first segment’s start.
854
+ let currentStart = range.start.set(startTime);
855
+ if (currentStart < range.start) {
856
+ currentStart = currentStart.plus({ days: 1 });
857
+ }
858
+
859
+ while (true) {
860
+ // Determine current segment's end depending on whether we cross midnight.
861
+ const currentEnd = crossesMidnight
862
+ ? currentStart.plus({ days: 1 }).set(endTime)
863
+ : currentStart.set(endTime);
864
+
865
+ if (currentEnd > range.end) {
866
+ // segments.push({ start: currentStart, end: range.end });
867
+ break;
868
+ }
869
+ segments.push({ start: currentStart, end: currentEnd });
870
+
871
+ // Move to the next segment’s start:
872
+ // (Always add one day and then snap to the start time.)
873
+ currentStart = currentStart.plus({ days: 1 }).set(startTime);
874
+ }
875
+
876
+ return segments;
877
+ }
878
+
879
+ export function dateTimeRangeFromDates(
880
+ startDate: Date | string,
881
+ endDate: Date | string,
882
+ interpretAsTimezone?: string,
883
+ timezoneToConvertTo?: string
884
+ ): LuxonDateRange {
885
+ return {
886
+ start: dateTimeFromDate(
887
+ startDate,
888
+ interpretAsTimezone,
889
+ timezoneToConvertTo
890
+ ),
891
+ end: dateTimeFromDate(endDate, interpretAsTimezone, timezoneToConvertTo),
892
+ } as LuxonDateRange;
893
+ }
894
+
895
+ export function dateTimeFromString(str: string) {
896
+ return DateTime.fromISO(str);
897
+ }
898
+
899
+ export function dateTimeToString(dateTime: DateTime): string | null {
900
+ return dateTime.toISO();
901
+ }
902
+
903
+ export function dateRangesFormat(dateTimes: LuxonDateRange[]): string {
904
+ const result = dateTimes.join(", ");
905
+ return result;
906
+ }