@purple-brock/time 0.0.0-stage → 1.0.1
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/LICENSE +21 -0
- package/README.md +934 -2
- package/dist/Badger.d.ts +154 -0
- package/dist/BadgerSett.d.ts +45 -0
- package/dist/BadgerSettings.d.ts +14 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +1568 -0
- package/dist/index.mjs +1564 -0
- package/dist/localeInfo.d.ts +10 -0
- package/package.json +60 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1568 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Global defaults for BadgerSett.
|
|
5
|
+
*
|
|
6
|
+
* Set once at app startup; all BadgerSett instances created without an explicit
|
|
7
|
+
* locale or timezone will inherit these values.
|
|
8
|
+
*
|
|
9
|
+
* Use `setLocale()` instead of setting `defaultLocale` directly when you need
|
|
10
|
+
* subscribers to be notified (e.g. on a language switcher).
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* // App startup
|
|
15
|
+
* BadgerSettings.defaultTimeZone = "Europe/Stockholm";
|
|
16
|
+
*
|
|
17
|
+
* // Language switcher
|
|
18
|
+
* const unsub = BadgerSettings.subscribe(locale => rerenderDates());
|
|
19
|
+
* BadgerSettings.setLocale(new Intl.Locale("sv-SE"));
|
|
20
|
+
*
|
|
21
|
+
* // Cleanup (e.g. component unmount)
|
|
22
|
+
* unsub();
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
class BadgerSettings {
|
|
26
|
+
/**
|
|
27
|
+
* Updates `defaultLocale` and notifies all active subscribers.
|
|
28
|
+
* Prefer this over setting `defaultLocale` directly when reactivity is needed.
|
|
29
|
+
*/
|
|
30
|
+
static setLocale(locale) {
|
|
31
|
+
BadgerSettings.defaultLocale = locale;
|
|
32
|
+
BadgerSettings.subscribers.forEach(fn => fn(locale));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Registers a callback that fires whenever `setLocale()` is called.
|
|
36
|
+
* Returns an unsubscribe function — call it to stop receiving updates.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* // React
|
|
41
|
+
* useEffect(() => BadgerSettings.subscribe(locale => setAppLocale(locale)), []);
|
|
42
|
+
*
|
|
43
|
+
* // Vue
|
|
44
|
+
* BadgerSettings.subscribe(locale => localeRef.value = locale.baseName);
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
static subscribe(fn) {
|
|
48
|
+
BadgerSettings.subscribers.add(fn);
|
|
49
|
+
return () => BadgerSettings.subscribers.delete(fn);
|
|
50
|
+
}
|
|
51
|
+
/** Restores all defaults and clears all subscribers. */
|
|
52
|
+
static reset() {
|
|
53
|
+
BadgerSettings.defaultLocale = undefined;
|
|
54
|
+
BadgerSettings.defaultTimeZone = undefined;
|
|
55
|
+
BadgerSettings.subscribers.clear();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
BadgerSettings.defaultLocale = undefined;
|
|
59
|
+
BadgerSettings.defaultTimeZone = undefined;
|
|
60
|
+
BadgerSettings.subscribers = new Set();
|
|
61
|
+
|
|
62
|
+
// Locale helpers that work on engines WITHOUT `Intl.Locale` (Hermes, React Native's engine).
|
|
63
|
+
// Everything that needs week info or a locale tag goes through here, so a missing Intl.Locale
|
|
64
|
+
// degrades to sensible defaults (en-GB, Monday-first, Sat+Sun weekend) instead of throwing
|
|
65
|
+
// "undefined cannot be used as a constructor".
|
|
66
|
+
const FALLBACK_TAG = "en-GB";
|
|
67
|
+
/** The BCP 47 tag for `locale`, falling back to `BadgerSettings.defaultLocale`, then `"en-GB"`. */
|
|
68
|
+
function localeTag(locale) {
|
|
69
|
+
const resolved = locale ?? BadgerSettings.defaultLocale;
|
|
70
|
+
if (resolved === undefined)
|
|
71
|
+
return FALLBACK_TAG;
|
|
72
|
+
return typeof resolved === "string" ? resolved : resolved.baseName;
|
|
73
|
+
}
|
|
74
|
+
/** `weekInfo` for a locale, or undefined when Intl.Locale / weekInfo is unavailable or the tag is bad. */
|
|
75
|
+
function weekInfo(locale) {
|
|
76
|
+
const resolved = locale ?? BadgerSettings.defaultLocale;
|
|
77
|
+
if (resolved === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
try {
|
|
80
|
+
const obj = typeof resolved === "string"
|
|
81
|
+
? new Intl.Locale(resolved)
|
|
82
|
+
: resolved;
|
|
83
|
+
return obj.weekInfo ?? obj.getWeekInfo?.();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return undefined; // no Intl.Locale (Hermes) or an invalid tag
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** ISO first day of the week (1 = Monday … 7 = Sunday). Defaults to Monday. */
|
|
90
|
+
function firstDayOfWeek(locale) {
|
|
91
|
+
const day = weekInfo(locale)?.firstDay;
|
|
92
|
+
return typeof day === "number" ? day : 1;
|
|
93
|
+
}
|
|
94
|
+
/** ISO weekend days (1 = Monday … 7 = Sunday). Defaults to Saturday + Sunday. */
|
|
95
|
+
function weekendDays(locale) {
|
|
96
|
+
const weekend = weekInfo(locale)?.weekend;
|
|
97
|
+
return Array.isArray(weekend) ? weekend : [6, 7];
|
|
98
|
+
}
|
|
99
|
+
/** Whether the locale's default clock is 12-hour, read from Intl.DateTimeFormat (no Intl.Locale). */
|
|
100
|
+
function uses12HourClock(locale) {
|
|
101
|
+
const opts = new Intl.DateTimeFormat(locale, { hour: "numeric" }).resolvedOptions();
|
|
102
|
+
if (opts.hourCycle)
|
|
103
|
+
return opts.hourCycle === "h11" || opts.hourCycle === "h12";
|
|
104
|
+
// Some engines report hour12 but not hourCycle.
|
|
105
|
+
return opts.hour12 === true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Strict ISO date-only matcher for holiday keys — rejects national formats and datetimes. */
|
|
109
|
+
const ISO_DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
110
|
+
/** Shared empty holiday set — the "weekends only" default, never mutated. */
|
|
111
|
+
const EMPTY_HOLIDAYS = new Set();
|
|
112
|
+
/**
|
|
113
|
+
* Upper bound on calendar-day steps taken per requested business day before the
|
|
114
|
+
* business-day walkers give up. Real holiday calendars never block more than a
|
|
115
|
+
* few consecutive days; a value this large only ever trips on a pathological set
|
|
116
|
+
* (e.g. every day marked a holiday), turning a would-be infinite loop into a throw.
|
|
117
|
+
*/
|
|
118
|
+
const MAX_STEPS_PER_BUSINESS_DAY = 1000;
|
|
119
|
+
/** Local-component `YYYY-MM-DD` key for a raw Date (matches Badger#localDateKey). */
|
|
120
|
+
function localKey(d) {
|
|
121
|
+
const y = d.getFullYear();
|
|
122
|
+
const m = d.getMonth() + 1;
|
|
123
|
+
const day = d.getDate();
|
|
124
|
+
return `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
125
|
+
}
|
|
126
|
+
/** Returns the ISO 8601 week number (1–53) for a given Date. */
|
|
127
|
+
function isoWeekNumber(d) {
|
|
128
|
+
const date = new Date(d.getTime());
|
|
129
|
+
date.setHours(0, 0, 0, 0);
|
|
130
|
+
date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7));
|
|
131
|
+
const jan4 = new Date(date.getFullYear(), 0, 4);
|
|
132
|
+
return (1 +
|
|
133
|
+
Math.round(((date.getTime() - jan4.getTime()) / 86400000 -
|
|
134
|
+
3 +
|
|
135
|
+
((jan4.getDay() + 6) % 7)) /
|
|
136
|
+
7));
|
|
137
|
+
}
|
|
138
|
+
/** Returns the ISO week-year for a given Date (may differ from calendar year at year boundaries). */
|
|
139
|
+
function isoWeekYear(d) {
|
|
140
|
+
const date = new Date(d.getTime());
|
|
141
|
+
date.setHours(0, 0, 0, 0);
|
|
142
|
+
date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7));
|
|
143
|
+
return date.getFullYear();
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Lightweight immutable-style date wrapper for arithmetic, comparison, and boundary operations.
|
|
147
|
+
*
|
|
148
|
+
* Date-only strings (`"YYYY-MM-DD"`) are anchored to LOCAL midnight, so every
|
|
149
|
+
* calendar getter and arithmetic operation reports the intended calendar day in any
|
|
150
|
+
* timezone. Datetime strings without a timezone indicator (e.g. `"2026-03-15T10:30:00"`)
|
|
151
|
+
* are treated as UTC (`"Z"` is appended). Getters use local time methods.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```ts
|
|
155
|
+
* const dt = new Badger("2026-03-15T10:30:00");
|
|
156
|
+
* dt.add(7, "day").isBefore(dt); // false
|
|
157
|
+
* dt.add(backendDiff, "millisecond"); // adjust for server clock skew
|
|
158
|
+
* dt.startOf("month"); // 2026-03-01T00:00:00
|
|
159
|
+
* dt.diff(other, "hour"); // 5
|
|
160
|
+
* dt.diff(other, "millisecond"); // exact ms delta
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
let Badger$1 = class Badger {
|
|
164
|
+
/**
|
|
165
|
+
* @param dateTime - A date string, `Date` object, or omit for current time.
|
|
166
|
+
*
|
|
167
|
+
* String parsing:
|
|
168
|
+
* - Date-only strings (`"YYYY-MM-DD"`) are anchored to LOCAL midnight. Because every
|
|
169
|
+
* calendar getter and arithmetic method works in local time, UTC midnight would read
|
|
170
|
+
* as the previous day west of UTC and drift month/range calculations; local midnight
|
|
171
|
+
* keeps the calendar day correct in every timezone.
|
|
172
|
+
* - Strings with an explicit timezone indicator (`Z` or `+HH:MM`/`-HH:MM`) are used as-is.
|
|
173
|
+
* - All other bare datetime strings (e.g. `"2026-03-15T10:30:00"`) are treated as UTC
|
|
174
|
+
* (`"Z"` appended).
|
|
175
|
+
*/
|
|
176
|
+
constructor(dateTime) {
|
|
177
|
+
if (dateTime instanceof Date) {
|
|
178
|
+
this.currentDate = new Date(dateTime.getTime());
|
|
179
|
+
}
|
|
180
|
+
else if (typeof dateTime === "string") {
|
|
181
|
+
if (dateTime === "") {
|
|
182
|
+
// Empty string is falsy but is still an invalid date input — produce Invalid Date
|
|
183
|
+
// so isValid() returns false rather than silently falling back to current time.
|
|
184
|
+
this.currentDate = new Date(NaN);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(dateTime);
|
|
188
|
+
const hasTimezoneIndicator = /(?:Z|[+-]\d{2}:?\d{2})$/.test(dateTime);
|
|
189
|
+
if (isDateOnly) {
|
|
190
|
+
// Anchor date-only strings to LOCAL midnight, not UTC midnight. Every
|
|
191
|
+
// calendar getter (.day/.month/.dayOfWeek) and add()/range() operate in
|
|
192
|
+
// local time, so a UTC-midnight instant reads as the *previous* calendar
|
|
193
|
+
// day west of UTC — which silently drifts month arithmetic and ranges.
|
|
194
|
+
// Local midnight makes the calendar day correct in every timezone.
|
|
195
|
+
const [y, m, d] = dateTime.split("-").map(Number);
|
|
196
|
+
this.currentDate = new Date(y, m - 1, d);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
this.currentDate = new Date(hasTimezoneIndicator ? dateTime : dateTime + "Z");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
this.currentDate = new Date(Date.now());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Returns the earliest of the provided `Badger` instances.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```ts
|
|
212
|
+
* const earliest = Badger.min(deadline, today, cutoff); // whichever comes first
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
static min(...dates) {
|
|
216
|
+
if (dates.length === 0)
|
|
217
|
+
throw new RangeError("Badger.min() requires at least one date");
|
|
218
|
+
const invalid = dates.find(d => !d.isValid());
|
|
219
|
+
if (invalid)
|
|
220
|
+
throw new RangeError("Badger.min(): all dates must be valid — use isValid() to filter first");
|
|
221
|
+
return dates.reduce((a, b) => (a.isBefore(b) ? a : b));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Returns the latest of the provided `Badger` instances.
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* ```ts
|
|
228
|
+
* const latest = Badger.max(deadline, today, cutoff); // whichever comes last
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
static max(...dates) {
|
|
232
|
+
if (dates.length === 0)
|
|
233
|
+
throw new RangeError("Badger.max() requires at least one date");
|
|
234
|
+
const invalid = dates.find(d => !d.isValid());
|
|
235
|
+
if (invalid)
|
|
236
|
+
throw new RangeError("Badger.max(): all dates must be valid — use isValid() to filter first");
|
|
237
|
+
return dates.reduce((a, b) => (a.isAfter(b) ? a : b));
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Creates a `Badger` from a local date string and time string, avoiding UTC conversion.
|
|
241
|
+
*
|
|
242
|
+
* The standard constructor treats bare *datetime* strings as UTC (`"2026-05-22T00:15"`
|
|
243
|
+
* gets `Z` appended), shifting the wall-clock time by the UTC offset. Use this factory
|
|
244
|
+
* when you have an explicit `"YYYY-MM-DD"` + `"HH:mm"` pair that represents local
|
|
245
|
+
* wall-clock time — for example, a user-selected pickup date and time. (A pure
|
|
246
|
+
* `"YYYY-MM-DD"` date-only string is already anchored to local midnight by the
|
|
247
|
+
* constructor; use `fromLocal` when you also need a specific time of day.)
|
|
248
|
+
*
|
|
249
|
+
* @param date - ISO date string `"YYYY-MM-DD"`
|
|
250
|
+
* @param time - Time string `"HH:mm"`
|
|
251
|
+
* @returns `Badger` representing the given local date and time.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```ts
|
|
255
|
+
* const dt = Badger.fromLocal("2026-05-22", "00:15");
|
|
256
|
+
* dt.hour; // 0 (local midnight — not shifted to UTC)
|
|
257
|
+
* dt.dayOfWeek; // 5 (Friday, correct local day of week)
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
static fromLocal(date, time) {
|
|
261
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
|
|
262
|
+
throw new RangeError(`fromLocal: date must be "YYYY-MM-DD", got "${date}"`);
|
|
263
|
+
if (!/^\d{2}:\d{2}$/.test(time))
|
|
264
|
+
throw new RangeError(`fromLocal: time must be "HH:mm", got "${time}"`);
|
|
265
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
266
|
+
const [h, m] = time.split(":").map(Number);
|
|
267
|
+
if (month < 1 || month > 12)
|
|
268
|
+
throw new RangeError(`fromLocal: month must be 1–12, got ${month}`);
|
|
269
|
+
if (day < 1 || day > 31)
|
|
270
|
+
throw new RangeError(`fromLocal: day must be 1–31, got ${day}`);
|
|
271
|
+
if (h > 23)
|
|
272
|
+
throw new RangeError(`fromLocal: hour must be 0–23, got ${h}`);
|
|
273
|
+
if (m > 59)
|
|
274
|
+
throw new RangeError(`fromLocal: minute must be 0–59, got ${m}`);
|
|
275
|
+
const result = new Badger(new Date(year, month - 1, day, h, m, 0, 0));
|
|
276
|
+
// Post-construction overflow check: catches e.g. Feb 31 overflowing into March.
|
|
277
|
+
if (result.month !== month || result.day !== day) {
|
|
278
|
+
throw new RangeError(`fromLocal: day ${day} is out of range for month ${month} in year ${year}`);
|
|
279
|
+
}
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Returns an array of `Badger` instances from `start` (inclusive) to `end` (exclusive),
|
|
284
|
+
* stepping by the given unit (`"day"`, `"week"`, or `"month"`).
|
|
285
|
+
*
|
|
286
|
+
* @example
|
|
287
|
+
* ```ts
|
|
288
|
+
* Badger.range("2026-06-01", "2026-06-08", "day")
|
|
289
|
+
* // → [Jun 1, Jun 2, Jun 3, Jun 4, Jun 5, Jun 6, Jun 7]
|
|
290
|
+
*
|
|
291
|
+
* Badger.range(start, end, "week") // one entry per week
|
|
292
|
+
* Badger.range(start, end, "month") // one entry per month
|
|
293
|
+
* ```
|
|
294
|
+
*/
|
|
295
|
+
static range(start, end, unit) {
|
|
296
|
+
const normalise = (d) => unit === "day" ? d.startOf("day") : d;
|
|
297
|
+
const s = normalise(start instanceof Badger ? start : new Badger(start));
|
|
298
|
+
const e = normalise(end instanceof Badger ? end : new Badger(end));
|
|
299
|
+
if (!s.isValid())
|
|
300
|
+
throw new RangeError("Badger.range: start is not a valid date");
|
|
301
|
+
if (!e.isValid())
|
|
302
|
+
throw new RangeError("Badger.range: end is not a valid date");
|
|
303
|
+
const results = [];
|
|
304
|
+
// Step from the ORIGINAL start by `i` units each iteration rather than
|
|
305
|
+
// chaining off the previous result. For "month" this re-anchors to the
|
|
306
|
+
// start's day-of-month (Jan 31 → Feb 28 → Mar 31, not the drift to Mar 28
|
|
307
|
+
// that chaining off the clamped Feb 28 would produce). For "day"/"week",
|
|
308
|
+
// which never clamp, origin-stepping is identical to chaining.
|
|
309
|
+
let i = 0;
|
|
310
|
+
let current = s;
|
|
311
|
+
while (current.isBefore(e)) {
|
|
312
|
+
results.push(current);
|
|
313
|
+
current = normalise(s.add(++i, unit));
|
|
314
|
+
}
|
|
315
|
+
return results;
|
|
316
|
+
}
|
|
317
|
+
/** Returns `true` if the date is valid. */
|
|
318
|
+
isValid() {
|
|
319
|
+
return !isNaN(this.currentDate.getTime());
|
|
320
|
+
}
|
|
321
|
+
// --- Getters ---
|
|
322
|
+
/** Returns a copy of the internal `Date` object. */
|
|
323
|
+
toDate() {
|
|
324
|
+
return new Date(this.currentDate.getTime());
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Returns the ISO 8601 string representation.
|
|
328
|
+
* @throws {RangeError} if the date is not valid — call `isValid()` first.
|
|
329
|
+
*/
|
|
330
|
+
toISOString() {
|
|
331
|
+
if (!this.isValid())
|
|
332
|
+
throw new RangeError("Badger.toISOString(): date is not valid — call isValid() first");
|
|
333
|
+
return this.currentDate.toISOString();
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Returns the **local** calendar date as a `"YYYY-MM-DD"` string, built from the
|
|
337
|
+
* local getters (`.year`/`.month`/`.day`).
|
|
338
|
+
*
|
|
339
|
+
* Use this — never `toISOString().slice(0, 10)` — whenever you need the local date
|
|
340
|
+
* of a `Badger`. `toISOString()` returns the UTC instant, so slicing it yields the
|
|
341
|
+
* *UTC* calendar day, which is the previous or next day near local midnight (and on
|
|
342
|
+
* every day for date-only values in a non-UTC zone). This method reads the same local
|
|
343
|
+
* frame as every other getter and comparison, so it is correct in every timezone.
|
|
344
|
+
*
|
|
345
|
+
* @throws {RangeError} if the date is not valid — call `isValid()` first.
|
|
346
|
+
*
|
|
347
|
+
* @example
|
|
348
|
+
* ```ts
|
|
349
|
+
* Badger("2026-02-01").toLocalISODate() // "2026-02-01" in every timezone
|
|
350
|
+
* Badger("2026-02-01").add(1, "day").toLocalISODate() // "2026-02-02" in every timezone
|
|
351
|
+
* ```
|
|
352
|
+
*/
|
|
353
|
+
toLocalISODate() {
|
|
354
|
+
if (!this.isValid())
|
|
355
|
+
throw new RangeError("Badger.toLocalISODate(): date is not valid — call isValid() first");
|
|
356
|
+
const y = this.currentDate.getFullYear();
|
|
357
|
+
const m = this.currentDate.getMonth() + 1;
|
|
358
|
+
const d = this.currentDate.getDate();
|
|
359
|
+
return `${y.toString().padStart(4, "0")}-${m.toString().padStart(2, "0")}-${d.toString().padStart(2, "0")}`;
|
|
360
|
+
}
|
|
361
|
+
/** Returns the Unix timestamp in seconds (floored). */
|
|
362
|
+
toUnixTimestamp() {
|
|
363
|
+
return Math.floor(this.currentDate.getTime() / 1000);
|
|
364
|
+
}
|
|
365
|
+
/** Returns the Unix timestamp in milliseconds. */
|
|
366
|
+
toMillisecondTimestamp() {
|
|
367
|
+
return this.currentDate.getTime();
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Returns a plain object with all date fields.
|
|
371
|
+
* Month is 1-indexed (1 = January).
|
|
372
|
+
*
|
|
373
|
+
* @example
|
|
374
|
+
* ```ts
|
|
375
|
+
* new Badger("2026-07-04T15:30:45.123Z").toObject()
|
|
376
|
+
* // { year: 2026, month: 7, day: 4, hour: 15, minute: 30, second: 45, millisecond: 123 }
|
|
377
|
+
* ```
|
|
378
|
+
*/
|
|
379
|
+
toObject() {
|
|
380
|
+
return {
|
|
381
|
+
year: this.year,
|
|
382
|
+
month: this.month,
|
|
383
|
+
day: this.day,
|
|
384
|
+
hour: this.hour,
|
|
385
|
+
minute: this.minute,
|
|
386
|
+
second: this.second,
|
|
387
|
+
millisecond: this.millisecond,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/** Creates an independent copy of this instance. */
|
|
391
|
+
clone() {
|
|
392
|
+
return new Badger(this.currentDate);
|
|
393
|
+
}
|
|
394
|
+
/** Full year (e.g. `2026`). */
|
|
395
|
+
get year() {
|
|
396
|
+
return this.currentDate.getFullYear();
|
|
397
|
+
}
|
|
398
|
+
/** 1-indexed month (1 = January, 12 = December). */
|
|
399
|
+
get month() {
|
|
400
|
+
return this.currentDate.getMonth() + 1;
|
|
401
|
+
}
|
|
402
|
+
/** Day of the month (1–31). */
|
|
403
|
+
get day() {
|
|
404
|
+
return this.currentDate.getDate();
|
|
405
|
+
}
|
|
406
|
+
/** Hour (0–23). */
|
|
407
|
+
get hour() {
|
|
408
|
+
return this.currentDate.getHours();
|
|
409
|
+
}
|
|
410
|
+
/** Minute (0–59). */
|
|
411
|
+
get minute() {
|
|
412
|
+
return this.currentDate.getMinutes();
|
|
413
|
+
}
|
|
414
|
+
/** Second (0–59). */
|
|
415
|
+
get second() {
|
|
416
|
+
return this.currentDate.getSeconds();
|
|
417
|
+
}
|
|
418
|
+
/** Milliseconds (0–999). */
|
|
419
|
+
get millisecond() {
|
|
420
|
+
return this.currentDate.getMilliseconds();
|
|
421
|
+
}
|
|
422
|
+
/** Quarter of the year (1–4). Q1 = Jan–Mar, Q2 = Apr–Jun, Q3 = Jul–Sep, Q4 = Oct–Dec. */
|
|
423
|
+
get quarter() {
|
|
424
|
+
return Math.ceil((this.currentDate.getMonth() + 1) / 3);
|
|
425
|
+
}
|
|
426
|
+
/** ISO week-year — the year that owns the current ISO week. May differ from `.year` at year boundaries. */
|
|
427
|
+
get weekYear() {
|
|
428
|
+
return isoWeekYear(this.currentDate);
|
|
429
|
+
}
|
|
430
|
+
/** Day of the year (1–365, or 1–366 in a leap year). */
|
|
431
|
+
get dayOfYear() {
|
|
432
|
+
const d = this.currentDate;
|
|
433
|
+
// Project local calendar date into UTC so the ms division is exact regardless of DST.
|
|
434
|
+
const start = Date.UTC(d.getFullYear(), 0, 0);
|
|
435
|
+
const current = Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
|
|
436
|
+
return Math.floor((current - start) / 86400000);
|
|
437
|
+
}
|
|
438
|
+
/** Day of the week (0 = Sunday, 6 = Saturday). */
|
|
439
|
+
get dayOfWeek() {
|
|
440
|
+
return this.currentDate.getDay();
|
|
441
|
+
}
|
|
442
|
+
/** Returns the number of days in the current month (28–31). */
|
|
443
|
+
getDaysInMonth() {
|
|
444
|
+
return new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0).getDate();
|
|
445
|
+
}
|
|
446
|
+
/** Returns the ISO 8601 week number (1–53). */
|
|
447
|
+
getWeekNumber() {
|
|
448
|
+
return isoWeekNumber(this.currentDate);
|
|
449
|
+
}
|
|
450
|
+
/** Returns the number of days in the current year (365 or 366). Returns `NaN` for invalid dates. */
|
|
451
|
+
daysInYear() {
|
|
452
|
+
if (!this.isValid())
|
|
453
|
+
return NaN;
|
|
454
|
+
return this.isLeapYear() ? 366 : 365;
|
|
455
|
+
}
|
|
456
|
+
/** Returns `true` if the current year is a leap year. Returns `false` for invalid dates. */
|
|
457
|
+
isLeapYear() {
|
|
458
|
+
if (!this.isValid())
|
|
459
|
+
return false;
|
|
460
|
+
const year = this.currentDate.getFullYear();
|
|
461
|
+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
462
|
+
}
|
|
463
|
+
// --- Start / End ---
|
|
464
|
+
/** Returns a new `Badger` at the start of the given unit. Does not mutate the original. */
|
|
465
|
+
startOf(unit) {
|
|
466
|
+
const clone = this.clone();
|
|
467
|
+
const d = clone.currentDate;
|
|
468
|
+
switch (unit) {
|
|
469
|
+
case "second":
|
|
470
|
+
d.setMilliseconds(0);
|
|
471
|
+
break;
|
|
472
|
+
case "minute":
|
|
473
|
+
d.setSeconds(0, 0);
|
|
474
|
+
break;
|
|
475
|
+
case "hour":
|
|
476
|
+
d.setMinutes(0, 0, 0);
|
|
477
|
+
break;
|
|
478
|
+
case "day":
|
|
479
|
+
d.setHours(0, 0, 0, 0);
|
|
480
|
+
break;
|
|
481
|
+
case "week": {
|
|
482
|
+
const day = d.getDay();
|
|
483
|
+
const diff = day === 0 ? 6 : day - 1;
|
|
484
|
+
d.setDate(d.getDate() - diff);
|
|
485
|
+
d.setHours(0, 0, 0, 0);
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
case "quarter": {
|
|
489
|
+
const startMonth = (Math.ceil((d.getMonth() + 1) / 3) - 1) * 3;
|
|
490
|
+
d.setMonth(startMonth, 1);
|
|
491
|
+
d.setHours(0, 0, 0, 0);
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
case "month":
|
|
495
|
+
d.setDate(1);
|
|
496
|
+
d.setHours(0, 0, 0, 0);
|
|
497
|
+
break;
|
|
498
|
+
case "year":
|
|
499
|
+
d.setMonth(0, 1);
|
|
500
|
+
d.setHours(0, 0, 0, 0);
|
|
501
|
+
break;
|
|
502
|
+
case "millisecond":
|
|
503
|
+
// Millisecond is the finest unit — already at full precision, return clone unchanged.
|
|
504
|
+
break;
|
|
505
|
+
default:
|
|
506
|
+
throw new TypeError(`startOf: unknown unit "${unit}"`);
|
|
507
|
+
}
|
|
508
|
+
return clone;
|
|
509
|
+
}
|
|
510
|
+
/** Returns a new `Badger` at the end of the given unit. Does not mutate the original. */
|
|
511
|
+
endOf(unit) {
|
|
512
|
+
const clone = this.clone();
|
|
513
|
+
const d = clone.currentDate;
|
|
514
|
+
switch (unit) {
|
|
515
|
+
case "second":
|
|
516
|
+
d.setMilliseconds(999);
|
|
517
|
+
break;
|
|
518
|
+
case "minute":
|
|
519
|
+
d.setSeconds(59, 999);
|
|
520
|
+
break;
|
|
521
|
+
case "hour":
|
|
522
|
+
d.setMinutes(59, 59, 999);
|
|
523
|
+
break;
|
|
524
|
+
case "day":
|
|
525
|
+
d.setHours(23, 59, 59, 999);
|
|
526
|
+
break;
|
|
527
|
+
case "week": {
|
|
528
|
+
const day = d.getDay();
|
|
529
|
+
const diff = day === 0 ? 0 : 7 - day;
|
|
530
|
+
d.setDate(d.getDate() + diff);
|
|
531
|
+
d.setHours(23, 59, 59, 999);
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
case "quarter": {
|
|
535
|
+
const endMonth = Math.ceil((d.getMonth() + 1) / 3) * 3;
|
|
536
|
+
d.setMonth(endMonth, 0);
|
|
537
|
+
d.setHours(23, 59, 59, 999);
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
case "month":
|
|
541
|
+
d.setMonth(d.getMonth() + 1);
|
|
542
|
+
d.setDate(0);
|
|
543
|
+
d.setHours(23, 59, 59, 999);
|
|
544
|
+
break;
|
|
545
|
+
case "year":
|
|
546
|
+
d.setMonth(11, 31);
|
|
547
|
+
d.setHours(23, 59, 59, 999);
|
|
548
|
+
break;
|
|
549
|
+
case "millisecond":
|
|
550
|
+
// Millisecond is the finest unit — already at full precision, return clone unchanged.
|
|
551
|
+
break;
|
|
552
|
+
default:
|
|
553
|
+
throw new TypeError(`endOf: unknown unit "${unit}"`);
|
|
554
|
+
}
|
|
555
|
+
return clone;
|
|
556
|
+
}
|
|
557
|
+
// --- Add / Subtract ---
|
|
558
|
+
/**
|
|
559
|
+
* Returns a new `Badger` with the given amount added. Does not mutate the original.
|
|
560
|
+
*
|
|
561
|
+
* Accepts either `(amount, unit)` or a duration object with plural or singular keys.
|
|
562
|
+
* Month and year addition clamps to the last valid day when the target month is shorter
|
|
563
|
+
* than the source day (e.g. Jan 31 + 1 month → Feb 28/29, not Mar 3).
|
|
564
|
+
*
|
|
565
|
+
* @example
|
|
566
|
+
* ```ts
|
|
567
|
+
* const dt = new Badger("2026-01-31T00:00:00Z");
|
|
568
|
+
* dt.add(1, "month").toISOString() // "2026-02-28T00:00:00.000Z" (clamped)
|
|
569
|
+
* dt.add({ days: 7, hours: 2 }).toISOString() // add 7 days and 2 hours at once
|
|
570
|
+
* dt.add({ months: 1, days: 5 }).toISOString() // chain multiple units
|
|
571
|
+
* ```
|
|
572
|
+
*/
|
|
573
|
+
add(amount, unit) {
|
|
574
|
+
if (typeof amount === "object") {
|
|
575
|
+
const units = [
|
|
576
|
+
["year", amount.years ?? amount.year ?? 0],
|
|
577
|
+
["month", amount.months ?? amount.month ?? 0],
|
|
578
|
+
["week", amount.weeks ?? amount.week ?? 0],
|
|
579
|
+
["day", amount.days ?? amount.day ?? 0],
|
|
580
|
+
["hour", amount.hours ?? amount.hour ?? 0],
|
|
581
|
+
["minute", amount.minutes ?? amount.minute ?? 0],
|
|
582
|
+
["second", amount.seconds ?? amount.second ?? 0],
|
|
583
|
+
["millisecond", amount.milliseconds ?? amount.millisecond ?? 0],
|
|
584
|
+
];
|
|
585
|
+
return units.reduce((acc, [u, v]) => (v !== 0 ? acc.add(v, u) : acc), this);
|
|
586
|
+
}
|
|
587
|
+
if (unit === undefined)
|
|
588
|
+
throw new TypeError("add: unit is required when amount is a number");
|
|
589
|
+
if (!Number.isFinite(amount))
|
|
590
|
+
throw new RangeError(`add: amount must be a finite number, got ${amount}`);
|
|
591
|
+
if (!Number.isInteger(amount))
|
|
592
|
+
throw new RangeError(`add: amount must be a whole number, got ${amount} — fractional amounts would be silently truncated`);
|
|
593
|
+
return this._addUnit(amount, unit);
|
|
594
|
+
}
|
|
595
|
+
_addUnit(amount, unit) {
|
|
596
|
+
const clone = this.clone();
|
|
597
|
+
const date = clone.currentDate;
|
|
598
|
+
switch (unit) {
|
|
599
|
+
case "year": {
|
|
600
|
+
const dayBefore = date.getDate();
|
|
601
|
+
date.setFullYear(date.getFullYear() + amount);
|
|
602
|
+
if (date.getDate() !== dayBefore)
|
|
603
|
+
date.setDate(0);
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
case "month": {
|
|
607
|
+
const dayBefore = date.getDate();
|
|
608
|
+
date.setMonth(date.getMonth() + amount);
|
|
609
|
+
if (date.getDate() !== dayBefore)
|
|
610
|
+
date.setDate(0);
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
case "quarter": {
|
|
614
|
+
const dayBefore = date.getDate();
|
|
615
|
+
date.setMonth(date.getMonth() + amount * 3);
|
|
616
|
+
if (date.getDate() !== dayBefore)
|
|
617
|
+
date.setDate(0);
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
case "week":
|
|
621
|
+
date.setDate(date.getDate() + amount * 7);
|
|
622
|
+
break;
|
|
623
|
+
case "day":
|
|
624
|
+
date.setDate(date.getDate() + amount);
|
|
625
|
+
break;
|
|
626
|
+
case "hour":
|
|
627
|
+
date.setHours(date.getHours() + amount);
|
|
628
|
+
break;
|
|
629
|
+
case "minute":
|
|
630
|
+
date.setMinutes(date.getMinutes() + amount);
|
|
631
|
+
break;
|
|
632
|
+
case "second":
|
|
633
|
+
date.setSeconds(date.getSeconds() + amount);
|
|
634
|
+
break;
|
|
635
|
+
case "millisecond":
|
|
636
|
+
date.setTime(date.getTime() + amount);
|
|
637
|
+
break;
|
|
638
|
+
default:
|
|
639
|
+
throw new TypeError(`add: unknown unit "${unit}"`);
|
|
640
|
+
}
|
|
641
|
+
return clone;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Returns a new `Badger` with a single field replaced. Does not mutate the original.
|
|
645
|
+
*
|
|
646
|
+
* Month is 1-indexed (1 = January). Setting the month clamps the day to the last valid
|
|
647
|
+
* day of the target month (e.g. Jan 31 → set month to Feb → Feb 28).
|
|
648
|
+
*
|
|
649
|
+
* @example
|
|
650
|
+
* ```ts
|
|
651
|
+
* const dt = new Badger("2026-06-15T10:30:00Z");
|
|
652
|
+
* dt.set("hour", 9).toISOString() // "2026-06-15T09:30:00.000Z"
|
|
653
|
+
* dt.set("month", 1).toISOString() // "2026-01-15T10:30:00.000Z"
|
|
654
|
+
* ```
|
|
655
|
+
*/
|
|
656
|
+
set(unit, value) {
|
|
657
|
+
if (!Number.isFinite(value))
|
|
658
|
+
throw new RangeError(`set(${unit}): value must be a finite number, got ${value}`);
|
|
659
|
+
if (!Number.isInteger(value))
|
|
660
|
+
throw new RangeError(`set(${unit}): value must be a whole number, got ${value} — fractional values would be silently truncated`);
|
|
661
|
+
if (unit === "month" && (value < 1 || value > 12))
|
|
662
|
+
throw new RangeError(`month must be 1–12, got ${value}`);
|
|
663
|
+
if (unit === "day" && (value < 1 || value > 31))
|
|
664
|
+
throw new RangeError(`day must be 1–31, got ${value}`);
|
|
665
|
+
if (unit === "hour" && (value < 0 || value > 23))
|
|
666
|
+
throw new RangeError(`hour must be 0–23, got ${value}`);
|
|
667
|
+
if (unit === "minute" && (value < 0 || value > 59))
|
|
668
|
+
throw new RangeError(`minute must be 0–59, got ${value}`);
|
|
669
|
+
if (unit === "second" && (value < 0 || value > 59))
|
|
670
|
+
throw new RangeError(`second must be 0–59, got ${value}`);
|
|
671
|
+
if (unit === "millisecond" && (value < 0 || value > 999))
|
|
672
|
+
throw new RangeError(`millisecond must be 0–999, got ${value}`);
|
|
673
|
+
const clone = this.clone();
|
|
674
|
+
const d = clone.currentDate;
|
|
675
|
+
switch (unit) {
|
|
676
|
+
case "year": {
|
|
677
|
+
const dayBefore = d.getDate();
|
|
678
|
+
d.setFullYear(value);
|
|
679
|
+
if (d.getDate() !== dayBefore)
|
|
680
|
+
d.setDate(0);
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
case "month": {
|
|
684
|
+
const dayBefore = d.getDate();
|
|
685
|
+
d.setMonth(value - 1);
|
|
686
|
+
if (d.getDate() !== dayBefore)
|
|
687
|
+
d.setDate(0);
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
case "day": {
|
|
691
|
+
const originalMonth = d.getMonth();
|
|
692
|
+
d.setDate(value);
|
|
693
|
+
if (d.getMonth() !== originalMonth)
|
|
694
|
+
d.setDate(0);
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
case "hour":
|
|
698
|
+
d.setHours(value);
|
|
699
|
+
break;
|
|
700
|
+
case "minute":
|
|
701
|
+
d.setMinutes(value);
|
|
702
|
+
break;
|
|
703
|
+
case "second":
|
|
704
|
+
d.setSeconds(value);
|
|
705
|
+
break;
|
|
706
|
+
case "millisecond":
|
|
707
|
+
d.setMilliseconds(value);
|
|
708
|
+
break;
|
|
709
|
+
default:
|
|
710
|
+
throw new TypeError(`set: unknown unit "${unit}"`);
|
|
711
|
+
}
|
|
712
|
+
return clone;
|
|
713
|
+
}
|
|
714
|
+
/** Returns a new `Badger` with the given amount subtracted. Does not mutate the original.
|
|
715
|
+
* Accepts either `(amount, unit)` or a duration object — same form as `add`. */
|
|
716
|
+
subtract(amount, unit) {
|
|
717
|
+
if (typeof amount === "object") {
|
|
718
|
+
const negated = Object.fromEntries(
|
|
719
|
+
/* v8 ignore next */
|
|
720
|
+
Object.entries(amount).map(([k, v]) => [k, -(v ?? 0)]));
|
|
721
|
+
return this.add(negated);
|
|
722
|
+
}
|
|
723
|
+
if (unit === undefined)
|
|
724
|
+
throw new TypeError("subtract: unit is required when amount is a number");
|
|
725
|
+
if (!Number.isFinite(amount))
|
|
726
|
+
throw new RangeError(`subtract: amount must be a finite number, got ${amount}`);
|
|
727
|
+
if (!Number.isInteger(amount))
|
|
728
|
+
throw new RangeError(`subtract: amount must be a whole number, got ${amount} — fractional amounts would be silently truncated`);
|
|
729
|
+
return this.add(-amount, unit);
|
|
730
|
+
}
|
|
731
|
+
// --- Comparison ---
|
|
732
|
+
/** Returns `true` if this date is strictly before `other`. */
|
|
733
|
+
isBefore(other) {
|
|
734
|
+
const otherTime = other instanceof Badger ? other.toDate().getTime() : other.getTime();
|
|
735
|
+
return this.currentDate.getTime() < otherTime;
|
|
736
|
+
}
|
|
737
|
+
/** Returns `true` if this date is strictly after `other`. */
|
|
738
|
+
isAfter(other) {
|
|
739
|
+
const otherTime = other instanceof Badger ? other.toDate().getTime() : other.getTime();
|
|
740
|
+
return this.currentDate.getTime() > otherTime;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Returns `true` if both dates are equal.
|
|
744
|
+
* When `unit` is provided, compares only down to that granularity — finer units are ignored.
|
|
745
|
+
*
|
|
746
|
+
* @example
|
|
747
|
+
* ```ts
|
|
748
|
+
* const a = new Badger("2026-06-15T10:30:00Z");
|
|
749
|
+
* const b = new Badger("2026-06-15T22:00:00Z");
|
|
750
|
+
*
|
|
751
|
+
* a.isSame(b) // false (different ms timestamp)
|
|
752
|
+
* a.isSame(b, "day") // true (same calendar day)
|
|
753
|
+
* a.isSame(b, "hour") // false (different hour)
|
|
754
|
+
* ```
|
|
755
|
+
*/
|
|
756
|
+
isSame(other, unit) {
|
|
757
|
+
const otherDate = other instanceof Badger ? other.toDate() : new Date(other.getTime());
|
|
758
|
+
if (!unit) {
|
|
759
|
+
return this.currentDate.getTime() === otherDate.getTime();
|
|
760
|
+
}
|
|
761
|
+
switch (unit) {
|
|
762
|
+
case "year":
|
|
763
|
+
return this.currentDate.getFullYear() === otherDate.getFullYear();
|
|
764
|
+
case "week":
|
|
765
|
+
return (isoWeekNumber(this.currentDate) === isoWeekNumber(otherDate) &&
|
|
766
|
+
isoWeekYear(this.currentDate) === isoWeekYear(otherDate));
|
|
767
|
+
case "month":
|
|
768
|
+
return (this.currentDate.getFullYear() === otherDate.getFullYear() &&
|
|
769
|
+
this.currentDate.getMonth() === otherDate.getMonth());
|
|
770
|
+
case "day":
|
|
771
|
+
return (this.currentDate.getFullYear() === otherDate.getFullYear() &&
|
|
772
|
+
this.currentDate.getMonth() === otherDate.getMonth() &&
|
|
773
|
+
this.currentDate.getDate() === otherDate.getDate());
|
|
774
|
+
case "hour":
|
|
775
|
+
return (this.currentDate.getFullYear() === otherDate.getFullYear() &&
|
|
776
|
+
this.currentDate.getMonth() === otherDate.getMonth() &&
|
|
777
|
+
this.currentDate.getDate() === otherDate.getDate() &&
|
|
778
|
+
this.currentDate.getHours() === otherDate.getHours());
|
|
779
|
+
case "minute":
|
|
780
|
+
return (this.currentDate.getFullYear() === otherDate.getFullYear() &&
|
|
781
|
+
this.currentDate.getMonth() === otherDate.getMonth() &&
|
|
782
|
+
this.currentDate.getDate() === otherDate.getDate() &&
|
|
783
|
+
this.currentDate.getHours() === otherDate.getHours() &&
|
|
784
|
+
this.currentDate.getMinutes() === otherDate.getMinutes());
|
|
785
|
+
case "second":
|
|
786
|
+
return (this.currentDate.getFullYear() === otherDate.getFullYear() &&
|
|
787
|
+
this.currentDate.getMonth() === otherDate.getMonth() &&
|
|
788
|
+
this.currentDate.getDate() === otherDate.getDate() &&
|
|
789
|
+
this.currentDate.getHours() === otherDate.getHours() &&
|
|
790
|
+
this.currentDate.getMinutes() === otherDate.getMinutes() &&
|
|
791
|
+
this.currentDate.getSeconds() === otherDate.getSeconds());
|
|
792
|
+
case "quarter":
|
|
793
|
+
return (this.currentDate.getFullYear() === otherDate.getFullYear() &&
|
|
794
|
+
Math.ceil((this.currentDate.getMonth() + 1) / 3) === Math.ceil((otherDate.getMonth() + 1) / 3));
|
|
795
|
+
case "millisecond":
|
|
796
|
+
return this.currentDate.getTime() === otherDate.getTime();
|
|
797
|
+
default:
|
|
798
|
+
throw new TypeError(`isSame: unknown unit "${unit}"`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Returns `true` if this date is before or equal to `other`.
|
|
803
|
+
* When `unit` is provided, compares only down to that granularity.
|
|
804
|
+
* Equivalent to `isBefore(other) || isSame(other)`.
|
|
805
|
+
*
|
|
806
|
+
* @example
|
|
807
|
+
* ```ts
|
|
808
|
+
* const deadline = new Badger("2026-06-30T00:00:00Z");
|
|
809
|
+
* new Badger("2026-06-30T00:00:00Z").isSameOrBefore(deadline) // true (equal)
|
|
810
|
+
* new Badger("2026-06-29T00:00:00Z").isSameOrBefore(deadline) // true (before)
|
|
811
|
+
* new Badger("2026-06-30T23:59:59Z").isSameOrBefore(deadline, "day") // true (same day)
|
|
812
|
+
* new Badger("2026-07-01T00:00:00Z").isSameOrBefore(deadline) // false (after)
|
|
813
|
+
* ```
|
|
814
|
+
*/
|
|
815
|
+
isSameOrBefore(other, unit) {
|
|
816
|
+
if (unit)
|
|
817
|
+
return this.isSame(other, unit) || this.isBefore(other);
|
|
818
|
+
const otherTime = other instanceof Badger ? other.toDate().getTime() : other.getTime();
|
|
819
|
+
return this.currentDate.getTime() <= otherTime;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Returns `true` if this date is after or equal to `other`.
|
|
823
|
+
* When `unit` is provided, compares only down to that granularity.
|
|
824
|
+
* Equivalent to `isAfter(other) || isSame(other)`.
|
|
825
|
+
*
|
|
826
|
+
* @example
|
|
827
|
+
* ```ts
|
|
828
|
+
* const start = new Badger("2026-06-01T00:00:00Z");
|
|
829
|
+
* new Badger("2026-06-01T00:00:00Z").isSameOrAfter(start) // true (equal)
|
|
830
|
+
* new Badger("2026-06-02T00:00:00Z").isSameOrAfter(start) // true (after)
|
|
831
|
+
* new Badger("2026-06-01T23:59:59Z").isSameOrAfter(start, "day") // true (same day)
|
|
832
|
+
* new Badger("2026-05-31T00:00:00Z").isSameOrAfter(start) // false (before)
|
|
833
|
+
* ```
|
|
834
|
+
*/
|
|
835
|
+
isSameOrAfter(other, unit) {
|
|
836
|
+
if (unit)
|
|
837
|
+
return this.isSame(other, unit) || this.isAfter(other);
|
|
838
|
+
const otherTime = other instanceof Badger ? other.toDate().getTime() : other.getTime();
|
|
839
|
+
return this.currentDate.getTime() >= otherTime;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Returns this date constrained to the range `[min, max]`.
|
|
843
|
+
* If the date is before `min`, returns `min`. If after `max`, returns `max`. Otherwise returns itself.
|
|
844
|
+
*
|
|
845
|
+
* @example
|
|
846
|
+
* ```ts
|
|
847
|
+
* const min = new Badger("2026-01-01T00:00:00Z");
|
|
848
|
+
* const max = new Badger("2026-12-31T00:00:00Z");
|
|
849
|
+
*
|
|
850
|
+
* new Badger("2025-06-15T00:00:00Z").clamp(min, max) // → min (too early)
|
|
851
|
+
* new Badger("2027-06-15T00:00:00Z").clamp(min, max) // → max (too late)
|
|
852
|
+
* new Badger("2026-06-15T00:00:00Z").clamp(min, max) // → unchanged
|
|
853
|
+
* ```
|
|
854
|
+
*/
|
|
855
|
+
clamp(min, max) {
|
|
856
|
+
const minTime = min instanceof Badger ? min.toDate().getTime() : min.getTime();
|
|
857
|
+
const maxTime = max instanceof Badger ? max.toDate().getTime() : max.getTime();
|
|
858
|
+
if (isNaN(minTime) || isNaN(maxTime))
|
|
859
|
+
throw new RangeError("clamp: min and max must be valid dates");
|
|
860
|
+
if (minTime > maxTime)
|
|
861
|
+
throw new RangeError("clamp: min must not be after max");
|
|
862
|
+
if (this.isBefore(min))
|
|
863
|
+
return min instanceof Badger ? min.clone() : new Badger(min);
|
|
864
|
+
if (this.isAfter(max))
|
|
865
|
+
return max instanceof Badger ? max.clone() : new Badger(max);
|
|
866
|
+
return this.clone();
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Returns `true` if this date falls between `start` and `end`.
|
|
870
|
+
*
|
|
871
|
+
* Both boundaries are **exclusive** by default — the boundary dates themselves return `false`.
|
|
872
|
+
* Use `includeStart` / `includeEnd` to make either boundary inclusive.
|
|
873
|
+
*
|
|
874
|
+
* @example
|
|
875
|
+
* ```ts
|
|
876
|
+
* const open = new Badger("2026-06-01T00:00:00Z");
|
|
877
|
+
* const close = new Badger("2026-06-30T00:00:00Z");
|
|
878
|
+
* const date = new Badger("2026-06-15T00:00:00Z");
|
|
879
|
+
*
|
|
880
|
+
* date.isBetween(open, close) // true (exclusive both ends)
|
|
881
|
+
* open.isBetween(open, close) // false (open itself excluded)
|
|
882
|
+
* open.isBetween(open, close, { includeStart: true }) // true (open included)
|
|
883
|
+
* close.isBetween(open, close, { includeEnd: true }) // true (close included)
|
|
884
|
+
* open.isBetween(open, close, { includeStart: true, includeEnd: true }) // true (both included)
|
|
885
|
+
* ```
|
|
886
|
+
*/
|
|
887
|
+
isBetween(start, end, options = {}) {
|
|
888
|
+
const startTime = start instanceof Badger ? start.toDate().getTime() : start.getTime();
|
|
889
|
+
const endTime = end instanceof Badger ? end.toDate().getTime() : end.getTime();
|
|
890
|
+
if (isNaN(startTime) || isNaN(endTime))
|
|
891
|
+
throw new RangeError("isBetween: start and end must be valid dates");
|
|
892
|
+
if (startTime > endTime)
|
|
893
|
+
throw new RangeError("isBetween: start must not be after end");
|
|
894
|
+
const afterStart = options.includeStart
|
|
895
|
+
? this.isSameOrAfter(start)
|
|
896
|
+
: this.isAfter(start);
|
|
897
|
+
const beforeEnd = options.includeEnd
|
|
898
|
+
? this.isSameOrBefore(end)
|
|
899
|
+
: this.isBefore(end);
|
|
900
|
+
return afterStart && beforeEnd;
|
|
901
|
+
}
|
|
902
|
+
/** Returns `true` if this date is strictly in the past (before the current instant). */
|
|
903
|
+
isPast() {
|
|
904
|
+
return this.currentDate.getTime() < Date.now();
|
|
905
|
+
}
|
|
906
|
+
/** Returns `true` if this date is strictly in the future (after the current instant). */
|
|
907
|
+
isFuture() {
|
|
908
|
+
return this.currentDate.getTime() > Date.now();
|
|
909
|
+
}
|
|
910
|
+
/** Returns `true` if the date is today (local time). */
|
|
911
|
+
isToday() {
|
|
912
|
+
return this.isSame(new Badger(), "day");
|
|
913
|
+
}
|
|
914
|
+
/** Returns `true` if the date is yesterday (local time). */
|
|
915
|
+
isYesterday() {
|
|
916
|
+
return this.isSame(new Badger().subtract(1, "day"), "day");
|
|
917
|
+
}
|
|
918
|
+
/** Returns `true` if the date is tomorrow (local time). */
|
|
919
|
+
isTomorrow() {
|
|
920
|
+
return this.isSame(new Badger().add(1, "day"), "day");
|
|
921
|
+
}
|
|
922
|
+
/** Returns `true` if the date falls on Saturday or Sunday (local day of week). Returns `false` for invalid dates. */
|
|
923
|
+
isWeekend() {
|
|
924
|
+
if (!this.isValid())
|
|
925
|
+
return false;
|
|
926
|
+
const day = this.currentDate.getDay(); // local — consistent with .dayOfWeek getter
|
|
927
|
+
return day === 0 || day === 6;
|
|
928
|
+
}
|
|
929
|
+
/** Returns `true` if the date falls on Monday–Friday (local day of week). Returns `false` for invalid dates. */
|
|
930
|
+
isWeekday() {
|
|
931
|
+
if (!this.isValid())
|
|
932
|
+
return false;
|
|
933
|
+
return !this.isWeekend();
|
|
934
|
+
}
|
|
935
|
+
// --- Countdown ---
|
|
936
|
+
/**
|
|
937
|
+
* Returns the time remaining until this date as `{ days, hours, minutes, seconds }`.
|
|
938
|
+
* All values are non-negative whole numbers.
|
|
939
|
+
* Returns `null` if the date is in the past or equal to `relativeTo`.
|
|
940
|
+
*
|
|
941
|
+
* @param relativeTo - Anchor date to count down from. Defaults to `new Date()`.
|
|
942
|
+
*
|
|
943
|
+
* @example
|
|
944
|
+
* ```ts
|
|
945
|
+
* Badger("2026-12-25").countdown()
|
|
946
|
+
* // { days: 207, hours: 4, minutes: 32, seconds: 11 }
|
|
947
|
+
*
|
|
948
|
+
* // Pair with Sett().formatDuration() for a display string
|
|
949
|
+
* Sett(new Date(), locale).formatDuration(Badger("2026-12-25").countdown())
|
|
950
|
+
* ```
|
|
951
|
+
*/
|
|
952
|
+
countdown(relativeTo) {
|
|
953
|
+
if (!this.isValid())
|
|
954
|
+
return null;
|
|
955
|
+
const now = relativeTo ?? new Date();
|
|
956
|
+
if (isNaN(now.getTime()))
|
|
957
|
+
return null;
|
|
958
|
+
const diffMs = this.currentDate.getTime() - now.getTime();
|
|
959
|
+
if (diffMs <= 0)
|
|
960
|
+
return null;
|
|
961
|
+
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
962
|
+
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
963
|
+
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
|
964
|
+
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
|
965
|
+
return { days, hours, minutes, seconds };
|
|
966
|
+
}
|
|
967
|
+
// --- Business days ---
|
|
968
|
+
/**
|
|
969
|
+
* Returns the weekend day numbers (ISO: 1=Mon…7=Sun) for the given locale.
|
|
970
|
+
* Resolution order: explicit `locale` arg → `BadgerSettings.defaultLocale` → `[6, 7]` (Sat, Sun).
|
|
971
|
+
*/
|
|
972
|
+
getWeekend(locale) {
|
|
973
|
+
return weekendDays(locale);
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Build a validated lookup Set of public-holiday dates. badger-time ships NO
|
|
977
|
+
* holiday data — the caller injects the dates they care about (typically from
|
|
978
|
+
* a source such as `date-holidays`, keyed off their region). Every entry is
|
|
979
|
+
* validated by {@link assertISODate}; an omitted set returns the shared empty
|
|
980
|
+
* set ("weekends only"), and a `Set` argument is validated and returned as-is
|
|
981
|
+
* (no copy). Throws if any entry is not a real ISO `YYYY-MM-DD` date.
|
|
982
|
+
*/
|
|
983
|
+
static buildHolidaySet(holidays) {
|
|
984
|
+
if (!holidays)
|
|
985
|
+
return EMPTY_HOLIDAYS;
|
|
986
|
+
if (holidays instanceof Set) {
|
|
987
|
+
for (const h of holidays)
|
|
988
|
+
Badger.assertISODate(h);
|
|
989
|
+
return holidays;
|
|
990
|
+
}
|
|
991
|
+
const set = new Set();
|
|
992
|
+
for (const h of holidays) {
|
|
993
|
+
Badger.assertISODate(h);
|
|
994
|
+
set.add(h);
|
|
995
|
+
}
|
|
996
|
+
return set;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Assert that `value` is a strict ISO `YYYY-MM-DD` string AND a real calendar
|
|
1000
|
+
* date. Rejects national formats (`25/12/2026`), datetimes, unpadded/whitespace
|
|
1001
|
+
* variants, and impossible dates (`2026-13-25`, `2026-02-30`) — so a bad holiday
|
|
1002
|
+
* fails loudly here instead of silently never matching a lookup key.
|
|
1003
|
+
* @throws {RangeError} If the value is not a real ISO `YYYY-MM-DD` date.
|
|
1004
|
+
*/
|
|
1005
|
+
static assertISODate(value) {
|
|
1006
|
+
if (typeof value !== "string" || !ISO_DATE_ONLY.test(value)) {
|
|
1007
|
+
throw new RangeError(`holiday must be an ISO 'YYYY-MM-DD' date string, got ${JSON.stringify(value)}`);
|
|
1008
|
+
}
|
|
1009
|
+
// Shape is valid — now reject impossible calendar dates (month 13, day 32,
|
|
1010
|
+
// Feb 30, etc.) so they fail loudly instead of silently never matching a key.
|
|
1011
|
+
const year = Number(value.slice(0, 4));
|
|
1012
|
+
const month = Number(value.slice(5, 7));
|
|
1013
|
+
const day = Number(value.slice(8, 10));
|
|
1014
|
+
const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
1015
|
+
const daysInMonth = [31, isLeap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
1016
|
+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth[month - 1]) {
|
|
1017
|
+
throw new RangeError(`holiday is not a real calendar date: ${JSON.stringify(value)}`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* The LOCAL calendar date as `YYYY-MM-DD`. This is the holiday-lookup key —
|
|
1022
|
+
* built from local components so it matches the local weekend evaluation
|
|
1023
|
+
* (using `toISOString().slice(0,10)` would key off UTC and mis-match for dates
|
|
1024
|
+
* near midnight in non-UTC zones).
|
|
1025
|
+
*/
|
|
1026
|
+
localDateKey() {
|
|
1027
|
+
const y = this.currentDate.getFullYear();
|
|
1028
|
+
const m = this.currentDate.getMonth() + 1;
|
|
1029
|
+
const d = this.currentDate.getDate();
|
|
1030
|
+
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Returns `true` if the date is a business day — i.e. neither a weekend nor a
|
|
1034
|
+
* supplied public holiday. Invalid dates return `false`.
|
|
1035
|
+
*
|
|
1036
|
+
* Resolution order for the weekend definition: explicit `locale` arg →
|
|
1037
|
+
* `BadgerSettings.defaultLocale` → Saturday+Sunday.
|
|
1038
|
+
*
|
|
1039
|
+
* @param locale - Weekend definition source. Omit to use the global default.
|
|
1040
|
+
* @param holidays - Optional public holidays to also treat as non-business
|
|
1041
|
+
* days. badger-time ships no holiday data — pass your own (e.g. from
|
|
1042
|
+
* `date-holidays`, keyed off the user's region). Each entry must be a strict
|
|
1043
|
+
* ISO `YYYY-MM-DD` string; a malformed, localized (`25/12/2026`), or
|
|
1044
|
+
* impossible (`2026-02-30`) value throws a `RangeError` rather than silently
|
|
1045
|
+
* never matching. Accepts an array or a `Set`; omitted/empty = weekends only.
|
|
1046
|
+
* Matched against the date's LOCAL calendar day, consistent with the weekend
|
|
1047
|
+
* evaluation.
|
|
1048
|
+
* @throws {RangeError} If any holiday string is not a real ISO `YYYY-MM-DD` date.
|
|
1049
|
+
*/
|
|
1050
|
+
isBusinessDay(locale, holidays) {
|
|
1051
|
+
const holidaySet = Badger.buildHolidaySet(holidays); // validate first, even for invalid dates
|
|
1052
|
+
if (!this.isValid())
|
|
1053
|
+
return false;
|
|
1054
|
+
const weekend = this.getWeekend(locale);
|
|
1055
|
+
const jsDay = this.currentDate.getDay(); // local — consistent with .dayOfWeek getter
|
|
1056
|
+
const isoDay = jsDay === 0 ? 7 : jsDay;
|
|
1057
|
+
if (weekend.includes(isoDay))
|
|
1058
|
+
return false;
|
|
1059
|
+
return !holidaySet.has(this.localDateKey());
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Returns a new `Badger` that is `n` business days after this date, skipping
|
|
1063
|
+
* both weekends (per the locale) and any supplied public holidays. The start
|
|
1064
|
+
* day itself is never tested — only the days stepped onto — so `n = 0` returns
|
|
1065
|
+
* an unchanged clone even if today is a weekend or holiday.
|
|
1066
|
+
*
|
|
1067
|
+
* Day-of-week is evaluated in local time (consistent with `.dayOfWeek` and other
|
|
1068
|
+
* getters). The date is stepped one LOCAL calendar day at a time (`setDate`),
|
|
1069
|
+
* which advances the local date by exactly 1 across DST transitions — so a
|
|
1070
|
+
* DST-shortened or -lengthened local day is never skipped or double-counted.
|
|
1071
|
+
*
|
|
1072
|
+
* @param n - Number of business days to advance. Must be a non-negative integer.
|
|
1073
|
+
* @param locale - Weekend definition source. Omit to use the global default.
|
|
1074
|
+
* @param holidays - Optional public holidays to skip, same contract as
|
|
1075
|
+
* {@link isBusinessDay}: strict ISO `YYYY-MM-DD` strings (array or `Set`),
|
|
1076
|
+
* validated, local-date keyed, omitted/empty = weekends only.
|
|
1077
|
+
* @throws {RangeError} If `n` is not a non-negative integer, if any holiday
|
|
1078
|
+
* string is invalid, or if the holiday set blocks every reachable day (the
|
|
1079
|
+
* walk is bounded, so a pathological set throws instead of looping forever).
|
|
1080
|
+
*/
|
|
1081
|
+
addBusinessDays(n, locale, holidays) {
|
|
1082
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0)
|
|
1083
|
+
throw new RangeError(`addBusinessDays: n must be a non-negative integer, got ${n}`);
|
|
1084
|
+
const holidaySet = Badger.buildHolidaySet(holidays);
|
|
1085
|
+
if (!this.isValid())
|
|
1086
|
+
return this.clone();
|
|
1087
|
+
const weekend = this.getWeekend(locale);
|
|
1088
|
+
let result = new Date(this.currentDate);
|
|
1089
|
+
let remaining = n;
|
|
1090
|
+
let guard = (n + 1) * MAX_STEPS_PER_BUSINESS_DAY;
|
|
1091
|
+
while (remaining > 0) {
|
|
1092
|
+
if (guard-- <= 0)
|
|
1093
|
+
throw new RangeError(`addBusinessDays: could not find ${n} business day(s) — the holiday set appears to block every reachable day`);
|
|
1094
|
+
result.setDate(result.getDate() + 1); // step in LOCAL days — advances the local calendar date by exactly 1 (DST-safe), matching the local getDay()/holiday check below
|
|
1095
|
+
const jsDay = result.getDay(); // local day-of-week
|
|
1096
|
+
const isoDay = jsDay === 0 ? 7 : jsDay;
|
|
1097
|
+
if (!weekend.includes(isoDay) && !holidaySet.has(localKey(result)))
|
|
1098
|
+
remaining--;
|
|
1099
|
+
}
|
|
1100
|
+
return new Badger(result);
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Returns a new `Badger` that is `n` business days before this date, skipping
|
|
1104
|
+
* both weekends (per the locale) and any supplied public holidays. The start
|
|
1105
|
+
* day itself is never tested — only the days stepped onto — so `n = 0` returns
|
|
1106
|
+
* an unchanged clone even if today is a weekend or holiday.
|
|
1107
|
+
*
|
|
1108
|
+
* Day-of-week is evaluated in local time (consistent with `.dayOfWeek` and other
|
|
1109
|
+
* getters). The date is stepped one LOCAL calendar day at a time (`setDate`),
|
|
1110
|
+
* which retreats the local date by exactly 1 across DST transitions — so a
|
|
1111
|
+
* DST-shortened or -lengthened local day is never skipped or double-counted.
|
|
1112
|
+
*
|
|
1113
|
+
* @param n - Number of business days to go back. Must be a non-negative integer.
|
|
1114
|
+
* @param locale - Weekend definition source. Omit to use the global default.
|
|
1115
|
+
* @param holidays - Optional public holidays to skip, same contract as
|
|
1116
|
+
* {@link isBusinessDay}: strict ISO `YYYY-MM-DD` strings (array or `Set`),
|
|
1117
|
+
* validated, local-date keyed, omitted/empty = weekends only.
|
|
1118
|
+
* @throws {RangeError} If `n` is not a non-negative integer, if any holiday
|
|
1119
|
+
* string is invalid, or if the holiday set blocks every reachable day (the
|
|
1120
|
+
* walk is bounded, so a pathological set throws instead of looping forever).
|
|
1121
|
+
*/
|
|
1122
|
+
subtractBusinessDays(n, locale, holidays) {
|
|
1123
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0)
|
|
1124
|
+
throw new RangeError(`subtractBusinessDays: n must be a non-negative integer, got ${n}`);
|
|
1125
|
+
const holidaySet = Badger.buildHolidaySet(holidays);
|
|
1126
|
+
if (!this.isValid())
|
|
1127
|
+
return this.clone();
|
|
1128
|
+
const weekend = this.getWeekend(locale);
|
|
1129
|
+
let result = new Date(this.currentDate);
|
|
1130
|
+
let remaining = n;
|
|
1131
|
+
let guard = (n + 1) * MAX_STEPS_PER_BUSINESS_DAY;
|
|
1132
|
+
while (remaining > 0) {
|
|
1133
|
+
if (guard-- <= 0)
|
|
1134
|
+
throw new RangeError(`subtractBusinessDays: could not find ${n} business day(s) — the holiday set appears to block every reachable day`);
|
|
1135
|
+
result.setDate(result.getDate() - 1); // step in LOCAL days — retreats the local calendar date by exactly 1 (DST-safe), matching the local getDay()/holiday check below
|
|
1136
|
+
const jsDay = result.getDay(); // local day-of-week
|
|
1137
|
+
const isoDay = jsDay === 0 ? 7 : jsDay;
|
|
1138
|
+
if (!weekend.includes(isoDay) && !holidaySet.has(localKey(result)))
|
|
1139
|
+
remaining--;
|
|
1140
|
+
}
|
|
1141
|
+
return new Badger(result);
|
|
1142
|
+
}
|
|
1143
|
+
// --- Diff ---
|
|
1144
|
+
/**
|
|
1145
|
+
* Returns the difference between this date and `other` in the given unit.
|
|
1146
|
+
* Positive when `this` is after `other`, negative when before.
|
|
1147
|
+
*
|
|
1148
|
+
* By default the result is floored to a whole number. Pass `float: true` to get
|
|
1149
|
+
* the fractional value — useful for progress bars or overlap calculations.
|
|
1150
|
+
*
|
|
1151
|
+
* Month and year diffs are calendar-based, not ms-based. Only the month (and year)
|
|
1152
|
+
* components are compared — day-of-month is ignored. Jan 1 → Jan 31 = 0 months
|
|
1153
|
+
* (same calendar month); Jan 31 → Feb 1 = 1 month (different calendar months,
|
|
1154
|
+
* even though they are only 1 day apart).
|
|
1155
|
+
*
|
|
1156
|
+
* @example
|
|
1157
|
+
* ```ts
|
|
1158
|
+
* const a = new Badger("2026-06-15T10:30:00Z");
|
|
1159
|
+
* const b = new Badger("2026-06-15T09:00:00Z");
|
|
1160
|
+
*
|
|
1161
|
+
* a.diff(b, "hour") // 1 (floored)
|
|
1162
|
+
* a.diff(b, "hour", true) // 1.5 (fractional)
|
|
1163
|
+
* b.diff(a, "minute") // -90 (negative — b is before a)
|
|
1164
|
+
* ```
|
|
1165
|
+
*/
|
|
1166
|
+
diff(other, unit, float) {
|
|
1167
|
+
const otherTime = other instanceof Badger ? other.toDate().getTime() : other.getTime();
|
|
1168
|
+
const diffMs = this.currentDate.getTime() - otherTime;
|
|
1169
|
+
const trunc = float ? (n) => n : Math.floor;
|
|
1170
|
+
switch (unit) {
|
|
1171
|
+
case "millisecond":
|
|
1172
|
+
return diffMs;
|
|
1173
|
+
case "second":
|
|
1174
|
+
return trunc(diffMs / 1000);
|
|
1175
|
+
case "minute":
|
|
1176
|
+
return trunc(diffMs / 60000);
|
|
1177
|
+
case "hour":
|
|
1178
|
+
return trunc(diffMs / 3600000);
|
|
1179
|
+
case "day": {
|
|
1180
|
+
if (float)
|
|
1181
|
+
return diffMs / 86400000;
|
|
1182
|
+
// Integer path: project local calendar dates into UTC so the count is exact across DST.
|
|
1183
|
+
const d1 = this.currentDate;
|
|
1184
|
+
const d2 = other instanceof Badger ? other.toDate() : other;
|
|
1185
|
+
const utc1 = Date.UTC(d1.getFullYear(), d1.getMonth(), d1.getDate());
|
|
1186
|
+
const utc2 = Date.UTC(d2.getFullYear(), d2.getMonth(), d2.getDate());
|
|
1187
|
+
return Math.floor((utc1 - utc2) / 86400000);
|
|
1188
|
+
}
|
|
1189
|
+
case "week": {
|
|
1190
|
+
if (float)
|
|
1191
|
+
return diffMs / 604800000;
|
|
1192
|
+
const d1 = this.currentDate;
|
|
1193
|
+
const d2 = other instanceof Badger ? other.toDate() : other;
|
|
1194
|
+
const utc1 = Date.UTC(d1.getFullYear(), d1.getMonth(), d1.getDate());
|
|
1195
|
+
const utc2 = Date.UTC(d2.getFullYear(), d2.getMonth(), d2.getDate());
|
|
1196
|
+
return Math.floor((utc1 - utc2) / 604800000);
|
|
1197
|
+
}
|
|
1198
|
+
case "month": {
|
|
1199
|
+
const d1 = this.currentDate;
|
|
1200
|
+
const d2 = other instanceof Badger ? other.toDate() : other;
|
|
1201
|
+
return ((d1.getFullYear() - d2.getFullYear()) * 12 +
|
|
1202
|
+
(d1.getMonth() - d2.getMonth()));
|
|
1203
|
+
}
|
|
1204
|
+
case "quarter": {
|
|
1205
|
+
const d1 = this.currentDate;
|
|
1206
|
+
const d2 = other instanceof Badger ? other.toDate() : other;
|
|
1207
|
+
return ((d1.getFullYear() - d2.getFullYear()) * 4 +
|
|
1208
|
+
(Math.ceil((d1.getMonth() + 1) / 3) - Math.ceil((d2.getMonth() + 1) / 3)));
|
|
1209
|
+
}
|
|
1210
|
+
case "year": {
|
|
1211
|
+
const d1 = this.currentDate;
|
|
1212
|
+
const d2 = other instanceof Badger ? other.toDate() : other;
|
|
1213
|
+
return d1.getFullYear() - d2.getFullYear();
|
|
1214
|
+
}
|
|
1215
|
+
default:
|
|
1216
|
+
throw new TypeError(`diff: unknown unit "${unit}"`);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
const defaultDateFormatOptions = {
|
|
1222
|
+
year: "numeric",
|
|
1223
|
+
month: "2-digit",
|
|
1224
|
+
day: "2-digit",
|
|
1225
|
+
hour: "2-digit",
|
|
1226
|
+
minute: "2-digit",
|
|
1227
|
+
second: "2-digit",
|
|
1228
|
+
hour12: false
|
|
1229
|
+
// No timeZone: Intl then uses the DEVICE's zone. (It used to default to Europe/London, which
|
|
1230
|
+
// showed dates near midnight a day early for everyone east of the UK.)
|
|
1231
|
+
};
|
|
1232
|
+
/**
|
|
1233
|
+
* Locale-aware date formatter built on `Intl.DateTimeFormat`.
|
|
1234
|
+
*
|
|
1235
|
+
* Defaults to `en-GB` locale and the device's own time zone (override with
|
|
1236
|
+
* `BadgerSettings.defaultTimeZone` or an explicit `timeZone` option).
|
|
1237
|
+
*
|
|
1238
|
+
* @example
|
|
1239
|
+
* ```ts
|
|
1240
|
+
* const df = new BadgerSett(new Date(), new Intl.Locale("en-GB"));
|
|
1241
|
+
* df.formatDate(); // "15/03/2026 10:30:45"
|
|
1242
|
+
* df.formatForBackend(); // "2026-03-15T10:30:45.000Z"
|
|
1243
|
+
* df.getDayName(); // "Sunday"
|
|
1244
|
+
* df.formatRelative(); // "in 2 days"
|
|
1245
|
+
* ```
|
|
1246
|
+
*/
|
|
1247
|
+
class BadgerSett extends Intl.DateTimeFormat {
|
|
1248
|
+
/**
|
|
1249
|
+
* @param date - Date to format. Defaults to `new Date()`.
|
|
1250
|
+
* @param locale - BCP 47 locale. Defaults to `en-GB`.
|
|
1251
|
+
* @param options - `Intl.DateTimeFormatOptions` override.
|
|
1252
|
+
*/
|
|
1253
|
+
constructor(date, locale, options) {
|
|
1254
|
+
// A plain tag, not `new Intl.Locale(...)` — Hermes has no Intl.Locale.
|
|
1255
|
+
const resolvedLocale = localeTag(locale);
|
|
1256
|
+
const resolvedOptions = options ?? (BadgerSettings.defaultTimeZone
|
|
1257
|
+
? { ...defaultDateFormatOptions, timeZone: BadgerSettings.defaultTimeZone }
|
|
1258
|
+
: defaultDateFormatOptions);
|
|
1259
|
+
super(resolvedLocale, resolvedOptions);
|
|
1260
|
+
if (typeof date === "string") {
|
|
1261
|
+
if (date === "") {
|
|
1262
|
+
this.currentDate = new Date(NaN);
|
|
1263
|
+
}
|
|
1264
|
+
else {
|
|
1265
|
+
const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(date);
|
|
1266
|
+
const hasTimezoneIndicator = /(?:Z|[+-]\d{2}:?\d{2})$/.test(date);
|
|
1267
|
+
this.currentDate = new Date(isDateOnly || hasTimezoneIndicator ? date : date + "Z");
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
this.currentDate = date ? new Date(date) : new Date();
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
/** @throws {RangeError} if `currentDate` is not a valid date. */
|
|
1275
|
+
assertDateValid(method) {
|
|
1276
|
+
if (isNaN(this.currentDate.getTime())) {
|
|
1277
|
+
throw new RangeError(`BadgerSett.${method}(): date is not valid — check isNaN(currentDate.getTime()) first`);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
/** Returns the date as a UTC ISO 8601 string (e.g. `"2026-03-15T09:30:45.000Z"`), suitable for backend APIs. */
|
|
1281
|
+
formatForBackend() {
|
|
1282
|
+
this.assertDateValid("formatForBackend");
|
|
1283
|
+
return this.currentDate.toISOString();
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Returns a UTC ISO string representing midnight (00:00:00) in the configured timezone.
|
|
1287
|
+
* Does not mutate `currentDate`.
|
|
1288
|
+
*/
|
|
1289
|
+
formatForBackendToStartOfDay() {
|
|
1290
|
+
this.assertDateValid("formatForBackendToStartOfDay");
|
|
1291
|
+
// Start of day = the FIRST UTC instant whose local calendar date matches
|
|
1292
|
+
// `currentDate`'s local date in the configured timezone.
|
|
1293
|
+
return new Date(this.firstInstantOfLocalDate(0)).toISOString();
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Returns a UTC ISO string representing end-of-day (23:59:59.999) in the configured timezone.
|
|
1297
|
+
* Does not mutate `currentDate`.
|
|
1298
|
+
*/
|
|
1299
|
+
formatForBackendToEndOfDay() {
|
|
1300
|
+
this.assertDateValid("formatForBackendToEndOfDay");
|
|
1301
|
+
// End of day = the LAST UTC instant of the local date = one ms before the
|
|
1302
|
+
// first instant of the NEXT local date.
|
|
1303
|
+
return new Date(this.firstInstantOfLocalDate(1) - 1).toISOString();
|
|
1304
|
+
}
|
|
1305
|
+
/** The `YYYY-MM-DD` calendar date of an instant in the configured timezone. */
|
|
1306
|
+
zonedDateKey(instant, tz) {
|
|
1307
|
+
const p = new Intl.DateTimeFormat("en-GB", {
|
|
1308
|
+
year: "numeric", month: "2-digit", day: "2-digit", timeZone: tz,
|
|
1309
|
+
}).formatToParts(instant);
|
|
1310
|
+
const g = (t) => p.find(x => x.type === t).value;
|
|
1311
|
+
return `${g("year")}-${g("month")}-${g("day")}`;
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Returns the first UTC instant (ms) whose local calendar date in the
|
|
1315
|
+
* configured timezone is `dayOffset` days after `currentDate`'s local date.
|
|
1316
|
+
*
|
|
1317
|
+
* Found by binary search on the local-date boundary rather than offset
|
|
1318
|
+
* arithmetic. Local calendar date is monotonic non-decreasing in absolute
|
|
1319
|
+
* time, so the search is exact and DST-proof — it handles skipped/ambiguous
|
|
1320
|
+
* midnights, half-hour/45-minute offsets (Lord Howe, Chatham), and large
|
|
1321
|
+
* offsets where a naive "wall time as UTC" guess lands on the wrong side of
|
|
1322
|
+
* that day's transition (Sydney/Auckland on their fall-back day).
|
|
1323
|
+
*/
|
|
1324
|
+
firstInstantOfLocalDate(dayOffset) {
|
|
1325
|
+
const tz = this.resolvedOptions().timeZone;
|
|
1326
|
+
// Target local date = currentDate's local date + dayOffset days.
|
|
1327
|
+
const [y, m, d] = this.zonedDateKey(this.currentDate, tz).split("-").map(Number);
|
|
1328
|
+
const targetMs = Date.UTC(y, m - 1, d + dayOffset);
|
|
1329
|
+
const target = new Date(targetMs).toISOString().slice(0, 10);
|
|
1330
|
+
// Bracket: real UTC offsets are within ±14h, so ±30h around the naive
|
|
1331
|
+
// guess is guaranteed to straddle the boundary (lo before it, hi at/after).
|
|
1332
|
+
let lo = targetMs - 30 * 3600000;
|
|
1333
|
+
let hi = targetMs + 30 * 3600000;
|
|
1334
|
+
while (lo < hi) {
|
|
1335
|
+
const mid = lo + Math.floor((hi - lo) / 2);
|
|
1336
|
+
if (this.zonedDateKey(new Date(mid), tz) >= target)
|
|
1337
|
+
hi = mid;
|
|
1338
|
+
else
|
|
1339
|
+
lo = mid + 1;
|
|
1340
|
+
}
|
|
1341
|
+
return lo;
|
|
1342
|
+
}
|
|
1343
|
+
/** Formats the full date and time using the configured locale and options. */
|
|
1344
|
+
formatDate() {
|
|
1345
|
+
this.assertDateValid("formatDate");
|
|
1346
|
+
return this.format(this.currentDate);
|
|
1347
|
+
}
|
|
1348
|
+
/** Formats only the date portion (year, month, day) without time. */
|
|
1349
|
+
formatDateOnly() {
|
|
1350
|
+
this.assertDateValid("formatDateOnly");
|
|
1351
|
+
return new Intl.DateTimeFormat(this.resolvedOptions().locale, {
|
|
1352
|
+
year: "numeric",
|
|
1353
|
+
month: "2-digit",
|
|
1354
|
+
day: "2-digit",
|
|
1355
|
+
timeZone: this.resolvedOptions().timeZone
|
|
1356
|
+
}).format(this.currentDate);
|
|
1357
|
+
}
|
|
1358
|
+
/** Formats only the time portion (hour, minute, second), using the locale's
|
|
1359
|
+
* preferred hour cycle — 12-hour for en-US/ar-SA, 24-hour for en-GB/de-DE etc. */
|
|
1360
|
+
formatTimeOnly() {
|
|
1361
|
+
this.assertDateValid("formatTimeOnly");
|
|
1362
|
+
const locale = this.resolvedOptions().locale;
|
|
1363
|
+
const is12h = uses12HourClock(locale);
|
|
1364
|
+
return new Intl.DateTimeFormat(locale, {
|
|
1365
|
+
hour: "2-digit",
|
|
1366
|
+
minute: "2-digit",
|
|
1367
|
+
second: "2-digit",
|
|
1368
|
+
hour12: is12h,
|
|
1369
|
+
timeZone: this.resolvedOptions().timeZone
|
|
1370
|
+
}).format(this.currentDate);
|
|
1371
|
+
}
|
|
1372
|
+
/** Returns the localised weekday name (e.g. `"Monday"`, `"Mon"`, `"M"`). */
|
|
1373
|
+
getDayName(format = "long") {
|
|
1374
|
+
this.assertDateValid("getDayName");
|
|
1375
|
+
return new Intl.DateTimeFormat(this.resolvedOptions().locale, {
|
|
1376
|
+
weekday: format
|
|
1377
|
+
}).format(this.currentDate);
|
|
1378
|
+
}
|
|
1379
|
+
/** Returns the localised month name (e.g. `"March"`, `"Mar"`, `"M"`). */
|
|
1380
|
+
getMonthName(format = "long") {
|
|
1381
|
+
this.assertDateValid("getMonthName");
|
|
1382
|
+
return new Intl.DateTimeFormat(this.resolvedOptions().locale, {
|
|
1383
|
+
month: format
|
|
1384
|
+
}).format(this.currentDate);
|
|
1385
|
+
}
|
|
1386
|
+
/** Returns all 7 weekday names starting from the locale's first day of week. */
|
|
1387
|
+
getDaysOfTheWeek(format = "long") {
|
|
1388
|
+
const locale = this.resolvedOptions().locale;
|
|
1389
|
+
const formatter = new Intl.DateTimeFormat(locale, { weekday: format });
|
|
1390
|
+
// The locale's first day of week (1 = Monday ISO default, 7 = Sunday for en-US, …);
|
|
1391
|
+
// Monday when week info is unavailable (e.g. Hermes has no Intl.Locale).
|
|
1392
|
+
const firstDay = firstDayOfWeek(locale);
|
|
1393
|
+
// Map Intl firstDay (1=Mon, 7=Sun) to JS day index (0=Sun, 1=Mon, …)
|
|
1394
|
+
const jsFirstDay = firstDay === 7 ? 0 : firstDay;
|
|
1395
|
+
// 2024-01-01 is a Monday (JS day 1). Offset from Monday to jsFirstDay.
|
|
1396
|
+
const mondayOffset = (jsFirstDay - 1 + 7) % 7;
|
|
1397
|
+
const monday = new Date(2024, 0, 1);
|
|
1398
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
1399
|
+
const day = new Date(monday);
|
|
1400
|
+
day.setDate(monday.getDate() + mondayOffset + i);
|
|
1401
|
+
return formatter.format(day);
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
/** Returns all 12 month names (January–December) in the configured locale. */
|
|
1405
|
+
getMonthsOfTheYear(format = "long") {
|
|
1406
|
+
const locale = this.resolvedOptions().locale;
|
|
1407
|
+
const formatter = new Intl.DateTimeFormat(locale, { month: format });
|
|
1408
|
+
return Array.from({ length: 12 }, (_, i) => {
|
|
1409
|
+
return formatter.format(new Date(2024, i, 1));
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Returns `true` if `currentDate` falls on a weekend day as defined by the
|
|
1414
|
+
* configured locale's `weekInfo.weekend` (e.g. Fri+Sat for `ar-SA`).
|
|
1415
|
+
* Falls back to Saturday+Sunday when `weekInfo` is unavailable.
|
|
1416
|
+
*/
|
|
1417
|
+
isWeekend() {
|
|
1418
|
+
const weekend = weekendDays(this.resolvedOptions().locale);
|
|
1419
|
+
// Use local getDay() — consistent with Badger.isWeekend and the .dayOfWeek getter.
|
|
1420
|
+
const jsDay = this.currentDate.getDay();
|
|
1421
|
+
const isoDay = jsDay === 0 ? 7 : jsDay;
|
|
1422
|
+
return weekend.includes(isoDay);
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Returns a new `Date` at local midnight of the first day of the week
|
|
1426
|
+
* for the configured locale (e.g. Sunday for `en-US`, Monday for `en-GB`).
|
|
1427
|
+
*
|
|
1428
|
+
* Uses local time throughout — consistent with `Badger.startOf("week")` and
|
|
1429
|
+
* the `.dayOfWeek` getter. In non-UTC timezones this gives the correct local
|
|
1430
|
+
* calendar day, whereas a UTC-based calculation would return the wrong week
|
|
1431
|
+
* for dates near midnight.
|
|
1432
|
+
*/
|
|
1433
|
+
startOfWeek() {
|
|
1434
|
+
this.assertDateValid("startOfWeek");
|
|
1435
|
+
const firstDay = firstDayOfWeek(this.resolvedOptions().locale);
|
|
1436
|
+
// weekInfo firstDay: 1=Mon…7=Sun → JS: Sun=0, else same as ISO
|
|
1437
|
+
const jsFirstDay = firstDay === 7 ? 0 : firstDay;
|
|
1438
|
+
const jsDay = this.currentDate.getDay(); // local day of week
|
|
1439
|
+
const daysBack = (jsDay - jsFirstDay + 7) % 7;
|
|
1440
|
+
const result = new Date(this.currentDate);
|
|
1441
|
+
result.setDate(result.getDate() - daysBack); // local date stepping
|
|
1442
|
+
result.setHours(0, 0, 0, 0); // local midnight
|
|
1443
|
+
return result;
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Convenience method — calls `countdown()` on the given `Badger` date and
|
|
1447
|
+
* formats the result with `Intl.DurationFormat` in the configured locale.
|
|
1448
|
+
* Returns `null` if the date is in the past or equal to now.
|
|
1449
|
+
*
|
|
1450
|
+
* Requires `Intl.DurationFormat` (Node ≥ 24, Chrome 127+, Firefox 127+, Safari 17.4+).
|
|
1451
|
+
*
|
|
1452
|
+
* @example
|
|
1453
|
+
* ```ts
|
|
1454
|
+
* Sett(new Date(), new Intl.Locale("en-GB")).formatCountdown(new Date("2026-12-25"))
|
|
1455
|
+
* // "206 days, 3 hours, 14 minutes, and 22 seconds" — or null if past
|
|
1456
|
+
* ```
|
|
1457
|
+
*/
|
|
1458
|
+
/* v8 ignore next 6 — Intl.DurationFormat only available in Node 24+; covered by CI */
|
|
1459
|
+
formatCountdown(target) {
|
|
1460
|
+
const remaining = new Badger$1(target).countdown(this.currentDate);
|
|
1461
|
+
if (!remaining)
|
|
1462
|
+
return null;
|
|
1463
|
+
return this.formatDuration(remaining);
|
|
1464
|
+
}
|
|
1465
|
+
/* v8 ignore next 8 — Intl.DurationFormat only available in Node 24+; covered by CI */
|
|
1466
|
+
formatDuration(duration) {
|
|
1467
|
+
const DurationFormat = Intl.DurationFormat;
|
|
1468
|
+
if (!DurationFormat) {
|
|
1469
|
+
throw new Error("Intl.DurationFormat is not supported in this environment");
|
|
1470
|
+
}
|
|
1471
|
+
return new DurationFormat(this.resolvedOptions().locale, { style: "long" }).format(duration);
|
|
1472
|
+
}
|
|
1473
|
+
/** Returns a human-readable relative time string (e.g. `"3 hours ago"`, `"in 2 days"`).
|
|
1474
|
+
* Optionally pass `relativeTo` to anchor the comparison to a specific date instead of now.
|
|
1475
|
+
* Month and year thresholds are calendar-based, not approximated. */
|
|
1476
|
+
formatRelative(relativeTo) {
|
|
1477
|
+
const now = relativeTo ?? new Date();
|
|
1478
|
+
if (isNaN(this.currentDate.getTime()))
|
|
1479
|
+
throw new RangeError("formatRelative: currentDate is not a valid date");
|
|
1480
|
+
if (isNaN(now.getTime()))
|
|
1481
|
+
throw new RangeError("formatRelative: relativeTo is not a valid date");
|
|
1482
|
+
const diffMs = now.getTime() - this.currentDate.getTime();
|
|
1483
|
+
const absDiffMs = Math.abs(diffMs);
|
|
1484
|
+
const isFuture = diffMs < 0;
|
|
1485
|
+
const seconds = Math.floor(absDiffMs / 1000);
|
|
1486
|
+
const minutes = Math.floor(seconds / 60);
|
|
1487
|
+
const hours = Math.floor(minutes / 60);
|
|
1488
|
+
const days = Math.floor(hours / 24);
|
|
1489
|
+
const weeks = Math.floor(days / 7);
|
|
1490
|
+
const earlier = isFuture ? now : this.currentDate;
|
|
1491
|
+
const later = isFuture ? this.currentDate : now;
|
|
1492
|
+
// Months to the NEAREST month: whole calendar months (end-of-month clamped, so 31 Jan + 1 month
|
|
1493
|
+
// = 28/29 Feb), plus one more if the leftover is half a month (15 days) or more. So 31 Jan → 1 Mar
|
|
1494
|
+
// is "last month" (not 2), while 28 days across a month boundary still rounds up to a month.
|
|
1495
|
+
const stepMonths = (later.getFullYear() - earlier.getFullYear()) * 12 +
|
|
1496
|
+
(later.getMonth() - earlier.getMonth());
|
|
1497
|
+
const addMonthsClamped = (d, n) => {
|
|
1498
|
+
const target = new Date(d.getFullYear(), d.getMonth() + n, 1, d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds());
|
|
1499
|
+
const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
|
|
1500
|
+
target.setDate(Math.min(d.getDate(), lastDay));
|
|
1501
|
+
return target;
|
|
1502
|
+
};
|
|
1503
|
+
const wholeMonths = addMonthsClamped(earlier, stepMonths) > later ? stepMonths - 1 : stepMonths;
|
|
1504
|
+
const leftoverDays = Math.floor((later.getTime() - addMonthsClamped(earlier, wholeMonths).getTime()) / 86400000);
|
|
1505
|
+
const calMonths = wholeMonths + (leftoverDays >= 15 ? 1 : 0);
|
|
1506
|
+
const calYears = Math.floor(calMonths / 12);
|
|
1507
|
+
let value;
|
|
1508
|
+
let unit;
|
|
1509
|
+
if (seconds < 60) {
|
|
1510
|
+
value = seconds;
|
|
1511
|
+
unit = "second";
|
|
1512
|
+
}
|
|
1513
|
+
else if (minutes < 60) {
|
|
1514
|
+
value = minutes;
|
|
1515
|
+
unit = "minute";
|
|
1516
|
+
}
|
|
1517
|
+
else if (hours < 24) {
|
|
1518
|
+
value = hours;
|
|
1519
|
+
unit = "hour";
|
|
1520
|
+
}
|
|
1521
|
+
else if (days < 7) {
|
|
1522
|
+
value = days;
|
|
1523
|
+
unit = "day";
|
|
1524
|
+
}
|
|
1525
|
+
else if (days < 28 || calMonths < 1) {
|
|
1526
|
+
value = weeks;
|
|
1527
|
+
unit = "week";
|
|
1528
|
+
}
|
|
1529
|
+
else if (calMonths < 12) {
|
|
1530
|
+
value = calMonths;
|
|
1531
|
+
unit = "month";
|
|
1532
|
+
}
|
|
1533
|
+
else {
|
|
1534
|
+
value = calYears;
|
|
1535
|
+
unit = "year";
|
|
1536
|
+
}
|
|
1537
|
+
const rtf = new Intl.RelativeTimeFormat(this.resolvedOptions().locale, {
|
|
1538
|
+
numeric: "auto"
|
|
1539
|
+
});
|
|
1540
|
+
return rtf.format(isFuture ? value : -value, unit);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
1545
|
+
const [major] = process.versions.node.split(".").map(Number);
|
|
1546
|
+
if (major < 20) {
|
|
1547
|
+
throw new Error(`badger-time requires Node.js 20 or higher. You are running Node.js ${process.versions.node}.`);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Creates a Badger date instance. Shorthand for `new Badger(date)`.
|
|
1552
|
+
*
|
|
1553
|
+
* The class statics (`fromLocal`, `range`, `min`, `max`) are attached to the
|
|
1554
|
+
* factory so the documented `Badger.fromLocal(...)` / `Badger.range(...)` calls
|
|
1555
|
+
* work on the public import — not just on the internal class.
|
|
1556
|
+
*/
|
|
1557
|
+
const Badger = Object.assign((date) => new Badger$1(date), {
|
|
1558
|
+
fromLocal: Badger$1.fromLocal.bind(Badger$1),
|
|
1559
|
+
range: Badger$1.range.bind(Badger$1),
|
|
1560
|
+
min: Badger$1.min.bind(Badger$1),
|
|
1561
|
+
max: Badger$1.max.bind(Badger$1),
|
|
1562
|
+
});
|
|
1563
|
+
/** Creates a BadgerSett formatter instance. Shorthand for `new BadgerSett(date, locale, options)`. */
|
|
1564
|
+
const Sett = (date, locale, options) => new BadgerSett(date, locale, options);
|
|
1565
|
+
|
|
1566
|
+
exports.Badger = Badger;
|
|
1567
|
+
exports.BadgerSettings = BadgerSettings;
|
|
1568
|
+
exports.Sett = Sett;
|