@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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +63 -0
- package/src/constants.ts +45 -0
- package/src/index.ts +23 -0
- package/src/schedule/available-dates.ts +129 -0
- package/src/schedule/generate.ts +276 -0
- package/src/schedule/get-schedules.ts +264 -0
- package/src/schedule/location.ts +58 -0
- package/src/types/business-hours.d.ts +32 -0
- package/src/types/common.d.ts +5 -0
- package/src/types/get-schedules.d.ts +122 -0
- package/src/types/index.ts +34 -0
- package/src/types/location.d.ts +25 -0
- package/src/types/schedule-filter.d.ts +27 -0
- package/src/types/schedule.d.ts +83 -0
- package/src/types/timezone-support.d.ts +31 -0
- package/src/utils/business-hours.ts +120 -0
- package/src/utils/catering.ts +85 -0
- package/src/utils/date.ts +163 -0
- package/src/utils/schedule-filter.ts +140 -0
- package/src/utils/store-hours.ts +223 -0
- package/src/utils/time.ts +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Per Diem Subscriptions Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @perdieminc/time-slots
|
|
2
|
+
|
|
3
|
+
Generate time slots for scheduling—pickup, delivery, and curbside—with timezone-aware business hours, prep time, and optional pre-sale / catering rules.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- **Node.js** ≥ 20
|
|
8
|
+
- **TypeScript** (consumers can use the package from source or your built output)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @perdieminc/time-slots
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Overview
|
|
17
|
+
|
|
18
|
+
This library helps you:
|
|
19
|
+
|
|
20
|
+
- Build **fulfillment schedules** (days and time slots) for a given location and fulfillment type (pickup, delivery, curbside).
|
|
21
|
+
- Respect **business hours** and overrides (e.g. holidays, special hours).
|
|
22
|
+
- Apply **prep time** (per shift or per day) and **slot gaps** to compute the first available slot and generate slots.
|
|
23
|
+
- Support **pre-sale** windows (date range and optional custom hours) and **weekly pre-sale** (fixed pickup/ordering days).
|
|
24
|
+
- Support **catering** flows with cart-derived prep time (by minute, hour, or day).
|
|
25
|
+
- Filter slots by **busy times** and optional **menu/category** rules.
|
|
26
|
+
|
|
27
|
+
All date/time logic is timezone-aware (e.g. `America/New_York`). The package supports multiple platforms for timezone handling: **web** (default, uses `@date-fns/tz`) and **ios/android** (uses `timezone-support`).
|
|
28
|
+
|
|
29
|
+
## Main API
|
|
30
|
+
|
|
31
|
+
### `getSchedules(params): GetSchedulesResult`
|
|
32
|
+
|
|
33
|
+
Builds the schedule for the current location and fulfillment preference.
|
|
34
|
+
|
|
35
|
+
**Parameters (`GetSchedulesParams`):**
|
|
36
|
+
|
|
37
|
+
| Field | Description |
|
|
38
|
+
|-------|-------------|
|
|
39
|
+
| `store` | Store config: ASAP/same-day flags, max future days, business hour overrides, pre-sale and weekly pre-sale config. |
|
|
40
|
+
| `locations` | List of locations (with `location_id`, `timezone`, and business hours). |
|
|
41
|
+
| `cartItems` | Cart items (used for pre-sale, weekly pre-sale, catering prep time, and category-based filtering). |
|
|
42
|
+
| `fulfillmentPreference` | `"PICKUP"` \| `"DELIVERY"` \| `"CURBSIDE"`. |
|
|
43
|
+
| `prepTimeSettings` | Prep time in minutes, per-weekday overrides, gap, busy times, cadence (minute/hour/day), frequency, and optional delivery buffer. |
|
|
44
|
+
| `currentLocation` | The location to generate the schedule for. |
|
|
45
|
+
| `isCateringFlow` | If `true`, prep time is derived from cart catering config. |
|
|
46
|
+
| `platform` | `"web"` \| `"ios"` \| `"android"` for timezone handling (default `"web"`). |
|
|
47
|
+
|
|
48
|
+
**Returns:** `{ schedule: FulfillmentSchedule, isWeeklyPreSaleAvailable: boolean }`.
|
|
49
|
+
|
|
50
|
+
- **`schedule`** is an array of **day schedules**, each with `date`, `openingTime`, `closingTime`, `firstAvailableSlot`, and `slots` (array of `Date`).
|
|
51
|
+
|
|
52
|
+
### Types and constants
|
|
53
|
+
|
|
54
|
+
- **Fulfillment:** `FULFILLMENT_TYPES`, `FulfillmentType`, `FulfillmentSchedule`, `DaySchedule`.
|
|
55
|
+
- **Prep time:** `PrepTimeBehaviour` (first shift / every shift / roll), `DEFAULT_PREP_TIME_IN_MINUTES`, `DEFAULT_GAP_IN_MINUTES`, `PrepTimeSettings`, `PrepTimeCadence` (minute / hour / day).
|
|
56
|
+
- **Store / cart:** `StoreConfig`, `PreSaleConfig`, `WeeklyPreSaleConfig`, `CartItem`, `PrepTimeSettings`, `CateringPrepTimeResult`.
|
|
57
|
+
- **Location / hours:** `LocationLike`, `BusinessHour`, `BusinessHoursOverrideInput` / `Output`, `getLocationsBusinessHoursOverrides`, `getOpeningClosingTime`.
|
|
58
|
+
- **Platform:** `PLATFORM` (web, ios, android).
|
|
59
|
+
|
|
60
|
+
## Utilities (exported)
|
|
61
|
+
|
|
62
|
+
- **`getCateringPrepTimeConfig(params)`** – Derives prep time cadence and frequency from cart items for catering.
|
|
63
|
+
- **`getPreSalePickupDates(pickupDays, orderingDays)`** – Dates when weekly pre-sale pickup is allowed.
|
|
64
|
+
- **`isTodayInTimeZone(date, timezone)`** / **`isTomorrowInTimeZone(date, timezone)`** – Date checks in a given timezone.
|
|
65
|
+
- **`overrideTimeZoneOnUTC(utcDate, timezone)`** – Interpret a UTC date in a store timezone.
|
|
66
|
+
- **`filterBusyTimesFromSchedule({ schedule, busyTimes, cartCategoryIds })`** – Remove busy blocks from a schedule.
|
|
67
|
+
- **`filterMenusFromSchedule`** – Filter schedule by menu type.
|
|
68
|
+
- **`getOpeningClosingTime(params)`** – Opening/closing time for a given date and business hours.
|
|
69
|
+
|
|
70
|
+
Internal schedule generation uses **`getNextAvailableDates`**-style logic (timezone-aware “next N open days”) and slot generation with configurable prep time behaviour and gap.
|
|
71
|
+
|
|
72
|
+
## Prep time (high level)
|
|
73
|
+
|
|
74
|
+
Prep time can be applied in different ways (see `PrepTimeBehaviour` and `PrepTimeSettings`):
|
|
75
|
+
|
|
76
|
+
- **Cadence:** by **minute**, **hour**, or **day** (e.g. “first slot after 2 days”).
|
|
77
|
+
- **Per weekday:** different prep minutes per day via `weekDayPrepTimes`.
|
|
78
|
+
- **Catering:** when `isCateringFlow` is true, cadence and frequency are derived from cart items via `getCateringPrepTimeConfig`.
|
|
79
|
+
- **Delivery:** optional `estimatedDeliveryMinutes` added to weekday prep times for delivery.
|
|
80
|
+
|
|
81
|
+
Detailed behaviour and edge cases are covered by the test suite. QA-friendly test cases (Given / When / Expected) are in [docs/TEST-CASES.md](docs/TEST-CASES.md).
|
|
82
|
+
|
|
83
|
+
## Scripts
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm run build # Compile TypeScript
|
|
87
|
+
npm run test # Run tests
|
|
88
|
+
npm run test:coverage
|
|
89
|
+
npm run lint # Lint with Biome
|
|
90
|
+
npm run format # Format with Biome
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT © Per Diem Subscriptions Inc.
|
|
96
|
+
|
|
97
|
+
## Repository
|
|
98
|
+
|
|
99
|
+
- **Homepage:** [perdiem-pkgs](https://github.com/PerDiemInc/perdiem-pkgs#readme)
|
|
100
|
+
- **Issues:** [perdiem-pkgs/issues](https://github.com/PerDiemInc/perdiem-pkgs/issues)
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@perdieminc/time-slots",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Generate time slots for scheduling",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"time-slots",
|
|
7
|
+
"schedule",
|
|
8
|
+
"slots"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Per Diem Subscriptions Inc.",
|
|
12
|
+
"homepage": "https://github.com/PerDiemInc/perdiem-pkgs#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/PerDiemInc/perdiem-pkgs/issues"
|
|
15
|
+
},
|
|
16
|
+
"main": "src/index.ts",
|
|
17
|
+
"types": "src/index.ts",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/PerDiemInc/perdiem-pkgs.git"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"lint": "biome lint .",
|
|
27
|
+
"lint:fix": "biome lint --write .",
|
|
28
|
+
"format": "biome check --write .",
|
|
29
|
+
"clean": "rm -rf lib types coverage",
|
|
30
|
+
"build": "tsc -b .",
|
|
31
|
+
"build:watch": "tsc --watch",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:coverage": "vitest run --coverage",
|
|
34
|
+
"coveralls": "vitest run --coverage",
|
|
35
|
+
"test:watch": "vitest"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@date-fns/tz": "^1.4.1",
|
|
39
|
+
"date-fns": "^4.1.0",
|
|
40
|
+
"date-fns-tz": "^3.2.0",
|
|
41
|
+
"timezone-support": "^2.2.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@biomejs/biome": "2.3.11",
|
|
45
|
+
"@types/node": "^20.19.27",
|
|
46
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
47
|
+
"husky": "^9.1.7",
|
|
48
|
+
"lint-staged": "^16.2.7",
|
|
49
|
+
"tsx": "^4.21.0",
|
|
50
|
+
"typescript": "^5.9.3",
|
|
51
|
+
"vitest": "^3.2.4"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"src"
|
|
55
|
+
],
|
|
56
|
+
"directories": {
|
|
57
|
+
"lib": "lib",
|
|
58
|
+
"test": "test"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fulfillment types for pickup, delivery, curbside.
|
|
3
|
+
*/
|
|
4
|
+
export const FULFILLMENT_TYPES = {
|
|
5
|
+
PICKUP: "PICKUP",
|
|
6
|
+
DELIVERY: "DELIVERY",
|
|
7
|
+
CURBSIDE: "CURBSIDE",
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_TIMEZONE = "America/New_York";
|
|
11
|
+
export type FulfillmentType =
|
|
12
|
+
(typeof FULFILLMENT_TYPES)[keyof typeof FULFILLMENT_TYPES];
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_GAP_IN_MINUTES = 15;
|
|
15
|
+
export const DEFAULT_PREP_TIME_IN_MINUTES = 5;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Prep time behaviour when computing first available slot.
|
|
19
|
+
*/
|
|
20
|
+
export const PrepTimeBehaviour = Object.freeze({
|
|
21
|
+
FIRST_SHIFT: 0,
|
|
22
|
+
EVERY_SHIFT: 1,
|
|
23
|
+
ROLL_FROM_FIRST_SHIFT: 2,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type PrepTimeBehaviourType =
|
|
27
|
+
(typeof PrepTimeBehaviour)[keyof typeof PrepTimeBehaviour];
|
|
28
|
+
|
|
29
|
+
export const PREP_TIME_CADENCE = {
|
|
30
|
+
MINUTE: "minute",
|
|
31
|
+
DAY: "day",
|
|
32
|
+
HOUR: "hour",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
export type PrepTimeCadence =
|
|
36
|
+
(typeof PREP_TIME_CADENCE)[keyof typeof PREP_TIME_CADENCE];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Platform for timezone handling (web uses @date-fns/tz; ios/android use timezone-support).
|
|
40
|
+
*/
|
|
41
|
+
export const PLATFORM = {
|
|
42
|
+
WEB: "web",
|
|
43
|
+
IOS: "ios",
|
|
44
|
+
ANDROID: "android",
|
|
45
|
+
} as const;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type { FulfillmentType, PrepTimeBehaviourType } from "./constants";
|
|
2
|
+
export {
|
|
3
|
+
DEFAULT_GAP_IN_MINUTES,
|
|
4
|
+
DEFAULT_PREP_TIME_IN_MINUTES,
|
|
5
|
+
FULFILLMENT_TYPES,
|
|
6
|
+
PLATFORM,
|
|
7
|
+
PrepTimeBehaviour,
|
|
8
|
+
} from "./constants";
|
|
9
|
+
export { getSchedules } from "./schedule/get-schedules";
|
|
10
|
+
export * from "./types";
|
|
11
|
+
export { getLocationsBusinessHoursOverrides } from "./utils/business-hours";
|
|
12
|
+
export { getCateringPrepTimeConfig } from "./utils/catering";
|
|
13
|
+
export {
|
|
14
|
+
getPreSalePickupDates,
|
|
15
|
+
isTodayInTimeZone,
|
|
16
|
+
isTomorrowInTimeZone,
|
|
17
|
+
overrideTimeZoneOnUTC,
|
|
18
|
+
} from "./utils/date";
|
|
19
|
+
export {
|
|
20
|
+
filterBusyTimesFromSchedule,
|
|
21
|
+
filterMenusFromSchedule,
|
|
22
|
+
} from "./utils/schedule-filter";
|
|
23
|
+
export { getOpeningClosingTime } from "./utils/store-hours";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { tz } from "@date-fns/tz";
|
|
2
|
+
import { isAfter, isSameDay, startOfDay } from "date-fns";
|
|
3
|
+
import { findTimeZone, getUnixTime, getZonedTime } from "timezone-support";
|
|
4
|
+
|
|
5
|
+
import { PLATFORM } from "../constants";
|
|
6
|
+
import type { GetNextAvailableDatesParams, Platform } from "../types";
|
|
7
|
+
import { addDaysInTimeZone, setHmOnDate } from "../utils/date";
|
|
8
|
+
|
|
9
|
+
function getStartOfDayInZone(
|
|
10
|
+
startDate: Date,
|
|
11
|
+
timeZone: string,
|
|
12
|
+
platform: Platform,
|
|
13
|
+
): Date {
|
|
14
|
+
if (platform !== PLATFORM.ANDROID) {
|
|
15
|
+
return startOfDay(startDate, { in: tz(timeZone) });
|
|
16
|
+
}
|
|
17
|
+
const zoned = getZonedTime(startDate, findTimeZone(timeZone));
|
|
18
|
+
return new Date(
|
|
19
|
+
getUnixTime({
|
|
20
|
+
...zoned,
|
|
21
|
+
hours: 0,
|
|
22
|
+
minutes: 0,
|
|
23
|
+
seconds: 0,
|
|
24
|
+
milliseconds: 0,
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getNextAvailableDates({
|
|
30
|
+
startDate,
|
|
31
|
+
timeZone,
|
|
32
|
+
businessHours,
|
|
33
|
+
businessHoursOverrides = [],
|
|
34
|
+
datesCount = 1,
|
|
35
|
+
preSaleDates = [],
|
|
36
|
+
presalePickupWeekDays = [],
|
|
37
|
+
endDate = null,
|
|
38
|
+
isDaysCadence = false,
|
|
39
|
+
platform = PLATFORM.WEB,
|
|
40
|
+
}: GetNextAvailableDatesParams): Date[] {
|
|
41
|
+
const startOfDayInZone = getStartOfDayInZone(startDate, timeZone, platform);
|
|
42
|
+
const zonedStartTime = getZonedTime(startOfDayInZone, findTimeZone(timeZone));
|
|
43
|
+
|
|
44
|
+
const dates: Date[] = [];
|
|
45
|
+
|
|
46
|
+
for (
|
|
47
|
+
let date = new Date(
|
|
48
|
+
getUnixTime({
|
|
49
|
+
...zonedStartTime,
|
|
50
|
+
hours: 0,
|
|
51
|
+
minutes: 0,
|
|
52
|
+
seconds: 0,
|
|
53
|
+
milliseconds: 0,
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
maxRuns = 0;
|
|
57
|
+
dates.length < datesCount && maxRuns <= 30;
|
|
58
|
+
date = addDaysInTimeZone(date, 1, timeZone), maxRuns += 1
|
|
59
|
+
) {
|
|
60
|
+
const lastDate = dates?.at(-1);
|
|
61
|
+
if (lastDate && isSameDay(lastDate, date)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (endDate && isAfter(date, endDate)) {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (date.getTime() < getUnixTime(zonedStartTime)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const zonedDate = getZonedTime(date, findTimeZone(timeZone));
|
|
74
|
+
const dayOfWeek = zonedDate.dayOfWeek ?? 0;
|
|
75
|
+
|
|
76
|
+
const todayBusinessHoursOverride = businessHoursOverrides.filter(
|
|
77
|
+
(override) =>
|
|
78
|
+
zonedDate.month === override.month && zonedDate.day === override.day,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const closedBusinessHoursOverride = todayBusinessHoursOverride.filter(
|
|
82
|
+
(override) => !override.startTime && !override.endTime,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (closedBusinessHoursOverride.length) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const todayBusinessHours = businessHours.filter(
|
|
90
|
+
(bh) => bh.day === dayOfWeek,
|
|
91
|
+
);
|
|
92
|
+
// If days cadence, we dont need to skip date even if it is after the last shift end time
|
|
93
|
+
if (isDaysCadence) {
|
|
94
|
+
const lastShiftEndTime = todayBusinessHours.at(-1)?.endTime;
|
|
95
|
+
const shiftEndDate = lastShiftEndTime
|
|
96
|
+
? setHmOnDate(date, lastShiftEndTime, timeZone)
|
|
97
|
+
: null;
|
|
98
|
+
/**
|
|
99
|
+
* Skip current date if current time is after the last shift end time
|
|
100
|
+
*/
|
|
101
|
+
if (shiftEndDate && isAfter(startDate, shiftEndDate)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Skip if today is closed by location hours or by override hours
|
|
107
|
+
*/
|
|
108
|
+
if (
|
|
109
|
+
!todayBusinessHours?.length &&
|
|
110
|
+
!todayBusinessHoursOverride.length &&
|
|
111
|
+
!endDate
|
|
112
|
+
) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (preSaleDates.length && presalePickupWeekDays.length) {
|
|
117
|
+
if (
|
|
118
|
+
preSaleDates.includes(zonedDate.day) &&
|
|
119
|
+
presalePickupWeekDays.includes(dayOfWeek)
|
|
120
|
+
) {
|
|
121
|
+
dates.push(date);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
dates.push(date);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return dates;
|
|
129
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addMinutes,
|
|
3
|
+
compareAsc,
|
|
4
|
+
eachMinuteOfInterval,
|
|
5
|
+
isAfter,
|
|
6
|
+
isBefore,
|
|
7
|
+
max,
|
|
8
|
+
} from "date-fns";
|
|
9
|
+
import { findTimeZone, getZonedTime } from "timezone-support";
|
|
10
|
+
import type { PrepTimeBehaviourType } from "../constants";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_PREP_TIME_IN_MINUTES,
|
|
13
|
+
PREP_TIME_CADENCE,
|
|
14
|
+
PrepTimeBehaviour,
|
|
15
|
+
} from "../constants";
|
|
16
|
+
import type {
|
|
17
|
+
BusinessHour,
|
|
18
|
+
BusinessHoursOverrideOutput,
|
|
19
|
+
DaySchedule,
|
|
20
|
+
GenerateScheduleParams,
|
|
21
|
+
} from "../types";
|
|
22
|
+
import {
|
|
23
|
+
isTodayInTimeZone,
|
|
24
|
+
isZeroPrepTimeForMidnightShift,
|
|
25
|
+
setHmOnDate,
|
|
26
|
+
} from "../utils/date";
|
|
27
|
+
|
|
28
|
+
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
interface GetSelectedBusinessHoursParams {
|
|
31
|
+
businessHours?: BusinessHour[];
|
|
32
|
+
businessHoursOverrides?: BusinessHoursOverrideOutput[];
|
|
33
|
+
date?: Date;
|
|
34
|
+
timeZone?: string;
|
|
35
|
+
preSaleHoursOverride?: Array<{
|
|
36
|
+
startTime: string;
|
|
37
|
+
endTime: string;
|
|
38
|
+
month?: number;
|
|
39
|
+
day?: number;
|
|
40
|
+
}> | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getSelectedBusinessHours({
|
|
44
|
+
businessHours = [],
|
|
45
|
+
businessHoursOverrides = [],
|
|
46
|
+
date,
|
|
47
|
+
timeZone,
|
|
48
|
+
preSaleHoursOverride,
|
|
49
|
+
}: GetSelectedBusinessHoursParams): [
|
|
50
|
+
BusinessHour[],
|
|
51
|
+
ReturnType<typeof getZonedTime>,
|
|
52
|
+
] {
|
|
53
|
+
if (!date || !timeZone) {
|
|
54
|
+
return [[], getZonedTime(new Date(), findTimeZone(timeZone ?? "UTC"))];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const zonedDate = getZonedTime(date, findTimeZone(timeZone));
|
|
58
|
+
const dayOfWeek = zonedDate.dayOfWeek ?? 0;
|
|
59
|
+
|
|
60
|
+
const dayBusinessHours = businessHours?.filter((bh) => bh.day === dayOfWeek);
|
|
61
|
+
|
|
62
|
+
const businessHoursOverride = businessHoursOverrides?.filter(
|
|
63
|
+
(override) =>
|
|
64
|
+
zonedDate.month === override.month && zonedDate.day === override.day,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const selectedBusinessHours: BusinessHour[] = preSaleHoursOverride
|
|
68
|
+
? preSaleHoursOverride.map((o) => ({
|
|
69
|
+
day: dayOfWeek,
|
|
70
|
+
startTime: o.startTime,
|
|
71
|
+
endTime: o.endTime,
|
|
72
|
+
}))
|
|
73
|
+
: businessHoursOverride.length
|
|
74
|
+
? businessHoursOverride.map((o) => ({
|
|
75
|
+
day: dayOfWeek,
|
|
76
|
+
startTime: o.startTime ?? "00:00",
|
|
77
|
+
endTime: o.endTime ?? "23:59",
|
|
78
|
+
}))
|
|
79
|
+
: (dayBusinessHours ?? []);
|
|
80
|
+
|
|
81
|
+
return [selectedBusinessHours, zonedDate];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export function generateSchedule({
|
|
87
|
+
currentDate = new Date(),
|
|
88
|
+
prepTimeBehaviour = PrepTimeBehaviour.ROLL_FROM_FIRST_SHIFT,
|
|
89
|
+
weekDayPrepTimes = {},
|
|
90
|
+
timeZone,
|
|
91
|
+
dates = [],
|
|
92
|
+
businessHours = [],
|
|
93
|
+
businessHoursOverrides = [],
|
|
94
|
+
preSaleHoursOverride,
|
|
95
|
+
gapInMinutes = 15,
|
|
96
|
+
prepTimeCadence = null,
|
|
97
|
+
}: GenerateScheduleParams): DaySchedule[] {
|
|
98
|
+
const isMinutesCadence = prepTimeCadence === PREP_TIME_CADENCE.MINUTE;
|
|
99
|
+
let shiftStartDateWithPrepTime: Date | null = null;
|
|
100
|
+
return dates
|
|
101
|
+
.map((date, index) => {
|
|
102
|
+
const lastDate = dates?.[index - 1];
|
|
103
|
+
|
|
104
|
+
const [selectedBusinessHours, zonedDate] = getSelectedBusinessHours({
|
|
105
|
+
businessHours,
|
|
106
|
+
businessHoursOverrides,
|
|
107
|
+
date,
|
|
108
|
+
timeZone,
|
|
109
|
+
preSaleHoursOverride,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const [prevSelectedBusinessHours] = getSelectedBusinessHours({
|
|
113
|
+
businessHours,
|
|
114
|
+
businessHoursOverrides,
|
|
115
|
+
date: lastDate,
|
|
116
|
+
timeZone,
|
|
117
|
+
preSaleHoursOverride,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const weekDayPrepTime =
|
|
121
|
+
weekDayPrepTimes[zonedDate.dayOfWeek] ?? DEFAULT_PREP_TIME_IN_MINUTES;
|
|
122
|
+
|
|
123
|
+
const storeTimes = {
|
|
124
|
+
openingTime: null as Date | null,
|
|
125
|
+
closingTime: null as Date | null,
|
|
126
|
+
remainingShifts: 0,
|
|
127
|
+
totalShifts: 0,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
let isPrevDayMidnightTransition = false;
|
|
131
|
+
|
|
132
|
+
const slots = selectedBusinessHours
|
|
133
|
+
.flatMap((businessHour, i) => {
|
|
134
|
+
const startDate = setHmOnDate(date, businessHour.startTime, timeZone);
|
|
135
|
+
const shiftStartDate =
|
|
136
|
+
isMinutesCadence && shiftStartDateWithPrepTime
|
|
137
|
+
? max([shiftStartDateWithPrepTime, startDate])
|
|
138
|
+
: startDate;
|
|
139
|
+
const shiftEndDate = setHmOnDate(
|
|
140
|
+
date,
|
|
141
|
+
businessHour.endTime,
|
|
142
|
+
timeZone,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (i === 0) {
|
|
146
|
+
storeTimes.openingTime = shiftStartDate;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (i === selectedBusinessHours.length - 1) {
|
|
150
|
+
storeTimes.closingTime = shiftEndDate;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!isBefore(shiftStartDate, shiftEndDate)) {
|
|
154
|
+
if (isMinutesCadence) {
|
|
155
|
+
shiftStartDateWithPrepTime = null;
|
|
156
|
+
}
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
storeTimes.totalShifts += 1;
|
|
161
|
+
|
|
162
|
+
const fixedSlots = eachMinuteOfInterval(
|
|
163
|
+
{ start: shiftStartDate, end: shiftEndDate },
|
|
164
|
+
{ step: gapInMinutes },
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (isTodayInTimeZone(date, timeZone)) {
|
|
168
|
+
const openingTime = storeTimes.openingTime ?? new Date(0);
|
|
169
|
+
const baseDate =
|
|
170
|
+
currentDate instanceof Date ? currentDate : new Date(currentDate);
|
|
171
|
+
const currentDateWithPrepTime = addMinutes(
|
|
172
|
+
new Date(Math.max(baseDate.getTime(), openingTime.getTime())),
|
|
173
|
+
Math.max(DEFAULT_PREP_TIME_IN_MINUTES, weekDayPrepTime),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (isAfter(currentDateWithPrepTime, shiftEndDate)) {
|
|
177
|
+
// If the prep time cadence is minutes, we need to set the shift start date with the prep time
|
|
178
|
+
if (isMinutesCadence) {
|
|
179
|
+
shiftStartDateWithPrepTime = currentDateWithPrepTime;
|
|
180
|
+
}
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (isBefore(currentDateWithPrepTime, shiftStartDate)) {
|
|
185
|
+
storeTimes.remainingShifts += 1;
|
|
186
|
+
|
|
187
|
+
if (
|
|
188
|
+
(prepTimeBehaviour as PrepTimeBehaviourType) ===
|
|
189
|
+
PrepTimeBehaviour.EVERY_SHIFT
|
|
190
|
+
) {
|
|
191
|
+
const shiftStartDateWithPrepTime = addMinutes(
|
|
192
|
+
shiftStartDate,
|
|
193
|
+
weekDayPrepTime,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const slotDates = fixedSlots.filter((d) =>
|
|
197
|
+
isAfter(d, shiftStartDateWithPrepTime),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
slotDates.unshift(shiftStartDateWithPrepTime);
|
|
201
|
+
return slotDates;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return fixedSlots;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const slotDates = fixedSlots.filter((d) =>
|
|
208
|
+
isAfter(d, currentDateWithPrepTime),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
slotDates.unshift(currentDateWithPrepTime);
|
|
212
|
+
storeTimes.remainingShifts += 1;
|
|
213
|
+
return slotDates;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (prepTimeBehaviour === PrepTimeBehaviour.FIRST_SHIFT && i !== 0) {
|
|
217
|
+
storeTimes.remainingShifts += 1;
|
|
218
|
+
return fixedSlots;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const allowZeroPrepTimeForMidnightShift =
|
|
222
|
+
isZeroPrepTimeForMidnightShift({
|
|
223
|
+
prevDayBusinessHours: prevSelectedBusinessHours,
|
|
224
|
+
businessHour,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const prepTimeSlot = addMinutes(
|
|
228
|
+
prepTimeBehaviour === PrepTimeBehaviour.ROLL_FROM_FIRST_SHIFT &&
|
|
229
|
+
!isPrevDayMidnightTransition &&
|
|
230
|
+
storeTimes.openingTime
|
|
231
|
+
? storeTimes.openingTime
|
|
232
|
+
: shiftStartDate,
|
|
233
|
+
allowZeroPrepTimeForMidnightShift ? 0 : weekDayPrepTime,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
isPrevDayMidnightTransition = allowZeroPrepTimeForMidnightShift;
|
|
237
|
+
|
|
238
|
+
if (prepTimeSlot > shiftEndDate) {
|
|
239
|
+
if (isMinutesCadence) {
|
|
240
|
+
shiftStartDateWithPrepTime = prepTimeSlot;
|
|
241
|
+
}
|
|
242
|
+
shiftStartDateWithPrepTime = prepTimeSlot;
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (prepTimeSlot < shiftStartDate) {
|
|
247
|
+
storeTimes.remainingShifts += 1;
|
|
248
|
+
return fixedSlots;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const slotDates = fixedSlots.filter((d) => isAfter(d, prepTimeSlot));
|
|
252
|
+
|
|
253
|
+
slotDates.unshift(prepTimeSlot);
|
|
254
|
+
storeTimes.remainingShifts += 1;
|
|
255
|
+
shiftStartDateWithPrepTime = null; //reset the shift start date with prep time
|
|
256
|
+
return slotDates;
|
|
257
|
+
})
|
|
258
|
+
.sort(compareAsc);
|
|
259
|
+
|
|
260
|
+
const currentDateMs =
|
|
261
|
+
currentDate instanceof Date ? currentDate.getTime() : currentDate;
|
|
262
|
+
const availableSlots = slots.filter((d) => d.getTime() >= currentDateMs);
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
date,
|
|
266
|
+
originalStoreOpeningTime: storeTimes.openingTime,
|
|
267
|
+
originalStoreClosingTime: storeTimes.closingTime,
|
|
268
|
+
remainingShifts: storeTimes.remainingShifts,
|
|
269
|
+
openingTime: slots[0],
|
|
270
|
+
closingTime: slots[slots.length - 1],
|
|
271
|
+
firstAvailableSlot: availableSlots[0],
|
|
272
|
+
slots: availableSlots,
|
|
273
|
+
};
|
|
274
|
+
})
|
|
275
|
+
.filter((a) => a.slots.length);
|
|
276
|
+
}
|