@ix-xs/node-comfort 1.1.31 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Time.js ADDED
@@ -0,0 +1,485 @@
1
+ /**
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"`).
18
+ *
19
+ * @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"
23
+ */
24
+
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
29
+ */
30
+
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
+ };
39
+
40
+ const UNIT_ALIASES = {
41
+ ms: "ms", milli: "ms", millisecond: "ms", milliseconds: "ms",
42
+ s: "s", sec: "s", secs: "s", second: "s", seconds: "s",
43
+ m: "m", min: "m", mins: "m", minute: "m", minutes: "m",
44
+ h: "h", hr: "h", hrs: "h", hour: "h", hours: "h",
45
+ d: "d", day: "d", days: "d",
46
+ w: "w", week: "w", weeks: "w",
47
+ };
48
+
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 };
57
+
58
+ /**
59
+ * Coerces a value into a Date.
60
+ * @private
61
+ * @param {Date|number|string} [value]
62
+ * @returns {Date}
63
+ */
64
+ const _toDate = (value) => (value instanceof Date ? new Date(value.getTime()) : value === undefined ? new Date() : new Date(value));
65
+
66
+ const _pad = (n, len = 2) => String(Math.abs(n)).padStart(len, "0");
67
+
68
+ /**
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 }}
75
+ */
76
+ const _numericParts = (d, timeZone) => {
77
+ if (!timeZone) {
78
+ 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(),
86
+ };
87
+ }
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;
100
+ 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(),
108
+ };
109
+ };
110
+
111
+ /**
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.
118
+ * @returns {string}
119
+ */
120
+ const _named = (d, opt, locale, timeZone) =>
121
+ new Intl.DateTimeFormat(locale, { ...opt, timeZone }).format(d);
122
+
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
+ }
231
+
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
+ }
270
+
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
+ }
287
+ }
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) => {
320
+ if (escaped !== undefined) return escaped;
321
+ switch (token) {
322
+ case "YYYY": return String(p.year);
323
+ 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);
326
+ case "MM": return _pad(p.month);
327
+ case "M": return String(p.month);
328
+ case "DD": return _pad(p.day);
329
+ 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);
332
+ case "HH": return _pad(p.hour);
333
+ 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);
336
+ case "mm": return _pad(p.minute);
337
+ case "m": return String(p.minute);
338
+ case "ss": return _pad(p.second);
339
+ case "s": return String(p.second);
340
+ case "SSS": return _pad(p.ms, 3);
341
+ case "A": return p.hour < 12 ? "AM" : "PM";
342
+ case "a": return p.hour < 12 ? "am" : "pm";
343
+ default: return token;
344
+ }
345
+ };
346
+
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;
374
+ }
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);
450
+ }
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
+ },
485
+ };