@purple-brock/time 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,935 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/lrprice79/badger-time/HEAD/assets/purple-brock-icon.png" width="140" alt="badger-time">
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <h1 align="center">badger-time</h1>
6
+
7
+ > The date library that actually understands where your users are sett.
8
+
9
+ A small, fast library for working with dates and times in JavaScript and TypeScript apps. No extra packages required — it works entirely with tools already built into browsers and Node.js. We dug deep so you don't have to.
10
+
11
+ ---
12
+
13
+ ## What does it do?
14
+
15
+ Working with dates sounds simple — until your app needs to handle users in different countries, different time zones, and different date formats. Most date libraries assume everyone works Monday to Friday, weeks start on Monday, and times are shown in 24-hour format. That's wrong for a large part of the world.
16
+
17
+ **badger-time** handles this automatically:
18
+
19
+ - **Weekends differ by country.** In Saudi Arabia, the weekend is Friday and Saturday. In Israel it is also Friday and Saturday. In most of the world it is Saturday and Sunday. badger-time knows this for every locale (country/language setting) in the world.
20
+ - **Week starts differ by country.** In the US, weeks start on Sunday. In the UK and most of Europe, they start on Monday. badger-time picks the right one automatically.
21
+ - **Time format differs by country.** In the US, people use 12-hour clocks (3:30 PM). In Germany, 24-hour clocks (15:30). badger-time switches automatically based on locale.
22
+ - **Daylight saving time is handled correctly.** Calculating "start of today" or "days between two dates" can give wrong answers when clocks change. badger-time does this correctly.
23
+
24
+ You set your app's locale once at startup — every date operation and every formatted date in your app picks it up automatically.
25
+
26
+ ---
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install @purple-brock/time
32
+ # or
33
+ yarn add @purple-brock/time
34
+ ```
35
+
36
+ **Requires Node.js 20 or higher.** The library will throw an error on startup if you are running an older version.
37
+
38
+ > `formatDuration` and `formatCountdown` require Node 24+. Everything else in the library works on Node 20+.
39
+
40
+ ---
41
+
42
+ ## How does it compare to other libraries?
43
+
44
+ | Feature | moment | date-fns | luxon | badger-time |
45
+ |---|:---:|:---:|:---:|:---:|
46
+ | No extra packages needed | ✗ | ✗ | ✅ | ✅ |
47
+ | Dates are never modified in place | ✗ | ✅ | ✅ | ✅ |
48
+ | No `new` keyword required | ✗ | ✅ | ✗ | ✅ |
49
+ | Uses the platform's built-in locale tools — no separate language files | ✗ | ✗ | ✅ | ✅ |
50
+ | Correct day/week differences across clock changes | ✗ | ✗ | ✅ | ✅ |
51
+ | Correct start/end of day across clock changes | ✗ | via date-fns-tz | ✅ | ✅ |
52
+ | Create a date from a local time (not UTC) | ✗ | ✗ | ✅ | ✅ |
53
+ | Fractional differences (e.g. 1.5 hours) | ✅ | ✅ | ✅ | ✅ |
54
+ | Inclusive range options | ✅ | partial | ✗ | ✅ |
55
+ | Correct weekends for every country | ✗ | ✗ | ✗ | ✅ |
56
+ | Correct week start for every country | ✗ | ✗ | ✗ | ✅ |
57
+ | 12h/24h clock chosen automatically by country | ✗ | ✗ | ✗ | ✅ |
58
+ | Business day calculations per country | ✗ | ✗ | ✗ | ✅ |
59
+ | Date range generator | ✗ | ✗ | ✗ | ✅ |
60
+ | Countdown that returns `null` for past dates | ✗ | ✗ | ✗ | ✅ |
61
+ | Duration formatting in the user's language | ✗ | ✗ | ✗ | ✅ |
62
+ | Reactive global locale (one call updates the whole app) | ✗ | ✗ | ✗ | ✅ |
63
+ | Bundle size (minified + gzipped) | ~72 KB | ~14 KB | ~23 KB | **~5.1 KB** |
64
+
65
+ ---
66
+
67
+ ## Quick start
68
+
69
+ ```ts
70
+ import { Badger, Sett, BadgerSettings } from "@purple-brock/time";
71
+
72
+ // Tell the library which country/language your app is running in.
73
+ // Do this once at startup — every date operation will use it automatically.
74
+ BadgerSettings.setLocale(new Intl.Locale("en-US"));
75
+ BadgerSettings.defaultTimeZone = "America/New_York";
76
+
77
+ // Add 7 days to a date
78
+ Badger("2026-06-15").add(7, "day").toISOString()
79
+ // "2026-06-22T00:00:00.000Z"
80
+
81
+ // Check if an order's delivery time is today
82
+ Badger(order.deliveryTime).isToday()
83
+
84
+ // Format a date for display — picks up locale automatically
85
+ Sett(order.deliveryTime).formatDate() // "06/15/2026, 03:30:45 PM"
86
+ Sett(order.deliveryTime).formatRelative() // "in 2 hours"
87
+ Sett(order.deliveryTime).formatTimeOnly() // "03:30:45 PM" (12-hour because en-US)
88
+
89
+ // Show a countdown — returns null automatically when the date has passed
90
+ Sett(new Date()).formatCountdown(new Date("2026-12-25"))
91
+ // "193 days, 4 hours, 22 minutes, and 11 seconds"
92
+ ```
93
+
94
+ ---
95
+
96
+ ## API
97
+
98
+ ### The three main exports
99
+
100
+ | Export | What it does |
101
+ |---|---|
102
+ | `Badger(date?)` | Date arithmetic — add/subtract time, compare dates, check if a date is today/past/future, generate ranges, business day calculations |
103
+ | `Sett(date?, locale?, options?)` | Formatting — display dates in the user's language, relative time ("2 hours ago"), countdown timers, sending dates to a backend |
104
+ | `BadgerSettings` | Global defaults — set the locale and time zone once, subscribe to locale changes |
105
+
106
+ No `new` keyword is needed anywhere. Every operation returns a new value — the original date is never changed.
107
+
108
+ ### TypeScript types
109
+
110
+ If you are using TypeScript, the factory names double as the instance types — so
111
+ `Badger` and `Sett` can be used as **both a value and a type**, with no separate
112
+ type import:
113
+
114
+ ```ts
115
+ import { Badger, Sett } from "@purple-brock/time";
116
+
117
+ function addBusinessDays(date: Badger, n: number): Badger {
118
+ return date.addBusinessDays(n);
119
+ }
120
+
121
+ function formatForDisplay(date: Badger): Sett {
122
+ return Sett(date.toDate());
123
+ }
124
+ ```
125
+
126
+ The explicit instance-type aliases `BadgerInstance` / `BadgerSettInstance` remain
127
+ exported and are equivalent — use whichever reads better:
128
+
129
+ ```ts
130
+ import type { BadgerInstance, BadgerSettInstance } from "@purple-brock/time";
131
+ ```
132
+
133
+ ---
134
+
135
+ ## Badger — working with dates
136
+
137
+ ### Creating a date
138
+
139
+ ```ts
140
+ Badger() // right now
141
+ Badger("2026-03-15T10:30:00") // a specific date and time (treated as UTC)
142
+ Badger("2026-03-15") // a specific date (local midnight)
143
+ Badger("2026-03-15T10:30:00Z") // explicit time zone offset — used as-is
144
+ Badger("2026-03-15T10:30:00+02:00") // with a +2 hour offset — used as-is
145
+ Badger(someDate) // copy of a JavaScript Date object
146
+ ```
147
+
148
+ **How strings are interpreted:**
149
+ - A date-only string like `"2026-03-15"` is anchored to **local midnight**, so its calendar day is correct in every time zone — reading `.day`, adding months, or building a range never drifts to the previous day for users west of UTC
150
+ - A string that already includes a time zone (ends in `Z` or `+02:00`) is used exactly as written
151
+ - Any other date-time string (no time zone) is treated as UTC
152
+
153
+ ---
154
+
155
+ ### Creating a date from local time
156
+
157
+ Use `Badger.fromLocal()` when a date and time come from a form or picker and represent the *user's local time*, not UTC.
158
+
159
+ ```ts
160
+ // The user selected "22 May 2026 at 00:15" in their local time zone
161
+ const dt = Badger.fromLocal("2026-05-22", "00:15");
162
+ dt.hour; // 0 — local midnight, not shifted
163
+ dt.dayOfWeek; // 5 — Friday in local time
164
+ ```
165
+
166
+ The date must be `"YYYY-MM-DD"` and the time must be `"HH:mm"`. An error is thrown if the format is wrong or if the date doesn't exist (e.g. `"2026-02-30"`).
167
+
168
+ ---
169
+
170
+ ### Finding the earliest or latest date
171
+
172
+ ```ts
173
+ const earliest = Badger.min(deadline, today, cutoff);
174
+ const latest = Badger.max(deadline, today, cutoff);
175
+ ```
176
+
177
+ Throws an error if called with no arguments or if any input is an invalid date. Filter out invalid dates first:
178
+
179
+ ```ts
180
+ const valid = dates.filter(d => d.isValid());
181
+ const earliest = valid.length ? Badger.min(...valid) : null;
182
+ ```
183
+
184
+ ---
185
+
186
+ ### Generating a list of dates (range)
187
+
188
+ Returns an array of dates from `start` up to (but not including) `end`. Useful for building calendars, chart labels, and report headers.
189
+
190
+ ```ts
191
+ // Every day in June 2026
192
+ Badger.range("2026-06-01", "2026-07-01", "day")
193
+ // → [Jun 1, Jun 2, … Jun 30] — 30 dates
194
+
195
+ // One entry per week across a quarter
196
+ Badger.range(startOfQuarter, endOfQuarter, "week")
197
+
198
+ // Monthly labels for a chart
199
+ Badger.range("2026-01-01", "2027-01-01", "month")
200
+ // → [Jan 1, Feb 1, Mar 1, … Dec 1]
201
+
202
+ // Strings, plain Dates, and Badger instances all work interchangeably
203
+ Badger.range(new Date("2026-06-01"), new Date("2026-06-08"), "day")
204
+ ```
205
+
206
+ > Monthly ranges re-anchor to the **start's day-of-month**, clamping per month rather than drifting. A range from January 31 yields Jan 31, Feb 28, Mar 31, Apr 30, May 31 — not a steady slide down to the 28th.
207
+
208
+ ---
209
+
210
+ ### Reading parts of a date
211
+
212
+ ```ts
213
+ const dt = Badger("2026-07-04T15:30:45.123Z");
214
+
215
+ dt.year // 2026
216
+ dt.month // 7 (1 = January, 12 = December)
217
+ dt.day // 4
218
+ dt.hour // 15
219
+ dt.minute // 30
220
+ dt.second // 45
221
+ dt.millisecond // 123
222
+ dt.dayOfWeek // 6 (0 = Sunday, 1 = Monday … 6 = Saturday, in local time)
223
+ dt.dayOfYear // 185 (which day of the year this is, 1–366)
224
+ dt.weekYear // 2026 (the ISO year for the week — may differ at year boundaries)
225
+ dt.quarter // 3 (1–4)
226
+ ```
227
+
228
+ ---
229
+
230
+ ### Adding and subtracting time
231
+
232
+ ```ts
233
+ dt.add(7, "day") // 7 days later
234
+ dt.subtract(1, "month") // 1 month earlier
235
+ dt.add(-2, "hour") // 2 hours earlier (negative numbers work)
236
+
237
+ // Apply several units at once
238
+ dt.add({ days: 7, hours: 2, minutes: 30 })
239
+ dt.subtract({ months: 1, days: 5 })
240
+ ```
241
+
242
+ Singular and plural both work: `"day"` and `"days"`, `"month"` and `"months"`, etc.
243
+
244
+ **Available units:** `"year"`, `"quarter"`, `"month"`, `"week"`, `"day"`, `"hour"`, `"minute"`, `"second"`, `"millisecond"`
245
+
246
+ > Adding months clamps to the last valid day of the target month — January 31 + 1 month becomes February 28 or 29, not March 3.
247
+
248
+ > Amounts must be **whole numbers**. A fractional amount (e.g. `add(1.5, "day")`) throws a `RangeError` rather than silently truncating to a whole unit — use a finer unit instead (`add(36, "hour")`).
249
+
250
+ ---
251
+
252
+ ### Changing one field
253
+
254
+ ```ts
255
+ dt.set("year", 2030) // returns a new date with just the year changed
256
+ dt.set("month", 12) // 1-indexed — 12 is December
257
+ dt.set("hour", 9)
258
+
259
+ // Clamps to the last valid day if needed
260
+ Badger("2026-01-31").set("month", 2) // → February 28
261
+ ```
262
+
263
+ Throws an error for impossible values (e.g. month 0, hour 25), fractional values (e.g. `set("hour", 1.5)`), or `NaN`.
264
+
265
+ ---
266
+
267
+ ### Business days
268
+
269
+ This is where badger-time is unique. It uses built-in browser/Node tools to know which days are weekends for every country in the world — no hard-coded rules, no extra data files.
270
+
271
+ **Weekend rules are looked up in this order:** explicit locale argument → `BadgerSettings.defaultLocale` → Saturday + Sunday.
272
+
273
+ ```ts
274
+ // Standard week (Saturday + Sunday weekend)
275
+ Badger("2026-06-12").isBusinessDay() // true (Friday)
276
+ Badger("2026-06-13").isBusinessDay() // false (Saturday)
277
+ Badger("2026-06-15").isWeekend() // false (Monday)
278
+ Badger("2026-06-20").isWeekend() // true (Saturday)
279
+
280
+ // Saudi Arabia uses a Friday + Saturday weekend
281
+ Badger("2026-06-12").isBusinessDay(new Intl.Locale("ar-SA")) // false (Friday is the weekend)
282
+ Badger("2026-06-14").isBusinessDay(new Intl.Locale("ar-SA")) // true (Sunday is a workday)
283
+
284
+ // Add business days — skips the correct weekends for the locale
285
+ // The count must be a whole number — 1.5 throws an error
286
+ Badger("2026-06-12").addBusinessDays(1) // Mon Jun 15 (skips Sat+Sun)
287
+ Badger("2026-06-12").addBusinessDays(1, new Intl.Locale("ar-SA")) // Sun Jun 14 (skips Fri+Sat)
288
+ Badger("2026-06-15").subtractBusinessDays(1) // Fri Jun 12
289
+
290
+ // Set the locale once — all business day methods pick it up
291
+ BadgerSettings.setLocale(new Intl.Locale("ar-SA"));
292
+ Badger("2026-06-12").isBusinessDay() // false — uses ar-SA automatically
293
+ Badger("2026-06-12").addBusinessDays(1) // Sun Jun 14
294
+ ```
295
+
296
+ No other date library does this natively. Other libraries just don't give a badger.
297
+
298
+ #### Public holidays (optional)
299
+
300
+ Weekends are derived automatically; **public holidays are not** — no locale or
301
+ region string can tell you that Dec 25 is a UK bank holiday but not a Saudi one,
302
+ and badger-time deliberately ships **no holiday data** (that would be a country-
303
+ by-country calendar to maintain forever). Instead every business-day method takes
304
+ an optional set of holiday dates that *you* supply — typically resolved from a
305
+ source like [`date-holidays`](https://www.npmjs.com/package/date-holidays), keyed
306
+ off the user's region. Holidays are then skipped exactly like weekends:
307
+
308
+ ```ts
309
+ const holidays = ["2026-12-25", "2026-12-28"]; // your dates, e.g. from date-holidays
310
+
311
+ Badger("2026-12-25").isBusinessDay(new Intl.Locale("en-GB"), holidays); // false
312
+ Badger("2026-12-24").addBusinessDays(1, new Intl.Locale("en-GB"), holidays); // Dec 29
313
+ Badger("2026-12-29").subtractBusinessDays(1, new Intl.Locale("en-GB"), holidays); // Dec 24
314
+ ```
315
+
316
+ Rules that keep this safe:
317
+
318
+ - **Strict ISO only.** Each entry must be a `YYYY-MM-DD` string. A typo, a
319
+ datetime, or a localized format (`25/12/2026`, `12/25/2026`) throws a
320
+ `RangeError` at the boundary rather than silently never matching — and so do
321
+ impossible dates (`2026-13-25`, `2026-02-30`). Convert any localized user input
322
+ to ISO *before* passing it in; the library never guesses `DD/MM` vs `MM/DD`.
323
+ - **Accepts an array or a `Set`** (anything `Iterable<string>`). Omitted or empty
324
+ means "weekends only" — fully backward compatible.
325
+ - **Local-date keyed.** A holiday matches the date's *local* calendar day,
326
+ consistent with the locale weekend evaluation (so it's correct near midnight in
327
+ any timezone, not just UTC).
328
+ - **Never hangs.** A pathological set that blocks every reachable day makes
329
+ `addBusinessDays`/`subtractBusinessDays` throw a `RangeError` instead of looping
330
+ forever.
331
+
332
+ ---
333
+
334
+ ### Countdown
335
+
336
+ Returns the time remaining until a future date as days, hours, minutes, and seconds. Returns `null` if the date has already passed — so you always know you need to handle the "expired" case.
337
+
338
+ ```ts
339
+ Badger("2026-12-25").countdown()
340
+ // { days: 193, hours: 4, minutes: 22, seconds: 11 }
341
+
342
+ Badger("2025-01-01").countdown()
343
+ // null — already passed
344
+
345
+ // Useful in tests: provide a fixed "now" instead of the real current time
346
+ Badger("2026-12-25").countdown(new Date("2026-06-01T00:00:00Z"))
347
+ // { days: 207, hours: 0, minutes: 0, seconds: 0 }
348
+
349
+ // Pair with Sett.formatCountdown() to get a display string
350
+ Sett(new Date()).formatCountdown(new Date("2026-12-25"))
351
+ // "193 days, 4 hours, 22 minutes, and 11 seconds"
352
+ // null if the date has passed
353
+ ```
354
+
355
+ ---
356
+
357
+ ### Start and end of a period
358
+
359
+ All operations use **local time** and never change the original date.
360
+
361
+ ```ts
362
+ dt.startOf("year") // 1 Jan, midnight (local time)
363
+ dt.startOf("quarter") // first day of the quarter, midnight
364
+ dt.startOf("month") // first of the month, midnight
365
+ dt.startOf("week") // Monday 00:00:00 (ISO 8601 standard)
366
+ dt.startOf("day") // midnight (00:00:00.000)
367
+ dt.startOf("hour") // top of the hour
368
+ dt.startOf("minute")
369
+ dt.startOf("second")
370
+
371
+ dt.endOf("year") // 31 Dec, 23:59:59.999 (local time)
372
+ dt.endOf("quarter")
373
+ dt.endOf("month") // last day of the month, 23:59:59.999
374
+ dt.endOf("week") // Sunday 23:59:59.999
375
+ dt.endOf("day") // 23:59:59.999
376
+ dt.endOf("hour")
377
+ dt.endOf("minute")
378
+ dt.endOf("second")
379
+ ```
380
+
381
+ ---
382
+
383
+ ### Comparing dates
384
+
385
+ ```ts
386
+ dt.isBefore(other)
387
+ dt.isAfter(other)
388
+ dt.isSameOrBefore(other)
389
+ dt.isSameOrAfter(other)
390
+ dt.isSame(other) // same exact millisecond
391
+ dt.isSame(other, "day") // same calendar day
392
+ dt.isSame(other, "month") // same month
393
+ dt.isSame(other, "week") // same ISO week (handles year boundaries correctly)
394
+ dt.isSame(other, "quarter")
395
+ ```
396
+
397
+ **Checking if a date is within a range:**
398
+
399
+ ```ts
400
+ // By default, the boundaries themselves are not included
401
+ date.isBetween(open, close)
402
+
403
+ // Include the start boundary
404
+ open.isBetween(open, close, { includeStart: true })
405
+
406
+ // Include the end boundary
407
+ close.isBetween(open, close, { includeEnd: true })
408
+
409
+ // Include both
410
+ date.isBetween(open, close, { includeStart: true, includeEnd: true })
411
+ ```
412
+
413
+ Throws an error if either boundary is invalid, or if `start` is after `end`.
414
+
415
+ **Clamping a date to a range:**
416
+
417
+ ```ts
418
+ // Returns the closest valid date within the range
419
+ Badger("2025-01-01").clamp(min, max) // → min (too early, clamped to start)
420
+ Badger("2027-01-01").clamp(min, max) // → max (too late, clamped to end)
421
+ Badger("2026-06-15").clamp(min, max) // → unchanged (already within range)
422
+ ```
423
+
424
+ **Quick helpers:**
425
+
426
+ ```ts
427
+ dt.isToday() // is this date today (in local time)?
428
+ dt.isYesterday()
429
+ dt.isTomorrow()
430
+ dt.isPast() // is this date before right now?
431
+ dt.isFuture() // is this date after right now?
432
+ dt.isWeekend() // is this a weekend day? (respects BadgerSettings.defaultLocale)
433
+ dt.isWeekday() // is this a working day? (respects BadgerSettings.defaultLocale)
434
+ ```
435
+
436
+ All comparisons accept either a `Badger` instance or a plain JavaScript `Date`.
437
+
438
+ ---
439
+
440
+ ### Difference between two dates
441
+
442
+ ```ts
443
+ a.diff(b, "hour") // 1 — whole hours, rounded down
444
+ a.diff(b, "hour", true) // 1.5 — fractional (pass true as the third argument)
445
+ b.diff(a, "minute") // -90 — negative when b is before a
446
+ ```
447
+
448
+ Day and week differences account for daylight saving time correctly — they count calendar days, not raw hours.
449
+
450
+ ---
451
+
452
+ ### Other utilities
453
+
454
+ ```ts
455
+ dt.toISOString() // "2026-03-15T10:30:00.000Z" — UTC instant; throws if invalid
456
+ dt.toLocalISODate() // "2026-03-15" — LOCAL calendar date; use this, never toISOString().slice(0,10)
457
+ dt.toDate() // a plain JavaScript Date (a copy — changes won't affect the Badger)
458
+ dt.toUnixTimestamp() // seconds since 1 Jan 1970 (floored)
459
+ dt.toMillisecondTimestamp() // milliseconds since 1 Jan 1970
460
+ dt.toObject() // { year, month, day, hour, minute, second, millisecond }
461
+ dt.clone() // independent copy
462
+ dt.isValid() // false if the date is corrupt or unparseable — always check this
463
+ dt.getDaysInMonth() // how many days are in the date's month (28–31)
464
+ dt.daysInYear() // 365 or 366 (NaN if invalid)
465
+ dt.getWeekNumber() // ISO 8601 week number (1–53)
466
+ dt.isLeapYear() // is this a leap year?
467
+ ```
468
+
469
+ ---
470
+
471
+ ### Invalid dates
472
+
473
+ Always call `isValid()` before using a `Badger` that was created from user input or an external source. When a date is invalid, methods return safe fallback values rather than crashing:
474
+
475
+ | Type of method | Examples | Returns when invalid |
476
+ |---|---|---|
477
+ | Yes/no checks | `isWeekend`, `isBusinessDay`, `isLeapYear`, `isPast`, `isBefore`… | `false` |
478
+ | Number values | `year`, `month`, `day`, `diff`, `toUnixTimestamp`… | `NaN` |
479
+ | Countdown | `countdown()` | `null` |
480
+ | Arithmetic | `add`, `subtract`, `startOf`, `endOf`, `clone` | An invalid Badger (further chaining stays invalid) |
481
+ | Business day iteration | `addBusinessDays`, `subtractBusinessDays` | Invalid clone — no looping |
482
+ | `toISOString()` / `toLocalISODate()` | — | Throws an error (there is no meaningful string to produce) |
483
+ | `Sett` formatters | `formatDate`, `formatTimeOnly`, `getDayName`… | Throw an error |
484
+
485
+ ```ts
486
+ const bad = Badger("not-a-date");
487
+ bad.isValid() // false — check this first
488
+ bad.isWeekend() // false
489
+ bad.countdown() // null
490
+ bad.daysInYear() // NaN
491
+ bad.addBusinessDays(5) // returns an invalid clone — no looping happens
492
+
493
+ // toISOString is the one that throws — it can't produce a string for an invalid date
494
+ bad.toISOString() // Error: Badger.toISOString(): date is not valid…
495
+
496
+ // Badger.min and Badger.max also throw on invalid input — filter first
497
+ const dates = [Badger("2026-01-01"), Badger("bad"), Badger("2026-06-15")];
498
+ const valid = dates.filter(d => d.isValid());
499
+ Badger.min(...valid) // Badger("2026-01-01")
500
+ ```
501
+
502
+ ---
503
+
504
+ ## Sett — displaying and formatting dates
505
+
506
+ `Sett` turns a date into a string your users can read. It uses the platform's built-in formatting tools, so it automatically uses the right format for the user's country — no extra language files, no manual configuration per locale.
507
+
508
+ Defaults to British English (`en-GB`) and London time unless you override it with `BadgerSettings`.
509
+
510
+ ### Creating a Sett
511
+
512
+ String inputs follow the same rules as `Badger()` — bare date-time strings are treated as UTC.
513
+
514
+ ```ts
515
+ Sett() // now, global defaults
516
+ Sett(someDate) // specific date, global defaults
517
+ Sett("2026-03-15T10:30:00") // string input → treated as UTC
518
+ Sett("2026-03-15") // date-only → LOCAL midnight
519
+ Sett(someDate, new Intl.Locale("sv-SE")) // override the locale
520
+ Sett(someDate, new Intl.Locale("sv-SE"), { // override locale + formatting options
521
+ timeZone: "Europe/Stockholm",
522
+ year: "numeric",
523
+ month: "2-digit",
524
+ day: "2-digit"
525
+ })
526
+ Sett(someDate, "sv-SE") // a plain locale string works too
527
+ ```
528
+
529
+ **Hermes caveat:** `formatRelative` needs `Intl.RelativeTimeFormat`, and `formatCountdown` /
530
+ `formatDuration` need `Intl.DurationFormat`. Hermes (React Native) doesn't document either, so treat
531
+ those three as **web/Node only**. Everything else is Hermes-safe. `formatRelative` rounds to the
532
+ nearest month (31 Jan → 1 Mar is "last month").
533
+
534
+ **Time zone default:** a `Sett` with no `timeZone` option uses `BadgerSettings.defaultTimeZone`, else
535
+ the **device's own zone** (it used to fall back to Europe/London).
536
+
537
+ **React Native / Hermes:** Hermes has no `Intl.Locale`. badger-time never needs one: the
538
+ default locale is the plain tag `"en-GB"`, and anything that wants week info (`isWeekend`,
539
+ `startOfWeek`, `getDaysOfTheWeek`, the business-day methods) or the 12/24-hour clock
540
+ (`formatTimeOnly`) reads it safely, falling back to **Monday-first, Saturday + Sunday weekend**
541
+ when `Intl.Locale` is missing. On Hermes, pass locales as **strings** (`"sv-SE"`) rather than
542
+ `new Intl.Locale(...)`. Every locale parameter accepts either form (`LocaleInput`).
543
+
544
+ ### Displaying a date
545
+
546
+ ```ts
547
+ const s = Sett(new Date("2026-03-15T14:30:45Z"));
548
+
549
+ s.formatDate() // "15/03/2026, 14:30:45" (en-GB format, 24-hour)
550
+ s.formatDateOnly() // "15/03/2026"
551
+ s.formatTimeOnly() // "14:30:45" (24-hour for en-GB, chosen automatically)
552
+ s.getDayName() // "Sunday" (pass "short" → "Sun", "narrow" → "S")
553
+ s.getMonthName() // "March" (pass "short" → "Mar", "narrow" → "M")
554
+ s.getDaysOfTheWeek() // ["Monday", "Tuesday", …] — starts on the locale's first day
555
+ s.getMonthsOfTheYear() // ["January", …, "December"]
556
+ ```
557
+
558
+ ### 12-hour vs 24-hour clocks — automatic
559
+
560
+ `formatTimeOnly()` reads the locale and picks the right clock format. You don't need to configure anything.
561
+
562
+ ```ts
563
+ // US English — 12-hour clock
564
+ Sett(new Date("2026-06-15T15:30:45Z"), new Intl.Locale("en-US"), { timeZone: "UTC" }).formatTimeOnly()
565
+ // "03:30:45 PM"
566
+
567
+ // German — 24-hour clock
568
+ Sett(new Date("2026-06-15T15:30:45Z"), new Intl.Locale("de-DE"), { timeZone: "UTC" }).formatTimeOnly()
569
+ // "15:30:45"
570
+
571
+ // Arabic (Saudi Arabia) — 12-hour in Arabic numerals
572
+ Sett(new Date("2026-06-15T15:30:45Z"), new Intl.Locale("ar-SA"), { timeZone: "UTC" }).formatTimeOnly()
573
+ ```
574
+
575
+ ### Locale-aware calendar helpers
576
+
577
+ `isWeekend()` and `startOfWeek()` use the platform's built-in country data — correct for every country worldwide. Both use local time, so they give the right answer even near midnight in non-UTC time zones.
578
+
579
+ ```ts
580
+ // Saudi Arabia: Friday and Saturday are the weekend
581
+ Sett(new Date("2026-06-05"), new Intl.Locale("ar-SA")).isWeekend() // true (Friday)
582
+ Sett(new Date("2026-06-07"), new Intl.Locale("ar-SA")).isWeekend() // false (Sunday is a workday)
583
+
584
+ // Where does the week start?
585
+ Sett(new Date("2026-06-10T12:00:00"), new Intl.Locale("en-US")).startOfWeek()
586
+ // → Midnight on Sunday Jun 7 (US weeks start Sunday)
587
+
588
+ Sett(new Date("2026-06-10T12:00:00"), new Intl.Locale("en-GB")).startOfWeek()
589
+ // → Midnight on Monday Jun 8 (UK weeks start Monday)
590
+ ```
591
+
592
+ ### Relative time
593
+
594
+ Shows how long ago or how far in the future a date is — like "3 hours ago" or "in 2 days".
595
+
596
+ ```ts
597
+ s.formatRelative() // "3 hours ago" / "in 2 days" / "last month"
598
+ s.formatRelative(anchorDate) // relative to a specific date instead of now
599
+ ```
600
+
601
+ Month and year thresholds are based on elapsed days, not calendar month boundaries. A 7-day gap that crosses a month boundary shows as "last week", not "last month". 28+ days in different months shows as months.
602
+
603
+ Throws an error if the date is invalid — never silently shows garbage.
604
+
605
+ ### Duration formatting
606
+
607
+ Shows a duration in the user's natural language. Uses the platform's built-in tools — no hand-rolled formatting.
608
+
609
+ > Requires Node 24+, Chrome 127+, Firefox 127+, or Safari 17.4+. If you call this on an older environment it throws — but the rest of `Sett` continues to work fine on Node 20+.
610
+
611
+ ```ts
612
+ Sett(new Date(), new Intl.Locale("en-GB")).formatDuration({ hours: 3, minutes: 22 })
613
+ // "3 hours and 22 minutes"
614
+
615
+ Sett(new Date(), new Intl.Locale("de-DE")).formatDuration({ hours: 2, minutes: 30 })
616
+ // "2 Stunden und 30 Minuten" (German)
617
+
618
+ Sett(new Date(), new Intl.Locale("fr-FR")).formatDuration({ days: 1, hours: 4 })
619
+ // "1 jour et 4 heures" (French)
620
+
621
+ // Calculate elapsed time and format in one go
622
+ const minutes = Badger(startTime).diff(Badger(), "minute");
623
+ Sett(new Date()).formatDuration({ minutes })
624
+ // "47 minutes"
625
+ ```
626
+
627
+ ### Countdown display
628
+
629
+ Combines the countdown calculation with duration formatting in a single call. Returns `null` if the date has passed.
630
+
631
+ ```ts
632
+ Sett(new Date()).formatCountdown(new Date("2026-12-25"))
633
+ // "193 days, 4 hours, 22 minutes, and 11 seconds"
634
+
635
+ Sett(new Date()).formatCountdown(new Date("2025-01-01"))
636
+ // null — date is in the past
637
+ ```
638
+
639
+ Live countdown timer in React Native — updates every second, switches to `"Expired"` when done:
640
+
641
+ ```tsx
642
+ function CountdownLabel({ endsAt }: { endsAt: string }) {
643
+ const [label, setLabel] = useState<string | null>(null);
644
+
645
+ useEffect(() => {
646
+ const tick = () => setLabel(
647
+ Sett(new Date()).formatCountdown(new Date(endsAt)) ?? "Expired"
648
+ );
649
+ tick();
650
+ const id = setInterval(tick, 1000);
651
+ return () => clearInterval(id);
652
+ }, [endsAt]);
653
+
654
+ return <Text>{label}</Text>;
655
+ }
656
+ ```
657
+
658
+ ### Sending dates to a backend server
659
+
660
+ These methods convert a date to a standard format (UTC ISO 8601) suitable for sending to a server. Start/end of day calculations account for daylight saving time — the correct UTC offset is resolved for that specific date, not approximated.
661
+
662
+ ```ts
663
+ s.formatForBackend() // "2026-03-15T10:30:45.000Z"
664
+ s.formatForBackendToStartOfDay() // midnight in the configured time zone → UTC
665
+ s.formatForBackendToEndOfDay() // 23:59:59.999 in the configured time zone → UTC
666
+ ```
667
+
668
+ ```ts
669
+ // London is UTC+1 in summer (BST) — midnight on 15 Jul = 23:00 UTC on 14 Jul
670
+ Sett("2026-07-15T12:00:00Z").formatForBackendToStartOfDay()
671
+ // "2026-07-14T23:00:00.000Z"
672
+
673
+ // London is UTC+0 in winter (GMT) — midnight on 15 Jan = 00:00 UTC
674
+ Sett("2026-01-15T12:00:00Z").formatForBackendToStartOfDay()
675
+ // "2026-01-15T00:00:00.000Z"
676
+ ```
677
+
678
+ ---
679
+
680
+ ## BadgerSettings — global defaults
681
+
682
+ Set your app's locale and time zone once at startup. Every `Sett()` and `Badger()` call will inherit these automatically, unless you override them at the call site.
683
+
684
+ ```ts
685
+ import { BadgerSettings } from "@purple-brock/time";
686
+
687
+ BadgerSettings.defaultLocale = new Intl.Locale("sv-SE");
688
+ BadgerSettings.defaultTimeZone = "Europe/Stockholm";
689
+
690
+ // No locale argument needed anywhere in the app
691
+ Sett(order.deliveryTime).formatDate() // formatted in sv-SE / Stockholm
692
+ Sett(order.deliveryTime).isWeekend() // uses Swedish weekend rules
693
+ Sett(order.deliveryTime).formatTimeOnly() // 24-hour (Sweden uses 24-hour clocks)
694
+
695
+ // Badger picks up the locale too
696
+ Badger("2026-06-12").isBusinessDay() // uses Swedish weekend definition
697
+ Badger("2026-06-12").addBusinessDays(3) // skips Swedish weekends
698
+
699
+ // You can always override the global locale for a single call
700
+ Sett(date, new Intl.Locale("fr-FR")).formatDate() // → French format, ignores global
701
+ ```
702
+
703
+ ### Reactive language switching
704
+
705
+ `setLocale()` updates the global locale and immediately notifies all active listeners. One call, entire app updates.
706
+
707
+ ```ts
708
+ // Listen for locale changes
709
+ const unsub = BadgerSettings.subscribe(locale => {
710
+ console.log("Switched to", locale.baseName);
711
+ });
712
+
713
+ BadgerSettings.setLocale(new Intl.Locale("fr-FR"));
714
+ // → "Switched to fr-FR"
715
+
716
+ unsub(); // stop listening — important to clean up in components
717
+ ```
718
+
719
+ ### React Native
720
+
721
+ ```tsx
722
+ // localeContext.tsx — wrap your app with this so any component can get the current locale
723
+ import { createContext, useContext, useState, useEffect } from "react";
724
+ import { BadgerSettings } from "@purple-brock/time";
725
+
726
+ const LocaleContext = createContext<Intl.Locale>(new Intl.Locale("en-GB"));
727
+
728
+ export function LocaleProvider({ children }) {
729
+ const [locale, setLocale] = useState(
730
+ BadgerSettings.defaultLocale ?? new Intl.Locale("en-GB")
731
+ );
732
+
733
+ // Subscribe to locale changes — unsubscribes automatically on unmount
734
+ useEffect(() => BadgerSettings.subscribe(setLocale), []);
735
+
736
+ return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;
737
+ }
738
+
739
+ export const useLocale = () => useContext(LocaleContext);
740
+ ```
741
+
742
+ ```tsx
743
+ // A language switcher button — one press updates every date in the app
744
+ <Button
745
+ title="Français"
746
+ onPress={() => BadgerSettings.setLocale(new Intl.Locale("fr-FR"))}
747
+ />
748
+ ```
749
+
750
+ ```tsx
751
+ // Any component — automatically re-renders when the locale changes
752
+ function OrderCard({ order }) {
753
+ const locale = useLocale();
754
+ return (
755
+ <View>
756
+ <Text>{Sett(order.deliveryTime, locale).formatDate()}</Text>
757
+ <Text>{Sett(order.deliveryTime, locale).formatRelative()}</Text>
758
+ </View>
759
+ );
760
+ }
761
+ ```
762
+
763
+ ### React (web)
764
+
765
+ ```tsx
766
+ // Restore the saved locale when the app loads
767
+ useEffect(() => {
768
+ const saved = localStorage.getItem("locale");
769
+ if (saved) BadgerSettings.setLocale(new Intl.Locale(saved));
770
+ }, []);
771
+ ```
772
+
773
+ ### Vue
774
+
775
+ ```ts
776
+ export const appLocale = ref(new Intl.Locale("en-GB"));
777
+ BadgerSettings.subscribe(locale => { appLocale.value = locale; });
778
+
779
+ // In a component
780
+ const formatted = computed(() => Sett(order.deliveryTime, appLocale.value).formatDate());
781
+ ```
782
+
783
+ ### Vanilla JS
784
+
785
+ ```ts
786
+ BadgerSettings.subscribe(locale => {
787
+ document.querySelectorAll<HTMLElement>("[data-timestamp]").forEach(el => {
788
+ el.textContent = Sett(new Date(el.dataset.timestamp!), locale).formatRelative();
789
+ });
790
+ });
791
+ ```
792
+
793
+ ### Resetting (for tests)
794
+
795
+ Clears the locale, time zone, and all subscribers. Essential in tests to prevent settings from leaking between test cases.
796
+
797
+ ```ts
798
+ afterEach(() => BadgerSettings.reset());
799
+ ```
800
+
801
+ ---
802
+
803
+ ## Real-world examples
804
+
805
+ ### Delivery app — order card
806
+
807
+ ```ts
808
+ const order = { dueAt: "2026-06-15T18:30:00Z", locale: new Intl.Locale("en-GB") };
809
+
810
+ // Is the order due today?
811
+ Badger(order.dueAt).isToday()
812
+
813
+ // How many minutes until it's due?
814
+ Badger(order.dueAt).diff(Badger(), "minute")
815
+
816
+ // Display the due time in the customer's locale (12h/24h chosen automatically)
817
+ Sett(order.dueAt, order.locale).formatTimeOnly()
818
+
819
+ // Show a countdown — or null if already past due
820
+ Sett(new Date(), order.locale).formatCountdown(new Date(order.dueAt))
821
+ // "3 hours and 22 minutes"
822
+ ```
823
+
824
+ ### HR system — notice periods
825
+
826
+ ```ts
827
+ // Add 30 business days, skipping the correct weekends for the employee's country
828
+ const lastDay = Badger(terminationDate).addBusinessDays(30, employeeLocale);
829
+
830
+ // Count remaining working days
831
+ const workingDays = Badger.range(Badger(), lastDay, "day")
832
+ .filter(d => d.isBusinessDay(employeeLocale)).length;
833
+
834
+ // Display the last working day
835
+ Sett(lastDay.toDate(), employeeLocale).formatDateOnly()
836
+ // "Monday 27 July 2026"
837
+ ```
838
+
839
+ ### Calendar UI — month grid
840
+
841
+ ```ts
842
+ // Get every day in June 2026
843
+ const days = Badger.range(
844
+ Badger("2026-06-01").startOf("month"),
845
+ Badger("2026-06-01").endOf("month").add(1, "day"),
846
+ "day"
847
+ );
848
+
849
+ // Week start and day names vary by country
850
+ const weekStart = Sett(new Date(), userLocale).startOfWeek();
851
+ const weekDayNames = Sett(new Date(), userLocale).getDaysOfTheWeek("short");
852
+ // en-GB: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
853
+ // en-US: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
854
+ ```
855
+
856
+ ### Event countdown — live timer
857
+
858
+ ```tsx
859
+ function EventBanner({ eventDate }: { eventDate: string }) {
860
+ const [label, setLabel] = useState<string | null>(null);
861
+
862
+ useEffect(() => {
863
+ const tick = () => setLabel(
864
+ Sett(new Date()).formatCountdown(new Date(eventDate)) ?? "Event has started!"
865
+ );
866
+ tick();
867
+ const id = setInterval(tick, 1000);
868
+ return () => clearInterval(id);
869
+ }, [eventDate]);
870
+
871
+ return <Text>{label}</Text>;
872
+ }
873
+ ```
874
+
875
+ ### Backend query — this week's data
876
+
877
+ ```ts
878
+ const locale = new Intl.Locale("en-GB");
879
+ const tz = "Europe/London";
880
+ const opts = {
881
+ timeZone: tz,
882
+ year: "numeric" as const, month: "2-digit" as const,
883
+ day: "2-digit" as const, hour: "2-digit" as const,
884
+ minute: "2-digit" as const, second: "2-digit" as const,
885
+ hour12: false
886
+ };
887
+
888
+ // Get the correct start and end of this week, accounting for clock changes
889
+ const weekStart = Sett(new Date(), locale, opts);
890
+ const weekEnd = Sett(Badger().endOf("week").toDate(), locale, opts);
891
+
892
+ const from = weekStart.formatForBackendToStartOfDay();
893
+ const to = weekEnd.formatForBackendToEndOfDay();
894
+
895
+ await fetch(`/api/orders?from=${from}&to=${to}`);
896
+ ```
897
+
898
+ ### Comparing dates across time zones
899
+
900
+ ```ts
901
+ // Server returns UTC timestamps — badger-time handles them correctly
902
+ const serverDate = Badger("2026-06-15T22:00:00Z");
903
+
904
+ // A user in Central European Summer Time (UTC+2) sees this as midnight on Jun 16
905
+ serverDate.isToday() // correct for the user's local time zone
906
+ serverDate.dayOfWeek // correct local day of week
907
+ serverDate.isWeekend() // correct local weekend check
908
+
909
+ // The backend always receives UTC, regardless of the user's time zone
910
+ Sett(serverDate.toDate()).formatForBackend()
911
+ // "2026-06-15T22:00:00.000Z"
912
+ ```
913
+
914
+ ---
915
+
916
+ ## Quality
917
+
918
+ - **817 tests** (8 skipped on Node < 24, where `formatDuration`/`formatCountdown` are unavailable) — every public method, every unit, edge cases including clock change transitions, year boundaries, ISO week rollovers, and locale-specific weekend/week-start logic, plus type-level tests that lock the public type surface
919
+ - **100% coverage** — statements, branches, functions, and lines
920
+ - **Four time zone suites** — tests run under Europe/Berlin, America/New_York, America/Los_Angeles, and UTC to verify calculations in UTC+ and UTC− zones. New York and Los Angeles cover spring-forward (March) and fall-back (November). Los Angeles also guards the date-only-string path west of UTC, where a mis-anchored parse would drift the calendar day. Kolkata (UTC+5:30, no clock changes) covers the half-hour offset across all months and month-ends
921
+ - **Structured audit** — every function was audited for bad inputs, missing inputs, and unexpected values. All findings fixed
922
+ - **No runtime dependencies** — the bundle is the entire library
923
+
924
+ ```bash
925
+ npm test # full suite + coverage
926
+ npm run test:cet # Europe/Berlin (CET/CEST) clock change suite
927
+ npm run test:ny # America/New_York (EST/EDT) clock change suite
928
+ npm run test:la # America/Los_Angeles — date-only west-of-UTC guard
929
+ ```
930
+
931
+ ---
932
+
933
+ ## License
934
+
935
+ [MIT](LICENSE) © 2026 Luke Price (The Purple Brock).