@ix-xs/node-comfort 1.1.31 → 2.0.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 +190 -0
- package/LICENSE +21 -0
- package/README.md +210 -748
- package/index.js +84 -13
- package/package.json +201 -21
- package/src/Arr.js +901 -0
- package/src/Async.js +585 -0
- package/src/Cache.js +329 -0
- package/src/Checker.js +979 -188
- package/src/Cli.js +1034 -0
- package/src/Color.js +415 -0
- package/src/Crypto.js +707 -0
- package/src/Emitter.js +289 -0
- package/src/Env.js +555 -0
- package/src/FS.js +1483 -768
- package/src/Func.js +692 -0
- package/src/Http.js +538 -0
- package/src/Id.js +418 -0
- package/src/Logger.js +739 -307
- package/src/Num.js +849 -0
- package/src/Obj.js +904 -0
- package/src/SQLite.js +1308 -994
- package/src/Schema.js +1586 -0
- package/src/Str.js +999 -0
- package/src/Sys.js +511 -0
- package/src/Time.js +1323 -0
- package/src/Utils.js +321 -325
- package/src/errors.js +292 -0
- package/types/index.d.ts +693 -120
- package/types/src/Arr.d.ts +562 -0
- package/types/src/Arr.d.ts.map +1 -0
- package/types/src/Async.d.ts +321 -0
- package/types/src/Async.d.ts.map +1 -0
- package/types/src/Cache.d.ts +195 -0
- package/types/src/Cache.d.ts.map +1 -0
- package/types/src/Checker.d.ts +609 -81
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +582 -0
- package/types/src/Cli.d.ts.map +1 -0
- package/types/src/Color.d.ts +294 -0
- package/types/src/Color.d.ts.map +1 -0
- package/types/src/Crypto.d.ts +534 -0
- package/types/src/Crypto.d.ts.map +1 -0
- package/types/src/Emitter.d.ts +170 -0
- package/types/src/Emitter.d.ts.map +1 -0
- package/types/src/Env.d.ts +382 -0
- package/types/src/Env.d.ts.map +1 -0
- package/types/src/FS.d.ts +709 -201
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +455 -0
- package/types/src/Func.d.ts.map +1 -0
- package/types/src/Http.d.ts +374 -0
- package/types/src/Http.d.ts.map +1 -0
- package/types/src/Id.d.ts +200 -0
- package/types/src/Id.d.ts.map +1 -0
- package/types/src/Logger.d.ts +374 -24
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +578 -0
- package/types/src/Num.d.ts.map +1 -0
- package/types/src/Obj.d.ts +475 -0
- package/types/src/Obj.d.ts.map +1 -0
- package/types/src/SQLite.d.ts +647 -305
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +940 -0
- package/types/src/Schema.d.ts.map +1 -0
- package/types/src/Str.d.ts +696 -0
- package/types/src/Str.d.ts.map +1 -0
- package/types/src/Sys.d.ts +269 -0
- package/types/src/Sys.d.ts.map +1 -0
- package/types/src/Time.d.ts +664 -0
- package/types/src/Time.d.ts.map +1 -0
- package/types/src/Utils.d.ts +154 -102
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +292 -0
- package/types/src/errors.d.ts.map +1 -0
- package/tsconfig.json +0 -18
- package/types/index.d.ts.map +0 -1
package/src/Time.js
ADDED
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dates and durations: formatting, relative time, calendar math, time zones
|
|
5
|
+
* and scheduling, in any language `Intl` knows. The default language is
|
|
6
|
+
* English so results are the same on every machine; change it with
|
|
7
|
+
* `setLocale()` or per call with `{ locale }`. Time zones work the same way
|
|
8
|
+
* with `setTimezone()` or `{ timeZone }`.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* time.setLocale("fr").setTimezone("Europe/Paris");
|
|
12
|
+
* time.relative(Date.now() - 3600e3); // "il y a 1 heure"
|
|
13
|
+
* time.format(new Date(), "dddd D MMMM YYYY"); // "lundi 15 janvier 2024"
|
|
14
|
+
* time.cron("0 9 * * 1-5", sendReport); // weekdays at 9:00
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { types } = require("node:util");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A date: a `Date`, a timestamp in ms, or a string `new Date()` understands
|
|
21
|
+
* (ISO 8601 is safest).
|
|
22
|
+
* @typedef {Date | number | string} DateInput
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A unit with a fixed length.
|
|
27
|
+
* @typedef {"ms" | "millisecond" | "milliseconds" | "s" | "sec" | "second" | "seconds" | "m" | "min" | "minute" | "minutes" | "h" | "hour" | "hours" | "d" | "day" | "days" | "w" | "week" | "weeks"} DurationUnit
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A fixed unit, or months and years, whose length varies.
|
|
32
|
+
* @typedef {DurationUnit | "M" | "month" | "months" | "y" | "year" | "years"} CalendarUnit
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A unit for `startOf()` and `endOf()`.
|
|
37
|
+
* @typedef {"year" | "quarter" | "month" | "week" | "isoWeek" | "day" | "hour" | "minute" | "second"} StartOfUnit
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Options for `formatDuration()`.
|
|
42
|
+
* @typedef {object} FormatDurationOptions
|
|
43
|
+
* @property {boolean} [long] Full, translated words: `"1 hour 30 minutes"` instead of `"1h 30m"`.
|
|
44
|
+
* @property {number} [units] Show at most this many units, largest first: `units: 2` turns `"1d 3h 5m"` into `"1d 3h"`.
|
|
45
|
+
* @property {boolean} [clock] Clock style, like `"01:30:05"`.
|
|
46
|
+
* @property {boolean} [milliseconds] Show milliseconds. Defaults to `true`.
|
|
47
|
+
* @property {string} [locale] Language for `long`. Defaults to the global locale.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Options for `relative()`.
|
|
52
|
+
* @typedef {object} RelativeOptions
|
|
53
|
+
* @property {string} [locale] Defaults to the global locale.
|
|
54
|
+
* @property {"always" | "auto"} [numeric] `"auto"` says `"yesterday"` instead of `"1 day ago"`. Defaults to `"always"`.
|
|
55
|
+
* @property {"long" | "short" | "narrow"} [style] `"short"` gives `"in 3 mo."`. Defaults to `"long"`.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Options for `format()`.
|
|
60
|
+
* @typedef {object} FormatDateOptions
|
|
61
|
+
* @property {string} [locale] Language of month and day names. Defaults to the global locale.
|
|
62
|
+
* @property {string} [timeZone] Show the date in this zone, like `"Asia/Tokyo"`. Defaults to the global zone, then the system's.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Options for `calendar()`.
|
|
67
|
+
* @typedef {object} CalendarOptions
|
|
68
|
+
* @property {string} [locale] Defaults to the global locale.
|
|
69
|
+
* @property {string} [timeZone] Defaults to the global zone, then the system's.
|
|
70
|
+
* @property {DateInput} [now] What "now" is. Defaults to the current time.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Week settings.
|
|
75
|
+
* @typedef {object} WeekOptions
|
|
76
|
+
* @property {0 | 1 | 2 | 3 | 4 | 5 | 6} [weekStartsOn] First day of the week: `0` for Sunday, `1` for Monday. Defaults to `1`.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Time zone setting.
|
|
81
|
+
* @typedef {object} TimeZoneOptions
|
|
82
|
+
* @property {string} [timeZone] Zone used to decide which day it is. Defaults to the global zone, then the system's.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Options for `every()`.
|
|
87
|
+
* @typedef {object} EveryOptions
|
|
88
|
+
* @property {boolean} [immediate] Also run once right away.
|
|
89
|
+
* @property {boolean} [unref] Let the process exit even if the schedule is active.
|
|
90
|
+
* @property {(error: unknown) => void} [onError] Called when the task fails. Without it, errors surface as unhandled errors.
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Options for `cron()` and `nextRun()`.
|
|
95
|
+
* @typedef {object} CronOptions
|
|
96
|
+
* @property {string} [timeZone] Zone in which the expression is read, like `"Europe/Paris"`. Defaults to the global zone, then the system's.
|
|
97
|
+
* @property {DateInput} [from] `nextRun()` only: search after this date. Defaults to now.
|
|
98
|
+
* @property {boolean} [unref] Let the process exit even if the schedule is active.
|
|
99
|
+
* @property {boolean} [overlap] Start a run even if the previous one hasn't finished. By default it's skipped.
|
|
100
|
+
* @property {(error: unknown) => void} [onError] Called when the task fails.
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A schedule returned by `every()` and `cron()`.
|
|
105
|
+
* @typedef {object} ScheduledTask
|
|
106
|
+
* @property {() => void} stop Stops the schedule. A run in progress finishes.
|
|
107
|
+
* @property {() => Date | undefined} next When the next run is planned.
|
|
108
|
+
* @property {() => boolean} isRunning Whether a run is in progress.
|
|
109
|
+
* @property {() => number} runs How many runs have started.
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Returned by `stopwatch()`.
|
|
114
|
+
* @typedef {object} Stopwatch
|
|
115
|
+
* @property {() => number} elapsed Milliseconds since the start.
|
|
116
|
+
* @property {(format?: boolean) => number | string} stop The elapsed time; `stop(true)` formats it, like `"1.5s"`.
|
|
117
|
+
* @property {(label?: string) => number} lap Records a lap and returns the ms since the previous one.
|
|
118
|
+
* @property {() => Array<{ label: string, ms: number, total: number }>} laps Every lap so far.
|
|
119
|
+
* @property {() => void} reset Starts over.
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
const MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
123
|
+
|
|
124
|
+
/** @type {Record<string, keyof typeof MS | "M" | "y">} */
|
|
125
|
+
const UNIT_ALIASES = {
|
|
126
|
+
ms: "ms", msec: "ms", milli: "ms", millis: "ms", millisecond: "ms", milliseconds: "ms",
|
|
127
|
+
s: "s", sec: "s", secs: "s", second: "s", seconds: "s",
|
|
128
|
+
m: "m", min: "m", mins: "m", minute: "m", minutes: "m",
|
|
129
|
+
h: "h", hr: "h", hrs: "h", hour: "h", hours: "h",
|
|
130
|
+
d: "d", day: "d", days: "d",
|
|
131
|
+
w: "w", wk: "w", wks: "w", week: "w", weeks: "w",
|
|
132
|
+
M: "M", mo: "M", month: "M", months: "M",
|
|
133
|
+
y: "y", yr: "y", yrs: "y", year: "y", years: "y",
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Global defaults. English by default so output never depends on the machine. */
|
|
137
|
+
const _config = { locale: "en", timeZone: /** @type {string | undefined} */ (undefined) };
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {DateInput | undefined} value
|
|
141
|
+
* @returns {Date}
|
|
142
|
+
*/
|
|
143
|
+
const _toDate = (value) => (types.isDate(value) ? new Date(value.getTime()) : value === undefined ? new Date() : new Date(value));
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {number} n
|
|
147
|
+
* @param {number} [len=2]
|
|
148
|
+
*/
|
|
149
|
+
const _pad = (n, len = 2) => String(Math.abs(n)).padStart(len, "0");
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} unit
|
|
153
|
+
* @returns {keyof typeof MS | "M" | "y" | undefined}
|
|
154
|
+
*/
|
|
155
|
+
const _unit = (unit) => UNIT_ALIASES[unit] ?? UNIT_ALIASES[unit.toLowerCase()];
|
|
156
|
+
|
|
157
|
+
/** @type {Map<string, Intl.DateTimeFormat>} */
|
|
158
|
+
const _dtfCache = new Map();
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Calendar parts of a date in a time zone.
|
|
162
|
+
* @param {Date} d
|
|
163
|
+
* @param {string} [timeZone]
|
|
164
|
+
* @returns {{ year: number, month: number, day: number, hour: number, minute: number, second: number, ms: number, weekday: number }}
|
|
165
|
+
*/
|
|
166
|
+
const _parts = (d, timeZone) => {
|
|
167
|
+
if (!timeZone) {
|
|
168
|
+
return {
|
|
169
|
+
year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), hour: d.getHours(),
|
|
170
|
+
minute: d.getMinutes(), second: d.getSeconds(), ms: d.getMilliseconds(), weekday: d.getDay(),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
let dtf = _dtfCache.get(timeZone);
|
|
174
|
+
if (!dtf) {
|
|
175
|
+
dtf = new Intl.DateTimeFormat("en-US", {
|
|
176
|
+
timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit",
|
|
177
|
+
minute: "2-digit", second: "2-digit", hourCycle: "h23", weekday: "short",
|
|
178
|
+
});
|
|
179
|
+
_dtfCache.set(timeZone, dtf);
|
|
180
|
+
}
|
|
181
|
+
/** @type {Record<string, string>} */
|
|
182
|
+
const p = {};
|
|
183
|
+
for (const part of dtf.formatToParts(d)) p[part.type] = part.value;
|
|
184
|
+
return {
|
|
185
|
+
year: Number(p.year), month: Number(p.month), day: Number(p.day), hour: Number(p.hour) % 24,
|
|
186
|
+
minute: Number(p.minute), second: Number(p.second), ms: d.getMilliseconds(),
|
|
187
|
+
weekday: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(p.weekday),
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* UTC offset of a zone at an instant, in minutes.
|
|
193
|
+
* @param {Date} d
|
|
194
|
+
* @param {string} [timeZone]
|
|
195
|
+
* @returns {number}
|
|
196
|
+
*/
|
|
197
|
+
const _offset = (d, timeZone) => {
|
|
198
|
+
if (!timeZone) return -d.getTimezoneOffset();
|
|
199
|
+
const p = _parts(d, timeZone);
|
|
200
|
+
const asUTC = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second, p.ms);
|
|
201
|
+
return Math.round((asUTC - d.getTime()) / 60_000);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Wall-clock time in a zone to an instant. `undefined` when that time is
|
|
206
|
+
* skipped by a daylight-saving change.
|
|
207
|
+
* @param {number} year
|
|
208
|
+
* @param {number} month 1-12
|
|
209
|
+
* @param {number} day
|
|
210
|
+
* @param {number} hour
|
|
211
|
+
* @param {number} minute
|
|
212
|
+
* @param {number} second
|
|
213
|
+
* @param {string} [timeZone]
|
|
214
|
+
* @returns {Date | undefined}
|
|
215
|
+
*/
|
|
216
|
+
const _fromWall = (year, month, day, hour, minute, second, timeZone) => {
|
|
217
|
+
const guess = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
218
|
+
let instant = guess - _offset(new Date(guess), timeZone) * 60_000;
|
|
219
|
+
const second2 = guess - _offset(new Date(instant), timeZone) * 60_000;
|
|
220
|
+
if (second2 !== instant) instant = second2;
|
|
221
|
+
const p = _parts(new Date(instant), timeZone);
|
|
222
|
+
if (p.year !== year || p.month !== month || p.day !== day || p.hour !== hour || p.minute !== minute) return undefined;
|
|
223
|
+
return new Date(instant);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {number} year
|
|
228
|
+
* @param {number} month 1-12
|
|
229
|
+
*/
|
|
230
|
+
const _daysIn = (year, month) => new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Adds calendar months, keeping the day in range (Jan 31 + 1 month = end of Feb).
|
|
234
|
+
* @param {Date} d
|
|
235
|
+
* @param {number} months
|
|
236
|
+
* @returns {Date}
|
|
237
|
+
*/
|
|
238
|
+
const _addMonths = (d, months) => {
|
|
239
|
+
const out = new Date(d.getTime());
|
|
240
|
+
const day = out.getDate();
|
|
241
|
+
out.setDate(1);
|
|
242
|
+
out.setMonth(out.getMonth() + months);
|
|
243
|
+
out.setDate(Math.min(day, _daysIn(out.getFullYear(), out.getMonth() + 1)));
|
|
244
|
+
return out;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* `setTimeout` that also works past its 24.8-day limit.
|
|
249
|
+
* @param {() => void} fn
|
|
250
|
+
* @param {number} ms
|
|
251
|
+
* @param {boolean} unref
|
|
252
|
+
* @returns {() => void}
|
|
253
|
+
*/
|
|
254
|
+
const _longTimeout = (fn, ms, unref) => {
|
|
255
|
+
const MAX = 2 ** 31 - 1;
|
|
256
|
+
/** @type {NodeJS.Timeout} */
|
|
257
|
+
let timer;
|
|
258
|
+
const schedule = (/** @type {number} */ remaining) => {
|
|
259
|
+
timer = setTimeout(() => (remaining > MAX ? schedule(remaining - MAX) : fn()), Math.min(remaining, MAX));
|
|
260
|
+
if (unref) timer.unref();
|
|
261
|
+
};
|
|
262
|
+
schedule(Math.max(0, ms));
|
|
263
|
+
return () => clearTimeout(timer);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {() => unknown} task
|
|
268
|
+
* @param {((error: unknown) => void) | undefined} onError
|
|
269
|
+
* @returns {Promise<void>}
|
|
270
|
+
*/
|
|
271
|
+
const _run = async (task, onError) => {
|
|
272
|
+
try {
|
|
273
|
+
await task();
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (onError) onError(error);
|
|
276
|
+
else setImmediate(() => { throw error; });
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Sets the default language for `relative`, `calendar`, `formatDuration`
|
|
282
|
+
* and `format`.
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* time.setLocale("fr");
|
|
286
|
+
* time.relative(Date.now() - 60e3); // "il y a 1 minute"
|
|
287
|
+
*
|
|
288
|
+
* @param {string} locale Like `"fr"`, `"en-GB"`, `"pt-BR"` or `"ja"`.
|
|
289
|
+
* @returns {typeof import("./Time")} The module, so calls chain.
|
|
290
|
+
*/
|
|
291
|
+
function setLocale(locale) {
|
|
292
|
+
_config.locale = locale;
|
|
293
|
+
return module.exports;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Sets the default time zone for `format`, `calendar`, `isSameDay`,
|
|
298
|
+
* `cron`... Pass `undefined` to go back to the system zone.
|
|
299
|
+
*
|
|
300
|
+
* @example
|
|
301
|
+
* time.setTimezone("Europe/Paris");
|
|
302
|
+
*
|
|
303
|
+
* @param {string | undefined} timeZone Like `"America/New_York"`.
|
|
304
|
+
* @returns {typeof import("./Time")} The module, so calls chain.
|
|
305
|
+
* @throws {RangeError} If the zone doesn't exist.
|
|
306
|
+
*/
|
|
307
|
+
function setTimezone(timeZone) {
|
|
308
|
+
if (timeZone !== undefined) new Intl.DateTimeFormat("en-US", { timeZone });
|
|
309
|
+
_config.timeZone = timeZone;
|
|
310
|
+
return module.exports;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The current defaults.
|
|
315
|
+
*
|
|
316
|
+
* @example
|
|
317
|
+
* time.getConfig(); // { locale: "en", timeZone: undefined }
|
|
318
|
+
*
|
|
319
|
+
* @returns {{ locale: string, timeZone: string | undefined }}
|
|
320
|
+
*/
|
|
321
|
+
function getConfig() {
|
|
322
|
+
return { ..._config };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Every time zone Node knows, sorted.
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* time.timezones().includes("Europe/Paris"); // true
|
|
330
|
+
*
|
|
331
|
+
* @returns {string[]}
|
|
332
|
+
*/
|
|
333
|
+
function timezones() {
|
|
334
|
+
return Intl.supportedValuesOf("timeZone");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A zone's UTC offset at a given date, in minutes. Daylight saving included.
|
|
339
|
+
*
|
|
340
|
+
* @example
|
|
341
|
+
* time.offset("Europe/Paris", "2024-07-01"); // 120
|
|
342
|
+
* time.offset("Europe/Paris", "2024-01-01"); // 60
|
|
343
|
+
*
|
|
344
|
+
* @param {string} [timeZone] Defaults to the global zone, then the system's.
|
|
345
|
+
* @param {DateInput} [date] Defaults to now.
|
|
346
|
+
* @returns {number}
|
|
347
|
+
*/
|
|
348
|
+
function offset(timeZone, date) {
|
|
349
|
+
return _offset(_toDate(date), timeZone ?? _config.timeZone);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Turns a duration into milliseconds. Understands `"1h30m"`, `"2 days"`,
|
|
354
|
+
* `"500ms"`, clock times like `"01:30:00"`, ISO durations like `"PT1H30M"`,
|
|
355
|
+
* and plain numbers. Months and years are refused because their length
|
|
356
|
+
* varies; use `add()` for those.
|
|
357
|
+
*
|
|
358
|
+
* @example
|
|
359
|
+
* time.parseDuration("1h30m"); // 5400000
|
|
360
|
+
* time.parseDuration("01:30:00"); // 5400000
|
|
361
|
+
* time.parseDuration("banana"); // null
|
|
362
|
+
*
|
|
363
|
+
* @param {string | number} input
|
|
364
|
+
* @returns {number | null} `null` when it isn't a duration.
|
|
365
|
+
*/
|
|
366
|
+
function parseDuration(input) {
|
|
367
|
+
if (typeof input === "number") return Number.isFinite(input) ? input : null;
|
|
368
|
+
if (typeof input !== "string") return null;
|
|
369
|
+
const text = input.trim();
|
|
370
|
+
if (!text) return null;
|
|
371
|
+
if (/^[-+]?\d+(?:\.\d+)?$/.test(text)) return Number(text);
|
|
372
|
+
|
|
373
|
+
const iso = /^([-+])?P(?:(\d+(?:[.,]\d+)?)W)?(?:(\d+(?:[.,]\d+)?)D)?(?:T(?:(\d+(?:[.,]\d+)?)H)?(?:(\d+(?:[.,]\d+)?)M)?(?:(\d+(?:[.,]\d+)?)S)?)?$/i.exec(text);
|
|
374
|
+
if (iso && /\d/.test(text)) {
|
|
375
|
+
const [, sign, w, d, h, m, s] = iso;
|
|
376
|
+
const n = (/** @type {string | undefined} */ v) => (v ? Number(v.replace(",", ".")) : 0);
|
|
377
|
+
const total = n(w) * MS.w + n(d) * MS.d + n(h) * MS.h + n(m) * MS.m + n(s) * MS.s;
|
|
378
|
+
return sign === "-" ? -total : total;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const clock = /^([-+])?(?:(\d+):)?(\d+):(\d{1,2})(?:\.(\d{1,3}))?$/.exec(text);
|
|
382
|
+
if (clock) {
|
|
383
|
+
const [, sign, h, m, s, ms] = clock;
|
|
384
|
+
const total = Number(h ?? 0) * MS.h + Number(m) * MS.m + Number(s) * MS.s + (ms ? Number(ms.padEnd(3, "0")) : 0);
|
|
385
|
+
return sign === "-" ? -total : total;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const regex = /(-?\d*\.?\d+)\s*([a-zA-Z]+)/g;
|
|
389
|
+
let total = 0;
|
|
390
|
+
let consumed = "";
|
|
391
|
+
let match;
|
|
392
|
+
while ((match = regex.exec(text)) !== null) {
|
|
393
|
+
const unit = _unit(match[2]);
|
|
394
|
+
if (!unit || unit === "M" || unit === "y") return null;
|
|
395
|
+
total += Number(match[1]) * MS[unit];
|
|
396
|
+
consumed += match[0];
|
|
397
|
+
}
|
|
398
|
+
if (!consumed || consumed.replace(/\s+/g, "") !== text.replace(/[\s,]+|and/g, "")) return null;
|
|
399
|
+
return total;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Writes a duration for humans.
|
|
404
|
+
*
|
|
405
|
+
* @example
|
|
406
|
+
* time.formatDuration(5_400_000); // "1h 30m"
|
|
407
|
+
* time.formatDuration(90_000, { long: true }); // "1 minute 30 seconds"
|
|
408
|
+
* time.formatDuration(90_000, { long: true, locale: "fr" }); // "1 minute 30 secondes"
|
|
409
|
+
* time.formatDuration(93_784_000, { units: 2 }); // "1d 2h"
|
|
410
|
+
* time.formatDuration(5_405_000, { clock: true }); // "01:30:05"
|
|
411
|
+
*
|
|
412
|
+
* @param {number} ms
|
|
413
|
+
* @param {FormatDurationOptions} [options]
|
|
414
|
+
* @returns {string}
|
|
415
|
+
*/
|
|
416
|
+
function formatDuration(ms, options = {}) {
|
|
417
|
+
if (!Number.isFinite(ms)) return String(ms);
|
|
418
|
+
const sign = ms < 0 ? "-" : "";
|
|
419
|
+
let rest = Math.abs(Math.trunc(ms));
|
|
420
|
+
|
|
421
|
+
if (options.clock) {
|
|
422
|
+
const d = Math.floor(rest / MS.d);
|
|
423
|
+
const h = Math.floor((rest % MS.d) / MS.h);
|
|
424
|
+
const m = Math.floor((rest % MS.h) / MS.m);
|
|
425
|
+
const s = Math.floor((rest % MS.m) / MS.s);
|
|
426
|
+
return `${sign}${d ? `${d}:${_pad(h)}` : _pad(h)}:${_pad(m)}:${_pad(s)}`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const locale = options.locale ?? _config.locale;
|
|
430
|
+
const showMs = options.milliseconds ?? true;
|
|
431
|
+
/** @type {Array<{ v: number, short: string, unit: string }>} */
|
|
432
|
+
const parts = [];
|
|
433
|
+
for (const [size, short, unit] of /** @type {Array<[number, string, string]>} */ ([[MS.d, "d", "day"], [MS.h, "h", "hour"], [MS.m, "m", "minute"], [MS.s, "s", "second"], [1, "ms", "millisecond"]])) {
|
|
434
|
+
const v = Math.floor(rest / size);
|
|
435
|
+
rest -= v * size;
|
|
436
|
+
if (v > 0 && (unit !== "millisecond" || showMs)) parts.push({ v, short, unit });
|
|
437
|
+
}
|
|
438
|
+
if (!parts.length) {
|
|
439
|
+
return options.long ? new Intl.NumberFormat(locale, { style: "unit", unit: "second", unitDisplay: "long" }).format(0) : "0s";
|
|
440
|
+
}
|
|
441
|
+
return sign + parts
|
|
442
|
+
.slice(0, options.units ?? Infinity)
|
|
443
|
+
.map((p) => (options.long ? new Intl.NumberFormat(locale, { style: "unit", unit: p.unit, unitDisplay: "long" }).format(p.v) : `${p.v}${p.short}`))
|
|
444
|
+
.join(" ");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Describes a date relative to now: `"3 hours ago"`, `"in 2 days"`, in
|
|
449
|
+
* any language.
|
|
450
|
+
*
|
|
451
|
+
* @example
|
|
452
|
+
* time.relative(Date.now() - 3_600_000); // "1 hour ago"
|
|
453
|
+
* time.relative(Date.now() - 86_400_000, undefined, { numeric: "auto" }); // "yesterday"
|
|
454
|
+
* time.relative(Date.now() - 3_600_000, undefined, { locale: "fr" }); // "il y a 1 heure"
|
|
455
|
+
*
|
|
456
|
+
* @param {DateInput} date
|
|
457
|
+
* @param {DateInput} [from] Compare with this date instead of now.
|
|
458
|
+
* @param {RelativeOptions} [options]
|
|
459
|
+
* @returns {string}
|
|
460
|
+
*/
|
|
461
|
+
function relative(date, from, options = {}) {
|
|
462
|
+
const locale = options.locale ?? _config.locale;
|
|
463
|
+
const diffMs = _toDate(date).getTime() - _toDate(from).getTime();
|
|
464
|
+
const abs = Math.abs(diffMs);
|
|
465
|
+
if (abs < MS.s) return new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: options.style ?? "long" }).format(0, "second");
|
|
466
|
+
/** @type {Array<[number, number, Intl.RelativeTimeFormatUnit]>} */
|
|
467
|
+
const scale = [
|
|
468
|
+
[MS.m, MS.s, "second"], [MS.h, MS.m, "minute"], [MS.d, MS.h, "hour"], [MS.w, MS.d, "day"],
|
|
469
|
+
[MS.d * 30, MS.w, "week"], [MS.d * 365, MS.d * 30, "month"], [Infinity, MS.d * 365, "year"],
|
|
470
|
+
];
|
|
471
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: options.numeric ?? "always", style: options.style ?? "long" });
|
|
472
|
+
for (const [limit, size, unit] of scale) {
|
|
473
|
+
if (abs < limit) {
|
|
474
|
+
const value = Math.floor(abs / size);
|
|
475
|
+
return rtf.format(diffMs < 0 ? -value : value, unit);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return rtf.format(0, "second");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Describes a date like a chat app does: `"Today at 2:30 PM"`,
|
|
483
|
+
* `"Yesterday at 9:05 AM"`, a weekday for the coming days, and the full date
|
|
484
|
+
* beyond that.
|
|
485
|
+
*
|
|
486
|
+
* @example
|
|
487
|
+
* time.calendar(new Date()); // "Today at 2:30 PM"
|
|
488
|
+
* time.calendar(yesterday, { locale: "fr" }); // "Hier à 09:05"
|
|
489
|
+
*
|
|
490
|
+
* @param {DateInput} date
|
|
491
|
+
* @param {CalendarOptions} [options]
|
|
492
|
+
* @returns {string}
|
|
493
|
+
*/
|
|
494
|
+
function calendar(date, options = {}) {
|
|
495
|
+
const locale = options.locale ?? _config.locale;
|
|
496
|
+
const timeZone = options.timeZone ?? _config.timeZone;
|
|
497
|
+
const d = _toDate(date);
|
|
498
|
+
const now = _toDate(options.now);
|
|
499
|
+
const p = _parts(d, timeZone);
|
|
500
|
+
const n = _parts(now, timeZone);
|
|
501
|
+
const dayIndex = (/** @type {{ year: number, month: number, day: number }} */ x) => Date.UTC(x.year, x.month - 1, x.day) / MS.d;
|
|
502
|
+
const delta = dayIndex(p) - dayIndex(n);
|
|
503
|
+
const hhmm = new Intl.DateTimeFormat(locale, { timeStyle: "short", timeZone }).format(d);
|
|
504
|
+
const at = (/** @type {string} */ label) => {
|
|
505
|
+
const joined = new Intl.DateTimeFormat(locale, { dateStyle: "full", timeStyle: "short", timeZone }).formatToParts(d);
|
|
506
|
+
const literal = joined.find((part, i) => part.type === "literal" && joined[i + 1]?.type === "hour")?.value ?? " ";
|
|
507
|
+
return `${label.charAt(0).toUpperCase()}${label.slice(1)}${literal}${hhmm}`;
|
|
508
|
+
};
|
|
509
|
+
if (Math.abs(delta) <= 1) {
|
|
510
|
+
return at(new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(delta, "day"));
|
|
511
|
+
}
|
|
512
|
+
if (delta > 1 && delta < 7) {
|
|
513
|
+
return at(new Intl.DateTimeFormat(locale, { weekday: "long", timeZone }).format(d));
|
|
514
|
+
}
|
|
515
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone }).format(d);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Formats a date with tokens, in any language and time zone.
|
|
520
|
+
*
|
|
521
|
+
* | Token | Output | | Token | Output |
|
|
522
|
+
* | --- | --- | --- | --- | --- |
|
|
523
|
+
* | `YYYY` / `YY` | 2024 / 24 | | `HH` / `H` | 09 / 9 (24h) |
|
|
524
|
+
* | `Q` | quarter, 1-4 | | `hh` / `h` | 09 / 9 (12h) |
|
|
525
|
+
* | `MMMM` / `MMM` | January / Jan | | `mm` / `m` | 05 / 5 |
|
|
526
|
+
* | `MM` / `M` | 01 / 1 | | `ss` / `s` | 07 / 7 |
|
|
527
|
+
* | `DD` / `D` | 05 / 5 | | `SSS` | 042 (ms) |
|
|
528
|
+
* | `dddd` / `ddd` | Monday / Mon | | `A` / `a` | PM / pm |
|
|
529
|
+
* | `d` | weekday, 0 is Sunday | | `Z` / `ZZ` | +02:00 / +0200 |
|
|
530
|
+
* | `W` / `WW` | ISO week | | `X` / `x` | Unix seconds / ms |
|
|
531
|
+
*
|
|
532
|
+
* Text in brackets stays as is: `"[Today is] dddd"`.
|
|
533
|
+
*
|
|
534
|
+
* @example
|
|
535
|
+
* time.format(new Date(), "YYYY-MM-DD HH:mm:ss"); // "2024-01-15 14:30:05"
|
|
536
|
+
* time.format(Date.now(), "dddd D MMMM YYYY", { locale: "fr" }); // "lundi 15 janvier 2024"
|
|
537
|
+
* time.format(Date.now(), "HH:mm Z", { timeZone: "Asia/Tokyo" }); // "22:30 +09:00"
|
|
538
|
+
*
|
|
539
|
+
* @param {DateInput} date
|
|
540
|
+
* @param {string} [pattern="YYYY-MM-DD HH:mm:ss"]
|
|
541
|
+
* @param {FormatDateOptions} [options]
|
|
542
|
+
* @returns {string} Or `"Invalid Date"`.
|
|
543
|
+
*/
|
|
544
|
+
function format(date, pattern = "YYYY-MM-DD HH:mm:ss", options = {}) {
|
|
545
|
+
const d = _toDate(date);
|
|
546
|
+
if (Number.isNaN(d.getTime())) return "Invalid Date";
|
|
547
|
+
const locale = options.locale ?? _config.locale;
|
|
548
|
+
const timeZone = options.timeZone ?? _config.timeZone;
|
|
549
|
+
const p = _parts(d, timeZone);
|
|
550
|
+
const named = (/** @type {Intl.DateTimeFormatOptions} */ opt) => new Intl.DateTimeFormat(locale, { ...opt, timeZone }).format(d);
|
|
551
|
+
const tz = (/** @type {string} */ sep) => {
|
|
552
|
+
const off = _offset(d, timeZone);
|
|
553
|
+
return `${off < 0 ? "-" : "+"}${_pad(Math.floor(Math.abs(off) / 60))}${sep}${_pad(Math.abs(off) % 60)}`;
|
|
554
|
+
};
|
|
555
|
+
const h12 = p.hour % 12 === 0 ? 12 : p.hour % 12;
|
|
556
|
+
const isoWeek = () => weekOfYear(Date.UTC(p.year, p.month - 1, p.day), { utc: true });
|
|
557
|
+
|
|
558
|
+
return pattern.replace(
|
|
559
|
+
/\[([^\]]*)\]|YYYY|MMMM|dddd|SSS|MMM|ddd|YY|MM|DD|HH|hh|mm|ss|ZZ|WW|M|D|d|H|h|m|s|A|a|Z|Q|X|x|W/g,
|
|
560
|
+
(token, escaped) => {
|
|
561
|
+
if (escaped !== undefined) return escaped;
|
|
562
|
+
switch (token) {
|
|
563
|
+
case "YYYY": return String(p.year);
|
|
564
|
+
case "YY": return String(p.year).slice(-2);
|
|
565
|
+
case "Q": return String(Math.ceil(p.month / 3));
|
|
566
|
+
case "MMMM": return named({ month: "long" });
|
|
567
|
+
case "MMM": return named({ month: "short" });
|
|
568
|
+
case "MM": return _pad(p.month);
|
|
569
|
+
case "M": return String(p.month);
|
|
570
|
+
case "DD": return _pad(p.day);
|
|
571
|
+
case "D": return String(p.day);
|
|
572
|
+
case "dddd": return named({ weekday: "long" });
|
|
573
|
+
case "ddd": return named({ weekday: "short" });
|
|
574
|
+
case "d": return String(p.weekday);
|
|
575
|
+
case "HH": return _pad(p.hour);
|
|
576
|
+
case "H": return String(p.hour);
|
|
577
|
+
case "hh": return _pad(h12);
|
|
578
|
+
case "h": return String(h12);
|
|
579
|
+
case "mm": return _pad(p.minute);
|
|
580
|
+
case "m": return String(p.minute);
|
|
581
|
+
case "ss": return _pad(p.second);
|
|
582
|
+
case "s": return String(p.second);
|
|
583
|
+
case "SSS": return _pad(p.ms, 3);
|
|
584
|
+
case "A": return p.hour < 12 ? "AM" : "PM";
|
|
585
|
+
case "a": return p.hour < 12 ? "am" : "pm";
|
|
586
|
+
case "Z": return tz(":");
|
|
587
|
+
case "ZZ": return tz("");
|
|
588
|
+
case "X": return String(Math.floor(d.getTime() / 1000));
|
|
589
|
+
case "x": return String(d.getTime());
|
|
590
|
+
case "W": return String(isoWeek());
|
|
591
|
+
case "WW": return _pad(isoWeek());
|
|
592
|
+
default: return token;
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The date as `YYYY-MM-DD`, the format `<input type="date">` and most APIs
|
|
600
|
+
* expect.
|
|
601
|
+
*
|
|
602
|
+
* @example
|
|
603
|
+
* time.toISODate(new Date(2024, 0, 5)); // "2024-01-05"
|
|
604
|
+
*
|
|
605
|
+
* @param {DateInput} [date] Defaults to now.
|
|
606
|
+
* @param {TimeZoneOptions} [options]
|
|
607
|
+
* @returns {string}
|
|
608
|
+
*/
|
|
609
|
+
function toISODate(date, options = {}) {
|
|
610
|
+
return format(date ?? new Date(), "YYYY-MM-DD", { timeZone: options.timeZone ?? _config.timeZone });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Adds time to a date and returns a new `Date`. Months and years follow the
|
|
615
|
+
* calendar: January 31 plus one month is the end of February.
|
|
616
|
+
*
|
|
617
|
+
* @example
|
|
618
|
+
* time.add(new Date(), "2h30m");
|
|
619
|
+
* time.add(new Date(), 3, "days");
|
|
620
|
+
* time.add(new Date(2024, 0, 31), 1, "month"); // 2024-02-29
|
|
621
|
+
*
|
|
622
|
+
* @param {DateInput} date
|
|
623
|
+
* @param {number | string} amount A number with `unit`, or a duration like `"1h30m"`.
|
|
624
|
+
* @param {CalendarUnit} [unit="ms"]
|
|
625
|
+
* @returns {Date}
|
|
626
|
+
*/
|
|
627
|
+
function add(date, amount, unit = "ms") {
|
|
628
|
+
const d = _toDate(date);
|
|
629
|
+
if (typeof amount === "string") return new Date(d.getTime() + (parseDuration(amount) ?? 0));
|
|
630
|
+
const u = _unit(unit) ?? "ms";
|
|
631
|
+
if (u === "M") return _addMonths(d, amount);
|
|
632
|
+
if (u === "y") return _addMonths(d, amount * 12);
|
|
633
|
+
return new Date(d.getTime() + amount * MS[u]);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Subtracts time from a date and returns a new `Date`.
|
|
638
|
+
*
|
|
639
|
+
* @example
|
|
640
|
+
* time.subtract(new Date(), 7, "days");
|
|
641
|
+
* time.subtract(new Date(), "90m");
|
|
642
|
+
*
|
|
643
|
+
* @param {DateInput} date
|
|
644
|
+
* @param {number | string} amount
|
|
645
|
+
* @param {CalendarUnit} [unit="ms"]
|
|
646
|
+
* @returns {Date}
|
|
647
|
+
*/
|
|
648
|
+
function subtract(date, amount, unit = "ms") {
|
|
649
|
+
if (typeof amount === "string") return add(date, -(parseDuration(amount) ?? 0));
|
|
650
|
+
return add(date, -amount, unit);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* `a - b` in a unit. Fixed units can give fractions; months and years are
|
|
655
|
+
* whole calendar months and years.
|
|
656
|
+
*
|
|
657
|
+
* @example
|
|
658
|
+
* time.diff("2024-01-03", "2024-01-01", "days"); // 2
|
|
659
|
+
* time.diff("2024-03-15", "2024-01-20", "months"); // 1
|
|
660
|
+
*
|
|
661
|
+
* @param {DateInput} a
|
|
662
|
+
* @param {DateInput} b
|
|
663
|
+
* @param {CalendarUnit} [unit="ms"]
|
|
664
|
+
* @returns {number}
|
|
665
|
+
*/
|
|
666
|
+
function diff(a, b, unit = "ms") {
|
|
667
|
+
const da = _toDate(a);
|
|
668
|
+
const db = _toDate(b);
|
|
669
|
+
const u = _unit(unit) ?? "ms";
|
|
670
|
+
if (u === "M" || u === "y") {
|
|
671
|
+
const sign = da >= db ? 1 : -1;
|
|
672
|
+
const [late, early] = sign > 0 ? [da, db] : [db, da];
|
|
673
|
+
let months = (late.getFullYear() - early.getFullYear()) * 12 + late.getMonth() - early.getMonth();
|
|
674
|
+
if (_addMonths(early, months) > late) months--;
|
|
675
|
+
return sign * (u === "y" ? Math.trunc(months / 12) : months) || 0;
|
|
676
|
+
}
|
|
677
|
+
return (da.getTime() - db.getTime()) / MS[u];
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* A new `Date` at the start of the day, week, month... in local time.
|
|
682
|
+
*
|
|
683
|
+
* @example
|
|
684
|
+
* time.startOf(new Date(), "day"); // today at 00:00
|
|
685
|
+
* time.startOf(new Date(), "week"); // Monday at 00:00
|
|
686
|
+
* time.startOf(new Date(), "week", { weekStartsOn: 0 }); // Sunday at 00:00
|
|
687
|
+
*
|
|
688
|
+
* @param {DateInput} date
|
|
689
|
+
* @param {StartOfUnit} unit
|
|
690
|
+
* @param {WeekOptions} [options]
|
|
691
|
+
* @returns {Date}
|
|
692
|
+
*/
|
|
693
|
+
function startOf(date, unit, options = {}) {
|
|
694
|
+
const d = _toDate(date);
|
|
695
|
+
switch (unit) {
|
|
696
|
+
case "year":
|
|
697
|
+
d.setMonth(0, 1);
|
|
698
|
+
d.setHours(0, 0, 0, 0);
|
|
699
|
+
break;
|
|
700
|
+
case "quarter":
|
|
701
|
+
d.setMonth(Math.floor(d.getMonth() / 3) * 3, 1);
|
|
702
|
+
d.setHours(0, 0, 0, 0);
|
|
703
|
+
break;
|
|
704
|
+
case "month":
|
|
705
|
+
d.setDate(1);
|
|
706
|
+
d.setHours(0, 0, 0, 0);
|
|
707
|
+
break;
|
|
708
|
+
case "week":
|
|
709
|
+
case "isoWeek": {
|
|
710
|
+
const start = unit === "isoWeek" ? 1 : options.weekStartsOn ?? 1;
|
|
711
|
+
d.setDate(d.getDate() - ((d.getDay() - start + 7) % 7));
|
|
712
|
+
d.setHours(0, 0, 0, 0);
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
case "day":
|
|
716
|
+
d.setHours(0, 0, 0, 0);
|
|
717
|
+
break;
|
|
718
|
+
case "hour":
|
|
719
|
+
d.setMinutes(0, 0, 0);
|
|
720
|
+
break;
|
|
721
|
+
case "minute":
|
|
722
|
+
d.setSeconds(0, 0);
|
|
723
|
+
break;
|
|
724
|
+
case "second":
|
|
725
|
+
d.setMilliseconds(0);
|
|
726
|
+
break;
|
|
727
|
+
}
|
|
728
|
+
return d;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* A new `Date` at the last millisecond of the day, week, month...
|
|
733
|
+
*
|
|
734
|
+
* @example
|
|
735
|
+
* time.endOf(new Date(), "month"); // last day of the month, 23:59:59.999
|
|
736
|
+
*
|
|
737
|
+
* @param {DateInput} date
|
|
738
|
+
* @param {StartOfUnit} unit
|
|
739
|
+
* @param {WeekOptions} [options]
|
|
740
|
+
* @returns {Date}
|
|
741
|
+
*/
|
|
742
|
+
function endOf(date, unit, options = {}) {
|
|
743
|
+
const start = startOf(date, unit, options);
|
|
744
|
+
/** @type {Date} */
|
|
745
|
+
let next;
|
|
746
|
+
switch (unit) {
|
|
747
|
+
case "year": next = _addMonths(start, 12); break;
|
|
748
|
+
case "quarter": next = _addMonths(start, 3); break;
|
|
749
|
+
case "month": next = _addMonths(start, 1); break;
|
|
750
|
+
case "week":
|
|
751
|
+
case "isoWeek": next = new Date(start); next.setDate(next.getDate() + 7); break;
|
|
752
|
+
case "day": next = new Date(start); next.setDate(next.getDate() + 1); break;
|
|
753
|
+
case "hour": next = new Date(start.getTime() + MS.h); break;
|
|
754
|
+
case "minute": next = new Date(start.getTime() + MS.m); break;
|
|
755
|
+
default: next = new Date(start.getTime() + MS.s); break;
|
|
756
|
+
}
|
|
757
|
+
return new Date(next.getTime() - 1);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Is `a` before `b`?
|
|
762
|
+
*
|
|
763
|
+
* @param {DateInput} a
|
|
764
|
+
* @param {DateInput} b
|
|
765
|
+
* @returns {boolean}
|
|
766
|
+
*/
|
|
767
|
+
function isBefore(a, b) {
|
|
768
|
+
return _toDate(a).getTime() < _toDate(b).getTime();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Is `a` after `b`?
|
|
773
|
+
*
|
|
774
|
+
* @example
|
|
775
|
+
* if (time.isAfter(expiresAt, Date.now())) grantAccess();
|
|
776
|
+
*
|
|
777
|
+
* @param {DateInput} a
|
|
778
|
+
* @param {DateInput} b
|
|
779
|
+
* @returns {boolean}
|
|
780
|
+
*/
|
|
781
|
+
function isAfter(a, b) {
|
|
782
|
+
return _toDate(a).getTime() > _toDate(b).getTime();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Is the date between `start` and `end`, both included?
|
|
787
|
+
*
|
|
788
|
+
* @param {DateInput} date
|
|
789
|
+
* @param {DateInput} start
|
|
790
|
+
* @param {DateInput} end
|
|
791
|
+
* @returns {boolean}
|
|
792
|
+
*/
|
|
793
|
+
function isBetween(date, start, end) {
|
|
794
|
+
const t = _toDate(date).getTime();
|
|
795
|
+
return t >= _toDate(start).getTime() && t <= _toDate(end).getTime();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Are both dates on the same calendar day?
|
|
800
|
+
*
|
|
801
|
+
* @example
|
|
802
|
+
* time.isSameDay(a, b); // local time
|
|
803
|
+
* time.isSameDay(a, b, { timeZone: "Asia/Tokyo" }); // as seen in Tokyo
|
|
804
|
+
*
|
|
805
|
+
* @param {DateInput} a
|
|
806
|
+
* @param {DateInput} b
|
|
807
|
+
* @param {TimeZoneOptions} [options]
|
|
808
|
+
* @returns {boolean}
|
|
809
|
+
*/
|
|
810
|
+
function isSameDay(a, b, options = {}) {
|
|
811
|
+
const timeZone = options.timeZone ?? _config.timeZone;
|
|
812
|
+
const pa = _parts(_toDate(a), timeZone);
|
|
813
|
+
const pb = _parts(_toDate(b), timeZone);
|
|
814
|
+
return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Is the date today?
|
|
819
|
+
*
|
|
820
|
+
* @param {DateInput} date
|
|
821
|
+
* @param {TimeZoneOptions} [options]
|
|
822
|
+
* @returns {boolean}
|
|
823
|
+
*/
|
|
824
|
+
function isToday(date, options = {}) {
|
|
825
|
+
return isSameDay(date, new Date(), options);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Was the date yesterday?
|
|
830
|
+
*
|
|
831
|
+
* @param {DateInput} date
|
|
832
|
+
* @param {TimeZoneOptions} [options]
|
|
833
|
+
* @returns {boolean}
|
|
834
|
+
*/
|
|
835
|
+
function isYesterday(date, options = {}) {
|
|
836
|
+
return isSameDay(date, add(new Date(), -1, "d"), options);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Is the date tomorrow?
|
|
841
|
+
*
|
|
842
|
+
* @param {DateInput} date
|
|
843
|
+
* @param {TimeZoneOptions} [options]
|
|
844
|
+
* @returns {boolean}
|
|
845
|
+
*/
|
|
846
|
+
function isTomorrow(date, options = {}) {
|
|
847
|
+
return isSameDay(date, add(new Date(), 1, "d"), options);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Is it a Saturday or a Sunday?
|
|
852
|
+
*
|
|
853
|
+
* @param {DateInput} date
|
|
854
|
+
* @param {TimeZoneOptions} [options]
|
|
855
|
+
* @returns {boolean}
|
|
856
|
+
*/
|
|
857
|
+
function isWeekend(date, options = {}) {
|
|
858
|
+
const day = _parts(_toDate(date), options.timeZone ?? _config.timeZone).weekday;
|
|
859
|
+
return day === 0 || day === 6;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Is it a leap year?
|
|
864
|
+
*
|
|
865
|
+
* @example
|
|
866
|
+
* time.isLeapYear(2024); // true
|
|
867
|
+
* time.isLeapYear(1900); // false
|
|
868
|
+
*
|
|
869
|
+
* @param {number | DateInput} yearOrDate A year (1000 to 9999) or a date.
|
|
870
|
+
* @returns {boolean}
|
|
871
|
+
*/
|
|
872
|
+
function isLeapYear(yearOrDate) {
|
|
873
|
+
const year = typeof yearOrDate === "number" && yearOrDate >= 1000 && yearOrDate <= 9999 ? yearOrDate : _toDate(yearOrDate).getFullYear();
|
|
874
|
+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Is it a real date, or something that parses to one?
|
|
879
|
+
*
|
|
880
|
+
* @example
|
|
881
|
+
* time.isValid("2024-02-29"); // true
|
|
882
|
+
* time.isValid("2024-02-30"); // false
|
|
883
|
+
*
|
|
884
|
+
* @param {unknown} value
|
|
885
|
+
* @returns {boolean}
|
|
886
|
+
*/
|
|
887
|
+
function isValid(value) {
|
|
888
|
+
if (types.isDate(value)) return !Number.isNaN(value.getTime());
|
|
889
|
+
if (typeof value === "string") {
|
|
890
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
|
891
|
+
if (m && Number(m[3]) > _daysIn(Number(m[1]), Number(m[2]))) return false;
|
|
892
|
+
}
|
|
893
|
+
if (typeof value !== "string" && typeof value !== "number") return false;
|
|
894
|
+
return !Number.isNaN(new Date(value).getTime());
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Number of days in a month.
|
|
899
|
+
*
|
|
900
|
+
* @example
|
|
901
|
+
* time.daysInMonth(new Date(2024, 1)); // 29
|
|
902
|
+
* time.daysInMonth(2023, 2); // 28
|
|
903
|
+
*
|
|
904
|
+
* @param {DateInput | number} dateOrYear A date, or a year when you pass `month`.
|
|
905
|
+
* @param {number} [month] 1 to 12.
|
|
906
|
+
* @returns {number}
|
|
907
|
+
*/
|
|
908
|
+
function daysInMonth(dateOrYear, month) {
|
|
909
|
+
if (typeof dateOrYear === "number" && month !== undefined) return _daysIn(dateOrYear, month);
|
|
910
|
+
const d = _toDate(dateOrYear);
|
|
911
|
+
return _daysIn(d.getFullYear(), d.getMonth() + 1);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Day of the year, from 1 to 366.
|
|
916
|
+
*
|
|
917
|
+
* @param {DateInput} [date] Defaults to now.
|
|
918
|
+
* @returns {number}
|
|
919
|
+
*/
|
|
920
|
+
function dayOfYear(date) {
|
|
921
|
+
const d = _toDate(date);
|
|
922
|
+
return Math.round((Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) - Date.UTC(d.getFullYear(), 0, 1)) / MS.d) + 1;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* ISO week number, from 1 to 53. Weeks start on Monday, and week 1 holds
|
|
927
|
+
* the year's first Thursday.
|
|
928
|
+
*
|
|
929
|
+
* @example
|
|
930
|
+
* time.weekOfYear(new Date(2021, 0, 3)); // 53, the last week of 2020
|
|
931
|
+
*
|
|
932
|
+
* @param {DateInput} [date] Defaults to now.
|
|
933
|
+
* @param {{ utc?: boolean }} [options] Read the date in UTC.
|
|
934
|
+
* @returns {number}
|
|
935
|
+
*/
|
|
936
|
+
function weekOfYear(date, options = {}) {
|
|
937
|
+
const d = _toDate(date);
|
|
938
|
+
const utc = options.utc
|
|
939
|
+
? new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
|
940
|
+
: new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
941
|
+
const day = utc.getUTCDay() || 7;
|
|
942
|
+
utc.setUTCDate(utc.getUTCDate() + 4 - day);
|
|
943
|
+
const yearStart = Date.UTC(utc.getUTCFullYear(), 0, 1);
|
|
944
|
+
return Math.ceil(((utc.getTime() - yearStart) / MS.d + 1) / 7);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* The earliest date.
|
|
949
|
+
*
|
|
950
|
+
* @param {Iterable<DateInput>} dates
|
|
951
|
+
* @returns {Date | undefined}
|
|
952
|
+
*/
|
|
953
|
+
function min(dates) {
|
|
954
|
+
/** @type {Date | undefined} */
|
|
955
|
+
let best;
|
|
956
|
+
for (const value of dates) {
|
|
957
|
+
const d = _toDate(value);
|
|
958
|
+
if (!best || d < best) best = d;
|
|
959
|
+
}
|
|
960
|
+
return best;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* The latest date.
|
|
965
|
+
*
|
|
966
|
+
* @param {Iterable<DateInput>} dates
|
|
967
|
+
* @returns {Date | undefined}
|
|
968
|
+
*/
|
|
969
|
+
function max(dates) {
|
|
970
|
+
/** @type {Date | undefined} */
|
|
971
|
+
let best;
|
|
972
|
+
for (const value of dates) {
|
|
973
|
+
const d = _toDate(value);
|
|
974
|
+
if (!best || d > best) best = d;
|
|
975
|
+
}
|
|
976
|
+
return best;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* The current Unix timestamp, in seconds.
|
|
981
|
+
*
|
|
982
|
+
* @returns {number}
|
|
983
|
+
*/
|
|
984
|
+
function unix() {
|
|
985
|
+
return Math.floor(Date.now() / 1000);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Starts a precise stopwatch.
|
|
990
|
+
*
|
|
991
|
+
* @example
|
|
992
|
+
* const sw = time.stopwatch();
|
|
993
|
+
* await loadConfig(); sw.lap("config");
|
|
994
|
+
* await connectDb(); sw.lap("db");
|
|
995
|
+
* console.log(sw.stop(true)); // "182.4ms"
|
|
996
|
+
*
|
|
997
|
+
* @returns {Stopwatch}
|
|
998
|
+
*/
|
|
999
|
+
function stopwatch() {
|
|
1000
|
+
let start = process.hrtime.bigint();
|
|
1001
|
+
let lastLap = start;
|
|
1002
|
+
/** @type {Array<{ label: string, ms: number, total: number }>} */
|
|
1003
|
+
let laps = [];
|
|
1004
|
+
const since = (/** @type {bigint} */ t) => Number(process.hrtime.bigint() - t) / 1e6;
|
|
1005
|
+
const pretty = (/** @type {number} */ ms) =>
|
|
1006
|
+
ms < 1000 ? `${Math.round(ms * 100) / 100}ms` : ms < MS.m ? `${Math.round(ms / 10) / 100}s` : formatDuration(ms, { units: 2, milliseconds: false });
|
|
1007
|
+
return {
|
|
1008
|
+
elapsed: () => since(start),
|
|
1009
|
+
stop: (fmt = false) => (fmt ? pretty(since(start)) : since(start)),
|
|
1010
|
+
lap: (label) => {
|
|
1011
|
+
const now = process.hrtime.bigint();
|
|
1012
|
+
const ms = Number(now - lastLap) / 1e6;
|
|
1013
|
+
lastLap = now;
|
|
1014
|
+
laps.push({ label: label ?? `lap ${laps.length + 1}`, ms, total: Number(now - start) / 1e6 });
|
|
1015
|
+
return ms;
|
|
1016
|
+
},
|
|
1017
|
+
laps: () => laps.map((l) => ({ ...l })),
|
|
1018
|
+
reset: () => {
|
|
1019
|
+
start = process.hrtime.bigint();
|
|
1020
|
+
lastLap = start;
|
|
1021
|
+
laps = [];
|
|
1022
|
+
},
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Runs a function, sync or async, and tells you how long it took.
|
|
1028
|
+
*
|
|
1029
|
+
* @example
|
|
1030
|
+
* const { result, duration } = await time.measure(() => db.query(sql));
|
|
1031
|
+
* nc.info(`Query took ${duration.toFixed(1)}ms`);
|
|
1032
|
+
*
|
|
1033
|
+
* @template T
|
|
1034
|
+
* @param {() => T} fn
|
|
1035
|
+
* @returns {Promise<{ result: Awaited<T>, duration: number }>} `duration` is in milliseconds.
|
|
1036
|
+
*/
|
|
1037
|
+
async function measure(fn) {
|
|
1038
|
+
const start = process.hrtime.bigint();
|
|
1039
|
+
const result = await fn();
|
|
1040
|
+
return { result, duration: Number(process.hrtime.bigint() - start) / 1e6 };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Runs a task at a regular interval. Unlike `setInterval`, a slow run
|
|
1045
|
+
* delays the next one instead of piling up, timing doesn't drift, and
|
|
1046
|
+
* errors are caught.
|
|
1047
|
+
*
|
|
1048
|
+
* @example
|
|
1049
|
+
* const job = time.every("5m", syncInventory, { immediate: true, onError: nc.error });
|
|
1050
|
+
* job.next(); // when it runs next
|
|
1051
|
+
* job.stop();
|
|
1052
|
+
*
|
|
1053
|
+
* @param {number | string} interval In ms, or a duration like `"30s"`.
|
|
1054
|
+
* @param {() => unknown} task Can be async.
|
|
1055
|
+
* @param {EveryOptions} [options]
|
|
1056
|
+
* @returns {ScheduledTask}
|
|
1057
|
+
* @throws {RangeError} If the interval isn't a positive duration.
|
|
1058
|
+
*/
|
|
1059
|
+
function every(interval, task, options = {}) {
|
|
1060
|
+
const ms = parseDuration(interval);
|
|
1061
|
+
if (ms === null || ms <= 0) throw new RangeError(`Invalid interval: ${String(interval)}`);
|
|
1062
|
+
let stopped = false;
|
|
1063
|
+
let running = false;
|
|
1064
|
+
let runs = 0;
|
|
1065
|
+
/** @type {() => void} */
|
|
1066
|
+
let cancel = () => {};
|
|
1067
|
+
let nextAt = Date.now() + (options.immediate ? 0 : ms);
|
|
1068
|
+
|
|
1069
|
+
const tick = async () => {
|
|
1070
|
+
if (stopped) return;
|
|
1071
|
+
running = true;
|
|
1072
|
+
runs++;
|
|
1073
|
+
await _run(task, options.onError);
|
|
1074
|
+
running = false;
|
|
1075
|
+
if (stopped) return;
|
|
1076
|
+
const now = Date.now();
|
|
1077
|
+
nextAt += ms;
|
|
1078
|
+
if (nextAt <= now) nextAt = now + ms - ((now - nextAt) % ms);
|
|
1079
|
+
cancel = _longTimeout(tick, nextAt - Date.now(), options.unref ?? false);
|
|
1080
|
+
};
|
|
1081
|
+
cancel = _longTimeout(tick, nextAt - Date.now(), options.unref ?? false);
|
|
1082
|
+
|
|
1083
|
+
return {
|
|
1084
|
+
stop: () => {
|
|
1085
|
+
stopped = true;
|
|
1086
|
+
cancel();
|
|
1087
|
+
},
|
|
1088
|
+
next: () => (stopped ? undefined : new Date(nextAt)),
|
|
1089
|
+
isRunning: () => running,
|
|
1090
|
+
runs: () => runs,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Parsed cron fields.
|
|
1096
|
+
* @typedef {{ seconds: Set<number>, minutes: Set<number>, hours: Set<number>, days: Set<number>, months: Set<number>, weekdays: Set<number>, anyDay: boolean, anyWeekday: boolean }} CronSpec
|
|
1097
|
+
*/
|
|
1098
|
+
|
|
1099
|
+
/** @type {Record<string, string>} */
|
|
1100
|
+
const _CRON_MACROS = {
|
|
1101
|
+
"@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *", "@weekly": "0 0 * * 0",
|
|
1102
|
+
"@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *",
|
|
1103
|
+
};
|
|
1104
|
+
const _MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
1105
|
+
const _DAY_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* @param {string} expression
|
|
1109
|
+
* @returns {CronSpec}
|
|
1110
|
+
*/
|
|
1111
|
+
const _parseCron = (expression) => {
|
|
1112
|
+
const text = _CRON_MACROS[expression.trim().toLowerCase()] ?? expression.trim();
|
|
1113
|
+
const fields = text.split(/\s+/);
|
|
1114
|
+
if (fields.length !== 5 && fields.length !== 6) throw new SyntaxError(`Invalid cron expression "${expression}": expected 5 or 6 fields`);
|
|
1115
|
+
if (fields.length === 5) fields.unshift("0");
|
|
1116
|
+
/** @type {(field: string, min: number, max: number, names?: string[]) => Set<number>} */
|
|
1117
|
+
const parse = (field, min, max, names) => {
|
|
1118
|
+
const values = new Set();
|
|
1119
|
+
for (const part of field.split(",")) {
|
|
1120
|
+
const [rangePart, stepPart] = part.split("/");
|
|
1121
|
+
const step = stepPart === undefined ? 1 : Number(stepPart);
|
|
1122
|
+
if (!Number.isInteger(step) || step < 1) throw new SyntaxError(`Invalid step "${part}" in cron expression "${expression}"`);
|
|
1123
|
+
const toNumber = (/** @type {string} */ token) => {
|
|
1124
|
+
const index = names ? names.indexOf(token.toLowerCase()) : -1;
|
|
1125
|
+
const n = index >= 0 ? index + (min === 1 ? 1 : 0) : Number(token);
|
|
1126
|
+
if (!Number.isInteger(n)) throw new SyntaxError(`Invalid value "${token}" in cron expression "${expression}"`);
|
|
1127
|
+
return n;
|
|
1128
|
+
};
|
|
1129
|
+
let from = min;
|
|
1130
|
+
let to = max;
|
|
1131
|
+
if (rangePart !== "*" && rangePart !== "?") {
|
|
1132
|
+
const [a, b] = rangePart.split("-");
|
|
1133
|
+
from = toNumber(a);
|
|
1134
|
+
to = b === undefined ? (stepPart === undefined ? from : max) : toNumber(b);
|
|
1135
|
+
}
|
|
1136
|
+
if (from < min || to > max || from > to) throw new SyntaxError(`Value out of range in "${part}" (allowed ${min}-${max}) in cron expression "${expression}"`);
|
|
1137
|
+
for (let v = from; v <= to; v += step) values.add(v);
|
|
1138
|
+
}
|
|
1139
|
+
return values;
|
|
1140
|
+
};
|
|
1141
|
+
const weekdays = parse(fields[5], 0, 7, _DAY_NAMES);
|
|
1142
|
+
if (weekdays.has(7)) {
|
|
1143
|
+
weekdays.delete(7);
|
|
1144
|
+
weekdays.add(0);
|
|
1145
|
+
}
|
|
1146
|
+
return {
|
|
1147
|
+
seconds: parse(fields[0], 0, 59),
|
|
1148
|
+
minutes: parse(fields[1], 0, 59),
|
|
1149
|
+
hours: parse(fields[2], 0, 23),
|
|
1150
|
+
days: parse(fields[3], 1, 31),
|
|
1151
|
+
months: parse(fields[4], 1, 12, _MONTH_NAMES),
|
|
1152
|
+
weekdays,
|
|
1153
|
+
anyDay: fields[3] === "*" || fields[3] === "?",
|
|
1154
|
+
anyWeekday: fields[5] === "*" || fields[5] === "?",
|
|
1155
|
+
};
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* @param {CronSpec} spec
|
|
1160
|
+
* @param {Date} from
|
|
1161
|
+
* @param {string | undefined} timeZone
|
|
1162
|
+
* @returns {Date | undefined}
|
|
1163
|
+
*/
|
|
1164
|
+
const _nextCron = (spec, from, timeZone) => {
|
|
1165
|
+
const start = _parts(new Date(from.getTime() + 1000), timeZone);
|
|
1166
|
+
// Walk wall-clock time (stored in a UTC Date) and convert matches to instants.
|
|
1167
|
+
const wall = new Date(Date.UTC(start.year, start.month - 1, start.day, start.hour, start.minute, start.second));
|
|
1168
|
+
const limit = wall.getTime() + 8 * 366 * MS.d;
|
|
1169
|
+
const dayMatches = () => {
|
|
1170
|
+
const dom = spec.days.has(wall.getUTCDate());
|
|
1171
|
+
const dow = spec.weekdays.has(wall.getUTCDay());
|
|
1172
|
+
if (spec.anyDay && spec.anyWeekday) return true;
|
|
1173
|
+
if (spec.anyDay) return dow;
|
|
1174
|
+
if (spec.anyWeekday) return dom;
|
|
1175
|
+
return dom || dow;
|
|
1176
|
+
};
|
|
1177
|
+
while (wall.getTime() <= limit) {
|
|
1178
|
+
if (!spec.months.has(wall.getUTCMonth() + 1)) {
|
|
1179
|
+
wall.setUTCMonth(wall.getUTCMonth() + 1, 1);
|
|
1180
|
+
wall.setUTCHours(0, 0, 0, 0);
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
if (!dayMatches()) {
|
|
1184
|
+
wall.setUTCDate(wall.getUTCDate() + 1);
|
|
1185
|
+
wall.setUTCHours(0, 0, 0, 0);
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
if (!spec.hours.has(wall.getUTCHours())) {
|
|
1189
|
+
wall.setUTCHours(wall.getUTCHours() + 1, 0, 0, 0);
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
if (!spec.minutes.has(wall.getUTCMinutes())) {
|
|
1193
|
+
wall.setUTCMinutes(wall.getUTCMinutes() + 1, 0, 0);
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
if (!spec.seconds.has(wall.getUTCSeconds())) {
|
|
1197
|
+
wall.setUTCSeconds(wall.getUTCSeconds() + 1, 0);
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
const instant = _fromWall(wall.getUTCFullYear(), wall.getUTCMonth() + 1, wall.getUTCDate(), wall.getUTCHours(), wall.getUTCMinutes(), wall.getUTCSeconds(), timeZone);
|
|
1201
|
+
if (instant && instant.getTime() > from.getTime()) return instant;
|
|
1202
|
+
wall.setUTCSeconds(wall.getUTCSeconds() + 1, 0);
|
|
1203
|
+
}
|
|
1204
|
+
return undefined;
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* The next date matching a cron expression, without scheduling anything.
|
|
1209
|
+
*
|
|
1210
|
+
* Expressions have 5 fields (`minute hour day month weekday`), or 6 with
|
|
1211
|
+
* seconds first. You can use `*`, lists (`1,15`), ranges (`1-5`), steps
|
|
1212
|
+
* (`*\/15`), names (`jan`, `mon-fri`) and `@daily`, `@hourly`, `@weekly`,
|
|
1213
|
+
* `@monthly`, `@yearly`. As in classic cron, when both the day and the
|
|
1214
|
+
* weekday are set, either one matching is enough.
|
|
1215
|
+
*
|
|
1216
|
+
* @example
|
|
1217
|
+
* time.nextRun("0 9 * * mon-fri"); // next weekday at 09:00
|
|
1218
|
+
* time.nextRun("0 0 1 * *", { timeZone: "America/New_York" }); // next 1st of the month
|
|
1219
|
+
*
|
|
1220
|
+
* @param {string} expression
|
|
1221
|
+
* @param {CronOptions} [options]
|
|
1222
|
+
* @returns {Date | undefined} `undefined` if nothing matches within 8 years.
|
|
1223
|
+
* @throws {SyntaxError} If the expression is invalid.
|
|
1224
|
+
*/
|
|
1225
|
+
function nextRun(expression, options = {}) {
|
|
1226
|
+
return _nextCron(_parseCron(expression), _toDate(options.from), options.timeZone ?? _config.timeZone);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Runs a task on a cron schedule, in any time zone. Daylight-saving changes
|
|
1231
|
+
* are handled: a skipped time is skipped, a repeated one runs once. See
|
|
1232
|
+
* `nextRun()` for the syntax.
|
|
1233
|
+
*
|
|
1234
|
+
* @example
|
|
1235
|
+
* const job = time.cron("0 9 * * 1-5", sendDailyReport, { timeZone: "Europe/Paris" });
|
|
1236
|
+
* time.cron("*\/15 * * * *", refreshCache, { onError: nc.error });
|
|
1237
|
+
* job.stop();
|
|
1238
|
+
*
|
|
1239
|
+
* @param {string} expression
|
|
1240
|
+
* @param {() => unknown} task Can be async.
|
|
1241
|
+
* @param {CronOptions} [options]
|
|
1242
|
+
* @returns {ScheduledTask}
|
|
1243
|
+
* @throws {SyntaxError} If the expression is invalid.
|
|
1244
|
+
*/
|
|
1245
|
+
function cron(expression, task, options = {}) {
|
|
1246
|
+
const spec = _parseCron(expression);
|
|
1247
|
+
const timeZone = options.timeZone ?? _config.timeZone;
|
|
1248
|
+
let stopped = false;
|
|
1249
|
+
let running = false;
|
|
1250
|
+
let runs = 0;
|
|
1251
|
+
/** @type {Date | undefined} */
|
|
1252
|
+
let nextDate;
|
|
1253
|
+
/** @type {() => void} */
|
|
1254
|
+
let cancel = () => {};
|
|
1255
|
+
|
|
1256
|
+
const plan = (/** @type {Date} */ after) => {
|
|
1257
|
+
nextDate = _nextCron(spec, after, timeZone);
|
|
1258
|
+
if (!nextDate || stopped) return;
|
|
1259
|
+
const target = nextDate;
|
|
1260
|
+
cancel = _longTimeout(() => {
|
|
1261
|
+
if (stopped) return;
|
|
1262
|
+
plan(target);
|
|
1263
|
+
if (running && !options.overlap) return;
|
|
1264
|
+
running = true;
|
|
1265
|
+
runs++;
|
|
1266
|
+
_run(task, options.onError).finally(() => {
|
|
1267
|
+
running = false;
|
|
1268
|
+
});
|
|
1269
|
+
}, target.getTime() - Date.now(), options.unref ?? false);
|
|
1270
|
+
};
|
|
1271
|
+
plan(new Date());
|
|
1272
|
+
|
|
1273
|
+
return {
|
|
1274
|
+
stop: () => {
|
|
1275
|
+
stopped = true;
|
|
1276
|
+
nextDate = undefined;
|
|
1277
|
+
cancel();
|
|
1278
|
+
},
|
|
1279
|
+
next: () => (stopped ? undefined : nextDate),
|
|
1280
|
+
isRunning: () => running,
|
|
1281
|
+
runs: () => runs,
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
module.exports = {
|
|
1286
|
+
setLocale,
|
|
1287
|
+
setTimezone,
|
|
1288
|
+
getConfig,
|
|
1289
|
+
timezones,
|
|
1290
|
+
offset,
|
|
1291
|
+
parseDuration,
|
|
1292
|
+
formatDuration,
|
|
1293
|
+
relative,
|
|
1294
|
+
calendar,
|
|
1295
|
+
format,
|
|
1296
|
+
toISODate,
|
|
1297
|
+
add,
|
|
1298
|
+
subtract,
|
|
1299
|
+
diff,
|
|
1300
|
+
startOf,
|
|
1301
|
+
endOf,
|
|
1302
|
+
isBefore,
|
|
1303
|
+
isAfter,
|
|
1304
|
+
isBetween,
|
|
1305
|
+
isSameDay,
|
|
1306
|
+
isToday,
|
|
1307
|
+
isYesterday,
|
|
1308
|
+
isTomorrow,
|
|
1309
|
+
isWeekend,
|
|
1310
|
+
isLeapYear,
|
|
1311
|
+
isValid,
|
|
1312
|
+
daysInMonth,
|
|
1313
|
+
dayOfYear,
|
|
1314
|
+
weekOfYear,
|
|
1315
|
+
min,
|
|
1316
|
+
max,
|
|
1317
|
+
unix,
|
|
1318
|
+
stopwatch,
|
|
1319
|
+
measure,
|
|
1320
|
+
every,
|
|
1321
|
+
nextRun,
|
|
1322
|
+
cron,
|
|
1323
|
+
};
|