@ix-xs/node-comfort 1.2.0 → 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.
Files changed (75) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +136 -549
  3. package/index.js +80 -35
  4. package/package.json +160 -33
  5. package/src/Arr.js +887 -419
  6. package/src/Async.js +585 -0
  7. package/src/Cache.js +329 -0
  8. package/src/Checker.js +971 -539
  9. package/src/Cli.js +1034 -0
  10. package/src/Color.js +415 -0
  11. package/src/Crypto.js +707 -0
  12. package/src/Emitter.js +289 -0
  13. package/src/Env.js +555 -0
  14. package/src/FS.js +1483 -894
  15. package/src/Func.js +670 -353
  16. package/src/Http.js +538 -0
  17. package/src/Id.js +384 -201
  18. package/src/Logger.js +739 -378
  19. package/src/Num.js +844 -361
  20. package/src/Obj.js +855 -372
  21. package/src/SQLite.js +1308 -1005
  22. package/src/Schema.js +1586 -0
  23. package/src/Str.js +983 -436
  24. package/src/Sys.js +511 -0
  25. package/src/Time.js +1260 -422
  26. package/src/Utils.js +321 -325
  27. package/src/errors.js +292 -0
  28. package/types/index.d.ts +693 -526
  29. package/types/src/Arr.d.ts +455 -145
  30. package/types/src/Arr.d.ts.map +1 -1
  31. package/types/src/Async.d.ts +321 -0
  32. package/types/src/Async.d.ts.map +1 -0
  33. package/types/src/Cache.d.ts +195 -0
  34. package/types/src/Cache.d.ts.map +1 -0
  35. package/types/src/Checker.d.ts +501 -235
  36. package/types/src/Checker.d.ts.map +1 -1
  37. package/types/src/Cli.d.ts +582 -0
  38. package/types/src/Cli.d.ts.map +1 -0
  39. package/types/src/Color.d.ts +294 -0
  40. package/types/src/Color.d.ts.map +1 -0
  41. package/types/src/Crypto.d.ts +534 -0
  42. package/types/src/Crypto.d.ts.map +1 -0
  43. package/types/src/Emitter.d.ts +170 -0
  44. package/types/src/Emitter.d.ts.map +1 -0
  45. package/types/src/Env.d.ts +382 -0
  46. package/types/src/Env.d.ts.map +1 -0
  47. package/types/src/FS.d.ts +686 -238
  48. package/types/src/FS.d.ts.map +1 -1
  49. package/types/src/Func.d.ts +380 -135
  50. package/types/src/Func.d.ts.map +1 -1
  51. package/types/src/Http.d.ts +374 -0
  52. package/types/src/Http.d.ts.map +1 -0
  53. package/types/src/Id.d.ts +149 -68
  54. package/types/src/Id.d.ts.map +1 -1
  55. package/types/src/Logger.d.ts +363 -46
  56. package/types/src/Logger.d.ts.map +1 -1
  57. package/types/src/Num.d.ts +473 -137
  58. package/types/src/Num.d.ts.map +1 -1
  59. package/types/src/Obj.d.ts +390 -104
  60. package/types/src/Obj.d.ts.map +1 -1
  61. package/types/src/SQLite.d.ts +646 -309
  62. package/types/src/SQLite.d.ts.map +1 -1
  63. package/types/src/Schema.d.ts +940 -0
  64. package/types/src/Schema.d.ts.map +1 -0
  65. package/types/src/Str.d.ts +569 -166
  66. package/types/src/Str.d.ts.map +1 -1
  67. package/types/src/Sys.d.ts +269 -0
  68. package/types/src/Sys.d.ts.map +1 -0
  69. package/types/src/Time.d.ts +663 -204
  70. package/types/src/Time.d.ts.map +1 -1
  71. package/types/src/Utils.d.ts +154 -102
  72. package/types/src/Utils.d.ts.map +1 -1
  73. package/types/src/errors.d.ts +292 -0
  74. package/types/src/errors.d.ts.map +1 -0
  75. package/types/index.d.ts.map +0 -1
package/src/Time.js CHANGED
@@ -1,338 +1,581 @@
1
+ "use strict";
2
+
1
3
  /**
2
- * Date & time utilities.
3
- *
4
- * Parsing durations, human-friendly relative time ("3 hours ago"), duration
5
- * formatting, add/diff arithmetic and a tiny date formatter • with no
6
- * dependency on heavyweight date libraries.
7
- *
8
- * ### Internationalization & time zones
9
- *
10
- * `relative`, `formatDuration` and `format` are locale-aware and use the
11
- * platform's built-in `Intl` (full ICU ships with Node), so **every language
12
- * is supported out of the box** • no locale files, no dependencies.
13
- *
14
- * The default locale is `"en"` (for deterministic output). Change it globally
15
- * with {@link module:Time.setLocale}, or per call via an `options.locale`. A
16
- * time zone can be set globally with {@link module:Time.setTimezone} or passed
17
- * per call via `options.timeZone` (IANA name, e.g. `"Europe/Paris"`).
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 }`.
18
9
  *
19
10
  * @example
20
- * nodeComfort.time.setLocale("fr").setTimezone("Europe/Paris");
21
- * nodeComfort.time.relative(Date.now() - 3600e3); // "il y a 1 heure"
22
- * nodeComfort.time.format(Date.now(), "dddd D MMMM YYYY"); // "lundi 15 janvier 2024"
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
23
  */
24
24
 
25
25
  /**
26
- * The time module itself • used as the return type of the chainable setters so
27
- * `setLocale().setTimezone()...` stays fully typed without inlining the type.
28
- * @typedef {typeof import("./Time")} TimeModule
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
29
28
  */
30
29
 
31
- const MS = {
32
- ms: 1,
33
- s: 1000,
34
- m: 60_000,
35
- h: 3_600_000,
36
- d: 86_400_000,
37
- w: 604_800_000,
38
- };
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
+ */
39
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">} */
40
125
  const UNIT_ALIASES = {
41
- ms: "ms", milli: "ms", millisecond: "ms", milliseconds: "ms",
126
+ ms: "ms", msec: "ms", milli: "ms", millis: "ms", millisecond: "ms", milliseconds: "ms",
42
127
  s: "s", sec: "s", secs: "s", second: "s", seconds: "s",
43
128
  m: "m", min: "m", mins: "m", minute: "m", minutes: "m",
44
129
  h: "h", hr: "h", hrs: "h", hour: "h", hours: "h",
45
130
  d: "d", day: "d", days: "d",
46
- w: "w", week: "w", weeks: "w",
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",
47
134
  };
48
135
 
49
- /**
50
- * Global defaults for locale-aware formatting. `locale` defaults to `"en"` so
51
- * output is deterministic regardless of the host machine; `timeZone`
52
- * `undefined` means the system local zone.
53
- * @private
54
- * @type {{ locale: string, timeZone: string|undefined }}
55
- */
56
- const _config = { locale: "en", timeZone: undefined };
136
+ /** Global defaults. English by default so output never depends on the machine. */
137
+ const _config = { locale: "en", timeZone: /** @type {string | undefined} */ (undefined) };
57
138
 
58
139
  /**
59
- * Coerces a value into a Date.
60
- * @private
61
- * @param {Date|number|string} [value]
140
+ * @param {DateInput | undefined} value
62
141
  * @returns {Date}
63
142
  */
64
- const _toDate = (value) => (value instanceof Date ? new Date(value.getTime()) : value === undefined ? new Date() : new Date(value));
143
+ const _toDate = (value) => (types.isDate(value) ? new Date(value.getTime()) : value === undefined ? new Date() : new Date(value));
65
144
 
145
+ /**
146
+ * @param {number} n
147
+ * @param {number} [len=2]
148
+ */
66
149
  const _pad = (n, len = 2) => String(Math.abs(n)).padStart(len, "0");
67
150
 
68
151
  /**
69
- * Extracts numeric calendar parts of a date, optionally in a target time zone.
70
- * When no zone is given, the host's local time is used (fast path).
71
- * @private
72
- * @param {Date} d - Date to read.
73
- * @param {string} [timeZone] - IANA time-zone name.
74
- * @returns {{ year: number, month: number, day: number, hour: number, minute: number, second: number, ms: number }}
152
+ * @param {string} unit
153
+ * @returns {keyof typeof MS | "M" | "y" | undefined}
75
154
  */
76
- const _numericParts = (d, timeZone) => {
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) => {
77
167
  if (!timeZone) {
78
168
  return {
79
- year: d.getFullYear(),
80
- month: d.getMonth() + 1,
81
- day: d.getDate(),
82
- hour: d.getHours(),
83
- minute: d.getMinutes(),
84
- second: d.getSeconds(),
85
- ms: d.getMilliseconds(),
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(),
86
171
  };
87
172
  }
88
- const dtf = new Intl.DateTimeFormat("en-US", {
89
- timeZone,
90
- year: "numeric",
91
- month: "2-digit",
92
- day: "2-digit",
93
- hour: "2-digit",
94
- minute: "2-digit",
95
- second: "2-digit",
96
- hourCycle: "h23",
97
- });
98
- const parts = {};
99
- for (const p of dtf.formatToParts(d)) parts[p.type] = p.value;
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;
100
184
  return {
101
- year: +parts.year,
102
- month: +parts.month,
103
- day: +parts.day,
104
- hour: +parts.hour,
105
- minute: +parts.minute,
106
- second: +parts.second,
107
- ms: d.getMilliseconds(),
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();
108
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
+ }
109
278
  };
110
279
 
111
280
  /**
112
- * Formats a single localized component (month/weekday name) via `Intl`.
113
- * @private
114
- * @param {Date} d - Date to format.
115
- * @param {Intl.DateTimeFormatOptions} opt - Component options.
116
- * @param {string} locale - BCP-47 locale.
117
- * @param {string} [timeZone] - IANA time-zone name.
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]
118
414
  * @returns {string}
119
415
  */
120
- const _named = (d, opt, locale, timeZone) =>
121
- new Intl.DateTimeFormat(locale, { ...opt, timeZone }).format(d);
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));
122
420
 
123
- module.exports = {
124
- /**
125
- * Sets the global default locale used by `relative`, `formatDuration` and
126
- * `format` (named tokens). Chainable.
127
- *
128
- * @example
129
- * nodeComfort.time.setLocale("fr");
130
- *
131
- * @param {string} locale - A BCP-47 locale tag (e.g. `"fr"`, `"en-GB"`, `"ja"`).
132
- * @returns {TimeModule} The time module (for chaining).
133
- */
134
- setLocale(locale) {
135
- _config.locale = locale;
136
- return this;
137
- },
138
-
139
- /**
140
- * Sets the global default time zone used by `format` and `isSameDay`.
141
- * Chainable. Pass `undefined` to fall back to the system local zone.
142
- *
143
- * @example
144
- * nodeComfort.time.setTimezone("Europe/Paris");
145
- *
146
- * @param {string|undefined} timeZone - An IANA time-zone name (e.g. `"America/New_York"`).
147
- * @returns {TimeModule} The time module (for chaining).
148
- */
149
- setTimezone(timeZone) {
150
- _config.timeZone = timeZone;
151
- return this;
152
- },
153
-
154
- /**
155
- * Returns the current global `{ locale, timeZone }` defaults.
156
- *
157
- * @returns {{ locale: string, timeZone: string|undefined }} A copy of the config.
158
- */
159
- getConfig() {
160
- return { ..._config };
161
- },
162
-
163
- /**
164
- * Parses a human duration string into milliseconds.
165
- *
166
- * Accepts things like `"1h"`, `"30m"`, `"1h30m"`, `"2 days"`, `"500ms"`.
167
- * A plain number is treated as milliseconds.
168
- *
169
- * @example
170
- * nodeComfort.time.parseDuration("1h30m"); // 5400000
171
- * nodeComfort.time.parseDuration("2 days"); // 172800000
172
- *
173
- * @param {string|number} input - Duration string or millisecond count.
174
- * @returns {number|null} Milliseconds, or null if it can't be parsed.
175
- */
176
- parseDuration(input) {
177
- if (typeof input === "number") return input;
178
- if (typeof input !== "string") return null;
179
-
180
- const regex = /(-?\d*\.?\d+)\s*([a-z]+)/gi;
181
- let total = 0;
182
- let matched = false;
183
- let match;
184
- while ((match = regex.exec(input)) !== null) {
185
- const value = parseFloat(match[1]);
186
- const unit = UNIT_ALIASES[match[2].toLowerCase()];
187
- if (!unit) return null;
188
- total += value * MS[unit];
189
- matched = true;
190
- }
191
- return matched ? total : null;
192
- },
193
-
194
- /**
195
- * Formats a millisecond duration into a human string.
196
- *
197
- * The compact form (default) is language-neutral (`"1h 30m"`). The `long`
198
- * form is fully localized through `Intl` • pass `options.locale` or set a
199
- * global locale with {@link module:Time.setLocale}.
200
- *
201
- * @example
202
- * nodeComfort.time.formatDuration(5400000); // "1h 30m"
203
- * nodeComfort.time.formatDuration(90000, { long: true }); // "1 minute 30 seconds"
204
- * nodeComfort.time.formatDuration(90000, { long: true, locale: "fr" }); // "1 minute 30 secondes"
205
- *
206
- * @param {number} ms - Duration in milliseconds.
207
- * @param {{ long?: boolean, units?: number, locale?: string }} [options] - Formatting options (`units` caps how many parts show).
208
- * @returns {string} The formatted duration.
209
- */
210
- formatDuration(ms, options = {}) {
211
- const long = options.long ?? false;
212
- const maxUnits = options.units ?? Infinity;
213
- const locale = options.locale ?? _config.locale;
214
- if (!Number.isFinite(ms)) return String(ms);
215
- const sign = ms < 0 ? "-" : "";
216
- ms = Math.abs(Math.trunc(ms));
217
-
218
- const parts = [
219
- { v: Math.floor(ms / MS.d), short: "d", unit: "day" },
220
- { v: Math.floor((ms % MS.d) / MS.h), short: "h", unit: "hour" },
221
- { v: Math.floor((ms % MS.h) / MS.m), short: "m", unit: "minute" },
222
- { v: Math.floor((ms % MS.m) / MS.s), short: "s", unit: "second" },
223
- { v: ms % MS.s, short: "ms", unit: "millisecond" },
224
- ].filter((p) => p.v > 0);
225
-
226
- if (!parts.length) {
227
- return long
228
- ? new Intl.NumberFormat(locale, { style: "unit", unit: "second", unitDisplay: "long" }).format(0)
229
- : "0s";
230
- }
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
+ }
231
428
 
232
- return sign + parts
233
- .slice(0, maxUnits)
234
- .map((p) =>
235
- long
236
- ? new Intl.NumberFormat(locale, { style: "unit", unit: p.unit, unitDisplay: "long" }).format(p.v)
237
- : `${p.v}${p.short}`,
238
- )
239
- .join(" ");
240
- },
241
-
242
- /**
243
- * Returns a human-friendly, localized relative time string.
244
- *
245
- * Built on `Intl.RelativeTimeFormat`, so it speaks every language. Set
246
- * `options.locale` (or a global locale) to translate; `options.numeric`
247
- * controls wording (`"always"` → "1 day ago", `"auto"` → "yesterday").
248
- *
249
- * @example
250
- * nodeComfort.time.relative(Date.now() - 3600e3); // "1 hour ago"
251
- * nodeComfort.time.relative(Date.now() - 3600e3, undefined, { locale: "fr" }); // "il y a 1 heure"
252
- * nodeComfort.time.relative(Date.now() - 86400e3, undefined, { numeric: "auto" }); // "yesterday"
253
- *
254
- * @param {Date|number|string} date - Target date.
255
- * @param {Date|number|string} [from] - Reference date (defaults to now).
256
- * @param {{ locale?: string, numeric?: "always"|"auto" }} [options] - Formatting options.
257
- * @returns {string} The relative time string.
258
- */
259
- relative(date, from, options = {}) {
260
- const locale = options.locale ?? _config.locale;
261
- const numeric = options.numeric ?? "always";
262
- const target = _toDate(date).getTime();
263
- const base = _toDate(from).getTime();
264
- const diff = target - base;
265
- const abs = Math.abs(diff);
266
-
267
- if (abs < MS.s) {
268
- return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(0, "second");
269
- }
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
+ }
270
446
 
271
- const units = [
272
- { limit: MS.m, div: MS.s, name: "second" },
273
- { limit: MS.h, div: MS.m, name: "minute" },
274
- { limit: MS.d, div: MS.h, name: "hour" },
275
- { limit: MS.w, div: MS.d, name: "day" },
276
- { limit: MS.d * 30, div: MS.w, name: "week" },
277
- { limit: MS.d * 365, div: MS.d * 30, name: "month" },
278
- { limit: Infinity, div: MS.d * 365, name: "year" },
279
- ];
280
-
281
- const rtf = new Intl.RelativeTimeFormat(locale, { numeric });
282
- for (const unit of units) {
283
- if (abs < unit.limit) {
284
- const value = Math.floor(abs / unit.div);
285
- return rtf.format(diff < 0 ? -value : value, /** @type {Intl.RelativeTimeFormatUnit} */ (unit.name));
286
- }
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);
287
476
  }
288
- return rtf.format(0, "second");
289
- },
290
-
291
- /**
292
- * Formats a date using a small token syntax, with optional locale & time zone.
293
- *
294
- * **Numeric tokens:** `YYYY` `YY` `MM` `M` `DD` `D` `HH` `H` (24h) `hh` `h`
295
- * (12h) `mm` `m` `ss` `s` `SSS`.
296
- * **Localized tokens:** `MMMM` (month name) `MMM` (short month) `dddd` (weekday)
297
- * `ddd` (short weekday) `A` (AM/PM) `a` (am/pm).
298
- *
299
- * Wrap literal text in square brackets to keep it verbatim: `"[Today is] dddd"`.
300
- *
301
- * @example
302
- * nodeComfort.time.format(new Date(), "YYYY-MM-DD HH:mm:ss");
303
- * nodeComfort.time.format(Date.now(), "dddd D MMMM YYYY", { locale: "fr" });
304
- * nodeComfort.time.format(Date.now(), "HH:mm", { timeZone: "Asia/Tokyo" });
305
- *
306
- * @param {Date|number|string} date - Date to format.
307
- * @param {string} [pattern="YYYY-MM-DD HH:mm:ss"] - Format pattern.
308
- * @param {{ locale?: string, timeZone?: string }} [options] - Locale / time-zone overrides.
309
- * @returns {string} The formatted date.
310
- */
311
- format(date, pattern = "YYYY-MM-DD HH:mm:ss", options = {}) {
312
- const d = _toDate(date);
313
- if (Number.isNaN(d.getTime())) return "Invalid Date";
314
-
315
- const locale = options.locale ?? _config.locale;
316
- const timeZone = options.timeZone ?? _config.timeZone;
317
- const p = _numericParts(d, timeZone);
318
-
319
- const replacer = (token, escaped) => {
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) => {
320
561
  if (escaped !== undefined) return escaped;
321
562
  switch (token) {
322
563
  case "YYYY": return String(p.year);
323
564
  case "YY": return String(p.year).slice(-2);
324
- case "MMMM": return _named(d, { month: "long" }, locale, timeZone);
325
- case "MMM": return _named(d, { month: "short" }, locale, timeZone);
565
+ case "Q": return String(Math.ceil(p.month / 3));
566
+ case "MMMM": return named({ month: "long" });
567
+ case "MMM": return named({ month: "short" });
326
568
  case "MM": return _pad(p.month);
327
569
  case "M": return String(p.month);
328
570
  case "DD": return _pad(p.day);
329
571
  case "D": return String(p.day);
330
- case "dddd": return _named(d, { weekday: "long" }, locale, timeZone);
331
- case "ddd": return _named(d, { weekday: "short" }, locale, timeZone);
572
+ case "dddd": return named({ weekday: "long" });
573
+ case "ddd": return named({ weekday: "short" });
574
+ case "d": return String(p.weekday);
332
575
  case "HH": return _pad(p.hour);
333
576
  case "H": return String(p.hour);
334
- case "hh": return _pad(p.hour % 12 === 0 ? 12 : p.hour % 12);
335
- case "h": return String(p.hour % 12 === 0 ? 12 : p.hour % 12);
577
+ case "hh": return _pad(h12);
578
+ case "h": return String(h12);
336
579
  case "mm": return _pad(p.minute);
337
580
  case "m": return String(p.minute);
338
581
  case "ss": return _pad(p.second);
@@ -340,146 +583,741 @@ module.exports = {
340
583
  case "SSS": return _pad(p.ms, 3);
341
584
  case "A": return p.hour < 12 ? "AM" : "PM";
342
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());
343
592
  default: return token;
344
593
  }
345
- };
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
+ }
346
962
 
347
- return pattern.replace(
348
- /\[([^\]]*)\]|YYYY|MMMM|dddd|SSS|MMM|ddd|YY|MM|DD|HH|hh|mm|ss|M|D|H|h|m|s|A|a/g,
349
- replacer,
350
- );
351
- },
352
-
353
- /**
354
- * Adds a duration to a date and returns a new Date.
355
- *
356
- * The duration may be a millisecond number or a string like `"1h30m"`.
357
- *
358
- * @example
359
- * nodeComfort.time.add(new Date(), "2h");
360
- * nodeComfort.time.add(new Date(), -1, "days");
361
- *
362
- * @param {Date|number|string} date - Base date.
363
- * @param {number|string} amount - Amount (ms/string) or a number paired with `unit`.
364
- * @param {"ms"|"s"|"m"|"h"|"d"|"w"} [unit] - Unit when `amount` is a plain number.
365
- * @returns {Date} The resulting date.
366
- */
367
- add(date, amount, unit) {
368
- const d = _toDate(date);
369
- let ms;
370
- if (typeof amount === "number") {
371
- ms = unit ? amount * (MS[UNIT_ALIASES[unit] ?? unit] ?? 1) : amount;
372
- } else {
373
- ms = this.parseDuration(amount) ?? 0;
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;
374
1182
  }
375
- return new Date(d.getTime() + ms);
376
- },
377
-
378
- /**
379
- * Subtracts a duration from a date and returns a new Date.
380
- *
381
- * @param {Date|number|string} date - Base date.
382
- * @param {number|string} amount - Amount (ms/string) or a number paired with `unit`.
383
- * @param {"ms"|"s"|"m"|"h"|"d"|"w"} [unit] - Unit when `amount` is a plain number.
384
- * @returns {Date} The resulting date.
385
- */
386
- subtract(date, amount, unit) {
387
- if (typeof amount === "number") return this.add(date, -amount, unit);
388
- const ms = this.parseDuration(amount) ?? 0;
389
- return this.add(date, -ms);
390
- },
391
-
392
- /**
393
- * Returns the difference between two dates in the requested unit.
394
- *
395
- * @example
396
- * nodeComfort.time.diff("2024-01-02", "2024-01-01", "d"); // 1
397
- *
398
- * @param {Date|number|string} a - First date.
399
- * @param {Date|number|string} b - Second date.
400
- * @param {"ms"|"s"|"m"|"h"|"d"|"w"} [unit="ms"] - Result unit.
401
- * @returns {number} The signed difference (`a - b`) in the given unit.
402
- */
403
- diff(a, b, unit = "ms") {
404
- const ms = _toDate(a).getTime() - _toDate(b).getTime();
405
- return ms / (MS[UNIT_ALIASES[unit] ?? unit] ?? 1);
406
- },
407
-
408
- /**
409
- * Checks whether two dates fall on the same calendar day.
410
- *
411
- * Uses local time by default; pass `options.timeZone` (or set a global zone)
412
- * to compare calendar days in a specific zone.
413
- *
414
- * @param {Date|number|string} a - First date.
415
- * @param {Date|number|string} b - Second date.
416
- * @param {{ timeZone?: string }} [options] - Time-zone override.
417
- * @returns {boolean} True if both are the same day.
418
- */
419
- isSameDay(a, b, options = {}) {
420
- const timeZone = options.timeZone ?? _config.timeZone;
421
- const pa = _numericParts(_toDate(a), timeZone);
422
- const pb = _numericParts(_toDate(b), timeZone);
423
- return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day;
424
- },
425
-
426
- /**
427
- * Returns a new Date at the start of the given unit (day/hour/etc).
428
- *
429
- * @example
430
- * nodeComfort.time.startOf(new Date(), "day"); // midnight today
431
- *
432
- * @param {Date|number|string} date - Base date.
433
- * @param {"year"|"month"|"day"|"hour"|"minute"|"second"} unit - Unit to reset to.
434
- * @returns {Date} The start-of-unit date.
435
- */
436
- startOf(date, unit) {
437
- const d = _toDate(date);
438
- switch (unit) {
439
- case "year": d.setMonth(0);
440
- // falls through
441
- case "month": d.setDate(1);
442
- // falls through
443
- case "day": d.setHours(0);
444
- // falls through
445
- case "hour": d.setMinutes(0);
446
- // falls through
447
- case "minute": d.setSeconds(0);
448
- // falls through
449
- case "second": d.setMilliseconds(0);
1183
+ if (!dayMatches()) {
1184
+ wall.setUTCDate(wall.getUTCDate() + 1);
1185
+ wall.setUTCHours(0, 0, 0, 0);
1186
+ continue;
450
1187
  }
451
- return d;
452
- },
453
-
454
- /**
455
- * Returns the current Unix timestamp in seconds.
456
- *
457
- * @returns {number} Seconds since the Unix epoch.
458
- */
459
- unix() {
460
- return Math.floor(Date.now() / 1000);
461
- },
462
-
463
- /**
464
- * Returns a high-resolution stopwatch you can `.stop()` to get elapsed ms.
465
- *
466
- * @example
467
- * const timer = nodeComfort.time.stopwatch();
468
- * doWork();
469
- * console.log(timer.stop()); // e.g. 12.34 (ms, sub-ms precise)
470
- * console.log(timer.stop(true)); // "12.34ms"
471
- *
472
- * @returns {{ elapsed: () => number, stop: (format?: boolean) => number|string }} A stopwatch handle.
473
- */
474
- stopwatch() {
475
- const start = process.hrtime.bigint();
476
- const elapsed = () => Number(process.hrtime.bigint() - start) / 1e6;
477
- return {
478
- elapsed,
479
- stop: (format = false) => {
480
- const ms = elapsed();
481
- return format ? `${Math.round(ms * 100) / 100}ms` : ms;
482
- },
483
- };
484
- },
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,
485
1323
  };