dsh-model-pool 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +75 -0
- package/LICENSE +21 -0
- package/README.md +508 -2
- package/README.zh-CN.md +331 -0
- package/client.js +2847 -0
- package/cordis.patch.yml +15 -0
- package/docs/installation.md +115 -0
- package/docs/installation.zh-CN.md +107 -0
- package/icon.svg +17 -0
- package/index.js +718 -0
- package/lib/adapter.js +151 -0
- package/lib/capabilities.js +198 -0
- package/lib/catalog.js +175 -0
- package/lib/effort.js +151 -0
- package/lib/grouping.js +404 -0
- package/lib/health.js +175 -0
- package/lib/holiday-calendar.js +595 -0
- package/lib/peak.js +495 -0
- package/lib/router.js +584 -0
- package/lib/strategies.js +215 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +77 -4
package/lib/peak.js
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peak/idle period resolution for the pool's priority strategy.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no timers, no I/O, no host services. Every function takes the instant it
|
|
5
|
+
* reasons about, so one routing decision is reproducible from `(now, config)`
|
|
6
|
+
* alone and the tests never depend on the wall clock.
|
|
7
|
+
*
|
|
8
|
+
* The defaults implement the requested policy: Beijing time, Monday to Friday
|
|
9
|
+
* 09:00-12:00 and 14:00-18:00 are **peak**; every other moment — weekends and every
|
|
10
|
+
* statutory holiday — is **idle**.
|
|
11
|
+
*
|
|
12
|
+
* The holiday dates are not defined here. A worked calendar cannot be derived from a
|
|
13
|
+
* rule, so the Host fetches and caches it (`lib/holiday-calendar.js`) and passes the
|
|
14
|
+
* resolved date sets in as `holidays` / `makeupWorkdays`. Keeping the data out of
|
|
15
|
+
* this module is what lets it stay pure while the dates still stay current.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-model-pool/lib/peak
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Peak period id. */
|
|
21
|
+
export const PEAK = "peak";
|
|
22
|
+
|
|
23
|
+
/** Idle period id. */
|
|
24
|
+
export const OFF_PEAK = "offPeak";
|
|
25
|
+
|
|
26
|
+
/** Time zone the defaults are expressed in. */
|
|
27
|
+
export const DEFAULT_PEAK_TIME_ZONE = "Asia/Shanghai";
|
|
28
|
+
|
|
29
|
+
/** Default peak windows, in local time. */
|
|
30
|
+
export const DEFAULT_PEAK_WINDOWS = ["09:00-12:00", "14:00-18:00"];
|
|
31
|
+
|
|
32
|
+
/** Default peak weekdays: ISO 1 = Monday … 7 = Sunday. */
|
|
33
|
+
export const DEFAULT_PEAK_WEEKDAYS = [1, 2, 3, 4, 5];
|
|
34
|
+
|
|
35
|
+
/** Minutes in one wall-clock day. */
|
|
36
|
+
const MINUTES_PER_DAY = 1440;
|
|
37
|
+
|
|
38
|
+
/** Milliseconds in one wall-clock day. */
|
|
39
|
+
const DAY_MS = 86_400_000;
|
|
40
|
+
|
|
41
|
+
/** Priority base for a provider the effective ranking does not list. */
|
|
42
|
+
const UNRANKED_PRIORITY = 1000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How many days `nextChangeAt` looks ahead before giving up.
|
|
46
|
+
* Sized for the longest idle run the fetched calendar can produce: the 2026 Spring
|
|
47
|
+
* Festival closes 2026-02-15..02-23, so the next peak starts 11 days after the last
|
|
48
|
+
* working Friday. 16 leaves room for a longer future block without turning a routing
|
|
49
|
+
* decision into a long scan.
|
|
50
|
+
*/
|
|
51
|
+
const LOOKAHEAD_DAYS = 16;
|
|
52
|
+
|
|
53
|
+
/** English short weekday names, as the ICU formatter spells them. */
|
|
54
|
+
const WEEKDAY_BY_SHORT = { Sun: 7, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
55
|
+
|
|
56
|
+
/** Formatter per time zone, reused across calls (construction is the expensive part). */
|
|
57
|
+
const FORMATTERS = new Map();
|
|
58
|
+
|
|
59
|
+
/** Whether one time zone is accepted by `Intl`, cached. */
|
|
60
|
+
const ZONE_USABLE = new Map();
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One `Intl` formatter for a time zone.
|
|
64
|
+
* @param timeZone - IANA zone name.
|
|
65
|
+
* @returns the cached formatter.
|
|
66
|
+
*/
|
|
67
|
+
function formatterFor(timeZone) {
|
|
68
|
+
let formatter = FORMATTERS.get(timeZone);
|
|
69
|
+
if (formatter === undefined) {
|
|
70
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
71
|
+
timeZone,
|
|
72
|
+
hourCycle: "h23",
|
|
73
|
+
year: "numeric",
|
|
74
|
+
month: "2-digit",
|
|
75
|
+
day: "2-digit",
|
|
76
|
+
hour: "2-digit",
|
|
77
|
+
minute: "2-digit",
|
|
78
|
+
second: "2-digit",
|
|
79
|
+
weekday: "short",
|
|
80
|
+
});
|
|
81
|
+
FORMATTERS.set(timeZone, formatter);
|
|
82
|
+
}
|
|
83
|
+
return formatter;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Whether `Intl` knows a time zone.
|
|
88
|
+
* @param timeZone - IANA zone name.
|
|
89
|
+
* @returns true when a formatter can be built for it.
|
|
90
|
+
*/
|
|
91
|
+
function zoneUsable(timeZone) {
|
|
92
|
+
const cached = ZONE_USABLE.get(timeZone);
|
|
93
|
+
if (cached !== undefined) return cached;
|
|
94
|
+
let usable = true;
|
|
95
|
+
try {
|
|
96
|
+
formatterFor(timeZone).format(0);
|
|
97
|
+
} catch {
|
|
98
|
+
usable = false;
|
|
99
|
+
}
|
|
100
|
+
ZONE_USABLE.set(timeZone, usable);
|
|
101
|
+
return usable;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a configured time zone to one that can actually be read.
|
|
106
|
+
* An empty or unknown zone falls back to the default, so a typo can never break
|
|
107
|
+
* routing — it only means "Beijing time".
|
|
108
|
+
* @param raw - configured zone.
|
|
109
|
+
* @returns a usable IANA zone name.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveTimeZone(raw) {
|
|
112
|
+
if (typeof raw !== "string") return DEFAULT_PEAK_TIME_ZONE;
|
|
113
|
+
const zone = raw.trim();
|
|
114
|
+
if (zone === "") return DEFAULT_PEAK_TIME_ZONE;
|
|
115
|
+
if (typeof Intl === "undefined") return DEFAULT_PEAK_TIME_ZONE;
|
|
116
|
+
return zoneUsable(zone) ? zone : DEFAULT_PEAK_TIME_ZONE;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Two-digit zero padding.
|
|
121
|
+
* @param value - number to pad.
|
|
122
|
+
* @returns the padded string.
|
|
123
|
+
*/
|
|
124
|
+
function pad(value) {
|
|
125
|
+
return String(value).padStart(2, "0");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* UTC wall-clock reading of an instant shifted by a fixed offset.
|
|
130
|
+
* The fallback path for a runtime without `Intl` support for the zone.
|
|
131
|
+
* @param epochMs - the instant.
|
|
132
|
+
* @param offsetMinutes - minutes east of UTC.
|
|
133
|
+
* @returns the wall-clock parts.
|
|
134
|
+
*/
|
|
135
|
+
function offsetParts(epochMs, offsetMinutes) {
|
|
136
|
+
const shifted = new Date(epochMs + offsetMinutes * 60_000);
|
|
137
|
+
const date = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`;
|
|
138
|
+
const utcWeekday = shifted.getUTCDay();
|
|
139
|
+
return {
|
|
140
|
+
date,
|
|
141
|
+
weekday: utcWeekday === 0 ? 7 : utcWeekday,
|
|
142
|
+
minuteOfDay: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
|
|
143
|
+
secondOfMinute: shifted.getUTCSeconds(),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Wall-clock reading of an instant in one time zone.
|
|
149
|
+
* @param epochMs - epoch milliseconds.
|
|
150
|
+
* @param timeZone - IANA zone name.
|
|
151
|
+
* @returns `{ date: 'YYYY-MM-DD', weekday: 1..7, minuteOfDay, secondOfMinute, timeZone }`.
|
|
152
|
+
*/
|
|
153
|
+
export function wallClock(epochMs, timeZone = DEFAULT_PEAK_TIME_ZONE) {
|
|
154
|
+
const at = Number.isFinite(epochMs) ? epochMs : Date.now();
|
|
155
|
+
const zone = resolveTimeZone(timeZone);
|
|
156
|
+
let parts;
|
|
157
|
+
try {
|
|
158
|
+
const bag = {};
|
|
159
|
+
for (const part of formatterFor(zone).formatToParts(new Date(at))) bag[part.type] = part.value;
|
|
160
|
+
const year = Number(bag.year);
|
|
161
|
+
const month = Number(bag.month);
|
|
162
|
+
const day = Number(bag.day);
|
|
163
|
+
const hour = Number(bag.hour) % 24;
|
|
164
|
+
const minute = Number(bag.minute);
|
|
165
|
+
const second = Number(bag.second);
|
|
166
|
+
if ([year, month, day, hour, minute].every((value) => Number.isFinite(value))) {
|
|
167
|
+
parts = {
|
|
168
|
+
date: `${year}-${pad(month)}-${pad(day)}`,
|
|
169
|
+
// A zone offset is always a whole number of minutes, so this is exact.
|
|
170
|
+
weekday: WEEKDAY_BY_SHORT[bag.weekday] ?? 1,
|
|
171
|
+
minuteOfDay: hour * 60 + minute,
|
|
172
|
+
secondOfMinute: Number.isFinite(second) ? second : 0,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
parts = undefined;
|
|
177
|
+
}
|
|
178
|
+
const clock = parts ?? offsetParts(at, 480);
|
|
179
|
+
return { ...clock, timeZone: zone };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parse one `HH:MM-HH:MM` window.
|
|
184
|
+
* `24:00` is accepted as an end, so a window may run to midnight.
|
|
185
|
+
* @param text - one configured window.
|
|
186
|
+
* @returns `{ start, end, label }` in minutes, or undefined when unusable.
|
|
187
|
+
*/
|
|
188
|
+
export function parsePeakWindow(text) {
|
|
189
|
+
if (typeof text !== "string") return undefined;
|
|
190
|
+
const match = /^\s*(\d{1,2}):(\d{2})\s*[-–—~至]\s*(\d{1,2}):(\d{2})\s*$/.exec(text);
|
|
191
|
+
if (match === null) return undefined;
|
|
192
|
+
const start = Number(match[1]) * 60 + Number(match[2]);
|
|
193
|
+
const end = Number(match[3]) * 60 + Number(match[4]);
|
|
194
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined;
|
|
195
|
+
if (start < 0 || start >= MINUTES_PER_DAY) return undefined;
|
|
196
|
+
if (end <= start || end > MINUTES_PER_DAY) return undefined;
|
|
197
|
+
return { start, end, label: formatWindow(start, end) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Render a minute range as `HH:MM-HH:MM`.
|
|
202
|
+
* @param start - first minute of the window.
|
|
203
|
+
* @param end - first minute after the window.
|
|
204
|
+
* @returns the label.
|
|
205
|
+
*/
|
|
206
|
+
export function formatWindow(start, end) {
|
|
207
|
+
return `${pad(Math.floor(start / 60))}:${pad(start % 60)}-${pad(Math.floor(end / 60))}:${pad(end % 60)}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parse a list of window strings.
|
|
212
|
+
* An absent list means the defaults; an explicit empty list means "never peak".
|
|
213
|
+
* Unusable entries are dropped rather than throwing.
|
|
214
|
+
* @param raw - configured windows.
|
|
215
|
+
* @returns the parsed windows, sorted and merged.
|
|
216
|
+
*/
|
|
217
|
+
export function parsePeakWindows(raw) {
|
|
218
|
+
const source = raw === undefined || raw === null ? DEFAULT_PEAK_WINDOWS : raw;
|
|
219
|
+
const parsed = (Array.isArray(source) ? source : [])
|
|
220
|
+
.map(parsePeakWindow)
|
|
221
|
+
.filter((window) => window !== undefined)
|
|
222
|
+
.sort((left, right) => left.start - right.start);
|
|
223
|
+
const merged = [];
|
|
224
|
+
for (const window of parsed) {
|
|
225
|
+
const last = merged.at(-1);
|
|
226
|
+
if (last !== undefined && window.start <= last.end) {
|
|
227
|
+
if (window.end > last.end) {
|
|
228
|
+
last.end = window.end;
|
|
229
|
+
last.label = formatWindow(last.start, last.end);
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
merged.push({ ...window });
|
|
234
|
+
}
|
|
235
|
+
return merged;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Normalize the weekday list.
|
|
240
|
+
* An absent list means Monday to Friday; an explicit empty list means "never peak".
|
|
241
|
+
* @param raw - configured weekdays.
|
|
242
|
+
* @returns ISO weekdays 1..7, in ascending order.
|
|
243
|
+
*/
|
|
244
|
+
export function parsePeakWeekdays(raw) {
|
|
245
|
+
const source = raw === undefined || raw === null ? DEFAULT_PEAK_WEEKDAYS : raw;
|
|
246
|
+
const days = new Set();
|
|
247
|
+
for (const entry of Array.isArray(source) ? source : []) {
|
|
248
|
+
const day = Number(entry);
|
|
249
|
+
if (Number.isInteger(day) && day >= 1 && day <= 7) days.add(day);
|
|
250
|
+
}
|
|
251
|
+
return [...days].sort((left, right) => left - right);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Whether a string is a real calendar date in `YYYY-MM-DD` form.
|
|
256
|
+
* @param value - candidate date.
|
|
257
|
+
* @returns true for a date such as `2026-02-17`.
|
|
258
|
+
*/
|
|
259
|
+
export function isDateString(value) {
|
|
260
|
+
if (typeof value !== "string") return false;
|
|
261
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
|
|
262
|
+
if (match === null) return false;
|
|
263
|
+
const year = Number(match[1]);
|
|
264
|
+
const month = Number(match[2]);
|
|
265
|
+
const day = Number(match[3]);
|
|
266
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
|
|
267
|
+
const probe = new Date(Date.UTC(year, month - 1, day));
|
|
268
|
+
return probe.getUTCFullYear() === year && probe.getUTCMonth() === month - 1 && probe.getUTCDate() === day;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Parse a list of `YYYY-MM-DD` dates into a set.
|
|
273
|
+
* Unusable entries are dropped; a present list replaces the default entirely.
|
|
274
|
+
* @param raw - configured dates, or undefined to use the caller's default.
|
|
275
|
+
* @param fallback - dates used when the field is absent.
|
|
276
|
+
* @returns the date set.
|
|
277
|
+
*/
|
|
278
|
+
export function parseDateSet(raw, fallback = []) {
|
|
279
|
+
const source = raw === undefined || raw === null ? fallback : raw;
|
|
280
|
+
const set = new Set();
|
|
281
|
+
for (const entry of Array.isArray(source) ? source : []) {
|
|
282
|
+
if (typeof entry !== "string") continue;
|
|
283
|
+
const date = entry.trim();
|
|
284
|
+
if (isDateString(date)) set.add(date);
|
|
285
|
+
}
|
|
286
|
+
return set;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Normalize the whole peak configuration into the shape the pure functions read.
|
|
291
|
+
*
|
|
292
|
+
* `holidays` and `makeupWorkdays` arrive already resolved: the Host reads them from the
|
|
293
|
+
* fetched calendar (`lib/holiday-calendar.js`) and layers `holidayOverride` on top before
|
|
294
|
+
* calling here. This module therefore has no built-in holiday list, and an absent list
|
|
295
|
+
* means "no known holidays" — the conservative reading that treats every day by the
|
|
296
|
+
* weekday rule when the calendar has never loaded.
|
|
297
|
+
*
|
|
298
|
+
* @param config - plain pool configuration (`peak*` fields plus resolved date sets).
|
|
299
|
+
* @returns the normalized peak configuration.
|
|
300
|
+
*/
|
|
301
|
+
export function normalizePeakConfig(config = {}) {
|
|
302
|
+
const source = config === null || config === undefined ? {} : config;
|
|
303
|
+
return {
|
|
304
|
+
enabled: source.peakEnabled !== false,
|
|
305
|
+
timeZone: resolveTimeZone(source.peakTimeZone),
|
|
306
|
+
windows: parsePeakWindows(source.peakWindows),
|
|
307
|
+
weekdays: parsePeakWeekdays(source.peakWeekdays),
|
|
308
|
+
holidays: parseDateSet(source.holidays, []),
|
|
309
|
+
makeupWorkdays: parseDateSet(source.makeupWorkdays, []),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Whether one calendar day counts as a peak day, ignoring the time of day.
|
|
315
|
+
*
|
|
316
|
+
* Precedence, highest first: statutory holiday (always idle) → make-up workday
|
|
317
|
+
* (peak even on a weekend) → the weekday rule. A holiday therefore wins even when
|
|
318
|
+
* an operator lists the same date as a make-up workday: the two lists answer
|
|
319
|
+
* different questions ("the State Council closed" vs "the State Council opened a
|
|
320
|
+
* Saturday"), and a date cannot be both, so the closed reading is the safe one.
|
|
321
|
+
*
|
|
322
|
+
* @param cfg - normalized peak configuration.
|
|
323
|
+
* @param weekday - ISO weekday of the date.
|
|
324
|
+
* @param date - `YYYY-MM-DD`.
|
|
325
|
+
* @returns true when the day is a working day under the peak policy.
|
|
326
|
+
*/
|
|
327
|
+
function isPeakDay(cfg, weekday, date) {
|
|
328
|
+
if (cfg.enabled !== true) return false;
|
|
329
|
+
if (cfg.holidays.has(date)) return false;
|
|
330
|
+
if (cfg.makeupWorkdays.has(date)) return true;
|
|
331
|
+
return cfg.weekdays.includes(weekday);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The period of one wall-clock reading.
|
|
336
|
+
* @param cfg - normalized peak configuration.
|
|
337
|
+
* @param clock - wall-clock parts.
|
|
338
|
+
* @returns `peak` or `offPeak`.
|
|
339
|
+
*/
|
|
340
|
+
function periodOfClock(cfg, clock) {
|
|
341
|
+
if (!isPeakDay(cfg, clock.weekday, clock.date)) return OFF_PEAK;
|
|
342
|
+
return cfg.windows.some((window) => clock.minuteOfDay >= window.start && clock.minuteOfDay < window.end)
|
|
343
|
+
? PEAK
|
|
344
|
+
: OFF_PEAK;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The period one instant falls in.
|
|
349
|
+
* @param epochMs - epoch milliseconds.
|
|
350
|
+
* @param config - plain pool configuration.
|
|
351
|
+
* @returns `peak` or `offPeak`.
|
|
352
|
+
*/
|
|
353
|
+
export function periodAt(epochMs, config = {}) {
|
|
354
|
+
const cfg = normalizePeakConfig(config);
|
|
355
|
+
const at = Number.isFinite(epochMs) ? epochMs : Date.now();
|
|
356
|
+
return periodOfClock(cfg, wallClock(at, cfg.timeZone));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Split one day into alternating segments.
|
|
361
|
+
* @param cfg - normalized peak configuration.
|
|
362
|
+
* @param weekday - ISO weekday of the date.
|
|
363
|
+
* @param date - `YYYY-MM-DD`.
|
|
364
|
+
* @returns segments `{ start, end, period }` covering the whole day, in order.
|
|
365
|
+
*/
|
|
366
|
+
function daySegments(cfg, weekday, date) {
|
|
367
|
+
const segments = [];
|
|
368
|
+
let cursor = 0;
|
|
369
|
+
if (isPeakDay(cfg, weekday, date)) {
|
|
370
|
+
for (const window of cfg.windows) {
|
|
371
|
+
if (window.start > cursor) segments.push({ start: cursor, end: window.start, period: OFF_PEAK });
|
|
372
|
+
if (window.end > window.start) segments.push({ start: window.start, end: window.end, period: PEAK });
|
|
373
|
+
cursor = window.end;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (cursor < MINUTES_PER_DAY) segments.push({ start: cursor, end: MINUTES_PER_DAY, period: OFF_PEAK });
|
|
377
|
+
return segments;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The next moment the period actually changes, looking ahead a bounded number of
|
|
382
|
+
* days. Boundaries that leave the period unchanged are stepped over: sitting at
|
|
383
|
+
* 19:00 on a Friday, the answer is Monday 09:00, not the next midnight.
|
|
384
|
+
* @param at - the instant to search from.
|
|
385
|
+
* @param cfg - normalized peak configuration.
|
|
386
|
+
* @param clock - wall-clock parts of `at`.
|
|
387
|
+
* @param current - the period at `at`.
|
|
388
|
+
* @returns `{ at, period }` of the next transition, or null when none is found.
|
|
389
|
+
*/
|
|
390
|
+
function nextTransition(at, cfg, clock, current) {
|
|
391
|
+
if (cfg.enabled !== true) return null;
|
|
392
|
+
const millisecondOfSecond = ((at % 1000) + 1000) % 1000;
|
|
393
|
+
const dayStart = at - (clock.minuteOfDay * 60_000 + clock.secondOfMinute * 1000 + millisecondOfSecond);
|
|
394
|
+
for (let offset = 0; offset <= LOOKAHEAD_DAYS; offset += 1) {
|
|
395
|
+
const probe = dayStart + offset * DAY_MS;
|
|
396
|
+
const parts = wallClock(probe, cfg.timeZone);
|
|
397
|
+
// Re-derive the day start from the probe rather than assuming 24h days, so a
|
|
398
|
+
// zone with a DST jump still yields exact minute boundaries.
|
|
399
|
+
const base = probe - (parts.minuteOfDay * 60_000 + parts.secondOfMinute * 1000);
|
|
400
|
+
for (const segment of daySegments(cfg, parts.weekday, parts.date)) {
|
|
401
|
+
const boundary = base + segment.start * 60_000;
|
|
402
|
+
// The boundary at the start of a segment carries the period that *begins*
|
|
403
|
+
// there, so only a boundary that changes the period is a transition.
|
|
404
|
+
if (segment.period === current) continue;
|
|
405
|
+
if (boundary > at) return { at: boundary, period: segment.period };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Describe the period of one instant, including why and when it changes.
|
|
413
|
+
* @param epochMs - epoch milliseconds.
|
|
414
|
+
* @param config - plain pool configuration.
|
|
415
|
+
* @returns a JSON-friendly description of the period.
|
|
416
|
+
*/
|
|
417
|
+
export function describePeriod(epochMs, config = {}) {
|
|
418
|
+
const cfg = normalizePeakConfig(config);
|
|
419
|
+
const at = Number.isFinite(epochMs) ? epochMs : Date.now();
|
|
420
|
+
const clock = wallClock(at, cfg.timeZone);
|
|
421
|
+
const period = periodOfClock(cfg, clock);
|
|
422
|
+
const next = nextTransition(at, cfg, clock, period);
|
|
423
|
+
return {
|
|
424
|
+
period,
|
|
425
|
+
at,
|
|
426
|
+
timeZone: cfg.timeZone,
|
|
427
|
+
date: clock.date,
|
|
428
|
+
weekday: clock.weekday,
|
|
429
|
+
minuteOfDay: clock.minuteOfDay,
|
|
430
|
+
enabled: cfg.enabled,
|
|
431
|
+
peakDay: isPeakDay(cfg, clock.weekday, clock.date),
|
|
432
|
+
holiday: cfg.holidays.has(clock.date),
|
|
433
|
+
makeupWorkday: cfg.makeupWorkdays.has(clock.date),
|
|
434
|
+
windows: cfg.windows.map((window) => ({ start: window.start, end: window.end, label: window.label })),
|
|
435
|
+
weekdays: [...cfg.weekdays],
|
|
436
|
+
nextChangeAt: next === null ? null : next.at,
|
|
437
|
+
nextPeriod: next === null ? null : next.period,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* De-duplicate a list of provider ids, dropping blanks and non-strings.
|
|
443
|
+
* @param raw - configured ids.
|
|
444
|
+
* @returns the ids in first-seen order.
|
|
445
|
+
*/
|
|
446
|
+
function uniqueProviderIds(raw) {
|
|
447
|
+
const ids = [];
|
|
448
|
+
for (const entry of Array.isArray(raw) ? raw : []) {
|
|
449
|
+
if (typeof entry !== "string") continue;
|
|
450
|
+
const id = entry.trim();
|
|
451
|
+
if (id === "" || ids.includes(id)) continue;
|
|
452
|
+
ids.push(id);
|
|
453
|
+
}
|
|
454
|
+
return ids;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The effective provider ranking for one period.
|
|
459
|
+
*
|
|
460
|
+
* The period's own list wins; providers it omits follow `providerOrder`, and any
|
|
461
|
+
* provider missing from both keeps registration order through `route.priority`
|
|
462
|
+
* (see `order` in `lib/strategies.js`, which places unlisted providers behind the
|
|
463
|
+
* ranking). Returns undefined when the period has no list of its own, which is
|
|
464
|
+
* what keeps the feature inert until it is configured.
|
|
465
|
+
*
|
|
466
|
+
* @param period - `peak` or `offPeak`.
|
|
467
|
+
* @param config - plain pool configuration.
|
|
468
|
+
* @returns an ordered provider id list, or undefined when the ranking is inert.
|
|
469
|
+
*/
|
|
470
|
+
export function priorityRanking(period, config = {}) {
|
|
471
|
+
const source = config === null || config === undefined ? {} : config;
|
|
472
|
+
if (source.peakEnabled === false) return undefined;
|
|
473
|
+
const listed = uniqueProviderIds(period === PEAK ? source.peakProviderOrder : source.offPeakProviderOrder);
|
|
474
|
+
if (listed.length === 0) return undefined;
|
|
475
|
+
const ranking = [...listed];
|
|
476
|
+
for (const id of uniqueProviderIds(source.providerOrder)) {
|
|
477
|
+
if (!ranking.includes(id)) ranking.push(id);
|
|
478
|
+
}
|
|
479
|
+
return ranking;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The priority key of one provider under a ranking.
|
|
484
|
+
* Unlisted providers sort behind every listed one, keeping their own
|
|
485
|
+
* `route.priority` order among themselves.
|
|
486
|
+
* @param ranking - effective provider ranking.
|
|
487
|
+
* @param provider - provider id.
|
|
488
|
+
* @param priority - the route's configured priority.
|
|
489
|
+
* @returns the sort key.
|
|
490
|
+
*/
|
|
491
|
+
export function rankOf(ranking, provider, priority) {
|
|
492
|
+
const listed = Array.isArray(ranking) ? ranking.indexOf(provider) : -1;
|
|
493
|
+
if (listed >= 0) return listed;
|
|
494
|
+
return UNRANKED_PRIORITY + (Number.isFinite(priority) ? priority : 0);
|
|
495
|
+
}
|