@perdieminc/time-slots 0.0.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.
@@ -0,0 +1,120 @@
1
+ import { FULFILLMENT_TYPES } from "../constants";
2
+ import type {
3
+ BusinessHour,
4
+ BusinessHoursOverrideInput,
5
+ BusinessHoursOverrideOutput,
6
+ FulfillmentPreference,
7
+ LocationLike,
8
+ } from "../types";
9
+
10
+ export function toBusinessHoursOverride(
11
+ businessHoursOverride: BusinessHoursOverrideInput,
12
+ ): BusinessHoursOverrideOutput {
13
+ const {
14
+ month,
15
+ day,
16
+ start_time: startTime,
17
+ end_time: endTime,
18
+ is_open: isOpen,
19
+ } = businessHoursOverride;
20
+
21
+ return {
22
+ month,
23
+ day,
24
+ startTime: isOpen ? startTime : null,
25
+ endTime: isOpen ? endTime : null,
26
+ };
27
+ }
28
+
29
+ export function getLocationsBusinessHoursOverrides(
30
+ businessHoursOverrides: BusinessHoursOverrideInput[],
31
+ locations: LocationLike[],
32
+ ): Record<string, BusinessHoursOverrideOutput[]> {
33
+ const result: Record<string, BusinessHoursOverrideOutput[]> = {};
34
+
35
+ for (const override of businessHoursOverrides) {
36
+ const { all_locations: allLocations, location_ids: locationIds } =
37
+ override || {};
38
+
39
+ if (allLocations === true) {
40
+ for (const location of locations || []) {
41
+ const id = location.location_id;
42
+ result[id] ??= [];
43
+ result[id].push(toBusinessHoursOverride(override));
44
+ }
45
+ } else {
46
+ for (const id of locationIds || []) {
47
+ const location = locations.find((loc) => loc.location_id === id);
48
+ if (location) {
49
+ result[id] ??= [];
50
+ result[id].push(toBusinessHoursOverride(override));
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ return result;
57
+ }
58
+
59
+ export const isLocationCateringEnabled = (
60
+ location: LocationLike | undefined,
61
+ ): boolean => {
62
+ if (!location) return false;
63
+ return (
64
+ !!location?.catering?.enabled && location?.catering?.enabled !== "false"
65
+ );
66
+ };
67
+
68
+ export function getLocationBusinessHoursForFulfillment(
69
+ location: LocationLike,
70
+ fulfillmentPreference: FulfillmentPreference,
71
+ isCatering = false,
72
+ ): BusinessHour[] {
73
+ const fulfillmentBusinessHours: Record<
74
+ string,
75
+ Array<{ day: number; start_time: string; end_time: string }> | undefined
76
+ > = {
77
+ [FULFILLMENT_TYPES.PICKUP]: location?.pickup_hours,
78
+ [FULFILLMENT_TYPES.DELIVERY]: location?.delivery_hours,
79
+ [FULFILLMENT_TYPES.CURBSIDE]: location?.curbside_hours?.use_pickup_hours
80
+ ? location?.pickup_hours
81
+ : location?.curbside_hours?.times,
82
+ };
83
+
84
+ const cateringBusinessTimings: Partial<
85
+ Record<FulfillmentPreference, { start_time: string; end_time: string }>
86
+ > = {
87
+ [FULFILLMENT_TYPES.PICKUP]: location?.catering?.pickup,
88
+ [FULFILLMENT_TYPES.DELIVERY]: location?.catering?.delivery,
89
+ };
90
+
91
+ const businessHours = fulfillmentBusinessHours[fulfillmentPreference];
92
+
93
+ const cateringBusinessTiming =
94
+ isCatering && isLocationCateringEnabled(location)
95
+ ? cateringBusinessTimings[fulfillmentPreference]
96
+ : null;
97
+
98
+ if (isCatering && !cateringBusinessTiming) {
99
+ //if catering is enabled but no business hours are set, return empty arrayif
100
+ return [];
101
+ }
102
+
103
+ return (
104
+ businessHours?.map((businessHour) => {
105
+ if (isCatering && cateringBusinessTiming) {
106
+ //if catering is enabled and catering business hours are set, return catering business hours
107
+ return {
108
+ day: businessHour.day,
109
+ startTime: cateringBusinessTiming.start_time,
110
+ endTime: cateringBusinessTiming.end_time,
111
+ };
112
+ }
113
+ return {
114
+ day: businessHour.day,
115
+ startTime: businessHour.start_time,
116
+ endTime: businessHour.end_time,
117
+ };
118
+ }) ?? []
119
+ );
120
+ }
@@ -0,0 +1,85 @@
1
+ import { findTimeZone, getZonedTime } from "timezone-support";
2
+ import { DEFAULT_TIMEZONE, PREP_TIME_CADENCE } from "../constants";
3
+ import type {
4
+ CateringPrepTimeResult,
5
+ GetCateringPrepTimeParams,
6
+ } from "../types";
7
+
8
+ function buildCateringPrepTimeResult(
9
+ prepTimeCadence: CateringPrepTimeResult["prepTimeCadence"],
10
+ prepTimeFrequency: number,
11
+ timezone: string = DEFAULT_TIMEZONE,
12
+ ): CateringPrepTimeResult {
13
+ const result: CateringPrepTimeResult = {
14
+ prepTimeCadence,
15
+ prepTimeFrequency,
16
+ };
17
+ if (prepTimeCadence === PREP_TIME_CADENCE.DAY) {
18
+ result.weekDayPrepTimes = {};
19
+ } else {
20
+ result.weekDayPrepTimes = {
21
+ [getZonedTime(new Date(), findTimeZone(timezone)).dayOfWeek]:
22
+ prepTimeCadence === PREP_TIME_CADENCE.HOUR
23
+ ? prepTimeFrequency * 60
24
+ : prepTimeFrequency,
25
+ };
26
+ }
27
+ return result;
28
+ }
29
+
30
+ /**
31
+ * Derives prep time config (cadence, frequency, weekDayPrepTimes) from cart items for catering flow.
32
+ * DAY cadence has priority; if any item uses days, returns max day frequency.
33
+ * Otherwise returns HOUR cadence with max hour frequency across items.
34
+ * When items are empty or have no catering prep time, falls back to params (e.g. from prepTimeSettings).
35
+ */
36
+ export function getCateringPrepTimeConfig(
37
+ params: GetCateringPrepTimeParams,
38
+ ): CateringPrepTimeResult {
39
+ const { items, timezone = DEFAULT_TIMEZONE } = params;
40
+
41
+ if (items.length === 0) {
42
+ return buildCateringPrepTimeResult(
43
+ params.prepTimeCadence ?? PREP_TIME_CADENCE.HOUR,
44
+ params.prepTimeFrequency ?? 1,
45
+ timezone,
46
+ );
47
+ }
48
+
49
+ const dayFrequencies: number[] = [];
50
+ const hourFrequencies: number[] = [];
51
+
52
+ for (const item of items) {
53
+ const cadence = item.cateringService?.prep_time?.cadence;
54
+ const frequency = item.cateringService?.prep_time?.frequency;
55
+ if (cadence == null || frequency == null) continue;
56
+
57
+ if (cadence === PREP_TIME_CADENCE.DAY) {
58
+ dayFrequencies.push(frequency);
59
+ } else if (cadence === PREP_TIME_CADENCE.HOUR) {
60
+ hourFrequencies.push(frequency);
61
+ }
62
+ }
63
+
64
+ if (dayFrequencies.length > 0) {
65
+ return buildCateringPrepTimeResult(
66
+ PREP_TIME_CADENCE.DAY,
67
+ Math.max(...dayFrequencies),
68
+ timezone,
69
+ );
70
+ }
71
+
72
+ if (hourFrequencies.length > 0) {
73
+ return buildCateringPrepTimeResult(
74
+ PREP_TIME_CADENCE.HOUR,
75
+ Math.max(...hourFrequencies),
76
+ timezone,
77
+ );
78
+ }
79
+
80
+ return buildCateringPrepTimeResult(
81
+ params.prepTimeCadence ?? PREP_TIME_CADENCE.HOUR,
82
+ params.prepTimeFrequency ?? 1,
83
+ timezone,
84
+ );
85
+ }
@@ -0,0 +1,163 @@
1
+ import { addDays, compareAsc } from "date-fns";
2
+ import { findTimeZone, getUnixTime, getZonedTime } from "timezone-support";
3
+
4
+ import type { BusinessHour } from "../types";
5
+
6
+ export function setHmOnDate(date: Date, hm: string, timeZone: string): Date {
7
+ const [hours, minutes] = String(hm).split(":");
8
+
9
+ const zonedTime = getZonedTime(new Date(date), findTimeZone(timeZone));
10
+
11
+ return new Date(
12
+ getUnixTime({
13
+ zone: zonedTime.zone,
14
+ year: zonedTime.year,
15
+ month: zonedTime.month,
16
+ day: zonedTime.day,
17
+ hours: hours === "24" ? 23 : Number(hours),
18
+ minutes: hours === "24" ? 59 : Number(minutes),
19
+ seconds: 0,
20
+ milliseconds: 0,
21
+ }),
22
+ );
23
+ }
24
+
25
+ export function getNextDateForDayOfWeek(
26
+ targetDayIndex: number,
27
+ referenceDate: Date = new Date(),
28
+ ): Date {
29
+ const currentDayIndex = referenceDate.getDay();
30
+ const daysUntilTarget = (targetDayIndex - currentDayIndex + 7) % 7;
31
+ return addDays(referenceDate, daysUntilTarget);
32
+ }
33
+
34
+ export function getPreSalePickupDates(
35
+ preSalePickupDays: number[] = [],
36
+ preSaleOrderingDays: number[] = [],
37
+ ): Date[] {
38
+ const today = new Date();
39
+ const currentDayIndex = today.getDay();
40
+
41
+ if (preSalePickupDays.includes(currentDayIndex)) {
42
+ return [];
43
+ }
44
+
45
+ if (!preSaleOrderingDays.includes(currentDayIndex)) {
46
+ return [];
47
+ }
48
+
49
+ return preSalePickupDays
50
+ .map((day) => getNextDateForDayOfWeek(day))
51
+ .sort(compareAsc);
52
+ }
53
+
54
+ export function overrideTimeZoneOnUTC(
55
+ date: Date | string,
56
+ timeZoneB: string,
57
+ ): Date {
58
+ const timeZoneATime = getZonedTime(new Date(date), findTimeZone("UTC"));
59
+ const timeZoneBTime = getZonedTime(new Date(date), findTimeZone(timeZoneB));
60
+
61
+ return new Date(
62
+ getUnixTime({
63
+ ...timeZoneATime,
64
+ zone: timeZoneBTime.zone,
65
+ }),
66
+ );
67
+ }
68
+
69
+ export function isTodayInTimeZone(date: Date, timeZone: string): boolean {
70
+ if (!date) {
71
+ return false;
72
+ }
73
+ const zonedNow = getZonedTime(Date.now(), findTimeZone(timeZone));
74
+ const zonedTime = getZonedTime(date, findTimeZone(timeZone));
75
+
76
+ return zonedNow.day === zonedTime.day && zonedNow.month === zonedTime.month;
77
+ }
78
+
79
+ export function isTomorrowInTimeZone(date: Date, timeZone: string): boolean {
80
+ const zonedNow = getZonedTime(addDays(Date.now(), 1), findTimeZone(timeZone));
81
+ const zonedTime = getZonedTime(date, findTimeZone(timeZone));
82
+
83
+ return zonedNow.day === zonedTime.day && zonedNow.month === zonedTime.month;
84
+ }
85
+
86
+ export function isSameDateInTimeZone(
87
+ dateLeft: Date,
88
+ dateRight: Date,
89
+ timeZone: string,
90
+ ): boolean {
91
+ const zonedDateLeft = getZonedTime(dateLeft, findTimeZone(timeZone));
92
+ const zonedDateRight = getZonedTime(dateRight, findTimeZone(timeZone));
93
+
94
+ return (
95
+ zonedDateLeft.year === zonedDateRight.year &&
96
+ zonedDateLeft.month === zonedDateRight.month &&
97
+ zonedDateLeft.day === zonedDateRight.day
98
+ );
99
+ }
100
+
101
+ export function isMidnightTransition(
102
+ endDate: Date,
103
+ startDateNextDay: Date,
104
+ timeZone: string,
105
+ ): boolean {
106
+ if (!endDate || !startDateNextDay) {
107
+ return false;
108
+ }
109
+ const zonedEndDate = getZonedTime(endDate, findTimeZone(timeZone));
110
+ const zonedStartDate = getZonedTime(startDateNextDay, findTimeZone(timeZone));
111
+
112
+ return (
113
+ zonedEndDate.hours === 23 &&
114
+ zonedEndDate.minutes === 59 &&
115
+ zonedStartDate.hours === 0 &&
116
+ zonedStartDate.minutes === 0
117
+ );
118
+ }
119
+
120
+ export function addDaysInTimeZone(
121
+ date: Date,
122
+ days: number,
123
+ timeZone: string,
124
+ ): Date {
125
+ const zonedTime = getZonedTime(addDays(date, days), findTimeZone(timeZone));
126
+ return new Date(
127
+ getUnixTime({
128
+ ...zonedTime,
129
+ hours: 0,
130
+ minutes: 0,
131
+ seconds: 0,
132
+ milliseconds: 0,
133
+ }),
134
+ );
135
+ }
136
+
137
+ export function isZeroPrepTimeForMidnightShift({
138
+ prevDayBusinessHours,
139
+ businessHour,
140
+ }: {
141
+ prevDayBusinessHours: BusinessHour[];
142
+ businessHour: BusinessHour;
143
+ }): boolean {
144
+ if (
145
+ !Array.isArray(prevDayBusinessHours) ||
146
+ prevDayBusinessHours.length === 0
147
+ ) {
148
+ return false;
149
+ }
150
+
151
+ if (!businessHour || businessHour.startTime !== "00:00") {
152
+ return false;
153
+ }
154
+
155
+ const currentDay = businessHour.day;
156
+ const prevDay = (currentDay + 6) % 7;
157
+
158
+ const prevDayHas24End = prevDayBusinessHours.some(
159
+ (bh) =>
160
+ bh.day === prevDay && (bh.endTime === "24:00" || bh.endTime === "23:59"),
161
+ );
162
+ return prevDayHas24End;
163
+ }
@@ -0,0 +1,140 @@
1
+ import { findTimeZone, getZonedTime } from "timezone-support";
2
+
3
+ import type {
4
+ BusyTimeItem,
5
+ FilterBusyTimesFromScheduleParams,
6
+ FulfillmentSchedule,
7
+ MenuType,
8
+ } from "../types";
9
+ import { isTimeInRange } from "./time";
10
+
11
+ // ── Private helpers ─────────────────────────────────────────────────────────
12
+
13
+ function isSlotBusy(
14
+ applicableBusyTimes: BusyTimeItem[],
15
+ slotTimeValue: Date | number,
16
+ ): boolean {
17
+ const slotTime = new Date(slotTimeValue);
18
+
19
+ if (Number.isNaN(slotTime.getTime())) {
20
+ return false;
21
+ }
22
+
23
+ return applicableBusyTimes.some((busyTime) => {
24
+ const busyStart = new Date(busyTime?.startTime);
25
+ const busyEnd = new Date(busyTime?.endTime);
26
+
27
+ if (Number.isNaN(busyStart.getTime()) || Number.isNaN(busyEnd.getTime())) {
28
+ return false;
29
+ }
30
+
31
+ return slotTime > busyStart && slotTime <= busyEnd;
32
+ });
33
+ }
34
+
35
+ // ── Public API ──────────────────────────────────────────────────────────────
36
+
37
+ export function filterBusyTimesFromSchedule({
38
+ schedule = [],
39
+ busyTimes = [],
40
+ cartCategoryIds = [],
41
+ }: FilterBusyTimesFromScheduleParams): FulfillmentSchedule {
42
+ if (!Array.isArray(schedule) || schedule.length === 0) {
43
+ return [];
44
+ }
45
+
46
+ if (!Array.isArray(busyTimes) || busyTimes.length === 0) {
47
+ return schedule;
48
+ }
49
+
50
+ const uniqueCartCategoryIds = Array.isArray(cartCategoryIds)
51
+ ? Array.from(new Set(cartCategoryIds.filter(Boolean)))
52
+ : [];
53
+
54
+ const applicableBusyTimes = busyTimes.filter((busyTime) => {
55
+ const thresholdCategoryIds = busyTime?.threshold?.categoryIds || [];
56
+
57
+ if (!thresholdCategoryIds.length) {
58
+ return true;
59
+ }
60
+
61
+ if (!uniqueCartCategoryIds.length) {
62
+ return false;
63
+ }
64
+
65
+ return uniqueCartCategoryIds.some((cartCategoryId) =>
66
+ thresholdCategoryIds.includes(cartCategoryId),
67
+ );
68
+ });
69
+
70
+ if (!applicableBusyTimes.length) {
71
+ return schedule;
72
+ }
73
+
74
+ return schedule
75
+ .map((daySchedule) => {
76
+ const slots = Array.isArray(daySchedule?.slots) ? daySchedule.slots : [];
77
+ const filteredSlots = slots.filter(
78
+ (slot) => !isSlotBusy(applicableBusyTimes, slot),
79
+ );
80
+
81
+ return {
82
+ ...daySchedule,
83
+ slots: filteredSlots,
84
+ openingTime: filteredSlots[0],
85
+ closingTime: filteredSlots[filteredSlots.length - 1],
86
+ firstAvailableSlot: filteredSlots[0],
87
+ };
88
+ })
89
+ .filter((daySchedule) => (daySchedule?.slots || []).length > 0);
90
+ }
91
+
92
+ export function filterMenusFromSchedule({
93
+ schedule = [],
94
+ menus = [],
95
+ timeZone,
96
+ }: {
97
+ schedule?: FulfillmentSchedule;
98
+ menus?: MenuType[];
99
+ timeZone: string;
100
+ }): FulfillmentSchedule {
101
+ return schedule
102
+ .map((daySchedule) => ({
103
+ ...daySchedule,
104
+ slots: daySchedule.slots.filter((slot) => {
105
+ const zonedSlot = getZonedTime(slot, findTimeZone(timeZone));
106
+ const dayOfWeek = zonedSlot.dayOfWeek;
107
+
108
+ if (!menus.length) {
109
+ return true;
110
+ }
111
+
112
+ return menus.some((menu) => {
113
+ const dayScheduleConfig = menu.times[String(dayOfWeek)];
114
+ if (!dayScheduleConfig) {
115
+ return false;
116
+ }
117
+
118
+ if (dayScheduleConfig.all_day) {
119
+ return true;
120
+ }
121
+ // Only show slot if it falls within the configured time range
122
+ // Check for null start_time or end_time
123
+ if (!dayScheduleConfig.start_time || !dayScheduleConfig.end_time) {
124
+ return false;
125
+ }
126
+ return isTimeInRange(
127
+ {
128
+ start_time: dayScheduleConfig.start_time,
129
+ end_time: dayScheduleConfig.end_time,
130
+ },
131
+ {
132
+ hours: Number(zonedSlot.hours),
133
+ minutes: Number(zonedSlot.minutes),
134
+ },
135
+ );
136
+ });
137
+ }),
138
+ }))
139
+ .filter((daySchedule) => daySchedule.slots.length > 0);
140
+ }