@aglyn/shared-util-timestamp 1.0.0-beta.143

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,302 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * CALENDAR MATH IN A NAMED ZONE.
18
+ *
19
+ * `Intl` is the only zone database a browser or a serverless function is
20
+ * guaranteed to have, and it only FORMATS: it turns an instant into the
21
+ * calendar fields a wall clock in a zone shows, and nothing turns fields back
22
+ * into an instant. Everything here is built on that one direction.
23
+ *
24
+ * ## Wall time to instant
25
+ *
26
+ * A wall time is read as if it were UTC, then shifted by the zone's offset.
27
+ * Which offset is the whole difficulty: on the day a clock changes, the
28
+ * offset a day before and the offset a day after differ, and a wall time can
29
+ * fall in the hour that never happens (the spring gap) or the hour that
30
+ * happens twice (the autumn overlap). So both offsets are tried and the
31
+ * result is whichever candidate formats back to the wall time asked for —
32
+ * the EARLIER one when both do, and, when neither does, the one that moves
33
+ * the time forward by the length of the gap, which is where a clock that
34
+ * skipped an hour puts it.
35
+ *
36
+ * ## Day-of-week and minute-of-day are wall-clock readings
37
+ *
38
+ * A window that opens at 09:00 opens at 09:00 on the clock on the day the
39
+ * clocks change too, so the minute of the day is `hour * 60 + minute` as the
40
+ * wall shows it, never the minutes elapsed since midnight.
41
+ *
42
+ * Imports nothing, on purpose: the package root extends the Firestore SDK's
43
+ * `Timestamp`, and a published page that only needed a weekday must not ship
44
+ * the Firestore client to get one. `zoned-time.isolation.spec.ts` holds it.
45
+ *==========================================*/ const MINUTE_MS = 60000;
46
+ const DAY_MS = 24 * 60 * MINUTE_MS;
47
+ /** Minutes in a day: the exclusive end of a window that runs to midnight. */ export const MINUTES_PER_DAY = 24 * 60;
48
+ const WEEKDAY_LABELS = [
49
+ 'Sun',
50
+ 'Mon',
51
+ 'Tue',
52
+ 'Wed',
53
+ 'Thu',
54
+ 'Fri',
55
+ 'Sat'
56
+ ];
57
+ /** Monday through Friday, in `Date#getUTCDay` numbering. */ export const MONDAY_TO_FRIDAY = [
58
+ 1,
59
+ 2,
60
+ 3,
61
+ 4,
62
+ 5
63
+ ];
64
+ /**
65
+ * Formatters by zone. Building one is far slower than using one, and a
66
+ * caller that walks a calendar asks the same zone thousands of times; a zone
67
+ * name that `Intl` refuses throws and is never stored.
68
+ */ const formatters = new Map();
69
+ function formatterFor(timeZone) {
70
+ let formatter = formatters.get(timeZone);
71
+ if (!formatter) {
72
+ formatter = new Intl.DateTimeFormat('en-US', {
73
+ timeZone,
74
+ hourCycle: 'h23',
75
+ weekday: 'short',
76
+ year: 'numeric',
77
+ month: '2-digit',
78
+ day: '2-digit',
79
+ hour: '2-digit',
80
+ minute: '2-digit',
81
+ second: '2-digit'
82
+ });
83
+ formatters.set(timeZone, formatter);
84
+ }
85
+ return formatter;
86
+ }
87
+ /** Whether `Intl` knows the zone, so a stored name can be checked before use. */ export function isValidTimeZone(value) {
88
+ if (typeof value !== 'string' || !value.trim()) return false;
89
+ try {
90
+ formatterFor(value);
91
+ return true;
92
+ } catch (unused) {
93
+ return false;
94
+ }
95
+ }
96
+ /**
97
+ * The wall clock in `timeZone` at `atMs`.
98
+ *
99
+ * @throws RangeError for a zone `Intl` does not know, or an instant that is
100
+ * not a finite number.
101
+ */ export function zonedDateTime(atMs, timeZone) {
102
+ const fields = {
103
+ year: 0,
104
+ month: 0,
105
+ day: 0,
106
+ hour: 0,
107
+ minute: 0,
108
+ second: 0,
109
+ weekday: 0
110
+ };
111
+ for (const part of formatterFor(timeZone).formatToParts(new Date(atMs))){
112
+ switch(part.type){
113
+ case 'year':
114
+ fields.year = Number(part.value);
115
+ break;
116
+ case 'month':
117
+ fields.month = Number(part.value);
118
+ break;
119
+ case 'day':
120
+ fields.day = Number(part.value);
121
+ break;
122
+ case 'hour':
123
+ // `h23` reads midnight as 00; the modulo keeps an engine that still
124
+ // prints 24 from starting a day at minute 1440.
125
+ fields.hour = Number(part.value) % 24;
126
+ break;
127
+ case 'minute':
128
+ fields.minute = Number(part.value);
129
+ break;
130
+ case 'second':
131
+ fields.second = Number(part.value);
132
+ break;
133
+ case 'weekday':
134
+ fields.weekday = Math.max(0, WEEKDAY_LABELS.indexOf(part.value));
135
+ break;
136
+ }
137
+ }
138
+ return fields;
139
+ }
140
+ const pad = (value, width)=>String(value).padStart(width, '0');
141
+ /** `YYYY-MM-DD` of the calendar day `atMs` falls in, in `timeZone`. */ export function zonedDayKey(atMs, timeZone) {
142
+ const { year, month, day } = zonedDateTime(atMs, timeZone);
143
+ return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
144
+ }
145
+ /** The wall clock's minute of the day, `hour * 60 + minute`, 0 through 1439. */ export function zonedMinuteOfDay(atMs, timeZone) {
146
+ const { hour, minute } = zonedDateTime(atMs, timeZone);
147
+ return hour * 60 + minute;
148
+ }
149
+ /** The zone's offset from UTC at `atMs`, in milliseconds, positive east. */ export function zoneOffsetMs(atMs, timeZone) {
150
+ const fields = zonedDateTime(atMs, timeZone);
151
+ const asUtc = Date.UTC(fields.year, fields.month - 1, fields.day, fields.hour, fields.minute, fields.second);
152
+ return asUtc - Math.floor(atMs / 1000) * 1000;
153
+ }
154
+ /**
155
+ * The instant a wall time happens in `timeZone` — see the module note for
156
+ * the two days a year it is ambiguous. Seconds are whole: a millisecond part
157
+ * of `second` is dropped.
158
+ */ export function zonedWallTimeToInstant(wall, timeZone) {
159
+ var _wall_hour, _wall_minute, _wall_second;
160
+ // Normalized first, so an overflowed field and the canonical one compare
161
+ // equal when a candidate is checked against it.
162
+ const asUtc = Date.UTC(wall.year, wall.month - 1, wall.day, (_wall_hour = wall.hour) != null ? _wall_hour : 0, (_wall_minute = wall.minute) != null ? _wall_minute : 0, Math.floor((_wall_second = wall.second) != null ? _wall_second : 0));
163
+ const target = new Date(asUtc);
164
+ const matches = (candidate)=>{
165
+ const fields = zonedDateTime(candidate, timeZone);
166
+ return fields.year === target.getUTCFullYear() && fields.month === target.getUTCMonth() + 1 && fields.day === target.getUTCDate() && fields.hour === target.getUTCHours() && fields.minute === target.getUTCMinutes() && fields.second === target.getUTCSeconds();
167
+ };
168
+ const before = asUtc - zoneOffsetMs(asUtc - DAY_MS, timeZone);
169
+ const after = asUtc - zoneOffsetMs(asUtc + DAY_MS, timeZone);
170
+ const beforeMatches = matches(before);
171
+ const afterMatches = matches(after);
172
+ if (beforeMatches && afterMatches) return Math.min(before, after);
173
+ if (beforeMatches) return before;
174
+ if (afterMatches) return after;
175
+ // Neither reads back: the wall time is inside a gap. The offset in force
176
+ // before the change carries it forward by the gap, which is the later of
177
+ // the two candidates wherever a clock jumps ahead.
178
+ return Math.max(before, after);
179
+ }
180
+ /** Midnight at the start of the calendar day `atMs` falls in, in `timeZone`. */ export function startOfZonedDay(atMs, timeZone) {
181
+ const { year, month, day } = zonedDateTime(atMs, timeZone);
182
+ return zonedWallTimeToInstant({
183
+ year,
184
+ month,
185
+ day
186
+ }, timeZone);
187
+ }
188
+ /**
189
+ * Midnight at the start of the NEXT calendar day in `timeZone` — built from
190
+ * the calendar rather than by adding 24 hours, because a day a clock change
191
+ * falls in is 23 or 25 hours long.
192
+ */ export function startOfNextZonedDay(atMs, timeZone) {
193
+ const { year, month, day } = zonedDateTime(atMs, timeZone);
194
+ return zonedWallTimeToInstant({
195
+ year,
196
+ month,
197
+ day: day + 1
198
+ }, timeZone);
199
+ }
200
+ /**
201
+ * How many calendar days separate the day `fromMs` falls in from the day
202
+ * `toMs` falls in, in `timeZone`: `0` for the same day, negative when `toMs`
203
+ * is on an earlier one. Counted on the calendar, so a 23-hour day is a day.
204
+ */ export function zonedCalendarDaysBetween(fromMs, toMs, timeZone) {
205
+ const from = zonedDateTime(fromMs, timeZone);
206
+ const to = zonedDateTime(toMs, timeZone);
207
+ return Math.round((Date.UTC(to.year, to.month - 1, to.day) - Date.UTC(from.year, from.month - 1, from.day)) / DAY_MS);
208
+ }
209
+ /**
210
+ * `count` business days after `atMs`, at the same wall-clock time, in
211
+ * `timeZone`.
212
+ *
213
+ * The calendar is walked a day at a time from the day after `atMs`, and each
214
+ * day whose weekday is in `businessDays` counts one. The time of day is the
215
+ * wall time `atMs` showed, so a 10:00 send is followed at 10:00 after a clock
216
+ * change as well; a wall time the destination day skips moves forward with
217
+ * the clock. `0` is `atMs` itself, whatever day that is.
218
+ *
219
+ * No holidays: a calendar that knew some countries' holidays would quietly
220
+ * shorten the wait in every country it did not know.
221
+ *
222
+ * @throws RangeError for a count that is not a whole number of days, or a
223
+ * `businessDays` list with no weekday in it.
224
+ */ export function addZonedBusinessDays(atMs, count, timeZone, businessDays = MONDAY_TO_FRIDAY) {
225
+ if (!Number.isInteger(count) || count < 0) {
226
+ throw new RangeError(`business days must be a whole number, got ${count}`);
227
+ }
228
+ if (count === 0) return atMs;
229
+ const open = new Set(businessDays.filter((day)=>Number.isInteger(day) && day >= 0 && day <= 6));
230
+ if (!open.size) throw new RangeError('no business day to count');
231
+ const start = zonedDateTime(atMs, timeZone);
232
+ let offset = 0;
233
+ let counted = 0;
234
+ while(counted < count){
235
+ offset += 1;
236
+ const calendar = new Date(Date.UTC(start.year, start.month - 1, start.day + offset));
237
+ if (open.has(calendar.getUTCDay())) counted += 1;
238
+ }
239
+ const landed = zonedWallTimeToInstant({
240
+ year: start.year,
241
+ month: start.month,
242
+ day: start.day + offset,
243
+ hour: start.hour,
244
+ minute: start.minute,
245
+ second: start.second
246
+ }, timeZone);
247
+ // The wall time is whole seconds; the instant keeps the millisecond it had.
248
+ return landed + (atMs - Math.floor(atMs / 1000) * 1000);
249
+ }
250
+ /** How far ahead an opening is looked for: two weeks covers any weekly shape. */ const OPENING_HORIZON_DAYS = 14;
251
+ function wallTimeAtMinute(date, minuteOfDay) {
252
+ return {
253
+ year: date.year,
254
+ month: date.month,
255
+ day: date.day,
256
+ hour: Math.floor(minuteOfDay / 60),
257
+ minute: minuteOfDay % 60
258
+ };
259
+ }
260
+ function usableIntervals(schedule, weekday) {
261
+ const intervals = schedule == null ? void 0 : schedule[weekday];
262
+ if (!Array.isArray(intervals)) return [];
263
+ return intervals.filter((interval)=>Number.isFinite(interval == null ? void 0 : interval.start) && Number.isFinite(interval == null ? void 0 : interval.end) && interval.start >= 0 && interval.end <= MINUTES_PER_DAY && interval.start < interval.end).sort((a, b)=>a.start - b.start);
264
+ }
265
+ /**
266
+ * The stretch of `schedule` that contains `atMs`, or else the next one to
267
+ * open after it — `startMs <= atMs` tells the two apart — or `null` when the
268
+ * schedule never opens.
269
+ *
270
+ * Each end of a stretch is placed on the calendar by
271
+ * {@link zonedWallTimeToInstant}, so the day a clock changes is measured as
272
+ * it is lived: 09:00–17:00 is eight hours of wall clock either way, a time
273
+ * the spring gap skips is read the way that function reads it, and a stretch
274
+ * that comes out empty — it starts inside the gap and ends as the gap ends —
275
+ * does not open that day.
276
+ */ export function nextWeeklyOpening(atMs, schedule, timeZone) {
277
+ const today = zonedDateTime(atMs, timeZone);
278
+ for(let offset = 0; offset <= OPENING_HORIZON_DAYS; offset += 1){
279
+ const calendar = new Date(Date.UTC(today.year, today.month - 1, today.day + offset));
280
+ const date = {
281
+ year: calendar.getUTCFullYear(),
282
+ month: calendar.getUTCMonth() + 1,
283
+ day: calendar.getUTCDate()
284
+ };
285
+ for (const interval of usableIntervals(schedule, calendar.getUTCDay())){
286
+ const startMs = zonedWallTimeToInstant(wallTimeAtMinute(date, interval.start), timeZone);
287
+ const endMs = zonedWallTimeToInstant(wallTimeAtMinute(date, interval.end), timeZone);
288
+ if (endMs <= startMs) continue;
289
+ if (atMs < endMs) return {
290
+ startMs,
291
+ endMs
292
+ };
293
+ }
294
+ }
295
+ return null;
296
+ }
297
+ /** Whether `atMs` falls inside an open stretch of `schedule`. */ export function isWithinWeeklySchedule(atMs, schedule, timeZone) {
298
+ const opening = nextWeeklyOpening(atMs, schedule, timeZone);
299
+ return opening !== null && opening.startMs <= atMs;
300
+ }
301
+
302
+ //# sourceMappingURL=zoned-time.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/shared/util/timestamp/src/lib/zoned-time.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * CALENDAR MATH IN A NAMED ZONE.\n *\n * `Intl` is the only zone database a browser or a serverless function is\n * guaranteed to have, and it only FORMATS: it turns an instant into the\n * calendar fields a wall clock in a zone shows, and nothing turns fields back\n * into an instant. Everything here is built on that one direction.\n *\n * ## Wall time to instant\n *\n * A wall time is read as if it were UTC, then shifted by the zone's offset.\n * Which offset is the whole difficulty: on the day a clock changes, the\n * offset a day before and the offset a day after differ, and a wall time can\n * fall in the hour that never happens (the spring gap) or the hour that\n * happens twice (the autumn overlap). So both offsets are tried and the\n * result is whichever candidate formats back to the wall time asked for —\n * the EARLIER one when both do, and, when neither does, the one that moves\n * the time forward by the length of the gap, which is where a clock that\n * skipped an hour puts it.\n *\n * ## Day-of-week and minute-of-day are wall-clock readings\n *\n * A window that opens at 09:00 opens at 09:00 on the clock on the day the\n * clocks change too, so the minute of the day is `hour * 60 + minute` as the\n * wall shows it, never the minutes elapsed since midnight.\n *\n * Imports nothing, on purpose: the package root extends the Firestore SDK's\n * `Timestamp`, and a published page that only needed a weekday must not ship\n * the Firestore client to get one. `zoned-time.isolation.spec.ts` holds it.\n *==========================================*/\n\nconst MINUTE_MS = 60_000\nconst DAY_MS = 24 * 60 * MINUTE_MS\n\n/** Minutes in a day: the exclusive end of a window that runs to midnight. */\nexport const MINUTES_PER_DAY = 24 * 60\n\nconst WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']\n\n/** Monday through Friday, in `Date#getUTCDay` numbering. */\nexport const MONDAY_TO_FRIDAY: readonly number[] = [1, 2, 3, 4, 5]\n\n/** The calendar fields a wall clock in a zone shows for one instant. */\nexport interface ZonedDateTime {\n year: number\n /** 1 January through 12 December. */\n month: number\n /** 1 through 31. */\n day: number\n /** 0 through 23. */\n hour: number\n minute: number\n second: number\n /** 0 Sunday through 6 Saturday. */\n weekday: number\n}\n\n/**\n * A wall time to find the instant of. Fields overflow the way `Date.UTC`\n * lets them — day 32 of a month is the first of the next — so a caller can\n * step a calendar by adding to `day`.\n */\nexport interface ZonedWallTime {\n year: number\n /** 1 January through 12 December; overflow rolls into the next year. */\n month: number\n day: number\n hour?: number\n minute?: number\n second?: number\n}\n\n/**\n * Formatters by zone. Building one is far slower than using one, and a\n * caller that walks a calendar asks the same zone thousands of times; a zone\n * name that `Intl` refuses throws and is never stored.\n */\nconst formatters = new Map<string, Intl.DateTimeFormat>()\n\nfunction formatterFor(timeZone: string): Intl.DateTimeFormat {\n let formatter = formatters.get(timeZone)\n if (!formatter) {\n formatter = new Intl.DateTimeFormat('en-US', {\n timeZone,\n hourCycle: 'h23',\n weekday: 'short',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n })\n formatters.set(timeZone, formatter)\n }\n return formatter\n}\n\n/** Whether `Intl` knows the zone, so a stored name can be checked before use. */\nexport function isValidTimeZone(value: unknown): value is string {\n if (typeof value !== 'string' || !value.trim()) return false\n try {\n formatterFor(value)\n return true\n } catch {\n return false\n }\n}\n\n/**\n * The wall clock in `timeZone` at `atMs`.\n *\n * @throws RangeError for a zone `Intl` does not know, or an instant that is\n * not a finite number.\n */\nexport function zonedDateTime(atMs: number, timeZone: string): ZonedDateTime {\n const fields: ZonedDateTime = {\n year: 0,\n month: 0,\n day: 0,\n hour: 0,\n minute: 0,\n second: 0,\n weekday: 0,\n }\n for (const part of formatterFor(timeZone).formatToParts(new Date(atMs))) {\n switch (part.type) {\n case 'year':\n fields.year = Number(part.value)\n break\n case 'month':\n fields.month = Number(part.value)\n break\n case 'day':\n fields.day = Number(part.value)\n break\n case 'hour':\n // `h23` reads midnight as 00; the modulo keeps an engine that still\n // prints 24 from starting a day at minute 1440.\n fields.hour = Number(part.value) % 24\n break\n case 'minute':\n fields.minute = Number(part.value)\n break\n case 'second':\n fields.second = Number(part.value)\n break\n case 'weekday':\n fields.weekday = Math.max(0, WEEKDAY_LABELS.indexOf(part.value))\n break\n }\n }\n return fields\n}\n\nconst pad = (value: number, width: number) => String(value).padStart(width, '0')\n\n/** `YYYY-MM-DD` of the calendar day `atMs` falls in, in `timeZone`. */\nexport function zonedDayKey(atMs: number, timeZone: string): string {\n const { year, month, day } = zonedDateTime(atMs, timeZone)\n return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`\n}\n\n/** The wall clock's minute of the day, `hour * 60 + minute`, 0 through 1439. */\nexport function zonedMinuteOfDay(atMs: number, timeZone: string): number {\n const { hour, minute } = zonedDateTime(atMs, timeZone)\n return hour * 60 + minute\n}\n\n/** The zone's offset from UTC at `atMs`, in milliseconds, positive east. */\nexport function zoneOffsetMs(atMs: number, timeZone: string): number {\n const fields = zonedDateTime(atMs, timeZone)\n const asUtc = Date.UTC(\n fields.year,\n fields.month - 1,\n fields.day,\n fields.hour,\n fields.minute,\n fields.second,\n )\n return asUtc - Math.floor(atMs / 1000) * 1000\n}\n\n/**\n * The instant a wall time happens in `timeZone` — see the module note for\n * the two days a year it is ambiguous. Seconds are whole: a millisecond part\n * of `second` is dropped.\n */\nexport function zonedWallTimeToInstant(\n wall: ZonedWallTime,\n timeZone: string,\n): number {\n // Normalized first, so an overflowed field and the canonical one compare\n // equal when a candidate is checked against it.\n const asUtc = Date.UTC(\n wall.year,\n wall.month - 1,\n wall.day,\n wall.hour ?? 0,\n wall.minute ?? 0,\n Math.floor(wall.second ?? 0),\n )\n const target = new Date(asUtc)\n const matches = (candidate: number): boolean => {\n const fields = zonedDateTime(candidate, timeZone)\n return (\n fields.year === target.getUTCFullYear() &&\n fields.month === target.getUTCMonth() + 1 &&\n fields.day === target.getUTCDate() &&\n fields.hour === target.getUTCHours() &&\n fields.minute === target.getUTCMinutes() &&\n fields.second === target.getUTCSeconds()\n )\n }\n const before = asUtc - zoneOffsetMs(asUtc - DAY_MS, timeZone)\n const after = asUtc - zoneOffsetMs(asUtc + DAY_MS, timeZone)\n const beforeMatches = matches(before)\n const afterMatches = matches(after)\n if (beforeMatches && afterMatches) return Math.min(before, after)\n if (beforeMatches) return before\n if (afterMatches) return after\n // Neither reads back: the wall time is inside a gap. The offset in force\n // before the change carries it forward by the gap, which is the later of\n // the two candidates wherever a clock jumps ahead.\n return Math.max(before, after)\n}\n\n/** Midnight at the start of the calendar day `atMs` falls in, in `timeZone`. */\nexport function startOfZonedDay(atMs: number, timeZone: string): number {\n const { year, month, day } = zonedDateTime(atMs, timeZone)\n return zonedWallTimeToInstant({ year, month, day }, timeZone)\n}\n\n/**\n * Midnight at the start of the NEXT calendar day in `timeZone` — built from\n * the calendar rather than by adding 24 hours, because a day a clock change\n * falls in is 23 or 25 hours long.\n */\nexport function startOfNextZonedDay(atMs: number, timeZone: string): number {\n const { year, month, day } = zonedDateTime(atMs, timeZone)\n return zonedWallTimeToInstant({ year, month, day: day + 1 }, timeZone)\n}\n\n/**\n * How many calendar days separate the day `fromMs` falls in from the day\n * `toMs` falls in, in `timeZone`: `0` for the same day, negative when `toMs`\n * is on an earlier one. Counted on the calendar, so a 23-hour day is a day.\n */\nexport function zonedCalendarDaysBetween(\n fromMs: number,\n toMs: number,\n timeZone: string,\n): number {\n const from = zonedDateTime(fromMs, timeZone)\n const to = zonedDateTime(toMs, timeZone)\n return Math.round(\n (Date.UTC(to.year, to.month - 1, to.day) -\n Date.UTC(from.year, from.month - 1, from.day)) /\n DAY_MS,\n )\n}\n\n/**\n * `count` business days after `atMs`, at the same wall-clock time, in\n * `timeZone`.\n *\n * The calendar is walked a day at a time from the day after `atMs`, and each\n * day whose weekday is in `businessDays` counts one. The time of day is the\n * wall time `atMs` showed, so a 10:00 send is followed at 10:00 after a clock\n * change as well; a wall time the destination day skips moves forward with\n * the clock. `0` is `atMs` itself, whatever day that is.\n *\n * No holidays: a calendar that knew some countries' holidays would quietly\n * shorten the wait in every country it did not know.\n *\n * @throws RangeError for a count that is not a whole number of days, or a\n * `businessDays` list with no weekday in it.\n */\nexport function addZonedBusinessDays(\n atMs: number,\n count: number,\n timeZone: string,\n businessDays: readonly number[] = MONDAY_TO_FRIDAY,\n): number {\n if (!Number.isInteger(count) || count < 0) {\n throw new RangeError(`business days must be a whole number, got ${count}`)\n }\n if (count === 0) return atMs\n const open = new Set(\n businessDays.filter((day) => Number.isInteger(day) && day >= 0 && day <= 6),\n )\n if (!open.size) throw new RangeError('no business day to count')\n const start = zonedDateTime(atMs, timeZone)\n let offset = 0\n let counted = 0\n while (counted < count) {\n offset += 1\n const calendar = new Date(Date.UTC(start.year, start.month - 1, start.day + offset))\n if (open.has(calendar.getUTCDay())) counted += 1\n }\n const landed = zonedWallTimeToInstant(\n {\n year: start.year,\n month: start.month,\n day: start.day + offset,\n hour: start.hour,\n minute: start.minute,\n second: start.second,\n },\n timeZone,\n )\n // The wall time is whole seconds; the instant keeps the millisecond it had.\n return landed + (atMs - Math.floor(atMs / 1000) * 1000)\n}\n\n/*==========================================\n * WEEKLY SCHEDULES.\n *==========================================*/\n\n/**\n * One open stretch of a day, in minutes after local midnight: `start`\n * inclusive, `end` exclusive, `0 <= start < end <= 1440`.\n */\nexport interface WeeklyInterval {\n start: number\n end: number\n}\n\n/**\n * Open stretches by weekday (`0` Sunday through `6` Saturday), read in one\n * zone. A weekday with no entry is closed.\n */\nexport type WeeklySchedule = Readonly<\n Partial<Record<number, readonly WeeklyInterval[]>>\n>\n\n/** A stretch of a schedule as instants: `startMs` inclusive, `endMs` exclusive. */\nexport interface ZonedInterval {\n startMs: number\n endMs: number\n}\n\n/** How far ahead an opening is looked for: two weeks covers any weekly shape. */\nconst OPENING_HORIZON_DAYS = 14\n\nfunction wallTimeAtMinute(\n date: { year: number; month: number; day: number },\n minuteOfDay: number,\n): ZonedWallTime {\n return {\n year: date.year,\n month: date.month,\n day: date.day,\n hour: Math.floor(minuteOfDay / 60),\n minute: minuteOfDay % 60,\n }\n}\n\nfunction usableIntervals(\n schedule: WeeklySchedule,\n weekday: number,\n): WeeklyInterval[] {\n const intervals = schedule?.[weekday]\n if (!Array.isArray(intervals)) return []\n return intervals\n .filter(\n (interval) =>\n Number.isFinite(interval?.start) &&\n Number.isFinite(interval?.end) &&\n interval.start >= 0 &&\n interval.end <= MINUTES_PER_DAY &&\n interval.start < interval.end,\n )\n .sort((a, b) => a.start - b.start)\n}\n\n/**\n * The stretch of `schedule` that contains `atMs`, or else the next one to\n * open after it — `startMs <= atMs` tells the two apart — or `null` when the\n * schedule never opens.\n *\n * Each end of a stretch is placed on the calendar by\n * {@link zonedWallTimeToInstant}, so the day a clock changes is measured as\n * it is lived: 09:00–17:00 is eight hours of wall clock either way, a time\n * the spring gap skips is read the way that function reads it, and a stretch\n * that comes out empty — it starts inside the gap and ends as the gap ends —\n * does not open that day.\n */\nexport function nextWeeklyOpening(\n atMs: number,\n schedule: WeeklySchedule,\n timeZone: string,\n): ZonedInterval | null {\n const today = zonedDateTime(atMs, timeZone)\n for (let offset = 0; offset <= OPENING_HORIZON_DAYS; offset += 1) {\n const calendar = new Date(Date.UTC(today.year, today.month - 1, today.day + offset))\n const date = {\n year: calendar.getUTCFullYear(),\n month: calendar.getUTCMonth() + 1,\n day: calendar.getUTCDate(),\n }\n for (const interval of usableIntervals(schedule, calendar.getUTCDay())) {\n const startMs = zonedWallTimeToInstant(wallTimeAtMinute(date, interval.start), timeZone)\n const endMs = zonedWallTimeToInstant(wallTimeAtMinute(date, interval.end), timeZone)\n if (endMs <= startMs) continue\n if (atMs < endMs) return { startMs, endMs }\n }\n }\n return null\n}\n\n/** Whether `atMs` falls inside an open stretch of `schedule`. */\nexport function isWithinWeeklySchedule(\n atMs: number,\n schedule: WeeklySchedule,\n timeZone: string,\n): boolean {\n const opening = nextWeeklyOpening(atMs, schedule, timeZone)\n return opening !== null && opening.startMs <= atMs\n}\n"],"names":["MINUTE_MS","DAY_MS","MINUTES_PER_DAY","WEEKDAY_LABELS","MONDAY_TO_FRIDAY","formatters","Map","formatterFor","timeZone","formatter","get","Intl","DateTimeFormat","hourCycle","weekday","year","month","day","hour","minute","second","set","isValidTimeZone","value","trim","zonedDateTime","atMs","fields","part","formatToParts","Date","type","Number","Math","max","indexOf","pad","width","String","padStart","zonedDayKey","zonedMinuteOfDay","zoneOffsetMs","asUtc","UTC","floor","zonedWallTimeToInstant","wall","target","matches","candidate","getUTCFullYear","getUTCMonth","getUTCDate","getUTCHours","getUTCMinutes","getUTCSeconds","before","after","beforeMatches","afterMatches","min","startOfZonedDay","startOfNextZonedDay","zonedCalendarDaysBetween","fromMs","toMs","from","to","round","addZonedBusinessDays","count","businessDays","isInteger","RangeError","open","Set","filter","size","start","offset","counted","calendar","has","getUTCDay","landed","OPENING_HORIZON_DAYS","wallTimeAtMinute","date","minuteOfDay","usableIntervals","schedule","intervals","Array","isArray","interval","isFinite","end","sort","a","b","nextWeeklyOpening","today","startMs","endMs","isWithinWeeklySchedule","opening"],"mappings":"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"}