@blamejs/blamejs-shop 0.0.64 → 0.0.66
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/CHANGELOG.md +4 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/business-hours.js +980 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +30 -0
- package/lib/metered-usage.js +782 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/preorder.js +595 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/site-redirects.js +690 -0
- package/lib/split-shipments.js +773 -0
- package/lib/theme-assets.js +711 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.businessHours
|
|
4
|
+
* @title Business hours — operator-defined open/close windows per
|
|
5
|
+
* location or tenant
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* The storefront, support-routing layer, and "we're closed" banner
|
|
9
|
+
* all ask the same three questions of an operator-authored schedule:
|
|
10
|
+
*
|
|
11
|
+
* - "Are we open right now?" (`isOpenAt`)
|
|
12
|
+
* - "If not, when do we re-open?" (`nextOpenAt`)
|
|
13
|
+
* - "If yes, when do we close?" (`nextCloseAt`)
|
|
14
|
+
*
|
|
15
|
+
* A schedule has three layered parts:
|
|
16
|
+
*
|
|
17
|
+
* 1. `weekly_hours` — base array of `{ day, open, close }` entries.
|
|
18
|
+
* `day` is 0..6 with Sun=0; `open` / `close` are `HH:MM` 24-hour
|
|
19
|
+
* strings in the schedule's IANA `timezone`. Days absent from
|
|
20
|
+
* the array are closed by default. Multiple entries for the
|
|
21
|
+
* same day are allowed (split shift — open mornings + evenings,
|
|
22
|
+
* closed at lunch).
|
|
23
|
+
*
|
|
24
|
+
* 2. Holidays — full-day closures attached to a specific calendar
|
|
25
|
+
* date (YYYY-MM-DD in the schedule's timezone). Suppress the
|
|
26
|
+
* regular weekly_hours for that weekday. Authored once a year.
|
|
27
|
+
*
|
|
28
|
+
* 3. Exceptions — per-date overrides that adjust open/close for a
|
|
29
|
+
* specific day without removing it (early-close for a private
|
|
30
|
+
* event, late-open after maintenance, fully-closed one-off).
|
|
31
|
+
* When `closed = true` the day is treated as closed regardless;
|
|
32
|
+
* otherwise non-null `open` / `close` columns REPLACE the
|
|
33
|
+
* weekly_hours window for that date (null inherits the base).
|
|
34
|
+
*
|
|
35
|
+
* All date math is calendar-day-deterministic against the schedule's
|
|
36
|
+
* IANA timezone via `Intl.DateTimeFormat` — no manual offset
|
|
37
|
+
* bookkeeping, no DST drift. The `when` input is epoch-ms; the
|
|
38
|
+
* `nextOpenAt` / `nextCloseAt` return epoch-ms so the caller can
|
|
39
|
+
* format for any locale without re-doing the timezone math.
|
|
40
|
+
*
|
|
41
|
+
* Monotonic clock pattern: every primitive that reads "now" accepts
|
|
42
|
+
* an injectable `when` (epoch-ms) so tests can pin a moment without
|
|
43
|
+
* stubbing the global clock; live callers omit `when` and the
|
|
44
|
+
* primitive falls back to `Date.now()`.
|
|
45
|
+
*
|
|
46
|
+
* Composes:
|
|
47
|
+
* - Pure JS + `Intl.DateTimeFormat` for tz-correct calendar math.
|
|
48
|
+
* No blamejs primitives are required for this module — the
|
|
49
|
+
* storage layer is the same query-injection shape every shop
|
|
50
|
+
* primitive uses (`opts.query` -> framework `externalDb.query`).
|
|
51
|
+
*
|
|
52
|
+
* Surface:
|
|
53
|
+
* defineSchedule({ slug, timezone, weekly_hours, holidays?, exceptions? })
|
|
54
|
+
* isOpenAt({ slug, when? })
|
|
55
|
+
* nextOpenAt({ slug, when? })
|
|
56
|
+
* nextCloseAt({ slug, when? })
|
|
57
|
+
* weekSummary({ slug })
|
|
58
|
+
* addException({ slug, date, open?, close?, closed? })
|
|
59
|
+
* removeException({ slug, date })
|
|
60
|
+
* addHoliday({ slug, date, name })
|
|
61
|
+
* listSchedules({ include_archived? })
|
|
62
|
+
* archiveSchedule(slug)
|
|
63
|
+
*
|
|
64
|
+
* Storage:
|
|
65
|
+
* - `business_hour_schedules` + `business_hour_exceptions`
|
|
66
|
+
* (migration `0127_business_hours.sql`).
|
|
67
|
+
*
|
|
68
|
+
* @primitive businessHours
|
|
69
|
+
* @related shop.deliveryEstimate
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
var MAX_SLUG_LEN = 64;
|
|
73
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
74
|
+
|
|
75
|
+
var TZ_RE = /^[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+){1,2}$/;
|
|
76
|
+
|
|
77
|
+
var HHMM_RE = /^([01][0-9]|2[0-3]):([0-5][0-9])$/;
|
|
78
|
+
|
|
79
|
+
var YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
80
|
+
|
|
81
|
+
var MAX_WEEKLY_HOURS = 64; // 7 days x 8 split-shifts each is more than any real schedule needs
|
|
82
|
+
var MAX_NAME_LEN = 200;
|
|
83
|
+
|
|
84
|
+
var DAY_MS = 24 * 60 * 60 * 1000;
|
|
85
|
+
var SEARCH_DAYS = 366 * 2; // worst-case "find next open" walk — 2 calendar years of closures
|
|
86
|
+
|
|
87
|
+
// ---- validators ---------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
function _hasControlByte(s) {
|
|
90
|
+
for (var i = 0; i < s.length; i += 1) {
|
|
91
|
+
var cc = s.charCodeAt(i);
|
|
92
|
+
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _slug(s) {
|
|
98
|
+
if (typeof s !== "string" || !s.length) {
|
|
99
|
+
throw new TypeError("businessHours: slug must be a non-empty string");
|
|
100
|
+
}
|
|
101
|
+
if (s.length > MAX_SLUG_LEN) {
|
|
102
|
+
throw new TypeError("businessHours: slug must be <= " + MAX_SLUG_LEN + " characters");
|
|
103
|
+
}
|
|
104
|
+
if (!SLUG_RE.test(s)) {
|
|
105
|
+
throw new TypeError(
|
|
106
|
+
"businessHours: slug must match /^[a-z0-9][a-z0-9_-]{0,63}$/ — " +
|
|
107
|
+
"lowercase alphanumerics with `_`/`-`, must not start with separator"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return s;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _tz(s, label) {
|
|
114
|
+
if (typeof s !== "string" || !TZ_RE.test(s)) {
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
"businessHours: " + label + " must be an IANA timezone name (Area/City)"
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
// Semantic gate — Intl throws on an unknown zone (typo at config
|
|
120
|
+
// time surfaces loud).
|
|
121
|
+
try {
|
|
122
|
+
new Intl.DateTimeFormat("en-US", { timeZone: s });
|
|
123
|
+
} catch (_e) {
|
|
124
|
+
throw new TypeError(
|
|
125
|
+
"businessHours: " + label + " is not a known IANA timezone (got " + JSON.stringify(s) + ")"
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return s;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _hhmm(s, label) {
|
|
132
|
+
if (typeof s !== "string" || !HHMM_RE.test(s)) {
|
|
133
|
+
throw new TypeError(
|
|
134
|
+
"businessHours: " + label + " must be a HH:MM 24-hour string (got " + JSON.stringify(s) + ")"
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return s;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _day(n, label) {
|
|
141
|
+
if (!Number.isInteger(n) || n < 0 || n > 6) {
|
|
142
|
+
throw new TypeError("businessHours: " + label + " must be an integer 0..6 (Sun=0)");
|
|
143
|
+
}
|
|
144
|
+
return n;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _ymd(s, label) {
|
|
148
|
+
if (typeof s !== "string" || !YMD_RE.test(s)) {
|
|
149
|
+
throw new TypeError("businessHours: " + label + " must be a YYYY-MM-DD string");
|
|
150
|
+
}
|
|
151
|
+
var y = Number(s.slice(0, 4));
|
|
152
|
+
var mo = Number(s.slice(5, 7));
|
|
153
|
+
var d = Number(s.slice(8, 10));
|
|
154
|
+
// Round-trip via Date.UTC — refuses Feb 30, month 13, etc.
|
|
155
|
+
var ts = Date.UTC(y, mo - 1, d);
|
|
156
|
+
var dt = new Date(ts);
|
|
157
|
+
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== (mo - 1) || dt.getUTCDate() !== d) {
|
|
158
|
+
throw new TypeError("businessHours: " + label + " — not a real calendar date");
|
|
159
|
+
}
|
|
160
|
+
return s;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _name(s, label) {
|
|
164
|
+
if (typeof s !== "string" || !s.length) {
|
|
165
|
+
throw new TypeError("businessHours: " + label + " must be a non-empty string");
|
|
166
|
+
}
|
|
167
|
+
if (s.length > MAX_NAME_LEN) {
|
|
168
|
+
throw new TypeError("businessHours: " + label + " must be <= " + MAX_NAME_LEN + " characters");
|
|
169
|
+
}
|
|
170
|
+
if (_hasControlByte(s)) {
|
|
171
|
+
throw new TypeError("businessHours: " + label + " must not contain control characters");
|
|
172
|
+
}
|
|
173
|
+
return s;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _epochMs(n, label) {
|
|
177
|
+
if (!Number.isInteger(n)) {
|
|
178
|
+
throw new TypeError("businessHours: " + label + " must be an integer epoch-ms");
|
|
179
|
+
}
|
|
180
|
+
return n;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Convert HH:MM into minutes-since-midnight (0..1439). 24:00 is NOT
|
|
184
|
+
// accepted as an alias for midnight — operators use 23:59 if they
|
|
185
|
+
// want "open until the very end of the day" and the lookup paths
|
|
186
|
+
// treat close-after-open as a normal window. Closed-day = absent
|
|
187
|
+
// row, not zero-length window.
|
|
188
|
+
function _hhmmToMinutes(s) {
|
|
189
|
+
var hh = Number(s.slice(0, 2));
|
|
190
|
+
var mm = Number(s.slice(3, 5));
|
|
191
|
+
return hh * 60 + mm;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Validate one weekly-hours row. Refuses close <= open (zero-length
|
|
195
|
+
// or inverted windows). Operators with overnight-open hours (bar
|
|
196
|
+
// 18:00 -> 02:00) author the row as two entries: day N 18:00-23:59
|
|
197
|
+
// and day N+1 00:00-02:00. Cross-midnight windows are intentionally
|
|
198
|
+
// not a single-row concept — the storefront banner has to know which
|
|
199
|
+
// calendar day owns "right now" and the two-row form makes that
|
|
200
|
+
// unambiguous.
|
|
201
|
+
function _weeklyHourEntry(row, label) {
|
|
202
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
203
|
+
throw new TypeError("businessHours: " + label + " must be an object {day, open, close}");
|
|
204
|
+
}
|
|
205
|
+
var day = _day(row.day, label + ".day");
|
|
206
|
+
var open = _hhmm(row.open, label + ".open");
|
|
207
|
+
var close = _hhmm(row.close, label + ".close");
|
|
208
|
+
var openM = _hhmmToMinutes(open);
|
|
209
|
+
var closeM = _hhmmToMinutes(close);
|
|
210
|
+
if (closeM <= openM) {
|
|
211
|
+
throw new TypeError(
|
|
212
|
+
"businessHours: " + label + " — close must be strictly after open " +
|
|
213
|
+
"(got " + JSON.stringify(open) + " -> " + JSON.stringify(close) + "). " +
|
|
214
|
+
"Author overnight-open windows as two adjacent-day entries"
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return { day: day, open: open, close: close };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function _weeklyHours(arr) {
|
|
221
|
+
if (!Array.isArray(arr)) {
|
|
222
|
+
throw new TypeError("businessHours: weekly_hours must be an array");
|
|
223
|
+
}
|
|
224
|
+
if (arr.length > MAX_WEEKLY_HOURS) {
|
|
225
|
+
throw new TypeError("businessHours: weekly_hours must be <= " + MAX_WEEKLY_HOURS + " entries");
|
|
226
|
+
}
|
|
227
|
+
var out = [];
|
|
228
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
229
|
+
out.push(_weeklyHourEntry(arr[i], "weekly_hours[" + i + "]"));
|
|
230
|
+
}
|
|
231
|
+
// Refuse overlapping windows on the same day — operator either
|
|
232
|
+
// authored a typo or expected merge semantics; neither is safe to
|
|
233
|
+
// assume silently.
|
|
234
|
+
var byDay = {};
|
|
235
|
+
for (var j = 0; j < out.length; j += 1) {
|
|
236
|
+
var e = out[j];
|
|
237
|
+
if (!byDay[e.day]) byDay[e.day] = [];
|
|
238
|
+
byDay[e.day].push(e);
|
|
239
|
+
}
|
|
240
|
+
for (var d = 0; d < 7; d += 1) {
|
|
241
|
+
var rows = byDay[d];
|
|
242
|
+
if (!rows || rows.length < 2) continue;
|
|
243
|
+
rows.sort(function (a, b) { return _hhmmToMinutes(a.open) - _hhmmToMinutes(b.open); });
|
|
244
|
+
for (var k = 1; k < rows.length; k += 1) {
|
|
245
|
+
var prevClose = _hhmmToMinutes(rows[k - 1].close);
|
|
246
|
+
var thisOpen = _hhmmToMinutes(rows[k].open);
|
|
247
|
+
if (thisOpen < prevClose) {
|
|
248
|
+
throw new TypeError(
|
|
249
|
+
"businessHours: weekly_hours — overlapping windows on day " + d +
|
|
250
|
+
" (" + rows[k - 1].open + "-" + rows[k - 1].close + " and " +
|
|
251
|
+
rows[k].open + "-" + rows[k].close + ")"
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _now() { return Date.now(); }
|
|
260
|
+
|
|
261
|
+
// ---- timezone-aware calendar math ---------------------------------------
|
|
262
|
+
|
|
263
|
+
// Decompose an epoch-ms into {y, m, d, hh, mm, weekday(0..6 Sun=0)}
|
|
264
|
+
// at the requested IANA timezone. Intl handles DST automatically —
|
|
265
|
+
// March / November transitions produce the wall-clock parts an
|
|
266
|
+
// operator authored against, no manual offset math.
|
|
267
|
+
function _wallClockIn(tz, epochMs) {
|
|
268
|
+
var fmt = new Intl.DateTimeFormat("en-US", {
|
|
269
|
+
timeZone: tz,
|
|
270
|
+
year: "numeric",
|
|
271
|
+
month: "2-digit",
|
|
272
|
+
day: "2-digit",
|
|
273
|
+
hour: "2-digit",
|
|
274
|
+
minute: "2-digit",
|
|
275
|
+
hour12: false,
|
|
276
|
+
weekday: "short",
|
|
277
|
+
});
|
|
278
|
+
var parts = fmt.formatToParts(new Date(epochMs));
|
|
279
|
+
var out = { y: 0, m: 0, d: 0, hh: 0, mm: 0, weekday: 0 };
|
|
280
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
281
|
+
var p = parts[i];
|
|
282
|
+
if (p.type === "year") out.y = Number(p.value);
|
|
283
|
+
if (p.type === "month") out.m = Number(p.value);
|
|
284
|
+
if (p.type === "day") out.d = Number(p.value);
|
|
285
|
+
if (p.type === "hour") {
|
|
286
|
+
// Intl's "h23" hourCycle emits 0..23 but some locales emit "24"
|
|
287
|
+
// at midnight; normalize defensively.
|
|
288
|
+
var hh = Number(p.value);
|
|
289
|
+
out.hh = hh === 24 ? 0 : hh;
|
|
290
|
+
}
|
|
291
|
+
if (p.type === "minute") out.mm = Number(p.value);
|
|
292
|
+
if (p.type === "weekday") {
|
|
293
|
+
var map = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
294
|
+
out.weekday = map[p.value] != null ? map[p.value] : 0;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Pad helpers
|
|
301
|
+
function _pad2(n) { return n < 10 ? "0" + n : String(n); }
|
|
302
|
+
|
|
303
|
+
// Format {y, m, d} as YYYY-MM-DD.
|
|
304
|
+
function _ymdString(parts) {
|
|
305
|
+
return String(parts.y) + "-" + _pad2(parts.m) + "-" + _pad2(parts.d);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Add `n` calendar days to {y, m, d}. UTC arithmetic — month / year
|
|
309
|
+
// roll over naturally without DST entanglement (this walks calendar
|
|
310
|
+
// days, not wall-clock spans).
|
|
311
|
+
function _addCalendarDays(parts, n) {
|
|
312
|
+
var ts = Date.UTC(parts.y, parts.m - 1, parts.d) + n * DAY_MS;
|
|
313
|
+
var dt = new Date(ts);
|
|
314
|
+
return {
|
|
315
|
+
y: dt.getUTCFullYear(),
|
|
316
|
+
m: dt.getUTCMonth() + 1,
|
|
317
|
+
d: dt.getUTCDate(),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Weekday (Sun=0..Sat=6) of a {y, m, d} calendar date — uses Date.UTC
|
|
322
|
+
// so the result is timezone-independent (a calendar date has a fixed
|
|
323
|
+
// weekday regardless of the viewer's zone).
|
|
324
|
+
function _weekdayOfYMD(parts) {
|
|
325
|
+
var ts = Date.UTC(parts.y, parts.m - 1, parts.d);
|
|
326
|
+
return new Date(ts).getUTCDay();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Resolve a (YYYY-MM-DD, HH:MM, IANA tz) tuple back to an epoch-ms.
|
|
330
|
+
// The challenge: `Date.UTC` doesn't know about DST. We need an epoch
|
|
331
|
+
// that, when re-formatted in `tz`, produces the requested wall clock.
|
|
332
|
+
//
|
|
333
|
+
// Algorithm: assume the local time is UTC, then ask Intl what wall
|
|
334
|
+
// clock that epoch maps to in `tz`, then correct by the delta. Two
|
|
335
|
+
// passes converge in every real case (the second iteration handles
|
|
336
|
+
// the rare DST-transition fold where the first guess landed on the
|
|
337
|
+
// wrong side of the boundary).
|
|
338
|
+
function _wallClockToEpochMs(tz, ymd, hhmm) {
|
|
339
|
+
var y = Number(ymd.slice(0, 4));
|
|
340
|
+
var mo = Number(ymd.slice(5, 7));
|
|
341
|
+
var d = Number(ymd.slice(8, 10));
|
|
342
|
+
var hh = Number(hhmm.slice(0, 2));
|
|
343
|
+
var mm = Number(hhmm.slice(3, 5));
|
|
344
|
+
// First guess: treat the input as UTC.
|
|
345
|
+
var guess = Date.UTC(y, mo - 1, d, hh, mm);
|
|
346
|
+
// Two refinement passes — DST transitions need at most one re-walk;
|
|
347
|
+
// a second pass is a no-op cheap safety.
|
|
348
|
+
for (var pass = 0; pass < 2; pass += 1) {
|
|
349
|
+
var wall = _wallClockIn(tz, guess);
|
|
350
|
+
var wallMs = Date.UTC(wall.y, wall.m - 1, wall.d, wall.hh, wall.mm);
|
|
351
|
+
var requestedMs = Date.UTC(y, mo - 1, d, hh, mm);
|
|
352
|
+
var delta = requestedMs - wallMs;
|
|
353
|
+
if (delta === 0) return guess;
|
|
354
|
+
guess += delta;
|
|
355
|
+
}
|
|
356
|
+
return guess;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---- factory ------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
function create(opts) {
|
|
362
|
+
opts = opts || {};
|
|
363
|
+
var query = opts.query;
|
|
364
|
+
if (!query) {
|
|
365
|
+
query = function (sql, params) { return require("./index").framework.externalDb.query(sql, params); };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function _hydrateSchedule(row) {
|
|
369
|
+
if (!row) return null;
|
|
370
|
+
var weekly = [];
|
|
371
|
+
try { weekly = JSON.parse(row.weekly_hours_json || "[]"); }
|
|
372
|
+
catch (_e) { weekly = []; /* drop-silent — by design; stored shape is primitive-owned */ }
|
|
373
|
+
return {
|
|
374
|
+
slug: row.slug,
|
|
375
|
+
timezone: row.timezone,
|
|
376
|
+
weekly_hours: weekly,
|
|
377
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
378
|
+
created_at: Number(row.created_at),
|
|
379
|
+
updated_at: Number(row.updated_at),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function _hydrateException(row) {
|
|
384
|
+
return {
|
|
385
|
+
date: row.date,
|
|
386
|
+
open: row.open == null ? null : row.open,
|
|
387
|
+
close: row.close == null ? null : row.close,
|
|
388
|
+
closed: Number(row.closed) === 1,
|
|
389
|
+
kind: row.kind,
|
|
390
|
+
name: row.name == null ? null : row.name,
|
|
391
|
+
created_at: Number(row.created_at),
|
|
392
|
+
updated_at: Number(row.updated_at),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function _getScheduleRaw(slug) {
|
|
397
|
+
var r = await query(
|
|
398
|
+
"SELECT * FROM business_hour_schedules WHERE slug = ?1",
|
|
399
|
+
[slug],
|
|
400
|
+
);
|
|
401
|
+
return r.rows[0] || null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function _getExceptionsByDate(slug) {
|
|
405
|
+
var r = await query(
|
|
406
|
+
"SELECT * FROM business_hour_exceptions WHERE schedule_slug = ?1",
|
|
407
|
+
[slug],
|
|
408
|
+
);
|
|
409
|
+
var byDate = {};
|
|
410
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
411
|
+
var row = r.rows[i];
|
|
412
|
+
byDate[row.date] = _hydrateException(row);
|
|
413
|
+
}
|
|
414
|
+
return byDate;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Build the list of `{open, close}` windows that apply on a given
|
|
418
|
+
// calendar date for the schedule. Returns `[]` for closed days. The
|
|
419
|
+
// override layering is: exception (with closed=true OR explicit
|
|
420
|
+
// open/close) > holiday > weekly_hours. Exceptions with one-side
|
|
421
|
+
// null inherit the other side from weekly_hours.
|
|
422
|
+
function _windowsForDate(schedule, ymd, overrides) {
|
|
423
|
+
var override = overrides[ymd];
|
|
424
|
+
if (override && override.kind === "holiday") return [];
|
|
425
|
+
if (override && override.kind === "exception" && override.closed) return [];
|
|
426
|
+
|
|
427
|
+
var weekday = _weekdayOfYMD({
|
|
428
|
+
y: Number(ymd.slice(0, 4)),
|
|
429
|
+
m: Number(ymd.slice(5, 7)),
|
|
430
|
+
d: Number(ymd.slice(8, 10)),
|
|
431
|
+
});
|
|
432
|
+
var base = [];
|
|
433
|
+
for (var i = 0; i < schedule.weekly_hours.length; i += 1) {
|
|
434
|
+
if (schedule.weekly_hours[i].day === weekday) {
|
|
435
|
+
base.push({
|
|
436
|
+
open: schedule.weekly_hours[i].open,
|
|
437
|
+
close: schedule.weekly_hours[i].close,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (!override || override.kind !== "exception") return base;
|
|
443
|
+
|
|
444
|
+
// Exception with explicit open/close — REPLACES the weekly_hours
|
|
445
|
+
// for this date. A null side inherits from the first weekly entry
|
|
446
|
+
// for the same weekday (most common: "close early today" leaves
|
|
447
|
+
// `open` null + sets `close` to 15:00).
|
|
448
|
+
var inheritedOpen = base.length ? base[0].open : null;
|
|
449
|
+
var inheritedClose = base.length ? base[0].close : null;
|
|
450
|
+
var openFinal = override.open != null ? override.open : inheritedOpen;
|
|
451
|
+
var closeFinal = override.close != null ? override.close : inheritedClose;
|
|
452
|
+
if (openFinal == null || closeFinal == null) {
|
|
453
|
+
// The operator authored an exception with one side null and no
|
|
454
|
+
// matching weekly_hours to inherit from — interpret as closed
|
|
455
|
+
// (no usable window). Drop-silent — the schedule renderer shows
|
|
456
|
+
// the unbalanced override; the lookup just refuses to serve a
|
|
457
|
+
// half-built window.
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
return [{ open: openFinal, close: closeFinal }];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
SLUG_RE: SLUG_RE,
|
|
465
|
+
TZ_RE: TZ_RE,
|
|
466
|
+
HHMM_RE: HHMM_RE,
|
|
467
|
+
YMD_RE: YMD_RE,
|
|
468
|
+
MAX_WEEKLY_HOURS: MAX_WEEKLY_HOURS,
|
|
469
|
+
|
|
470
|
+
// Register a schedule. Holidays + exceptions can be supplied
|
|
471
|
+
// inline; both go into `business_hour_exceptions` (the table
|
|
472
|
+
// doesn't distinguish — the `kind` column does).
|
|
473
|
+
//
|
|
474
|
+
// Re-calling with the same slug refuses — the audit trail is
|
|
475
|
+
// threaded by slug, mutations route through `addException` /
|
|
476
|
+
// `removeException` / `addHoliday` / `archiveSchedule` instead.
|
|
477
|
+
defineSchedule: async function (input) {
|
|
478
|
+
if (!input || typeof input !== "object") {
|
|
479
|
+
throw new TypeError("businessHours.defineSchedule: input object required");
|
|
480
|
+
}
|
|
481
|
+
var slug = _slug(input.slug);
|
|
482
|
+
var timezone = _tz(input.timezone, "timezone");
|
|
483
|
+
var weeklyHours = _weeklyHours(input.weekly_hours || []);
|
|
484
|
+
|
|
485
|
+
var holidays = input.holidays == null ? [] : input.holidays;
|
|
486
|
+
if (!Array.isArray(holidays)) {
|
|
487
|
+
throw new TypeError("businessHours.defineSchedule: holidays must be an array");
|
|
488
|
+
}
|
|
489
|
+
var exceptions = input.exceptions == null ? [] : input.exceptions;
|
|
490
|
+
if (!Array.isArray(exceptions)) {
|
|
491
|
+
throw new TypeError("businessHours.defineSchedule: exceptions must be an array");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Validate every holiday + exception up-front so a malformed
|
|
495
|
+
// entry doesn't leave a half-defined schedule on disk.
|
|
496
|
+
var validatedHolidays = [];
|
|
497
|
+
var seenDates = {};
|
|
498
|
+
for (var i = 0; i < holidays.length; i += 1) {
|
|
499
|
+
var h = holidays[i];
|
|
500
|
+
if (!h || typeof h !== "object") {
|
|
501
|
+
throw new TypeError("businessHours.defineSchedule: holidays[" + i + "] must be an object {date, name}");
|
|
502
|
+
}
|
|
503
|
+
var hDate = _ymd(h.date, "holidays[" + i + "].date");
|
|
504
|
+
var hName = _name(h.name, "holidays[" + i + "].name");
|
|
505
|
+
if (seenDates[hDate]) {
|
|
506
|
+
throw new TypeError(
|
|
507
|
+
"businessHours.defineSchedule: holidays[" + i + "] duplicates date " + hDate
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
seenDates[hDate] = true;
|
|
511
|
+
validatedHolidays.push({ date: hDate, name: hName });
|
|
512
|
+
}
|
|
513
|
+
var validatedExceptions = [];
|
|
514
|
+
for (var j = 0; j < exceptions.length; j += 1) {
|
|
515
|
+
var e = exceptions[j];
|
|
516
|
+
if (!e || typeof e !== "object") {
|
|
517
|
+
throw new TypeError("businessHours.defineSchedule: exceptions[" + j + "] must be an object");
|
|
518
|
+
}
|
|
519
|
+
var eDate = _ymd(e.date, "exceptions[" + j + "].date");
|
|
520
|
+
if (seenDates[eDate]) {
|
|
521
|
+
throw new TypeError(
|
|
522
|
+
"businessHours.defineSchedule: exceptions[" + j + "] duplicates date " + eDate
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
seenDates[eDate] = true;
|
|
526
|
+
var eOpen = e.open == null ? null : _hhmm(e.open, "exceptions[" + j + "].open");
|
|
527
|
+
var eClose = e.close == null ? null : _hhmm(e.close, "exceptions[" + j + "].close");
|
|
528
|
+
var eClosed = e.closed === true;
|
|
529
|
+
if (eOpen && eClose && _hhmmToMinutes(eClose) <= _hhmmToMinutes(eOpen)) {
|
|
530
|
+
throw new TypeError(
|
|
531
|
+
"businessHours.defineSchedule: exceptions[" + j + "] — close must be strictly after open"
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (!eClosed && eOpen == null && eClose == null) {
|
|
535
|
+
throw new TypeError(
|
|
536
|
+
"businessHours.defineSchedule: exceptions[" + j + "] — must set closed=true OR at least one of open/close"
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
validatedExceptions.push({
|
|
540
|
+
date: eDate, open: eOpen, close: eClose, closed: eClosed,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
var existing = await _getScheduleRaw(slug);
|
|
545
|
+
if (existing) {
|
|
546
|
+
var err = new Error(
|
|
547
|
+
"businessHours.defineSchedule: refused — schedule '" + slug + "' already exists" +
|
|
548
|
+
(existing.archived_at != null ? " (archived)" : "") +
|
|
549
|
+
". Mutations route through addException / addHoliday / archiveSchedule"
|
|
550
|
+
);
|
|
551
|
+
err.code = "BUSINESS_HOURS_SCHEDULE_EXISTS";
|
|
552
|
+
throw err;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
var ts = _now();
|
|
556
|
+
await query(
|
|
557
|
+
"INSERT INTO business_hour_schedules " +
|
|
558
|
+
"(slug, timezone, weekly_hours_json, archived_at, created_at, updated_at) " +
|
|
559
|
+
"VALUES (?1, ?2, ?3, NULL, ?4, ?4)",
|
|
560
|
+
[slug, timezone, JSON.stringify(weeklyHours), ts],
|
|
561
|
+
);
|
|
562
|
+
for (var p = 0; p < validatedHolidays.length; p += 1) {
|
|
563
|
+
var vh = validatedHolidays[p];
|
|
564
|
+
await query(
|
|
565
|
+
"INSERT INTO business_hour_exceptions " +
|
|
566
|
+
"(schedule_slug, date, open, close, closed, kind, name, created_at, updated_at) " +
|
|
567
|
+
"VALUES (?1, ?2, NULL, NULL, 1, 'holiday', ?3, ?4, ?4)",
|
|
568
|
+
[slug, vh.date, vh.name, ts],
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
for (var q = 0; q < validatedExceptions.length; q += 1) {
|
|
572
|
+
var ve = validatedExceptions[q];
|
|
573
|
+
await query(
|
|
574
|
+
"INSERT INTO business_hour_exceptions " +
|
|
575
|
+
"(schedule_slug, date, open, close, closed, kind, name, created_at, updated_at) " +
|
|
576
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'exception', NULL, ?6, ?6)",
|
|
577
|
+
[slug, ve.date, ve.open, ve.close, ve.closed ? 1 : 0, ts],
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return await this.getSchedule(slug);
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
// Read one. Returns `null` when no such schedule exists; archived
|
|
585
|
+
// schedules ARE returned (audit dashboards want them). The lookup
|
|
586
|
+
// paths (`isOpenAt` etc.) filter archived rows themselves.
|
|
587
|
+
getSchedule: async function (slug) {
|
|
588
|
+
var s = _slug(slug);
|
|
589
|
+
var raw = await _getScheduleRaw(s);
|
|
590
|
+
if (!raw) return null;
|
|
591
|
+
var hydrated = _hydrateSchedule(raw);
|
|
592
|
+
var byDate = await _getExceptionsByDate(s);
|
|
593
|
+
var holidays = [];
|
|
594
|
+
var exceptions = [];
|
|
595
|
+
var dates = Object.keys(byDate).sort();
|
|
596
|
+
for (var i = 0; i < dates.length; i += 1) {
|
|
597
|
+
var ex = byDate[dates[i]];
|
|
598
|
+
if (ex.kind === "holiday") holidays.push({ date: ex.date, name: ex.name });
|
|
599
|
+
else exceptions.push({
|
|
600
|
+
date: ex.date, open: ex.open, close: ex.close, closed: ex.closed,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
hydrated.holidays = holidays;
|
|
604
|
+
hydrated.exceptions = exceptions;
|
|
605
|
+
return hydrated;
|
|
606
|
+
},
|
|
607
|
+
|
|
608
|
+
// List schedules. Default returns every non-archived schedule;
|
|
609
|
+
// `include_archived: true` includes archived rows for an audit
|
|
610
|
+
// dashboard. Slug ASC ordering for determinism.
|
|
611
|
+
listSchedules: async function (listOpts) {
|
|
612
|
+
listOpts = listOpts || {};
|
|
613
|
+
var includeArchived = listOpts.include_archived === true;
|
|
614
|
+
var sql = "SELECT * FROM business_hour_schedules";
|
|
615
|
+
if (!includeArchived) sql += " WHERE archived_at IS NULL";
|
|
616
|
+
sql += " ORDER BY slug ASC";
|
|
617
|
+
var r = await query(sql, []);
|
|
618
|
+
var out = [];
|
|
619
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateSchedule(r.rows[i]));
|
|
620
|
+
return out;
|
|
621
|
+
},
|
|
622
|
+
|
|
623
|
+
// Soft-delete — stamps archived_at. The row stays on disk for
|
|
624
|
+
// audit; archived schedules drop out of `isOpenAt` / `nextOpenAt`
|
|
625
|
+
// / `nextCloseAt` / `weekSummary` lookups. Idempotent.
|
|
626
|
+
archiveSchedule: async function (slug) {
|
|
627
|
+
var s = _slug(slug);
|
|
628
|
+
var current = await _getScheduleRaw(s);
|
|
629
|
+
if (!current) return null;
|
|
630
|
+
if (current.archived_at != null) return await this.getSchedule(s);
|
|
631
|
+
var ts = _now();
|
|
632
|
+
await query(
|
|
633
|
+
"UPDATE business_hour_schedules SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
|
|
634
|
+
[ts, s],
|
|
635
|
+
);
|
|
636
|
+
return await this.getSchedule(s);
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
// Add or replace a holiday on a date. Upsert semantics — re-
|
|
640
|
+
// calling on the same date REPLACES the prior holiday name (and
|
|
641
|
+
// converts an exception row to a holiday row if needed). Archived
|
|
642
|
+
// schedules refuse all mutations.
|
|
643
|
+
addHoliday: async function (input) {
|
|
644
|
+
if (!input || typeof input !== "object") {
|
|
645
|
+
throw new TypeError("businessHours.addHoliday: input object required");
|
|
646
|
+
}
|
|
647
|
+
var slug = _slug(input.slug);
|
|
648
|
+
var date = _ymd(input.date, "date");
|
|
649
|
+
var name = _name(input.name, "name");
|
|
650
|
+
var current = await _getScheduleRaw(slug);
|
|
651
|
+
if (!current) return null;
|
|
652
|
+
if (current.archived_at != null) {
|
|
653
|
+
var refused = new Error("businessHours.addHoliday: refused — schedule is archived");
|
|
654
|
+
refused.code = "BUSINESS_HOURS_SCHEDULE_ARCHIVED";
|
|
655
|
+
throw refused;
|
|
656
|
+
}
|
|
657
|
+
var ts = _now();
|
|
658
|
+
// Idempotent upsert — DELETE then INSERT keeps the AUTOINCREMENT
|
|
659
|
+
// ID monotonic and avoids relying on UPSERT semantics that vary
|
|
660
|
+
// across D1 driver versions.
|
|
661
|
+
await query(
|
|
662
|
+
"DELETE FROM business_hour_exceptions WHERE schedule_slug = ?1 AND date = ?2",
|
|
663
|
+
[slug, date],
|
|
664
|
+
);
|
|
665
|
+
await query(
|
|
666
|
+
"INSERT INTO business_hour_exceptions " +
|
|
667
|
+
"(schedule_slug, date, open, close, closed, kind, name, created_at, updated_at) " +
|
|
668
|
+
"VALUES (?1, ?2, NULL, NULL, 1, 'holiday', ?3, ?4, ?4)",
|
|
669
|
+
[slug, date, name, ts],
|
|
670
|
+
);
|
|
671
|
+
await query(
|
|
672
|
+
"UPDATE business_hour_schedules SET updated_at = ?1 WHERE slug = ?2",
|
|
673
|
+
[ts, slug],
|
|
674
|
+
);
|
|
675
|
+
return await this.getSchedule(slug);
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
// Add or replace an exception on a date. Either `closed: true` OR
|
|
679
|
+
// at least one of (`open`, `close`) must be supplied. A null side
|
|
680
|
+
// inherits from the weekly_hours at lookup time. Upsert semantics.
|
|
681
|
+
addException: async function (input) {
|
|
682
|
+
if (!input || typeof input !== "object") {
|
|
683
|
+
throw new TypeError("businessHours.addException: input object required");
|
|
684
|
+
}
|
|
685
|
+
var slug = _slug(input.slug);
|
|
686
|
+
var date = _ymd(input.date, "date");
|
|
687
|
+
var open = input.open == null ? null : _hhmm(input.open, "open");
|
|
688
|
+
var close = input.close == null ? null : _hhmm(input.close, "close");
|
|
689
|
+
var closed = input.closed === true;
|
|
690
|
+
if (open && close && _hhmmToMinutes(close) <= _hhmmToMinutes(open)) {
|
|
691
|
+
throw new TypeError(
|
|
692
|
+
"businessHours.addException: close must be strictly after open"
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (!closed && open == null && close == null) {
|
|
696
|
+
throw new TypeError(
|
|
697
|
+
"businessHours.addException: must set closed=true OR at least one of open/close"
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
var current = await _getScheduleRaw(slug);
|
|
701
|
+
if (!current) return null;
|
|
702
|
+
if (current.archived_at != null) {
|
|
703
|
+
var refused = new Error("businessHours.addException: refused — schedule is archived");
|
|
704
|
+
refused.code = "BUSINESS_HOURS_SCHEDULE_ARCHIVED";
|
|
705
|
+
throw refused;
|
|
706
|
+
}
|
|
707
|
+
var ts = _now();
|
|
708
|
+
await query(
|
|
709
|
+
"DELETE FROM business_hour_exceptions WHERE schedule_slug = ?1 AND date = ?2",
|
|
710
|
+
[slug, date],
|
|
711
|
+
);
|
|
712
|
+
await query(
|
|
713
|
+
"INSERT INTO business_hour_exceptions " +
|
|
714
|
+
"(schedule_slug, date, open, close, closed, kind, name, created_at, updated_at) " +
|
|
715
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'exception', NULL, ?6, ?6)",
|
|
716
|
+
[slug, date, open, close, closed ? 1 : 0, ts],
|
|
717
|
+
);
|
|
718
|
+
await query(
|
|
719
|
+
"UPDATE business_hour_schedules SET updated_at = ?1 WHERE slug = ?2",
|
|
720
|
+
[ts, slug],
|
|
721
|
+
);
|
|
722
|
+
return await this.getSchedule(slug);
|
|
723
|
+
},
|
|
724
|
+
|
|
725
|
+
// Remove ANY override (holiday or exception) on a specific date.
|
|
726
|
+
// Returns the refreshed schedule, or `null` when the schedule
|
|
727
|
+
// doesn't exist. Idempotent — removing a date that had no
|
|
728
|
+
// override is a no-op that still bumps `updated_at` so the audit
|
|
729
|
+
// trail records the operator action.
|
|
730
|
+
removeException: async function (input) {
|
|
731
|
+
if (!input || typeof input !== "object") {
|
|
732
|
+
throw new TypeError("businessHours.removeException: input object required");
|
|
733
|
+
}
|
|
734
|
+
var slug = _slug(input.slug);
|
|
735
|
+
var date = _ymd(input.date, "date");
|
|
736
|
+
var current = await _getScheduleRaw(slug);
|
|
737
|
+
if (!current) return null;
|
|
738
|
+
if (current.archived_at != null) {
|
|
739
|
+
var refused = new Error("businessHours.removeException: refused — schedule is archived");
|
|
740
|
+
refused.code = "BUSINESS_HOURS_SCHEDULE_ARCHIVED";
|
|
741
|
+
throw refused;
|
|
742
|
+
}
|
|
743
|
+
var ts = _now();
|
|
744
|
+
await query(
|
|
745
|
+
"DELETE FROM business_hour_exceptions WHERE schedule_slug = ?1 AND date = ?2",
|
|
746
|
+
[slug, date],
|
|
747
|
+
);
|
|
748
|
+
await query(
|
|
749
|
+
"UPDATE business_hour_schedules SET updated_at = ?1 WHERE slug = ?2",
|
|
750
|
+
[ts, slug],
|
|
751
|
+
);
|
|
752
|
+
return await this.getSchedule(slug);
|
|
753
|
+
},
|
|
754
|
+
|
|
755
|
+
// Snapshot of the weekly rhythm, day-by-day. Returns an array of
|
|
756
|
+
// length 7 (Sun=0..Sat=6); each entry carries the list of windows
|
|
757
|
+
// for that weekday in `HH:MM-HH:MM` form, or `[]` for closed.
|
|
758
|
+
// Holidays + exceptions are NOT applied here — `weekSummary` is
|
|
759
|
+
// the "what's the operator's nominal weekly schedule" view; the
|
|
760
|
+
// banner UI fetches it once and renders "Open Mon-Fri 09:00-17:00,
|
|
761
|
+
// closed Sat/Sun." Use `isOpenAt` / `nextOpenAt` for the
|
|
762
|
+
// exception-aware live state.
|
|
763
|
+
weekSummary: async function (input) {
|
|
764
|
+
if (!input || typeof input !== "object") {
|
|
765
|
+
throw new TypeError("businessHours.weekSummary: input object required");
|
|
766
|
+
}
|
|
767
|
+
var slug = _slug(input.slug);
|
|
768
|
+
var schedule = await this.getSchedule(slug);
|
|
769
|
+
if (!schedule) return null;
|
|
770
|
+
var byDay = [[], [], [], [], [], [], []];
|
|
771
|
+
for (var i = 0; i < schedule.weekly_hours.length; i += 1) {
|
|
772
|
+
var w = schedule.weekly_hours[i];
|
|
773
|
+
byDay[w.day].push({ open: w.open, close: w.close });
|
|
774
|
+
}
|
|
775
|
+
for (var d = 0; d < 7; d += 1) {
|
|
776
|
+
byDay[d].sort(function (a, b) { return _hhmmToMinutes(a.open) - _hhmmToMinutes(b.open); });
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
slug: schedule.slug,
|
|
780
|
+
timezone: schedule.timezone,
|
|
781
|
+
days: byDay,
|
|
782
|
+
};
|
|
783
|
+
},
|
|
784
|
+
|
|
785
|
+
// Is the schedule currently open at `when` (epoch-ms; defaults to
|
|
786
|
+
// `Date.now()` — the monotonic clock pattern lets tests pin a
|
|
787
|
+
// moment without stubbing the global clock).
|
|
788
|
+
isOpenAt: async function (input) {
|
|
789
|
+
if (!input || typeof input !== "object") {
|
|
790
|
+
throw new TypeError("businessHours.isOpenAt: input object required");
|
|
791
|
+
}
|
|
792
|
+
var slug = _slug(input.slug);
|
|
793
|
+
var when = input.when == null ? _now() : _epochMs(input.when, "when");
|
|
794
|
+
|
|
795
|
+
var schedule = await this.getSchedule(slug);
|
|
796
|
+
if (!schedule || schedule.archived_at != null) return false;
|
|
797
|
+
|
|
798
|
+
var wall = _wallClockIn(schedule.timezone, when);
|
|
799
|
+
var ymd = _ymdString(wall);
|
|
800
|
+
var overrides = {};
|
|
801
|
+
var i;
|
|
802
|
+
for (i = 0; i < (schedule.holidays || []).length; i += 1) {
|
|
803
|
+
overrides[schedule.holidays[i].date] = {
|
|
804
|
+
kind: "holiday", name: schedule.holidays[i].name,
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
for (i = 0; i < (schedule.exceptions || []).length; i += 1) {
|
|
808
|
+
var ex = schedule.exceptions[i];
|
|
809
|
+
overrides[ex.date] = {
|
|
810
|
+
kind: "exception", open: ex.open, close: ex.close, closed: ex.closed,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
var windows = _windowsForDate(schedule, ymd, overrides);
|
|
814
|
+
var nowMinutes = wall.hh * 60 + wall.mm;
|
|
815
|
+
for (i = 0; i < windows.length; i += 1) {
|
|
816
|
+
var openM = _hhmmToMinutes(windows[i].open);
|
|
817
|
+
var closeM = _hhmmToMinutes(windows[i].close);
|
|
818
|
+
if (nowMinutes >= openM && nowMinutes < closeM) return true;
|
|
819
|
+
}
|
|
820
|
+
return false;
|
|
821
|
+
},
|
|
822
|
+
|
|
823
|
+
// Next open-moment at or after `when`. Returns `{ at, date,
|
|
824
|
+
// open }` where `at` is the epoch-ms of the opening transition,
|
|
825
|
+
// `date` is the YYYY-MM-DD in the schedule's timezone, and `open`
|
|
826
|
+
// is the HH:MM. Returns `null` when no opening is found within
|
|
827
|
+
// SEARCH_DAYS (operator authored a fully-closed schedule, or the
|
|
828
|
+
// far horizon is closed by accumulated overrides — the lookup
|
|
829
|
+
// exits gracefully rather than walking forever).
|
|
830
|
+
//
|
|
831
|
+
// If `when` itself falls inside an open window, returns `when`'s
|
|
832
|
+
// own opening for that window (so the caller can treat "open now"
|
|
833
|
+
// as "the open transition was at <today 09:00>").
|
|
834
|
+
nextOpenAt: async function (input) {
|
|
835
|
+
if (!input || typeof input !== "object") {
|
|
836
|
+
throw new TypeError("businessHours.nextOpenAt: input object required");
|
|
837
|
+
}
|
|
838
|
+
var slug = _slug(input.slug);
|
|
839
|
+
var when = input.when == null ? _now() : _epochMs(input.when, "when");
|
|
840
|
+
|
|
841
|
+
var schedule = await this.getSchedule(slug);
|
|
842
|
+
if (!schedule || schedule.archived_at != null) return null;
|
|
843
|
+
|
|
844
|
+
var overrides = {};
|
|
845
|
+
var i;
|
|
846
|
+
for (i = 0; i < (schedule.holidays || []).length; i += 1) {
|
|
847
|
+
overrides[schedule.holidays[i].date] = {
|
|
848
|
+
kind: "holiday", name: schedule.holidays[i].name,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
for (i = 0; i < (schedule.exceptions || []).length; i += 1) {
|
|
852
|
+
var ex = schedule.exceptions[i];
|
|
853
|
+
overrides[ex.date] = {
|
|
854
|
+
kind: "exception", open: ex.open, close: ex.close, closed: ex.closed,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
var wall = _wallClockIn(schedule.timezone, when);
|
|
859
|
+
var cur = { y: wall.y, m: wall.m, d: wall.d };
|
|
860
|
+
var nowMinutes = wall.hh * 60 + wall.mm;
|
|
861
|
+
for (var day = 0; day < SEARCH_DAYS; day += 1) {
|
|
862
|
+
var ymd = _ymdString(cur);
|
|
863
|
+
var windows = _windowsForDate(schedule, ymd, overrides);
|
|
864
|
+
// Sort windows by open ASC so split-shift schedules return the
|
|
865
|
+
// earliest still-future opening of the day.
|
|
866
|
+
windows.sort(function (a, b) { return _hhmmToMinutes(a.open) - _hhmmToMinutes(b.open); });
|
|
867
|
+
for (i = 0; i < windows.length; i += 1) {
|
|
868
|
+
var openM = _hhmmToMinutes(windows[i].open);
|
|
869
|
+
var closeM = _hhmmToMinutes(windows[i].close);
|
|
870
|
+
if (day === 0) {
|
|
871
|
+
// Already open inside this window — return this window's
|
|
872
|
+
// open transition (callers building "we're open until X"
|
|
873
|
+
// banners pair this with `nextCloseAt`).
|
|
874
|
+
if (nowMinutes >= openM && nowMinutes < closeM) {
|
|
875
|
+
return {
|
|
876
|
+
at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].open),
|
|
877
|
+
date: ymd,
|
|
878
|
+
open: windows[i].open,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
// Open later today — return this future opening.
|
|
882
|
+
if (openM > nowMinutes) {
|
|
883
|
+
return {
|
|
884
|
+
at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].open),
|
|
885
|
+
date: ymd,
|
|
886
|
+
open: windows[i].open,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
} else {
|
|
890
|
+
return {
|
|
891
|
+
at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].open),
|
|
892
|
+
date: ymd,
|
|
893
|
+
open: windows[i].open,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
cur = _addCalendarDays(cur, 1);
|
|
898
|
+
}
|
|
899
|
+
return null;
|
|
900
|
+
},
|
|
901
|
+
|
|
902
|
+
// Next close-moment AT or after `when`. Returns `{ at, date,
|
|
903
|
+
// close }` for the close transition of the currently-open window
|
|
904
|
+
// OR (when closed) the close of the next opening. Returns `null`
|
|
905
|
+
// when no opening is found within SEARCH_DAYS.
|
|
906
|
+
nextCloseAt: async function (input) {
|
|
907
|
+
if (!input || typeof input !== "object") {
|
|
908
|
+
throw new TypeError("businessHours.nextCloseAt: input object required");
|
|
909
|
+
}
|
|
910
|
+
var slug = _slug(input.slug);
|
|
911
|
+
var when = input.when == null ? _now() : _epochMs(input.when, "when");
|
|
912
|
+
|
|
913
|
+
var schedule = await this.getSchedule(slug);
|
|
914
|
+
if (!schedule || schedule.archived_at != null) return null;
|
|
915
|
+
|
|
916
|
+
var overrides = {};
|
|
917
|
+
var i;
|
|
918
|
+
for (i = 0; i < (schedule.holidays || []).length; i += 1) {
|
|
919
|
+
overrides[schedule.holidays[i].date] = {
|
|
920
|
+
kind: "holiday", name: schedule.holidays[i].name,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
for (i = 0; i < (schedule.exceptions || []).length; i += 1) {
|
|
924
|
+
var ex = schedule.exceptions[i];
|
|
925
|
+
overrides[ex.date] = {
|
|
926
|
+
kind: "exception", open: ex.open, close: ex.close, closed: ex.closed,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
var wall = _wallClockIn(schedule.timezone, when);
|
|
931
|
+
var cur = { y: wall.y, m: wall.m, d: wall.d };
|
|
932
|
+
var nowMinutes = wall.hh * 60 + wall.mm;
|
|
933
|
+
for (var day = 0; day < SEARCH_DAYS; day += 1) {
|
|
934
|
+
var ymd = _ymdString(cur);
|
|
935
|
+
var windows = _windowsForDate(schedule, ymd, overrides);
|
|
936
|
+
windows.sort(function (a, b) { return _hhmmToMinutes(a.open) - _hhmmToMinutes(b.open); });
|
|
937
|
+
for (i = 0; i < windows.length; i += 1) {
|
|
938
|
+
var openM = _hhmmToMinutes(windows[i].open);
|
|
939
|
+
var closeM = _hhmmToMinutes(windows[i].close);
|
|
940
|
+
if (day === 0) {
|
|
941
|
+
// Inside this window — return its close.
|
|
942
|
+
if (nowMinutes >= openM && nowMinutes < closeM) {
|
|
943
|
+
return {
|
|
944
|
+
at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].close),
|
|
945
|
+
date: ymd,
|
|
946
|
+
close: windows[i].close,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
// Before this window — return its close (when caller is
|
|
950
|
+
// about to be open, the next close is THIS window's close).
|
|
951
|
+
if (openM > nowMinutes) {
|
|
952
|
+
return {
|
|
953
|
+
at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].close),
|
|
954
|
+
date: ymd,
|
|
955
|
+
close: windows[i].close,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
} else {
|
|
959
|
+
return {
|
|
960
|
+
at: _wallClockToEpochMs(schedule.timezone, ymd, windows[i].close),
|
|
961
|
+
date: ymd,
|
|
962
|
+
close: windows[i].close,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
cur = _addCalendarDays(cur, 1);
|
|
967
|
+
}
|
|
968
|
+
return null;
|
|
969
|
+
},
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
module.exports = {
|
|
974
|
+
create: create,
|
|
975
|
+
SLUG_RE: SLUG_RE,
|
|
976
|
+
TZ_RE: TZ_RE,
|
|
977
|
+
HHMM_RE: HHMM_RE,
|
|
978
|
+
YMD_RE: YMD_RE,
|
|
979
|
+
MAX_WEEKLY_HOURS: MAX_WEEKLY_HOURS,
|
|
980
|
+
};
|