@ix-xs/node-comfort 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +32 -30
  3. package/index.js +3 -3
  4. package/package.json +8 -7
  5. package/src/Arr.js +222 -58
  6. package/src/Async.js +322 -90
  7. package/src/Cache.js +148 -44
  8. package/src/Checker.js +257 -26
  9. package/src/Cli.js +404 -145
  10. package/src/Color.js +224 -83
  11. package/src/Crypto.js +481 -106
  12. package/src/Emitter.js +104 -37
  13. package/src/Env.js +176 -42
  14. package/src/FS.js +203 -60
  15. package/src/Func.js +226 -76
  16. package/src/Http.js +311 -149
  17. package/src/Id.js +281 -96
  18. package/src/Logger.js +302 -78
  19. package/src/Num.js +182 -60
  20. package/src/Obj.js +340 -125
  21. package/src/SQLite.js +426 -105
  22. package/src/Schema.js +602 -87
  23. package/src/Str.js +316 -86
  24. package/src/Sys.js +197 -54
  25. package/src/Time.js +572 -317
  26. package/src/Utils.js +62 -41
  27. package/src/errors.js +54 -11
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +59 -17
  30. package/types/src/Arr.d.ts +78 -0
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +52 -1
  33. package/types/src/Async.d.ts.map +1 -1
  34. package/types/src/Cache.d.ts +33 -7
  35. package/types/src/Cache.d.ts.map +1 -1
  36. package/types/src/Checker.d.ts +155 -2
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +97 -15
  39. package/types/src/Cli.d.ts.map +1 -1
  40. package/types/src/Color.d.ts +7 -1
  41. package/types/src/Color.d.ts.map +1 -1
  42. package/types/src/Crypto.d.ts +99 -13
  43. package/types/src/Crypto.d.ts.map +1 -1
  44. package/types/src/Emitter.d.ts +36 -17
  45. package/types/src/Emitter.d.ts.map +1 -1
  46. package/types/src/Env.d.ts +31 -1
  47. package/types/src/Env.d.ts.map +1 -1
  48. package/types/src/FS.d.ts +36 -5
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +147 -19
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +113 -43
  53. package/types/src/Http.d.ts.map +1 -1
  54. package/types/src/Id.d.ts +60 -8
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +53 -2
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +48 -7
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +67 -4
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +123 -14
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +256 -7
  65. package/types/src/Schema.d.ts.map +1 -1
  66. package/types/src/Str.d.ts +49 -1
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +51 -6
  69. package/types/src/Sys.d.ts.map +1 -1
  70. package/types/src/Time.d.ts +130 -34
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +15 -11
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +27 -3
  75. package/types/src/errors.d.ts.map +1 -1
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
package/src/Time.js CHANGED
@@ -74,6 +74,21 @@ const { types } = require("node:util");
74
74
  * Week settings.
75
75
  * @typedef {object} WeekOptions
76
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
+ * @property {string} [timeZone] Zone whose calendar is used. Defaults to the global zone, then the system's.
78
+ */
79
+
80
+ /**
81
+ * Options for `parse()`.
82
+ * @typedef {object} ParseDateOptions
83
+ * @property {string} [locale] Language of month names in the text. Defaults to the global locale.
84
+ * @property {string} [timeZone] Zone the text is written in, unless it carries its own offset. Defaults to the global zone, then the system's.
85
+ */
86
+
87
+ /**
88
+ * Options for `range()`.
89
+ * @typedef {object} DateRangeOptions
90
+ * @property {number} [step] How many units between two dates. Defaults to `1`.
91
+ * @property {string} [timeZone] Zone whose calendar is used for days, weeks, months and years.
77
92
  */
78
93
 
79
94
  /**
@@ -97,6 +112,7 @@ const { types } = require("node:util");
97
112
  * @property {DateInput} [from] `nextRun()` only: search after this date. Defaults to now.
98
113
  * @property {boolean} [unref] Let the process exit even if the schedule is active.
99
114
  * @property {boolean} [overlap] Start a run even if the previous one hasn't finished. By default it's skipped.
115
+ * @property {boolean} [immediate] `cron()` only: also run once right away.
100
116
  * @property {(error: unknown) => void} [onError] Called when the task fails.
101
117
  */
102
118
 
@@ -119,22 +135,11 @@ const { types } = require("node:util");
119
135
  * @property {() => void} reset Starts over.
120
136
  */
121
137
 
122
- const MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
138
+ const shared = require("./internal/shared.js");
123
139
 
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
- };
140
+ const { MS, intl, unitOf: _unit, setLongTimeout: _longTimeout } = shared;
135
141
 
136
- /** Global defaults. English by default so output never depends on the machine. */
137
- const _config = { locale: "en", timeZone: /** @type {string | undefined} */ (undefined) };
142
+ const _config = shared.timeConfig;
138
143
 
139
144
  /**
140
145
  * @param {DateInput | undefined} value
@@ -148,11 +153,6 @@ const _toDate = (value) => (types.isDate(value) ? new Date(value.getTime()) : va
148
153
  */
149
154
  const _pad = (n, len = 2) => String(Math.abs(n)).padStart(len, "0");
150
155
 
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
156
 
157
157
  /** @type {Map<string, Intl.DateTimeFormat>} */
158
158
  const _dtfCache = new Map();
@@ -227,42 +227,156 @@ const _fromWall = (year, month, day, hour, minute, second, timeZone) => {
227
227
  * @param {number} year
228
228
  * @param {number} month 1-12
229
229
  */
230
- const _daysIn = (year, month) => new Date(Date.UTC(year, month, 0)).getUTCDate();
230
+ const _daysIn = (year, month) => (month === 2 ? ((year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28) : month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31);
231
+
232
+ /**
233
+ * Wall-clock time in a zone to an instant. A time that happens twice (clocks
234
+ * going back) keeps its first occurrence; one that never happens (clocks
235
+ * going forward) moves past the gap.
236
+ * @param {number} year
237
+ * @param {number} month 1-12
238
+ * @param {number} day
239
+ * @param {number} hour
240
+ * @param {number} minute
241
+ * @param {number} second
242
+ * @param {number} ms
243
+ * @param {string} [timeZone]
244
+ * @returns {Date}
245
+ */
246
+ const _instantOf = (year, month, day, hour, minute, second, ms, timeZone) => {
247
+ if (!timeZone) return new Date(year, month - 1, day, hour, minute, second, ms);
248
+ const guess = Date.UTC(year, month - 1, day, hour, minute, second, ms);
249
+ // The offsets a day before and a day after frame any clock change.
250
+ const early = guess - _offset(new Date(guess - MS.d), timeZone) * 60_000;
251
+ const late = guess - _offset(new Date(guess + MS.d), timeZone) * 60_000;
252
+ if (early === late) return new Date(early);
253
+ const first = Math.min(early, late);
254
+ const at = _parts(new Date(first), timeZone);
255
+ if (at.day === day && at.hour === hour && at.minute === minute) return new Date(first);
256
+ return new Date(Math.max(early, late));
257
+ };
258
+
259
+ /**
260
+ * The first moment of a unit, as wall-clock fields.
261
+ * @param {{ year: number, month: number, day: number, hour: number, minute: number, second: number, weekday: number }} at
262
+ * @param {string} unit
263
+ * @param {number} [weekStartsOn]
264
+ * @returns {[number, number, number, number, number, number]}
265
+ * @throws {TypeError} If the unit isn't one.
266
+ */
267
+ const _startWall = (at, unit, weekStartsOn) => {
268
+ switch (unit) {
269
+ case "year": return [at.year, 1, 1, 0, 0, 0];
270
+ case "quarter": return [at.year, Math.floor((at.month - 1) / 3) * 3 + 1, 1, 0, 0, 0];
271
+ case "month": return [at.year, at.month, 1, 0, 0, 0];
272
+ case "week":
273
+ case "isoWeek": {
274
+ const first = unit === "isoWeek" ? 1 : weekStartsOn ?? 1;
275
+ const monday = new Date(Date.UTC(at.year, at.month - 1, at.day - ((at.weekday - first + 7) % 7)));
276
+ return [monday.getUTCFullYear(), monday.getUTCMonth() + 1, monday.getUTCDate(), 0, 0, 0];
277
+ }
278
+ case "day": return [at.year, at.month, at.day, 0, 0, 0];
279
+ case "hour": return [at.year, at.month, at.day, at.hour, 0, 0];
280
+ case "minute": return [at.year, at.month, at.day, at.hour, at.minute, 0];
281
+ case "second": return [at.year, at.month, at.day, at.hour, at.minute, at.second];
282
+ default: throw new TypeError(`Unknown unit: ${JSON.stringify(unit)}`);
283
+ }
284
+ };
285
+
286
+ /**
287
+ * Calendar days from `b` to `a` in a zone: 1 means "the day after".
288
+ * @param {Date} a
289
+ * @param {Date} b
290
+ * @param {string} [timeZone]
291
+ * @returns {number}
292
+ */
293
+ const _dayDelta = (a, b, timeZone) => {
294
+ const x = _parts(a, timeZone);
295
+ const y = _parts(b, timeZone);
296
+ return Math.round((Date.UTC(x.year, x.month - 1, x.day) - Date.UTC(y.year, y.month - 1, y.day)) / MS.d);
297
+ };
298
+
299
+ /**
300
+ * Adds calendar days: the same wall-clock time, that many days later.
301
+ * @param {Date} d
302
+ * @param {number} days
303
+ * @param {string} [timeZone]
304
+ * @returns {Date}
305
+ */
306
+ const _addDays = (d, days, timeZone) => {
307
+ if (timeZone === "UTC") return new Date(d.getTime() + days * MS.d);
308
+ const at = _parts(d, timeZone);
309
+ // Date.UTC carries a day past the end of the month into the next one.
310
+ const day = new Date(Date.UTC(at.year, at.month - 1, at.day + days));
311
+ return _instantOf(day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), at.hour, at.minute, at.second, at.ms, timeZone);
312
+ };
313
+
314
+ /**
315
+ * The short name of a unit, or an error that names what was given.
316
+ * @param {string} unit
317
+ * @returns {"ms" | "s" | "m" | "h" | "d" | "w" | "M" | "y"}
318
+ */
319
+ const _knownUnit = (unit) => {
320
+ const known = _unit(unit);
321
+ if (!known) throw new TypeError(`Unknown unit: ${JSON.stringify(unit)}`);
322
+ return known;
323
+ };
231
324
 
232
325
  /**
233
326
  * Adds calendar months, keeping the day in range (Jan 31 + 1 month = end of Feb).
234
327
  * @param {Date} d
235
328
  * @param {number} months
329
+ * @param {string} [timeZone]
236
330
  * @returns {Date}
237
331
  */
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;
332
+ const _addMonths = (d, months, timeZone) => {
333
+ const at = _parts(d, timeZone);
334
+ const index = at.year * 12 + at.month - 1 + Math.trunc(months);
335
+ const year = Math.floor(index / 12);
336
+ const month = index - year * 12 + 1;
337
+ return _instantOf(year, month, Math.min(at.day, _daysIn(year, month)), at.hour, at.minute, at.second, at.ms, timeZone);
245
338
  };
246
339
 
247
340
  /**
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);
341
+ * Adds a duration written as text: a fixed one (`"1h30m"`) or one with
342
+ * calendar units (`"1 month"`, `"2y 3d"`).
343
+ * @param {Date} d
344
+ * @param {string} text
345
+ * @param {1 | -1} sign
346
+ * @param {string} [timeZone]
347
+ * @returns {Date}
348
+ */
349
+ const _addText = (d, text, sign, timeZone) => {
350
+ const fixed = shared.parseDuration(text);
351
+ // Under a day there is no calendar to follow.
352
+ if (fixed !== null && Math.abs(fixed) < MS.d) return new Date(d.getTime() + sign * fixed);
353
+ const regex = /([-+]?\d*\.?\d+)\s*([a-zA-Z]+)/g;
354
+ let months = 0;
355
+ let days = 0;
356
+ let ms = 0;
357
+ let consumed = "";
358
+ let match;
359
+ while ((match = regex.exec(text)) !== null) {
360
+ const unit = _unit(match[2]);
361
+ if (!unit) break;
362
+ const amount = Number(match[1]);
363
+ if (unit === "M") months += amount;
364
+ else if (unit === "y") months += amount * 12;
365
+ else if ((unit === "d" || unit === "w") && Number.isInteger(amount)) days += unit === "w" ? amount * 7 : amount;
366
+ else ms += amount * MS[unit];
367
+ consumed += match[0];
368
+ }
369
+ if (!consumed || consumed.replace(/\s+/g, "") !== text.replace(/[\s,]+|and/g, "")) {
370
+ // A bare number of milliseconds, as text.
371
+ if (fixed !== null) return new Date(d.getTime() + sign * fixed);
372
+ throw new TypeError(`Invalid duration: ${JSON.stringify(text)}`);
373
+ }
374
+ let moved = months ? _addMonths(d, sign * months, timeZone) : d;
375
+ if (days) moved = _addDays(moved, sign * days, timeZone);
376
+ return new Date(moved.getTime() + sign * ms);
264
377
  };
265
378
 
379
+
266
380
  /**
267
381
  * @param {() => unknown} task
268
382
  * @param {((error: unknown) => void) | undefined} onError
@@ -364,39 +478,7 @@ function offset(timeZone, date) {
364
478
  * @returns {number | null} `null` when it isn't a duration.
365
479
  */
366
480
  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;
481
+ return shared.parseDuration(input);
400
482
  }
401
483
 
402
484
  /**
@@ -408,13 +490,15 @@ function parseDuration(input) {
408
490
  * time.formatDuration(90_000, { long: true, locale: "fr" }); // "1 minute 30 secondes"
409
491
  * time.formatDuration(93_784_000, { units: 2 }); // "1d 2h"
410
492
  * time.formatDuration(5_405_000, { clock: true }); // "01:30:05"
493
+ * time.formatDuration("90m"); // "1h 30m"
411
494
  *
412
- * @param {number} ms
495
+ * @param {number | string} duration Milliseconds, or text like `"90m"`.
413
496
  * @param {FormatDurationOptions} [options]
414
497
  * @returns {string}
415
498
  */
416
- function formatDuration(ms, options = {}) {
417
- if (!Number.isFinite(ms)) return String(ms);
499
+ function formatDuration(duration, options = {}) {
500
+ const ms = typeof duration === "string" ? shared.parseDuration(duration) ?? NaN : duration;
501
+ if (!Number.isFinite(ms)) return String(duration);
418
502
  const sign = ms < 0 ? "-" : "";
419
503
  let rest = Math.abs(Math.trunc(ms));
420
504
 
@@ -428,20 +512,21 @@ function formatDuration(ms, options = {}) {
428
512
 
429
513
  const locale = options.locale ?? _config.locale;
430
514
  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 });
515
+ const limit = options.units ?? Infinity;
516
+ /** @param {string} unit */
517
+ const long = (unit) => intl.number(locale, { style: "unit", unit, unitDisplay: "long" });
518
+ let out = "";
519
+ let shown = 0;
520
+ for (let i = 0; i < _DURATION_UNITS.length && shown < limit; i++) {
521
+ const [size, short, unit] = _DURATION_UNITS[i];
522
+ const value = Math.floor(rest / size);
523
+ rest -= value * size;
524
+ if (value === 0 || (unit === "millisecond" && !showMs)) continue;
525
+ out += `${shown ? " " : ""}${options.long ? long(unit).format(value) : `${value}${short}`}`;
526
+ shown++;
437
527
  }
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(" ");
528
+ if (!shown) return options.long ? long("second").format(0) : "0s";
529
+ return sign + out;
445
530
  }
446
531
 
447
532
  /**
@@ -460,16 +545,13 @@ function formatDuration(ms, options = {}) {
460
545
  */
461
546
  function relative(date, from, options = {}) {
462
547
  const locale = options.locale ?? _config.locale;
548
+ const style = options.style ?? "long";
463
549
  const diffMs = _toDate(date).getTime() - _toDate(from).getTime();
464
550
  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) {
551
+ if (abs < MS.s) return intl.relativeTime(locale, { numeric: "auto", style }).format(0, "second");
552
+ const rtf = intl.relativeTime(locale, { numeric: options.numeric ?? "always", style });
553
+ for (let i = 0; i < _RELATIVE_SCALE.length; i++) {
554
+ const [limit, size, unit] = _RELATIVE_SCALE[i];
473
555
  if (abs < limit) {
474
556
  const value = Math.floor(abs / size);
475
557
  return rtf.format(diffMs < 0 ? -value : value, unit);
@@ -495,24 +577,14 @@ function calendar(date, options = {}) {
495
577
  const locale = options.locale ?? _config.locale;
496
578
  const timeZone = options.timeZone ?? _config.timeZone;
497
579
  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);
580
+ const delta = _dayDelta(d, _toDate(options.now), timeZone);
581
+ if (delta < -1 || delta >= 7) return intl.dateTime(locale, { dateStyle: "long", timeZone }).format(d);
582
+ const hhmm = intl.dateTime(locale, { timeStyle: "short", timeZone }).format(d);
583
+ const label = delta <= 1 ? intl.relativeTime(locale, { numeric: "auto" }).format(delta, "day") : intl.dateTime(locale, { weekday: "long", timeZone }).format(d);
584
+ // The word that joins a date and a time in this language ("at", "à", "um").
585
+ const joined = intl.dateTime(locale, { dateStyle: "full", timeStyle: "short", timeZone }).formatToParts(d);
586
+ const literal = joined.find((part, i) => part.type === "literal" && joined[i + 1]?.type === "hour")?.value ?? " ";
587
+ return `${label.charAt(0).toUpperCase()}${label.slice(1)}${literal}${hhmm}`;
516
588
  }
517
589
 
518
590
  /**
@@ -528,6 +600,7 @@ function calendar(date, options = {}) {
528
600
  * | `dddd` / `ddd` | Monday / Mon | | `A` / `a` | PM / pm |
529
601
  * | `d` | weekday, 0 is Sunday | | `Z` / `ZZ` | +02:00 / +0200 |
530
602
  * | `W` / `WW` | ISO week | | `X` / `x` | Unix seconds / ms |
603
+ * | `Do` | 5th | | `z` | zone name, like EST or GMT+1 |
531
604
  *
532
605
  * Text in brackets stays as is: `"[Today is] dddd"`.
533
606
  *
@@ -546,53 +619,246 @@ function format(date, pattern = "YYYY-MM-DD HH:mm:ss", options = {}) {
546
619
  if (Number.isNaN(d.getTime())) return "Invalid Date";
547
620
  const locale = options.locale ?? _config.locale;
548
621
  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;
622
+ const at = _parts(d, timeZone);
623
+ let compiled = _patterns.get(pattern);
624
+ if (compiled === undefined) {
625
+ compiled = _compile(pattern);
626
+ if (_patterns.size >= 200) _patterns.clear();
627
+ _patterns.set(pattern, compiled);
628
+ }
629
+ // Literal text and tokens alternate, starting with text.
630
+ let out = compiled[0];
631
+ for (let i = 1; i < compiled.length; i += 2) out += _token(compiled[i], d, at, locale, timeZone) + compiled[i + 1];
632
+ return out;
633
+ }
634
+
635
+ const _TOKENS = /\[([^\]]*)\]|YYYY|MMMM|dddd|SSS|MMM|ddd|Do|YY|MM|DD|HH|hh|mm|ss|ZZ|WW|M|D|d|H|h|m|s|A|a|Z|z|Q|X|x|W/g;
636
+
637
+ /** @type {Map<string, string[]>} */
638
+ const _patterns = new Map();
639
+
640
+ /**
641
+ * Splits a pattern into literal text and tokens, alternating.
642
+ * @param {string} pattern
643
+ * @returns {string[]}
644
+ */
645
+ const _compile = (pattern) => {
646
+ /** @type {string[]} */
647
+ const out = [""];
648
+ let last = 0;
649
+ for (const match of pattern.matchAll(_TOKENS)) {
650
+ const literal = pattern.slice(last, match.index);
651
+ last = match.index + match[0].length;
652
+ if (match[1] !== undefined) {
653
+ out[out.length - 1] += literal + match[1];
654
+ continue;
655
+ }
656
+ out[out.length - 1] += literal;
657
+ out.push(match[0], "");
658
+ }
659
+ out[out.length - 1] += pattern.slice(last);
660
+ return out;
661
+ };
662
+
663
+ /**
664
+ * @param {Date} d
665
+ * @param {string | undefined} timeZone
666
+ * @param {string} separator
667
+ */
668
+ const _offsetText = (d, timeZone, separator) => {
669
+ const offset = _offset(d, timeZone);
670
+ const abs = Math.abs(offset);
671
+ return `${offset < 0 ? "-" : "+"}${_pad(Math.floor(abs / 60))}${separator}${_pad(abs % 60)}`;
672
+ };
673
+
674
+ /**
675
+ * @param {string} token
676
+ * @param {Date} d
677
+ * @param {{ year: number, month: number, day: number, hour: number, minute: number, second: number, ms: number, weekday: number }} at
678
+ * @param {string} locale
679
+ * @param {string} [timeZone]
680
+ * @returns {string}
681
+ */
682
+ const _token = (token, d, at, locale, timeZone) => {
683
+ switch (token) {
684
+ case "YYYY": return String(at.year);
685
+ case "YY": return String(at.year).slice(-2);
686
+ case "Q": return String(Math.ceil(at.month / 3));
687
+ case "MMMM": return intl.dateTime(locale, { month: "long", timeZone }).format(d);
688
+ case "MMM": return intl.dateTime(locale, { month: "short", timeZone }).format(d);
689
+ case "MM": return _pad(at.month);
690
+ case "M": return String(at.month);
691
+ case "DD": return _pad(at.day);
692
+ case "D": return String(at.day);
693
+ case "Do": return require("./Num.js").ordinal(at.day, { locale });
694
+ case "dddd": return intl.dateTime(locale, { weekday: "long", timeZone }).format(d);
695
+ case "ddd": return intl.dateTime(locale, { weekday: "short", timeZone }).format(d);
696
+ case "d": return String(at.weekday);
697
+ case "HH": return _pad(at.hour);
698
+ case "H": return String(at.hour);
699
+ case "hh": return _pad(at.hour % 12 === 0 ? 12 : at.hour % 12);
700
+ case "h": return String(at.hour % 12 === 0 ? 12 : at.hour % 12);
701
+ case "mm": return _pad(at.minute);
702
+ case "m": return String(at.minute);
703
+ case "ss": return _pad(at.second);
704
+ case "s": return String(at.second);
705
+ case "SSS": return _pad(at.ms, 3);
706
+ case "A": return at.hour < 12 ? "AM" : "PM";
707
+ case "a": return at.hour < 12 ? "am" : "pm";
708
+ case "Z": return _offsetText(d, timeZone, ":");
709
+ case "ZZ": return _offsetText(d, timeZone, "");
710
+ case "z": return intl.dateTime(locale, { timeZoneName: "short", timeZone }).formatToParts(d).find((part) => part.type === "timeZoneName")?.value ?? "";
711
+ case "X": return String(Math.floor(d.getTime() / 1000));
712
+ case "x": return String(d.getTime());
713
+ case "W": return String(_isoWeek(at.year, at.month, at.day));
714
+ case "WW": return _pad(_isoWeek(at.year, at.month, at.day));
715
+ default: return token;
716
+ }
717
+ };
718
+
719
+ /**
720
+ * ISO week number of a calendar date.
721
+ * @param {number} year
722
+ * @param {number} month 1-12
723
+ * @param {number} day
724
+ * @returns {number}
725
+ */
726
+ const _isoWeek = (year, month, day) => {
727
+ const utc = new Date(Date.UTC(year, month - 1, day));
728
+ utc.setUTCDate(utc.getUTCDate() + 4 - (utc.getUTCDay() || 7));
729
+ return Math.ceil(((utc.getTime() - Date.UTC(utc.getUTCFullYear(), 0, 1)) / MS.d + 1) / 7);
730
+ };
731
+
732
+ /** @type {Array<[number, string, string]>} */
733
+ const _DURATION_UNITS = [[MS.d, "d", "day"], [MS.h, "h", "hour"], [MS.m, "m", "minute"], [MS.s, "s", "second"], [1, "ms", "millisecond"]];
734
+
735
+ /** @type {Array<[number, number, Intl.RelativeTimeFormatUnit]>} */
736
+ const _RELATIVE_SCALE = [
737
+ [MS.m, MS.s, "second"], [MS.h, MS.m, "minute"], [MS.d, MS.h, "hour"], [MS.w, MS.d, "day"],
738
+ [MS.d * 30, MS.w, "week"], [MS.d * 365, MS.d * 30, "month"], [Infinity, MS.d * 365, "year"],
739
+ ];
740
+
741
+ /** @type {Map<string, { regex: RegExp, tokens: string[] }>} */
742
+ const _parsers = new Map();
743
+
744
+ /** @type {Record<string, string>} */
745
+ const _PARSE_TOKENS = {
746
+ YYYY: "(\\d{4})", YY: "(\\d{2})", MM: "(\\d{2})", M: "(\\d{1,2})", DD: "(\\d{2})", D: "(\\d{1,2})", Do: "(\\d{1,2})\\D{0,4}?",
747
+ HH: "(\\d{2})", H: "(\\d{1,2})", hh: "(\\d{2})", h: "(\\d{1,2})", mm: "(\\d{2})", m: "(\\d{1,2})", ss: "(\\d{2})", s: "(\\d{1,2})",
748
+ SSS: "(\\d{3})", A: "([AaPp][Mm])", a: "([AaPp][Mm])", Z: "(Z|[+-]\\d{2}:\\d{2})", ZZ: "(Z|[+-]\\d{4})", X: "(-?\\d+)", x: "(-?\\d+)",
749
+ MMMM: "([^\\d\\s,./-]+\\.?)", MMM: "([^\\d\\s,./-]+\\.?)", dddd: "[^\\d\\s,]+\\.?", ddd: "[^\\d\\s,]+\\.?",
750
+ };
751
+
752
+ /** @type {Map<string, Map<string, number>>} */
753
+ const _monthNames = new Map();
754
+
755
+ /**
756
+ * Month names of a language, long and short, mapped to 1-12.
757
+ * @param {string} locale
758
+ * @returns {Map<string, number>}
759
+ */
760
+ const _monthsOf = (locale) => {
761
+ let names = _monthNames.get(locale);
762
+ if (!names) {
763
+ names = new Map();
764
+ for (let month = 1; month <= 12; month++) {
765
+ const sample = new Date(Date.UTC(2024, month - 1, 15));
766
+ for (const style of /** @type {const} */ (["long", "short"])) {
767
+ names.set(intl.dateTime(locale, { month: style, timeZone: "UTC" }).format(sample).toLowerCase().replace(/\.$/, ""), month);
593
768
  }
594
- },
595
- );
769
+ }
770
+ _monthNames.set(locale, names);
771
+ }
772
+ return names;
773
+ };
774
+
775
+ /**
776
+ * Reads a date written in a known format: the opposite of `format()`, with
777
+ * the same tokens. Parts the pattern doesn't have default to today's date
778
+ * and to midnight.
779
+ *
780
+ * @example
781
+ * time.parse("15/01/2024 14:30", "DD/MM/YYYY HH:mm");
782
+ * time.parse("15 janvier 2024", "D MMMM YYYY", { locale: "fr" });
783
+ * time.parse("2024-01-15 09:00", "YYYY-MM-DD HH:mm", { timeZone: "Asia/Tokyo" });
784
+ * time.parse("31/02/2024", "DD/MM/YYYY"); // undefined, no such day
785
+ *
786
+ * @param {string} text
787
+ * @param {string} pattern Tokens like `YYYY`, `MM`, `DD`, `HH`, `mm`, `ss`, `A`, `Z`. Text in brackets must match as is.
788
+ * @param {ParseDateOptions} [options]
789
+ * @returns {Date | undefined} `undefined` when the text doesn't match or isn't a real date.
790
+ */
791
+ function parse(text, pattern, options = {}) {
792
+ let parser = _parsers.get(pattern);
793
+ if (!parser) {
794
+ const compiled = _compile(pattern);
795
+ const escape = (/** @type {string} */ literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
796
+ let source = escape(compiled[0]);
797
+ /** @type {string[]} */
798
+ const tokens = [];
799
+ for (let i = 1; i < compiled.length; i += 2) {
800
+ const expression = _PARSE_TOKENS[compiled[i]];
801
+ if (!expression) throw new TypeError(`parse() does not support the "${compiled[i]}" token`);
802
+ if (expression.startsWith("(")) tokens.push(compiled[i]);
803
+ source += expression + escape(compiled[i + 1]);
804
+ }
805
+ parser = { regex: new RegExp(`^\\s*${source}\\s*$`), tokens };
806
+ if (_parsers.size >= 200) _parsers.clear();
807
+ _parsers.set(pattern, parser);
808
+ }
809
+ const match = parser.regex.exec(String(text));
810
+ if (!match) return undefined;
811
+
812
+ const locale = options.locale ?? _config.locale;
813
+ const timeZone = options.timeZone ?? _config.timeZone;
814
+ /** @type {Record<string, number | undefined>} */
815
+ const found = {};
816
+ /** @type {string | undefined} */
817
+ let meridiem;
818
+ /** @type {number | undefined} */
819
+ let offset;
820
+ for (let i = 0; i < parser.tokens.length; i++) {
821
+ const token = parser.tokens[i];
822
+ const value = match[i + 1];
823
+ switch (token) {
824
+ case "YYYY": found.year = Number(value); break;
825
+ case "YY": found.year = 2000 + Number(value); break;
826
+ case "MM": case "M": found.month = Number(value); break;
827
+ case "MMMM": case "MMM": {
828
+ const month = _monthsOf(locale).get(value.toLowerCase().replace(/\.$/, ""));
829
+ if (month === undefined) return undefined;
830
+ found.month = month;
831
+ break;
832
+ }
833
+ case "DD": case "D": case "Do": found.day = Number(value); break;
834
+ case "HH": case "H": case "hh": case "h": found.hour = Number(value); break;
835
+ case "mm": case "m": found.minute = Number(value); break;
836
+ case "ss": case "s": found.second = Number(value); break;
837
+ case "SSS": found.ms = Number(value); break;
838
+ case "A": case "a": meridiem = value.toLowerCase(); break;
839
+ case "Z": case "ZZ": {
840
+ const digits = value.replace(":", "");
841
+ offset = value === "Z" ? 0 : (digits[0] === "-" ? -1 : 1) * (Number(digits.slice(1, 3)) * 60 + Number(digits.slice(3, 5)));
842
+ break;
843
+ }
844
+ case "X": return new Date(Number(value) * 1000);
845
+ case "x": return new Date(Number(value));
846
+ }
847
+ }
848
+ const today = _parts(new Date(), offset === undefined ? timeZone : "UTC");
849
+ const year = found.year ?? today.year;
850
+ const month = found.month ?? (found.year === undefined ? today.month : 1);
851
+ const day = found.day ?? (found.year === undefined && found.month === undefined ? today.day : 1);
852
+ let hour = found.hour ?? 0;
853
+ if (meridiem !== undefined) {
854
+ if (hour < 1 || hour > 12) return undefined;
855
+ hour = (hour % 12) + (meridiem === "pm" ? 12 : 0);
856
+ }
857
+ const minute = found.minute ?? 0;
858
+ const second = found.second ?? 0;
859
+ if (month < 1 || month > 12 || day < 1 || day > _daysIn(year, month) || hour > 23 || minute > 59 || second > 59) return undefined;
860
+ if (offset !== undefined) return new Date(Date.UTC(year, month - 1, day, hour, minute, second, found.ms ?? 0) - offset * 60_000);
861
+ return _instantOf(year, month, day, hour, minute, second, found.ms ?? 0, timeZone);
596
862
  }
597
863
 
598
864
  /**
@@ -611,25 +877,34 @@ function toISODate(date, options = {}) {
611
877
  }
612
878
 
613
879
  /**
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.
880
+ * Adds time to a date and returns a new `Date`. Whole days, weeks, months
881
+ * and years follow the calendar: one day later is the same time tomorrow,
882
+ * even when the clocks change in between, and January 31 plus one month is
883
+ * the end of February. Hours and smaller units are exact.
616
884
  *
617
885
  * @example
618
886
  * time.add(new Date(), "2h30m");
619
887
  * time.add(new Date(), 3, "days");
888
+ * time.add(new Date(), "1 month");
620
889
  * time.add(new Date(2024, 0, 31), 1, "month"); // 2024-02-29
890
+ * time.add(date, "1 day", { timeZone: "Europe/Paris" });
621
891
  *
622
892
  * @param {DateInput} date
623
- * @param {number | string} amount A number with `unit`, or a duration like `"1h30m"`.
624
- * @param {CalendarUnit} [unit="ms"]
893
+ * @param {number | string} amount A number with `unit`, or a duration like `"1h30m"` or `"1 month"`.
894
+ * @param {CalendarUnit | TimeZoneOptions} [unit="ms"] The unit of a number. With a text amount there is none to give: the options can come here.
895
+ * @param {TimeZoneOptions} [options] The zone whose calendar is followed.
625
896
  * @returns {Date}
897
+ * @throws {TypeError} If a text amount isn't a duration, or the unit isn't one.
626
898
  */
627
- function add(date, amount, unit = "ms") {
899
+ function add(date, amount, unit = "ms", options = {}) {
628
900
  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);
901
+ if (typeof unit !== "string") return _addText(d, String(amount), 1, unit.timeZone ?? _config.timeZone);
902
+ const timeZone = options.timeZone ?? _config.timeZone;
903
+ if (typeof amount === "string") return _addText(d, amount, 1, timeZone);
904
+ const u = _knownUnit(unit);
905
+ if (u === "M") return _addMonths(d, amount, timeZone);
906
+ if (u === "y") return _addMonths(d, amount * 12, timeZone);
907
+ if ((u === "d" || u === "w") && Number.isInteger(amount)) return _addDays(d, u === "w" ? amount * 7 : amount, timeZone);
633
908
  return new Date(d.getTime() + amount * MS[u]);
634
909
  }
635
910
 
@@ -642,12 +917,15 @@ function add(date, amount, unit = "ms") {
642
917
  *
643
918
  * @param {DateInput} date
644
919
  * @param {number | string} amount
645
- * @param {CalendarUnit} [unit="ms"]
920
+ * @param {CalendarUnit | TimeZoneOptions} [unit="ms"] The unit of a number, or the options when the amount is text.
921
+ * @param {TimeZoneOptions} [options]
646
922
  * @returns {Date}
923
+ * @throws {TypeError} If a text amount isn't a duration, or the unit isn't one.
647
924
  */
648
- function subtract(date, amount, unit = "ms") {
649
- if (typeof amount === "string") return add(date, -(parseDuration(amount) ?? 0));
650
- return add(date, -amount, unit);
925
+ function subtract(date, amount, unit = "ms", options = {}) {
926
+ const given = typeof unit === "string" ? options : unit;
927
+ if (typeof amount === "string" || typeof unit !== "string") return _addText(_toDate(date), String(amount), -1, given.timeZone ?? _config.timeZone);
928
+ return add(date, -amount, unit, options);
651
929
  }
652
930
 
653
931
  /**
@@ -661,29 +939,36 @@ function subtract(date, amount, unit = "ms") {
661
939
  * @param {DateInput} a
662
940
  * @param {DateInput} b
663
941
  * @param {CalendarUnit} [unit="ms"]
942
+ * @param {TimeZoneOptions} [options] The zone whose calendar months and years follow.
664
943
  * @returns {number}
944
+ * @throws {TypeError} If the unit isn't one.
665
945
  */
666
- function diff(a, b, unit = "ms") {
946
+ function diff(a, b, unit = "ms", options = {}) {
667
947
  const da = _toDate(a);
668
948
  const db = _toDate(b);
669
- const u = _unit(unit) ?? "ms";
949
+ const u = _knownUnit(unit);
670
950
  if (u === "M" || u === "y") {
951
+ const timeZone = options.timeZone ?? _config.timeZone;
671
952
  const sign = da >= db ? 1 : -1;
672
953
  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--;
954
+ const x = _parts(late, timeZone);
955
+ const y = _parts(early, timeZone);
956
+ let months = (x.year - y.year) * 12 + x.month - y.month;
957
+ if (_addMonths(early, months, timeZone) > late) months--;
675
958
  return sign * (u === "y" ? Math.trunc(months / 12) : months) || 0;
676
959
  }
677
960
  return (da.getTime() - db.getTime()) / MS[u];
678
961
  }
679
962
 
680
963
  /**
681
- * A new `Date` at the start of the day, week, month... in local time.
964
+ * A new `Date` at the start of the day, week, month... in the global time
965
+ * zone, or the one you name.
682
966
  *
683
967
  * @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
968
+ * time.startOf(new Date(), "day"); // today at 00:00
969
+ * time.startOf(new Date(), "week"); // Monday at 00:00
970
+ * time.startOf(new Date(), "week", { weekStartsOn: 0 }); // Sunday at 00:00
971
+ * time.startOf(new Date(), "day", { timeZone: "Asia/Tokyo" }); // midnight in Tokyo
687
972
  *
688
973
  * @param {DateInput} date
689
974
  * @param {StartOfUnit} unit
@@ -692,40 +977,9 @@ function diff(a, b, unit = "ms") {
692
977
  */
693
978
  function startOf(date, unit, options = {}) {
694
979
  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;
980
+ const timeZone = options.timeZone ?? _config.timeZone;
981
+ const wall = _startWall(_parts(d, timeZone), unit, options.weekStartsOn);
982
+ return _instantOf(wall[0], wall[1], wall[2], wall[3], wall[4], wall[5], 0, timeZone);
729
983
  }
730
984
 
731
985
  /**
@@ -740,21 +994,31 @@ function startOf(date, unit, options = {}) {
740
994
  * @returns {Date}
741
995
  */
742
996
  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);
997
+ const d = _toDate(date);
998
+ const timeZone = options.timeZone ?? _config.timeZone;
999
+ const wall = _startWall(_parts(d, timeZone), unit, options.weekStartsOn);
1000
+ // The start of the next unit, minus one millisecond.
1001
+ const step = unit === "year" ? 0 : unit === "quarter" || unit === "month" ? 1 : unit === "week" || unit === "isoWeek" || unit === "day" ? 2 : unit === "hour" ? 3 : unit === "minute" ? 4 : 5;
1002
+ wall[step] += unit === "quarter" ? 3 : unit === "week" || unit === "isoWeek" ? 7 : 1;
1003
+ const next = new Date(Date.UTC(wall[0], wall[1] - 1, wall[2], wall[3], wall[4], wall[5]));
1004
+ return new Date(_instantOf(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate(), next.getUTCHours(), next.getUTCMinutes(), next.getUTCSeconds(), 0, timeZone).getTime() - 1);
1005
+ }
1006
+
1007
+ /**
1008
+ * Are both dates in the same year, month, week, day, hour...?
1009
+ *
1010
+ * @example
1011
+ * time.isSame(a, b, "month");
1012
+ * time.isSame(a, b, "day", { timeZone: "America/New_York" });
1013
+ *
1014
+ * @param {DateInput} a
1015
+ * @param {DateInput} b
1016
+ * @param {StartOfUnit} unit
1017
+ * @param {WeekOptions} [options]
1018
+ * @returns {boolean}
1019
+ */
1020
+ function isSame(a, b, unit, options = {}) {
1021
+ return startOf(a, unit, options).getTime() === startOf(b, unit, options).getTime();
758
1022
  }
759
1023
 
760
1024
  /**
@@ -833,7 +1097,7 @@ function isToday(date, options = {}) {
833
1097
  * @returns {boolean}
834
1098
  */
835
1099
  function isYesterday(date, options = {}) {
836
- return isSameDay(date, add(new Date(), -1, "d"), options);
1100
+ return _dayDelta(_toDate(date), new Date(), options.timeZone ?? _config.timeZone) === -1;
837
1101
  }
838
1102
 
839
1103
  /**
@@ -844,7 +1108,7 @@ function isYesterday(date, options = {}) {
844
1108
  * @returns {boolean}
845
1109
  */
846
1110
  function isTomorrow(date, options = {}) {
847
- return isSameDay(date, add(new Date(), 1, "d"), options);
1111
+ return _dayDelta(_toDate(date), new Date(), options.timeZone ?? _config.timeZone) === 1;
848
1112
  }
849
1113
 
850
1114
  /**
@@ -915,11 +1179,12 @@ function daysInMonth(dateOrYear, month) {
915
1179
  * Day of the year, from 1 to 366.
916
1180
  *
917
1181
  * @param {DateInput} [date] Defaults to now.
1182
+ * @param {TimeZoneOptions} [options]
918
1183
  * @returns {number}
919
1184
  */
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;
1185
+ function dayOfYear(date, options = {}) {
1186
+ const at = _parts(_toDate(date), options.timeZone ?? _config.timeZone);
1187
+ return Math.round((Date.UTC(at.year, at.month - 1, at.day) - Date.UTC(at.year, 0, 1)) / MS.d) + 1;
923
1188
  }
924
1189
 
925
1190
  /**
@@ -930,18 +1195,12 @@ function dayOfYear(date) {
930
1195
  * time.weekOfYear(new Date(2021, 0, 3)); // 53, the last week of 2020
931
1196
  *
932
1197
  * @param {DateInput} [date] Defaults to now.
933
- * @param {{ utc?: boolean }} [options] Read the date in UTC.
1198
+ * @param {{ utc?: boolean, timeZone?: string }} [options] `utc` reads the date in UTC, `timeZone` in another zone.
934
1199
  * @returns {number}
935
1200
  */
936
1201
  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);
1202
+ const at = _parts(_toDate(date), options.utc ? "UTC" : options.timeZone ?? _config.timeZone);
1203
+ return _isoWeek(at.year, at.month, at.day);
945
1204
  }
946
1205
 
947
1206
  /**
@@ -985,6 +1244,52 @@ function unix() {
985
1244
  return Math.floor(Date.now() / 1000);
986
1245
  }
987
1246
 
1247
+ /**
1248
+ * A `Date` from a Unix timestamp in seconds, as found in JWTs and many APIs.
1249
+ *
1250
+ * @example
1251
+ * time.fromUnix(1705329000); // 2024-01-15T14:30:00.000Z
1252
+ *
1253
+ * @param {number} seconds
1254
+ * @returns {Date}
1255
+ */
1256
+ function fromUnix(seconds) {
1257
+ return new Date(seconds * 1000);
1258
+ }
1259
+
1260
+ /**
1261
+ * Every date from `start` to `end`, both included, one unit apart. Handy
1262
+ * for charts, calendars and reports.
1263
+ *
1264
+ * @example
1265
+ * time.range("2024-01-01", "2024-01-07", "day"); // 7 dates
1266
+ * time.range(start, end, "month"); // the same day each month
1267
+ * time.range(start, end, "minute", { step: 15 }); // every quarter of an hour
1268
+ *
1269
+ * @param {DateInput} start
1270
+ * @param {DateInput} end
1271
+ * @param {CalendarUnit} [unit="day"]
1272
+ * @param {DateRangeOptions} [options]
1273
+ * @returns {Date[]}
1274
+ * @throws {RangeError} If the step isn't positive, or the range would hold more than a million dates.
1275
+ */
1276
+ function range(start, end, unit = "day", options = {}) {
1277
+ const from = _toDate(start);
1278
+ const to = _toDate(end).getTime();
1279
+ const step = options.step ?? 1;
1280
+ if (!(step > 0)) throw new RangeError("range() needs a positive step");
1281
+ /** @type {Date[]} */
1282
+ const out = [];
1283
+ // Each date is computed from the start, so a month-end start never drifts.
1284
+ for (let i = 0; ; i++) {
1285
+ const next = add(from, i * step, unit, options);
1286
+ if (!(next.getTime() <= to)) break;
1287
+ if (out.length >= 1_000_000) throw new RangeError("range() would return more than a million dates");
1288
+ out.push(next);
1289
+ }
1290
+ return out;
1291
+ }
1292
+
988
1293
  /**
989
1294
  * Starts a precise stopwatch.
990
1295
  *
@@ -1057,7 +1362,7 @@ async function measure(fn) {
1057
1362
  * @throws {RangeError} If the interval isn't a positive duration.
1058
1363
  */
1059
1364
  function every(interval, task, options = {}) {
1060
- const ms = parseDuration(interval);
1365
+ const ms = shared.parseDuration(interval);
1061
1366
  if (ms === null || ms <= 0) throw new RangeError(`Invalid interval: ${String(interval)}`);
1062
1367
  let stopped = false;
1063
1368
  let running = false;
@@ -1091,72 +1396,10 @@ function every(interval, task, options = {}) {
1091
1396
  };
1092
1397
  }
1093
1398
 
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"];
1399
+ const _parseCron = shared.parseCron;
1106
1400
 
1107
1401
  /**
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
1402
+ * @param {import("./internal/shared").CronSpec} spec
1160
1403
  * @param {Date} from
1161
1404
  * @param {string | undefined} timeZone
1162
1405
  * @returns {Date | undefined}
@@ -1167,7 +1410,7 @@ const _nextCron = (spec, from, timeZone) => {
1167
1410
  const wall = new Date(Date.UTC(start.year, start.month - 1, start.day, start.hour, start.minute, start.second));
1168
1411
  const limit = wall.getTime() + 8 * 366 * MS.d;
1169
1412
  const dayMatches = () => {
1170
- const dom = spec.days.has(wall.getUTCDate());
1413
+ const dom = spec.days.has(wall.getUTCDate()) || (spec.lastDay && wall.getUTCDate() === _daysIn(wall.getUTCFullYear(), wall.getUTCMonth() + 1));
1171
1414
  const dow = spec.weekdays.has(wall.getUTCDay());
1172
1415
  if (spec.anyDay && spec.anyWeekday) return true;
1173
1416
  if (spec.anyDay) return dow;
@@ -1209,9 +1452,10 @@ const _nextCron = (spec, from, timeZone) => {
1209
1452
  *
1210
1453
  * Expressions have 5 fields (`minute hour day month weekday`), or 6 with
1211
1454
  * 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.
1455
+ * (`*\/15`), names (`jan`, `mon-fri`), `L` for the last day of the month,
1456
+ * and `@daily`, `@hourly`, `@weekly`, `@monthly`, `@yearly`. As in classic
1457
+ * cron, when both the day and the weekday are set, either one matching is
1458
+ * enough.
1215
1459
  *
1216
1460
  * @example
1217
1461
  * time.nextRun("0 9 * * mon-fri"); // next weekday at 09:00
@@ -1269,6 +1513,13 @@ function cron(expression, task, options = {}) {
1269
1513
  }, target.getTime() - Date.now(), options.unref ?? false);
1270
1514
  };
1271
1515
  plan(new Date());
1516
+ if (options.immediate) {
1517
+ running = true;
1518
+ runs++;
1519
+ void _run(task, options.onError).finally(() => {
1520
+ running = false;
1521
+ });
1522
+ }
1272
1523
 
1273
1524
  return {
1274
1525
  stop: () => {
@@ -1293,6 +1544,7 @@ module.exports = {
1293
1544
  relative,
1294
1545
  calendar,
1295
1546
  format,
1547
+ parse,
1296
1548
  toISODate,
1297
1549
  add,
1298
1550
  subtract,
@@ -1302,6 +1554,7 @@ module.exports = {
1302
1554
  isBefore,
1303
1555
  isAfter,
1304
1556
  isBetween,
1557
+ isSame,
1305
1558
  isSameDay,
1306
1559
  isToday,
1307
1560
  isYesterday,
@@ -1315,6 +1568,8 @@ module.exports = {
1315
1568
  min,
1316
1569
  max,
1317
1570
  unix,
1571
+ fromUnix,
1572
+ range,
1318
1573
  stopwatch,
1319
1574
  measure,
1320
1575
  every,