daymath 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +255 -6
  2. package/index.d.ts +54 -2
  3. package/index.js +670 -66
  4. package/package.json +24 -7
package/README.md CHANGED
@@ -5,10 +5,11 @@
5
5
  [![codecov](https://codecov.io/gh/leemr/daymath/graph/badge.svg)](https://codecov.io/gh/leemr/daymath)
6
6
  [![license](https://img.shields.io/npm/l/daymath.svg)](./LICENSE)
7
7
  [![node](https://img.shields.io/node/v/daymath.svg)](https://www.npmjs.com/package/daymath)
8
+ [![bundle](https://img.shields.io/bundlejs/size/daymath)](https://bundlejs.com/?q=daymath)
8
9
 
9
10
  **ISO 8601** calendar day math. **date-fns-shaped** names. **Temporal.PlainDate** under the hood.
10
11
 
11
- No `Date`. No time zones. No silent “local now.” ISO 8601 ❤️
12
+ No `Date`. No time zones. No silent “local now”. ISO 8601 ❤️
12
13
 
13
14
  [**Play →**](https://leemr.github.io/daymath/) · [npm](https://www.npmjs.com/package/daymath) · [Changelog](./CHANGELOG.md) · [Contributing](./CONTRIBUTING.md) · [FUTURE](./FUTURE.md)
14
15
 
@@ -27,7 +28,7 @@ npm install @leemr/daymath
27
28
  Most people should keep using **`daymath` on npmjs**.
28
29
 
29
30
  ```js
30
- import { addDays, addMonths, differenceInDays, isSameDay } from 'daymath'
31
+ import { day, addDays, addMonths, differenceInDays, isSameDay } from 'daymath'
31
32
 
32
33
  addDays('2026-08-06', 1) // '2026-08-07'
33
34
  addMonths('2026-01-31', 1) // '2026-02-28'
@@ -35,6 +36,67 @@ differenceInDays('2026-08-06', '2026-08-01') // 5
35
36
  isSameDay('2026-08-06', '2026-08-06') // true
36
37
  ```
37
38
 
39
+ `day` is the way in. It turns a moment into a day, and it is the only function
40
+ that reads a clock.
41
+
42
+ Start here. No arguments means today, in UTC.
43
+
44
+ ```js
45
+ day() // '2026-08-08'
46
+ addDays(day(), 2) // '2026-08-10'
47
+ ```
48
+
49
+ Then hand it whatever you already have. It converts; it never stores what you gave it.
50
+
51
+ ```js
52
+ day(row.createdAt) // '2026-08-07' a Date
53
+ day(1761616161771) // '2025-10-28' epoch milliseconds
54
+ day('1999-01-01T00:00:00Z') // '1999-01-01' an ISO timestamp
55
+ day('2026-05-05') // '2026-05-05' already a day
56
+ ```
57
+
58
+ Name a zone when the answer depends on one.
59
+
60
+ ```js
61
+ day('Asia/Tokyo') // '2026-08-09' today in Tokyo
62
+ day(row.createdAt, 'America/New_York') // '2026-08-06' the evening before
63
+ day('2026-08-08T23:00:00Z', 'Asia/Tokyo') // '2026-08-09' same instant, next day
64
+ day(zdt.toString()) // the zone in the string wins
65
+ ```
66
+
67
+ Both defaults are **stated**, not assumed.
68
+ UTC is still not your day for part of every day — it runs ahead of `America/New_York`
69
+ for 16.7% of the day, and behind `Asia/Tokyo` for 37.5% — so name your zone when
70
+ that matters.
71
+
72
+ Four rules worth knowing:
73
+
74
+ - A **number is epoch milliseconds**, exactly as `new Date(n)` reads it. A seconds
75
+ timestamp read as milliseconds lands in 1970, with no error. daymath states the
76
+ unit rather than sniffing it, because no rule can separate the two: 13 digits
77
+ means milliseconds for 2001–2286 and seconds for the year 275760, and both are
78
+ inside the supported range.
79
+ - A **day carries no time**, so a zone does not apply to one.
80
+ `day('2026-05-05', 'Asia/Tokyo')` is `'2026-05-05'`.
81
+ - A **timestamp carrying `Z` or an offset** names an exact instant, so it is read
82
+ as a moment.
83
+ - A **string carrying a `[Zone]`** names its own zone, so it keeps its own day and
84
+ the UTC default never applies. `day(zdt.toString())` equals `zdt.toPlainDate()`.
85
+ A browser sending `'2026-08-08T20:00:00-04:00[America/New_York]'` gets back the
86
+ 8th, which is the date its user saw. Passing `tz` as well throws.
87
+ - A string with **neither** is refused. `'2026-08-08T12:00'` names no instant and
88
+ no zone, so daymath would have to pick one, and it will not pick for you. Name
89
+ the zone — `'2026-08-08T12:00[America/New_York]'` — and it is accepted.
90
+ - **`'11/12/2026'` is refused.** Nobody can tell November from December in it.
91
+
92
+ A lone string takes one of four roles, decided in this order: a day, a zoned time,
93
+ an instant, then a zone. The zone test is by **shape** — an IANA name, which carries no `:`, or
94
+ a bare offset such as `+05:30`. Temporal's own zone grammar cannot decide the role:
95
+ it accepts a whole timestamp and reads the zone out of it, so
96
+ `day('1999-01-01T00:00:00Z')` would answer today. That grammar also differs between
97
+ implementations — `'T12:00:00Z'` is a zone to native Temporal and not to the
98
+ polyfill — which would make the answer depend on the runtime.
99
+
38
100
  ```bash
39
101
  node examples/basic.mjs # from a clone
40
102
  ```
@@ -46,9 +108,14 @@ node examples/basic.mjs # from a clone
46
108
  | In | Out |
47
109
  |----|-----|
48
110
  | `YYYY-MM-DD` or expanded `±YYYYYY-MM-DD` | same forms (Temporal `toString`) |
49
- | or `Temporal.PlainDate` | string |
111
+ | or `Temporal.PlainDate`, from any implementation | string |
112
+
113
+ A `Date` **throws** everywhere except `day()`, including in `isValid`.
114
+ `isValid('asdf')` → `false`.
50
115
 
51
- `Date` **throws** (including `isValid`). `isValid('asdf')` `false`.
116
+ `day()` is the single door a `Date` comes through, and it makes you name the zone
117
+ or take the stated UTC default. Nothing carries a zone past that point, and
118
+ nothing gives you a `Date` back.
52
119
 
53
120
  Range: `-271821-04-19` … `+275760-09-13` — the `Temporal.PlainDate` limit, roughly ±10⁸ days from the epoch. A day outside it throws a `RangeError`.
54
121
 
@@ -66,6 +133,8 @@ Range: `-271821-04-19` … `+275760-09-13` — the `Temporal.PlainDate` limit, r
66
133
 
67
134
  ## API
68
135
 
136
+ **Start here** — `day`
137
+
69
138
  **Parse** — `parse` · `format` · `isValid`
70
139
 
71
140
  **Add/sub** — Days · Weeks · Months · Years · Quarters
@@ -86,11 +155,191 @@ Amounts are finite integers.
86
155
 
87
156
  ## Temporal
88
157
 
89
- Uses global `Temporal` when present; otherwise [`temporal-polyfill`](https://www.npmjs.com/package/temporal-polyfill).
158
+ Uses global `Temporal` when present; otherwise [`temporal-polyfill`](https://www.npmjs.com/package/temporal-polyfill),
159
+ which does that resolution itself — so on a runtime with native Temporal the polyfill steps aside.
160
+
161
+ A `Temporal.PlainDate` from *any* implementation is accepted — native, the bundled polyfill,
162
+ or a second copy of it in the same dependency tree. daymath reads its ISO day and builds its
163
+ own instance, so it never depends on `instanceof` agreeing across copies. The common case is
164
+ `Temporal.Now.plainDateISO()`: daymath has no `today()` on purpose, so that is where a caller
165
+ gets one.
166
+
167
+ ### Calendars
168
+
169
+ Temporal can put a calendar on a `PlainDate`. The same *day* then carries different numbers:
170
+
171
+ | calendar | year | month | day | `toString()` | daymath |
172
+ |---|---|---|---|---|---|
173
+ | `iso8601` | 2026 | 1 | 31 | `2026-01-31` | accepted |
174
+ | `buddhist` | **2569** | 1 | 31 | `2026-01-31[u-ca=buddhist]` | **accepted** |
175
+ | `roc` | **115** | 1 | 31 | `2026-01-31[u-ca=roc]` | **accepted** |
176
+ | `japanese` | 2026 | 1 | 31 | `2026-01-31[u-ca=japanese]` | **accepted** |
177
+ | `gregory` | 2026 | 1 | 31 | `2026-01-31[u-ca=gregory]` | **accepted** |
178
+ | `hebrew` | 5786 | **5** | **13** | `2026-01-31[u-ca=hebrew]` | refused |
179
+ | `chinese` | 2025 | **13** | **13** | `2026-01-31[u-ca=chinese]` | refused |
180
+
181
+ **daymath accepts a calendar that only relabels the year, and refuses one that renumbers.** The
182
+ line is measured at runtime, not held as a list, so a calendar CLDR adds later needs no code
183
+ change here. Two conditions, both required, on nine probe dates spanning 1900 to 2100:
184
+
185
+ 1. **Month and day equal the ISO fields.** A month *count* would be wrong: `hebrew` is lunisolar,
186
+ so it has 12 months in 2025 and 13 in 2027.
187
+ 2. **The year offset is constant.** Two probe pairs straddle a Japanese era boundary, because an
188
+ era change inside one ISO year is what separates a label from a renumbering.
189
+
190
+ For the accepting family only the year label moves, so every export still answers honestly, and
191
+ the annotation rides along:
192
+
193
+ ```js
194
+ getYear('2026-01-31[u-ca=buddhist]') // 2569, not 2026
195
+ getMonth('2026-01-31[u-ca=buddhist]') // 1
196
+ addDays('2026-01-31[u-ca=buddhist]', 1) // '2026-02-01[u-ca=buddhist]'
197
+ setYear('2026-01-31[u-ca=buddhist]', 2570) // '2027-01-31[u-ca=buddhist]'
198
+ getYear('2026-01-31[u-ca=roc]') // 115
199
+ getYear('2026-01-31[u-ca=japanese]') // 2026
200
+ ```
201
+
202
+ A renumbering calendar is refused, and the message names the calendar and the way out. A calendar
203
+ this runtime cannot build at all gets its own message, so a typo does not read as a renumbering
204
+ calendar:
205
+
206
+ ```js
207
+ getMonth('2026-01-31[u-ca=hebrew]')
208
+ // RangeError: daymath: date calendar "hebrew" renumbers months or days, so daymath
209
+ // cannot answer a day in it (convert with withCalendar('iso8601'))
210
+
211
+ getYear('2026-01-31[u-ca=buddhst]')
212
+ // RangeError: daymath: date calendar "buddhst" is not a calendar this runtime knows
213
+ // (convert with withCalendar('iso8601'))
214
+
215
+ format(hebrewDate.withCalendar('iso8601')) // '2026-01-31'
216
+ ```
217
+
218
+ **A day is the same day whatever its year is labelled, so a mixed pair measures rather than
219
+ throwing.** Temporal's own `since` refuses this with `Mismatched calendars`, and its `equals`
220
+ compares the calendar as well as the day. Neither matters to a day count, so daymath normalises
221
+ both sides for measurement. Only the exports that read or write a field honour the label:
222
+
223
+ ```js
224
+ differenceInDays('2026-03-01', '2026-01-31[u-ca=buddhist]') // 29
225
+ isEqual('2026-01-31[u-ca=buddhist]', '2026-01-31') // true
226
+ ```
227
+
228
+ **The object form is a different day, and that is Temporal's rule, not daymath's.** A string's
229
+ date part is always ISO; the annotation changes how fields are *read*, never how the string
230
+ *parses*:
231
+
232
+ ```js
233
+ Temporal.PlainDate.from('2026-01-31[u-ca=buddhist]') // ISO 2026-01-31, .year 2569
234
+ Temporal.PlainDate.from({year: 2026, month: 1, day: 31,
235
+ calendar: 'buddhist'}) // ISO 1483-01-31, .year 2026
236
+ ```
237
+
238
+ 543 years apart. daymath takes strings and `PlainDate` objects, never the fields form, so it
239
+ inherits Temporal's rule and stays consistent with it.
240
+
241
+ `[u-ca=iso8601]` is accepted and dropped rather than carried. Temporal writes it itself for
242
+ `toString({ calendarName: 'always' })`, and daymath already answers in it:
243
+
244
+ ```js
245
+ const written = plainDate.toString({ calendarName: 'always' }) // '2026-01-31[u-ca=iso8601]'
246
+ getYear(written) // 2026
247
+ ```
248
+
249
+ A calendar is judged where it is **applied**. On a day string it is, and with a `[Zone]` bracket
250
+ it is. Without a bracket the string names an `Instant`, which has no year, month or day for a
251
+ calendar to renumber, so the annotation is inert.
252
+
253
+ **`day()` accepts every annotation the other exports accept, and drops it from the result.** It is
254
+ the normaliser: a moment converts to a plain ISO day, and so does a day. One rule, three input
255
+ shapes:
256
+
257
+ ```js
258
+ day('2026-08-08T20:00:00Z[u-ca=buddhist]') // '2026-08-08'
259
+ day('2026-08-08T12:00[America/New_York][u-ca=buddhist]') // '2026-08-08'
260
+ day('2026-08-08[u-ca=buddhist]') // '2026-08-08'
261
+ day('1999-06-06[Asia/Tokyo][u-ca=hebrew]') // throws
262
+
263
+ parse('2026-08-08[u-ca=buddhist]') // '2026-08-08[u-ca=buddhist]' parse keeps it
264
+ ```
265
+
266
+ `parse` validates and preserves. `day()` normalises. Every other export carries the annotation,
267
+ because the caller asked for that numbering.
268
+
269
+ ### Working in a non-ISO calendar
270
+
271
+ **Do not pass the calendar's own year as a bare ISO year.** This is the one way to get a wrong
272
+ answer with no error, and it is why the annotation is not decoration.
273
+
274
+ Buddhist 2567 is ISO 2024, which is a leap year. The Buddhist year is ISO + 543, and 543 mod 4 is
275
+ 3, so the leap years land in different places:
276
+
277
+ | call | bare `2567` | annotated, the real Buddhist 2567 |
278
+ |---|---|---|
279
+ | `isLeapYear` | `false` | `true` |
280
+ | `getDaysInMonth` for February | `28` | `29` |
281
+ | `parse('…-02-29')` | throws `invalid date` | `2024-02-29[u-ca=buddhist]` |
282
+ | `addDays(Feb 28, 1)` | `2567-03-01` | `2024-02-29[u-ca=buddhist]` |
283
+
284
+ **The two disagree in 49 of the 101 Buddhist years from 2500 to 2600.** Nothing throws on the
285
+ bare form, and `addDays('2567-02-28', 1)` answering `2567-03-01` looks reasonable, so a date lands
286
+ one day early for the rest of that year. Every other month is identical, because February is the
287
+ only month whose length varies.
288
+
289
+ **A `Date` cannot help you here, because a `Date` has no calendar.** It is one number of
290
+ milliseconds. `getUTCFullYear()` is always Gregorian, so a Thai user's `Date` already holds `2026`.
291
+ The `2569` exists only at display time, when `Intl` formats it:
292
+
293
+ ```js
294
+ new Intl.DateTimeFormat('th-TH-u-ca-buddhist', {dateStyle: 'short'}).format(d) // '8/8/69'
295
+ ```
296
+
297
+ So the recipe is:
298
+
299
+ 1. From a `Date` or a timestamp, call `day(…)`. You get a plain ISO day.
300
+ 2. To work in Buddhist years, annotate that day: `'2026-08-08[u-ca=buddhist]'`. Now `getYear` is
301
+ `2569`, `isLeapYear` is right, and every export carries the annotation through.
302
+ 3. **From a Buddhist year *number*, use Temporal's fields form.** This is the one place the
303
+ fields/string asymmetry helps rather than traps:
304
+
305
+ ```js
306
+ Temporal.PlainDate.from({year: 2569, month: 8, day: 8, calendar: 'buddhist'}).toString()
307
+ // '2026-08-08[u-ca=buddhist]' <- and daymath accepts the object directly too
308
+ ```
309
+
310
+ 4. For display, use `Intl`. daymath does no localised formatting.
311
+
312
+ One more trap in the same family: `'2569-08-08[u-ca=buddhist]'` is a valid string, and its
313
+ `getYear` is **3112**. The date part is ISO 2569, and the annotation adds 543 on top.
314
+
315
+ Supporting these calendars needs `temporal-polyfill/full`, because the base build cannot construct
316
+ them where the runtime has no native Temporal. That costs **4.2 kB gzip**, and it is what makes
317
+ the answers identical on every runtime rather than only on the ones with native Temporal.
318
+
319
+ Error messages quote no Temporal text, because implementations word the same failure
320
+ differently. The original error is on `cause`.
321
+
322
+ Behaviour is checked against a recorded baseline on Node, Deno (which ships **native**
323
+ Temporal) and Bun — every export, `npm run test:runtimes`. The exact call count lives
324
+ in `scripts/cross-runtime.baseline.json`, which is the only place it cannot go stale.
325
+
326
+ ## Requirements
327
+
328
+ ESM only. **Node 20.19+, or 22.12+.**
329
+
330
+ That floor is `require()`, not `import`. Node's `require(esm)` landed in 20.19 and 22.12, so
331
+ those are the versions where `require('daymath')` works. `engines` states the range exactly,
332
+ including the gap at 22.0–22.11. `temporal-polyfill` is ESM-only too, so a CJS build of daymath
333
+ would not escape this. Bundlers and browsers are unaffected. A Jest consumer needs a
334
+ `transformIgnorePatterns` entry, because Jest does not use Node's resolution.
335
+
336
+ Node 18 was supported through 0.3.0 and is dropped here. It went end-of-life in April 2025.
90
337
 
91
338
  ## Types & tests
92
339
 
93
- Plain JS + `index.d.ts` (no compile step). CI runs on Node 18, 20, 22, 24, and 26; the coverage gate runs on 24.
340
+ Plain JS + `index.d.ts` (no compile step). CI runs on Node 20, 22, 24, and 26; the
341
+ coverage gate runs on 26, which is also the version in `.node-version` and the one used
342
+ to publish. The matrix still proves the floor.
94
343
 
95
344
  ```bash
96
345
  npm test
package/index.d.ts CHANGED
@@ -8,6 +8,18 @@ import type { Temporal } from 'temporal-polyfill'
8
8
  * Usable range is the Temporal `PlainDate` range `-271821-04-19` …
9
9
  * `+275760-09-13`; a day outside it throws a `RangeError`.
10
10
  * `Date` is rejected at runtime (TypeError).
11
+ *
12
+ * A `[u-ca=…]` calendar annotation is accepted when the calendar only relabels
13
+ * the year, and it rides along into the result:
14
+ * `getYear('2026-01-31[u-ca=buddhist]')` is `2569` and `addDays` of it is
15
+ * `'2026-02-01[u-ca=buddhist]'`. Today that admits `buddhist`, `roc`,
16
+ * `japanese` and `gregory`. A calendar that renumbers months or days, such as
17
+ * `hebrew` or `chinese`, throws a `RangeError`. The line is measured at runtime
18
+ * rather than held as a list, so no code names a calendar.
19
+ *
20
+ * Measurement ignores the label, because a day is the same day whatever its
21
+ * year is called: `differenceInDays` and `isEqual` normalise both operands, so a
22
+ * mixed pair answers instead of throwing `Mismatched calendars`.
11
23
  */
12
24
  export type DayInput = string | Temporal.PlainDate
13
25
 
@@ -26,6 +38,40 @@ export type WeekOptions = {
26
38
  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
27
39
  }
28
40
 
41
+ /**
42
+ * The calendar day of a moment, in a zone. The way in.
43
+ *
44
+ * Both defaults are stated: the moment is now, the zone is UTC. A number is
45
+ * read as epoch **milliseconds**, exactly as `new Date(n)` reads it. An ISO day
46
+ * string is already a day, so a zone does not apply to it. An ISO timestamp
47
+ * carrying `Z` or an offset names an exact instant, so it is read as a moment.
48
+ *
49
+ * A string carrying a `[Zone]` annotation names its own zone, so it answers its
50
+ * own civil day and the UTC default never applies. Passing `tz` as well throws.
51
+ *
52
+ * `'11/12/2026'` is refused, because nobody can tell November from December in
53
+ * it. `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath
54
+ * would have to pick one. Name the zone and it is accepted.
55
+ *
56
+ * A lone string takes one of four roles, in this order: a day, a zoned time, an
57
+ * instant, then a zone. The zone test is by shape — an IANA name, which carries
58
+ * no `:`, or a bare offset — so a timestamp can never be read as a zone.
59
+ *
60
+ * **day() is the normaliser, and that is the whole rule: a moment converts to a
61
+ * plain ISO day, and so does a day.** A `[u-ca=…]` annotation is accepted
62
+ * wherever the other exports accept it, and then dropped from the result, so
63
+ * `day` never returns `[u-ca=…]`. Every other export carries it. `parse`
64
+ * validates and preserves; `day` normalises.
65
+ *
66
+ * A calendar that renumbers months or days is still refused where it is
67
+ * applied, which means with a `[Zone]` bracket and on a day string.
68
+ */
69
+ export function day(tz?: string): string
70
+ export function day(
71
+ moment: Date | number | DayInput | null | undefined,
72
+ tz?: string,
73
+ ): string
74
+
29
75
  /**
30
76
  * True for a valid daymath day string / PlainDate.
31
77
  * Invalid strings → false. `Date` → throws TypeError (not a quiet false).
@@ -81,11 +127,17 @@ export function endOfWeek(date: DayInput, options?: WeekOptions): string
81
127
  export function differenceInDays(dateLeft: DayInput, dateRight: DayInput): number
82
128
  export function differenceInWeeks(dateLeft: DayInput, dateRight: DayInput): number
83
129
  export function differenceInMonths(dateLeft: DayInput, dateRight: DayInput): number
84
- export function differenceInCalendarMonths(dateLeft: DayInput, dateRight: DayInput): number
130
+ export function differenceInCalendarMonths(
131
+ dateLeft: DayInput,
132
+ dateRight: DayInput,
133
+ ): number
85
134
  export function differenceInYears(dateLeft: DayInput, dateRight: DayInput): number
86
135
  export function differenceInCalendarYears(dateLeft: DayInput, dateRight: DayInput): number
87
136
  export function differenceInQuarters(dateLeft: DayInput, dateRight: DayInput): number
88
- export function differenceInCalendarQuarters(dateLeft: DayInput, dateRight: DayInput): number
137
+ export function differenceInCalendarQuarters(
138
+ dateLeft: DayInput,
139
+ dateRight: DayInput,
140
+ ): number
89
141
 
90
142
  export function isBefore(date: DayInput, dateToCompare: DayInput): boolean
91
143
  export function isAfter(date: DayInput, dateToCompare: DayInput): boolean
package/index.js CHANGED
@@ -1,7 +1,95 @@
1
- /** daymath — calendar date math (ISO 8601 day). date-fns-shaped. No Date / time zones. */
2
- import { Temporal as TemporalPolyfill } from 'temporal-polyfill'
1
+ /** daymath — calendar date math (ISO 8601 day). date-fns-shaped. No Date. No time zones. */
2
+ // The selection is HERE on purpose, and it must not be removed again.
3
+ //
4
+ // `temporal-polyfill`'s base entry resolves `globalThis.Temporal || bundled` itself, so importing
5
+ // it gave native Temporal for free. But that base build can construct only iso8601 and gregory, so
6
+ // it cannot serve the calendar rule below. `temporal-polyfill/full` can construct sixteen — and its
7
+ // entry is a bare re-export with NO selection, so importing it alone silenced native Temporal on
8
+ // every runtime, including Node 26 and Deno. Round 1 of a review caught that.
9
+ //
10
+ // Round 2 then caught the naive repair. A global `Temporal` is NOT necessarily native: an app doing
11
+ // `import 'temporal-polyfill/global'` installs the BASE build, and the polyfill's own README calls
12
+ // that the most common entry point. Selecting it blindly cost daymath three of its four calendars
13
+ // on Node 20 and 22, which have no native Temporal, and the error blamed the caller's calendar
14
+ // rather than naming the capability loss. Import order decided it, which is worse than a wrong
15
+ // answer. So the candidate is PROBED, not trusted.
16
+ //
17
+ // The probe names no calendar. It asks the runtime for one, which keeps the "method, not a list"
18
+ // rule that governs the calendar surface below.
19
+ //
20
+ // Measured: the selection costs 17 B gzip, because the polyfill ships either way — a bundler cannot
21
+ // know at build time whether the runtime has Temporal, which is why FUTURE.md records dynamic
22
+ // import as a dead end. Native and the bundled full build agree on every calendar daymath touches:
23
+ // 464 date/calendar pairs over the four accepted calendars from 1900 to 2100, plus the eleven
24
+ // refused ones, with zero disagreements.
25
+ //
26
+ // Native matters for three reasons beyond taste. daymath's contract is Temporal's behaviour, and
27
+ // native IS Temporal. The deno CI lane exists to prove the polyfill and the standard agree, and it
28
+ // cannot prove that if daymath runs the polyfill there. And an engine-level Temporal fix then
29
+ // reaches callers with no release from here.
30
+ import { Temporal as bundledTemporal } from 'temporal-polyfill/full'
3
31
 
4
- const Temporal = globalThis.Temporal ?? TemporalPolyfill
32
+ /**
33
+ * Every calendar this runtime names. Read once, lowercased, because BCP-47 keys are
34
+ * case-insensitive while a `Map` key is not.
35
+ *
36
+ * This set is also what bounds the verdict cache below. It is closed and small — 18 ids on Node 26
37
+ * — so an id the runtime does not name is refused without ever being stored.
38
+ *
39
+ * `Intl.supportedValuesOf` is ES2022 and present on every runtime in `engines`.
40
+ */
41
+ const RUNTIME_CALENDARS = new Set(
42
+ Intl.supportedValuesOf('calendar').map((id) => id.toLowerCase()),
43
+ )
44
+
45
+ /**
46
+ * Can this Temporal build a calendar beyond the two every build has?
47
+ *
48
+ * The cast is needed because `Temporal` is not declared on `globalThis` in the type space, and
49
+ * daymath deliberately installs no global of its own.
50
+ * @param {unknown} candidate
51
+ * @returns {candidate is typeof bundledTemporal}
52
+ */
53
+ /*
54
+ * Every branch here turns on `globalThis.Temporal`, which is read once at module load. A test in
55
+ * this process cannot change it after the fact, so none of these branches is reachable from the
56
+ * suite. They are covered three other ways, and each is real:
57
+ *
58
+ * 1. `test.js` spawns a CHILD process that installs the BASE polyfill global and then imports
59
+ * daymath. That is the exact scenario this function exists for, and it asserts the four
60
+ * calendars survive. A child's execution does not count toward this file's coverage.
61
+ * 2. The Node 20 and bun CI lanes have no native Temporal, so they take the fallback for real.
62
+ * 3. `npm run test:runtimes` proves every lane answers identically.
63
+ */
64
+ /* c8 ignore start */
65
+ function buildsExoticCalendars(candidate) {
66
+ const temporal = /** @type {typeof bundledTemporal | undefined} */ (candidate)
67
+ if (temporal?.PlainDate?.from === undefined) return false
68
+ // Ask the runtime for a calendar rather than naming one. The two excluded here are the pair every
69
+ // build can construct, so anything else proves the exotic calendar data is present.
70
+ const exotic = [...RUNTIME_CALENDARS].find((id) => id !== 'iso8601' && id !== 'gregory')
71
+ if (exotic === undefined) return true // nothing to prove it against
72
+ try {
73
+ temporal.PlainDate.from('2026-01-31').withCalendar(exotic)
74
+ return true
75
+ } catch {
76
+ return false
77
+ }
78
+ }
79
+ /* c8 ignore stop */
80
+
81
+ // The fallback needs a runtime whose global Temporal is absent or not calendar-capable, so a process
82
+ // with native Temporal cannot reach it. It is covered for real by the Node 20 and bun CI lanes, and
83
+ // `npm run test:runtimes` proves those lanes answer identically to the native ones.
84
+ // One cast, because `Temporal` is not declared on `globalThis` in the type space and daymath
85
+ // deliberately installs no global of its own.
86
+ const globalTemporal = /** @type {{Temporal?: unknown}} */ (globalThis).Temporal
87
+
88
+ // The fallback needs a runtime whose global Temporal is absent or not calendar-capable, so a process
89
+ // with native Temporal cannot reach it. It is covered for real by the Node 20 and bun CI lanes, and
90
+ // `npm run test:runtimes` proves those lanes answer identically to the native ones.
91
+ /* c8 ignore next */
92
+ const Temporal = buildsExoticCalendars(globalTemporal) ? globalTemporal : bundledTemporal
5
93
 
6
94
  /**
7
95
  * ISO 8601 calendar day string:
@@ -13,10 +101,79 @@ const Temporal = globalThis.Temporal ?? TemporalPolyfill
13
101
  * the epoch). Outside it, Temporal throws and we re-throw with the `daymath:`
14
102
  * prefix.
15
103
  */
16
- const ISO_DAY =
17
- /^(?:[+-]\d{6}|\d{4})-\d{2}-\d{2}$/
104
+ const ISO_DAY = /^(?:[+-]\d{6}|\d{4})-\d{2}-\d{2}$/u
105
+
106
+ /**
107
+ * Temporal's calendar annotation, `[u-ca=…]` or the critical `[!u-ca=…]`.
108
+ * Returns what it is attached to, and the calendar it names, or `null`.
109
+ *
110
+ * String operations, not a regex, and the reason is measured. The annotation can
111
+ * sit behind another one — `'…[America/New_York][u-ca=buddhist]'` — so the head
112
+ * may itself contain `[`. A pattern that allows that needs `^(.*)\[…\]$`, whose
113
+ * `.*` backtracks: `'[u-ca='.repeat(64000)` cost 9.0 s, growing with the square
114
+ * of the input. `lastIndexOf` answers the same question in one pass. CodeQL
115
+ * `js/polynomial-redos` caught the regex form; timing it confirmed the report.
116
+ * @param {string} text
117
+ * @returns {{ head: string, calendar: string } | null}
118
+ */
119
+ function calendarAnnotation(text) {
120
+ if (!text.endsWith(']')) return null
121
+ const open = text.lastIndexOf('[')
122
+ if (open === -1) return null
123
+ const inner = text.slice(open + 1, -1)
124
+ const body = inner.startsWith('!') ? inner.slice(1) : inner
125
+ if (!body.startsWith('u-ca=')) return null
126
+ const calendar = body.slice(5)
127
+ // A `]` inside means the brackets do not nest as they appear, so this is not
128
+ // an annotation. `'[u-ca=[u-ca=[u-ca=x]]]'` reads as the calendar `x]]` here
129
+ // and as a malformed day everywhere else, which is what it is.
130
+ if (calendar === '' || calendar.includes(']')) return null
131
+ return { head: text.slice(0, open), calendar }
132
+ }
133
+
134
+ /**
135
+ * True when the string carries a time-zone annotation, `[Zone]` or `[!Zone]`.
136
+ * It is the one annotation with no `=`, which separates it from `[u-ca=…]`, and
137
+ * Temporal writes it first, so only the leading bracket can be one.
138
+ *
139
+ * Asking "does the string name a zone?" is a different question from "does it
140
+ * resolve?". A string can name one and still fail: an offset that disagrees with
141
+ * the zone, or a misspelled name. Both must be errors, never a silent fallback
142
+ * to the UTC default.
143
+ *
144
+ * String operations again. An unanchored `/\[!?[^\]=]+\]/` restarts at every
145
+ * position: `'[!'.repeat(64000)` cost 6.8 s, and it is the same defect an
146
+ * earlier commit removed from the calendar pattern.
147
+ * @param {string} text
148
+ */
149
+ function hasZoneAnnotation(text) {
150
+ const open = text.indexOf('[')
151
+ if (open === -1) return false
152
+ const close = text.indexOf(']', open)
153
+ if (close === -1) return false
154
+ return !text.slice(open + 1, close).includes('=')
155
+ }
18
156
 
19
- /** @typedef {string | Temporal.PlainDate} DayInput */
157
+ /**
158
+ * A time zone, by shape: an IANA name, or a bare offset.
159
+ *
160
+ * A name is letter-led, slash-separated segments of letters, digits, `_`, `+`,
161
+ * `-` and `.`. It carries no `:`, which is what keeps an ISO time out. The
162
+ * `(?![Tt]\d)` guard rejects the compact spelling `T120000Z`, which has no `:`
163
+ * to catch it. Both matter: `T12:00:00Z` is a zone to native Temporal and not
164
+ * to temporal-polyfill, so letting either through splits the answer by runtime.
165
+ *
166
+ * Verified against every zone this runtime knows: 0 of 418 rejected, plus the
167
+ * aliases `UTC`, `GMT`, `US/Eastern`, `Asia/Calcutta` and `Etc/GMT+5`.
168
+ */
169
+ const ZONE_LIKE =
170
+ /^(?:(?![Tt]\d)[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)*|[+-]\d{2}(?::?\d{2})?)$/u
171
+
172
+ // `Temporal` is a const now, not an imported namespace, so it cannot carry types. `PlainDate` has
173
+ // seven use sites, so a typedef earns itself. `Instant` and `ZonedDateTime` have one each and are
174
+ // inlined, because measured the typedef plus its use is longer than the inline form.
175
+ /** @typedef {import('temporal-polyfill').Temporal.PlainDate} PlainDate */
176
+ /** @typedef {string | PlainDate} DayInput */
20
177
  /**
21
178
  * @typedef {object} Interval
22
179
  * @property {DayInput} start
@@ -31,11 +188,182 @@ const ISO_DAY =
31
188
 
32
189
  // ─── core conversion ───────────────────────────────────────────────
33
190
 
191
+ /**
192
+ * The bare ISO day inside a string, or `null` if there is not one.
193
+ *
194
+ * An annotation is dropped here, never judged. `supportedCalendar` owns that rule, and the two do
195
+ * not run in a fixed order: `toPlainDate` judges first, and `day()` calls this first. That is safe
196
+ * precisely because this function strips the annotation without reading it.
197
+ *
198
+ * One predicate, because `toPlainDate` and `day()` both ask this question. They
199
+ * asked it separately once, and day() alone then refused a string that every
200
+ * other export accepted.
201
+ * @param {string} text
202
+ * @returns {string | null}
203
+ */
204
+ function bareDay(text) {
205
+ const annotated = calendarAnnotation(text)
206
+ const bare = annotated ? annotated.head : text
207
+ return ISO_DAY.test(bare) ? bare : null
208
+ }
209
+
210
+ /**
211
+ * Nine probe dates for the calendar rule below.
212
+ *
213
+ * They span 1900 to 2100, they sit in different months, and two pairs straddle a Japanese era
214
+ * boundary: 1989-01-07/08 is Showa 64 into Heisei 1, and 2019-04-30/05-01 is Heisei 31 into
215
+ * Reiwa 1. An era change *inside* one ISO year is the case a single probe cannot see, and it is
216
+ * exactly what separates a year label from a year renumbering.
217
+ */
218
+ const CALENDAR_PROBES = [
219
+ '1900-01-01',
220
+ '1900-07-01',
221
+ '1989-01-07',
222
+ '1989-01-08',
223
+ '2019-04-30',
224
+ '2019-05-01',
225
+ '2026-01-31',
226
+ '2026-12-31',
227
+ '2100-06-15',
228
+ ]
229
+
230
+ /** @type {Map<string, {offset: number} | {reason: 'unknown' | 'renumbers'}>} */
231
+ const calendarRuleCache = new Map()
232
+
233
+ /**
234
+ * Is this a calendar the runtime actually names?
235
+ *
236
+ * **This is what bounds `calendarRuleCache`, and it is a memory-exhaustion fix.** The cache keys on
237
+ * whatever sits between `[u-ca=` and `]`, which is caller input.
238
+ *
239
+ * A shape guard was tried first and was only half a fix. It bounded the key LENGTH and not the
240
+ * entry COUNT, so any BCP-47-shaped string still bought a permanent entry: 800,000 distinct
241
+ * guard-passing ids retained 76.8 MB, growing linearly and never released. Case made it worse,
242
+ * because the shape test ignored case while the `Map` key did not, so one name had 256 keys.
243
+ *
244
+ * A closed set fixes both at once. `RUNTIME_CALENDARS` holds 18 ids on Node 26, so entries can
245
+ * never exceed what the runtime has, and a lowercased key collapses the case variants. It also
246
+ * needs no length cap and no pattern, which removes the last quadratic-regex risk from this file.
247
+ *
248
+ * The quiet path was `isValid`, which swallows the `RangeError`, so a loop raised nothing and
249
+ * logged nothing.
250
+ * @param {string} lowerId
251
+ */
252
+ function runtimeKnowsCalendar(lowerId) {
253
+ return RUNTIME_CALENDARS.has(lowerId)
254
+ }
255
+
256
+ /**
257
+ * Measure whether a calendar only relabels the year.
258
+ *
259
+ * **This is a method, not a list.** Nothing here names a calendar, so a calendar CLDR adds later
260
+ * is admitted or refused by the same measurement, with no code change. Today it admits
261
+ * `buddhist` (+543), `roc` (−1911), `japanese` (0) and `gregory` (0), and refuses the other
262
+ * eleven the runtime knows.
263
+ *
264
+ * Two conditions, both required on every probe:
265
+ *
266
+ * 1. **Month and day equal the ISO fields.** A month-count test would be wrong: `hebrew` is
267
+ * lunisolar, so `monthsInYear` is 13 in 2024, 12 in 2025 and 13 in 2027. Comparing the fields
268
+ * is correct in every year.
269
+ * 2. **The year offset is constant.** `japanese` and `roc` pass condition 1, and Temporal reports
270
+ * a continuous year for both, so both are pure labels — `roc` 1900 is −11, not "12 before".
271
+ * Reading the year from `Intl` instead would fail here, because `Intl` reports the *era* year:
272
+ * Reiwa 8 and B.R.O.C. 12. Temporal is the reference, so Temporal is what this asks.
273
+ *
274
+ * The verdict is cached per calendar. It cannot change while the process runs.
275
+ * @param {string} calendar
276
+ */
277
+ function calendarRule(calendar) {
278
+ const cached = calendarRuleCache.get(calendar)
279
+ if (cached !== undefined) return cached
280
+ /** @type {{offset: number} | {reason: 'unknown' | 'renumbers'}} */
281
+ let verdict = { reason: 'renumbers' }
282
+ try {
283
+ const offsets = new Set()
284
+ for (const probe of CALENDAR_PROBES) {
285
+ const iso = Temporal.PlainDate.from(probe)
286
+ const dated = iso.withCalendar(calendar)
287
+ if (dated.month !== iso.month || dated.day !== iso.day) {
288
+ offsets.clear()
289
+ break
290
+ }
291
+ offsets.add(dated.year - iso.year)
292
+ }
293
+ // The offset is the discriminant — `'offset' in verdict` is what accepts — and the number
294
+ // itself is kept unread on purpose, so a debugger shows WHY a calendar passed.
295
+ if (offsets.size === 1) verdict = { offset: [...offsets][0] }
296
+ } catch {
297
+ // The runtime cannot build this calendar at all, which is a different message for the caller.
298
+ verdict = { reason: 'unknown' }
299
+ }
300
+ calendarRuleCache.set(calendar, verdict)
301
+ return verdict
302
+ }
303
+
304
+ /**
305
+ * Accept a calendar that only relabels the year, and refuse one that renumbers.
306
+ *
307
+ * Checked on the *string*, before any parse, so the outcome cannot depend on where the annotation
308
+ * sits. Returns the calendar for `toPlainDate` to carry, or `undefined` for plain ISO.
309
+ *
310
+ * `[u-ca=iso8601]` returns `undefined` rather than carrying: Temporal writes it itself for
311
+ * `toString({ calendarName: 'always' })`, and daymath already answers in it, so there is nothing
312
+ * to relabel.
313
+ * @param {string} text
314
+ * @param {string} label
315
+ * @returns {string | undefined}
316
+ */
317
+ function supportedCalendar(text, label) {
318
+ const annotated = calendarAnnotation(text)
319
+ // A bare `return` rather than `return undefined`: oxlint's no-useless-undefined forbids the
320
+ // explicit spelling, and "nothing to carry" is what both of these mean.
321
+ if (annotated === null) return
322
+ const { calendar } = annotated
323
+ // Lowercased once. BCP-47 keys are case-insensitive, and this is also the cache key, so the case
324
+ // variants of one name must collapse to one entry rather than 256.
325
+ const lowerId = calendar.toLowerCase()
326
+ if (lowerId === 'iso8601') return
327
+ // Membership FIRST, so an id the runtime does not name is never stored. See runtimeKnowsCalendar.
328
+ // One throw, not two: an unknown id and a renumbering one differ only in the reason.
329
+ const verdict = runtimeKnowsCalendar(lowerId)
330
+ ? calendarRule(lowerId)
331
+ : /** @type {const} */ ({ reason: 'unknown' })
332
+ if ('offset' in verdict) return calendar
333
+ const why =
334
+ verdict.reason === 'unknown'
335
+ ? 'is not a calendar this runtime knows'
336
+ : 'renumbers months or days, so daymath cannot answer a day in it'
337
+ // The id is sliced because it is caller input, so a megabyte in cannot become a megabyte of error.
338
+ throw new RangeError(
339
+ `daymath: ${label} calendar ${JSON.stringify(calendar.slice(0, 40))} ${why} (convert with withCalendar('iso8601'))`,
340
+ )
341
+ }
342
+
343
+ /**
344
+ * True for a `Temporal.PlainDate` from *any* implementation: native, the
345
+ * bundled polyfill, or a second copy of it in the same dependency tree.
346
+ * `instanceof` recognises only one of those. Every Temporal puts this tag on
347
+ * `PlainDate.prototype` as a non-writable property, so it is the portable brand.
348
+ * @param {unknown} value
349
+ * @returns {value is PlainDate} a predicate, so callers narrow
350
+ */
351
+ function isPlainDate(value) {
352
+ return Object.prototype.toString.call(value) === '[object Temporal.PlainDate]'
353
+ }
354
+
34
355
  /**
35
356
  * Reject Date and non-calendar values. Accept ISO day string or PlainDate.
357
+ *
358
+ * A PlainDate becomes its ISO day string first, so every input reaches one
359
+ * validation path and daymath owns the resulting instance.
360
+ *
361
+ * A calendar annotation is judged by `supportedCalendar` and then CARRIED, so `getYear` answers
362
+ * the caller's own number: `2026-01-31[u-ca=buddhist]` reads 2569, not 2026. The rule and the
363
+ * reasons live on `supportedCalendar`; this function only applies the verdict.
36
364
  * @param {unknown} value
37
365
  * @param {string} label
38
- * @returns {Temporal.PlainDate}
366
+ * @returns {PlainDate}
39
367
  */
40
368
  function toPlainDate(value, label = 'date') {
41
369
  if (value instanceof Date) {
@@ -43,29 +371,48 @@ function toPlainDate(value, label = 'date') {
43
371
  `daymath: Date is not allowed for ${label} (pass ISO 8601 day string)`,
44
372
  )
45
373
  }
46
- if (typeof value === 'string') {
47
- if (!ISO_DAY.test(value)) {
48
- throw new RangeError(
49
- `daymath: ${label} must be ISO 8601 day YYYY-MM-DD or ±YYYYYY-MM-DD (got ${JSON.stringify(value)})`,
50
- )
51
- }
52
- try {
53
- return Temporal.PlainDate.from(value)
54
- } catch (err) {
55
- throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(value)}`, {
56
- cause: err,
57
- })
58
- }
374
+ // Named `text`, not `day`: a local `day` would shadow the exported day().
375
+ const text = isPlainDate(value) ? value.toString() : value
376
+ if (typeof text !== 'string') {
377
+ throw new TypeError(
378
+ `daymath: ${label} must be ISO 8601 day string or Temporal.PlainDate`,
379
+ )
59
380
  }
60
- if (value instanceof Temporal.PlainDate) {
61
- return value
381
+ // Before the shape check: an annotated day is well formed, not malformed.
382
+ const calendar = supportedCalendar(text, label)
383
+ const bare = bareDay(text)
384
+ if (bare === null) {
385
+ throw new RangeError(
386
+ `daymath: ${label} must be ISO 8601 day YYYY-MM-DD or ±YYYYYY-MM-DD (got ${JSON.stringify(text)})`,
387
+ )
62
388
  }
63
- throw new TypeError(
64
- `daymath: ${label} must be ISO 8601 day string or Temporal.PlainDate`,
65
- )
389
+ try {
390
+ const plain = Temporal.PlainDate.from(bare)
391
+ // The annotation rides along, so getYear answers 2569 for a Buddhist day and every
392
+ // returned string keeps the calendar the caller named.
393
+ return calendar === undefined ? plain : plain.withCalendar(calendar)
394
+ } catch (err) {
395
+ throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(text)}`, {
396
+ cause: err,
397
+ })
398
+ }
399
+ }
400
+
401
+ /**
402
+ * The same day in ISO, for measurement only.
403
+ *
404
+ * Temporal refuses `since` and `until` across two calendars with `Mismatched calendars`, and its
405
+ * `equals` compares the calendar as well as the day. Neither matters to a day count: a day is the
406
+ * same day whatever the year is labelled, so daymath normalises and answers. Only the exports that
407
+ * *read* or *write* a field honour the label.
408
+ * @param {PlainDate} plain
409
+ * @returns {PlainDate}
410
+ */
411
+ function isoOf(plain) {
412
+ return plain.calendarId === 'iso8601' ? plain : plain.withCalendar('iso8601')
66
413
  }
67
414
 
68
- /** @param {Temporal.PlainDate} plain @returns {string} */
415
+ /** @param {PlainDate} plain @returns {string} */
69
416
  function toDayString(plain) {
70
417
  return plain.toString()
71
418
  }
@@ -82,9 +429,14 @@ function assertFiniteNumber(n, label) {
82
429
 
83
430
  /**
84
431
  * Run a Temporal op and keep the `daymath:` message contract when it fails.
85
- * Temporal throws bare `Out-of-bounds date` / `Non-positive day`; we prefix and
86
- * keep the original text plus `cause`. Covers both a result past the range and
87
- * an argument Temporal refuses outright, hence the neutral wording.
432
+ * Covers both a result past the range and an argument Temporal refuses
433
+ * outright, hence the neutral wording.
434
+ *
435
+ * The message carries no Temporal text. Implementations word the same failure
436
+ * differently — the polyfill says `Out-of-bounds date` where native V8 says
437
+ * `Temporal error: epoch days exceed maximum range.` — so quoting it made
438
+ * daymath's own message vary by runtime. `cause` still holds the original
439
+ * error, which is where the detail belongs.
88
440
  * @template T
89
441
  * @param {string} label
90
442
  * @param {() => T} op
@@ -94,10 +446,9 @@ function guardRange(label, op) {
94
446
  try {
95
447
  return op()
96
448
  } catch (err) {
97
- throw new RangeError(
98
- `daymath: ${label} could not produce a valid date (${/** @type {Error} */ (err).message})`,
99
- { cause: err },
100
- )
449
+ throw new RangeError(`daymath: ${label} could not produce a valid date`, {
450
+ cause: err,
451
+ })
101
452
  }
102
453
  }
103
454
 
@@ -123,7 +474,7 @@ function assertNonEmptyDates(dates) {
123
474
 
124
475
  /**
125
476
  * @param {WeekOptions} [options]
126
- * @returns {0|1|2|3|4|5|6}
477
+ * @returns {0|1|2|3|4|5|6|7} 7 is the default, so 0-6 was never the real range
127
478
  */
128
479
  function weekStartsOnFrom(options) {
129
480
  const w = options?.weekStartsOn ?? 7
@@ -135,10 +486,18 @@ function weekStartsOnFrom(options) {
135
486
 
136
487
  /**
137
488
  * @param {unknown} interval
138
- * @returns {{ start: Temporal.PlainDate, end: Temporal.PlainDate }}
489
+ * @returns {{ start: PlainDate, end: PlainDate }}
139
490
  */
140
491
  function toInterval(interval) {
141
- if (interval == null || typeof interval !== 'object') {
492
+ // `typeof x === 'object'` alone let a Date, an array or a PlainDate through,
493
+ // and the failure then surfaced as "start must be ISO 8601 day string" —
494
+ // naming a property the caller never meant to pass.
495
+ if (
496
+ interval === null || // typeof null is 'object', so it needs its own test
497
+ typeof interval !== 'object' || // and this already catches undefined
498
+ !('start' in interval) ||
499
+ !('end' in interval)
500
+ ) {
142
501
  throw new TypeError('daymath: interval must be { start, end }')
143
502
  }
144
503
  const start = toPlainDate(/** @type {Interval} */ (interval).start, 'start')
@@ -146,6 +505,228 @@ function toInterval(interval) {
146
505
  return { start, end }
147
506
  }
148
507
 
508
+ // ─── the clock ─────────────────────────────────────────────────────
509
+
510
+ /**
511
+ * The calendar day of a moment, in a zone. The way in.
512
+ *
513
+ * Both arguments have a **stated** default: the moment is now, and the zone is
514
+ * UTC. A default that is written down is not a guess; a default that is assumed
515
+ * is. UTC is still not your day for part of every day — it runs ahead of
516
+ * America/New_York for 16.7% of the day, and behind Asia/Tokyo for 37.5% — so
517
+ * name your zone when that matters.
518
+ *
519
+ * `moment` accepts what the world actually hands you:
520
+ * - a `Date`, the only carrier of an instant JavaScript has
521
+ * - a number, read as **epoch milliseconds**, exactly as `new Date(n)` reads it,
522
+ * truncated the same way, so a fractional value is not an error
523
+ * - an ISO 8601 day string, or a `Temporal.PlainDate` from any implementation,
524
+ * both of which are already a day
525
+ * - an ISO 8601 timestamp carrying `Z` or an offset, which names an exact
526
+ * instant, so there is nothing left to guess
527
+ * - a string carrying a `[Zone]` annotation, which names its own zone, so it
528
+ * answers its own civil day and the `tz` default never applies
529
+ *
530
+ * **day() is the normaliser, and that is the one rule to hold: a moment
531
+ * converts to a plain ISO day, and so does a day.** A `[u-ca=…]` annotation is
532
+ * accepted wherever the other 68 exports accept it, and then dropped from the
533
+ * result. Those exports carry it, because the caller asked for that numbering;
534
+ * day() exists to hand back the canonical form. It cannot do both, because an
535
+ * `Instant` has no fields for a calendar to renumber, so an annotation on
536
+ * `'…T20:00:00Z[u-ca=buddhist]'` is inert and that path has nothing to carry.
537
+ * Carrying only on the `[Zone]` path would make the same annotation behave two
538
+ * ways in one function.
539
+ *
540
+ * `'11/12/2026'` is refused. Nobody can tell November from December in it.
541
+ * `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath would
542
+ * have to pick one, and it will not pick on the caller's behalf. Name the zone
543
+ * — `'2026-08-08T12:00[America/New_York]'` — and it is accepted.
544
+ *
545
+ * A lone string takes one of four roles, decided in this order: a day, then a
546
+ * zoned time, then an instant, then a zone. The zone test is by **shape**, and
547
+ * the shape is on `ZONE_LIKE`. Temporal's own zone grammar cannot decide the role, because it
548
+ * accepts a whole timestamp and reads the zone out of it, so
549
+ * `day('1999-01-01T00:00:00Z')` would answer today. The grammar also differs
550
+ * between implementations: `'2026-08-08T25:00:00Z'` and `'T12:00:00Z'` are both
551
+ * zones to native Temporal and neither is one to `temporal-polyfill`. Shape
552
+ * settles the role and the runtime split together.
553
+ *
554
+ * With `day()` this is the only export that reads a clock, so the only one
555
+ * whose answer depends on when you call it. Give it a moment and it becomes a
556
+ * pure function, which is how the cross-runtime battery covers it.
557
+ *
558
+ * @param {Date | number | DayInput | null} [moment] instant, epoch ms, day, or a zone
559
+ * @param {string} [tz] IANA time zone id, e.g. `'utc'`, `'Asia/Tokyo'`
560
+ * @returns {string} a plain ISO day. `YYYY-MM-DD`, or expanded `±YYYYYY-MM-DD` outside years
561
+ * 0000-9999 — the annotation said `YYYY-MM-DD` alone, which was already wrong for
562
+ * `day('+010000-01-01')`. **Never carries `[u-ca=…]`.** day() is the normaliser, so a calendar
563
+ * annotation is accepted on input and dropped from the result. Every other export carries it.
564
+ * @throws {TypeError} If `moment` is not one of the accepted shapes
565
+ * @throws {RangeError} On an Invalid Date, a non-finite number, or an unknown zone
566
+ * @example day() // '2026-08-08' now, UTC
567
+ * @example day('Asia/Tokyo') // '2026-08-09' today in Tokyo
568
+ * @example day(row.createdAt) // '2026-08-07' a Date, UTC
569
+ * @example day(row.createdAt, 'America/New_York') // '2026-08-06'
570
+ * @example day(1761616161771) // '2025-10-28' epoch ms
571
+ * @example day('1999-01-01T00:00:00Z') // '1999-01-01' an ISO timestamp
572
+ * @example day(zdt.toString()) // the zone in the string wins
573
+ * @example addDays(day(), 2) // '2026-08-10'
574
+ */
575
+ export function day(moment, tz) {
576
+ // Already a day, in every accepted spelling. `bareDay` is the same predicate
577
+ // toPlainDate uses, so day() cannot drift from the other 68 exports.
578
+ const isDay =
579
+ isPlainDate(moment) || (typeof moment === 'string' && bareDay(moment) !== null)
580
+ const isMoment = isDay || moment instanceof Date || typeof moment === 'number'
581
+
582
+ let zone = tz
583
+ /** @type {import('temporal-polyfill').Temporal.Instant | undefined} */
584
+ let instant
585
+ /** @type {import('temporal-polyfill').Temporal.ZonedDateTime | undefined} */
586
+ let zoned
587
+ if (!isMoment && moment !== undefined && moment !== null) {
588
+ if (typeof moment !== 'string') {
589
+ throw new TypeError(
590
+ 'daymath: day() takes a Date, epoch milliseconds, or an ISO 8601 day',
591
+ )
592
+ }
593
+ // A `[Zone]` annotation makes the string a ZonedDateTime, and Temporal will
594
+ // not build one without it: a bare offset is refused. So the bracket is the
595
+ // caller naming a zone, and the string carries its own civil day. Reading
596
+ // the instant instead and applying UTC would move a browser's date by one.
597
+ //
598
+ // Naming a zone and resolving as one are different questions, so the
599
+ // annotation is detected first. A string can name a zone and still fail:
600
+ // `'…-05:00[America/New_York]'` has an offset the zone contradicts, and
601
+ // `'…[Asia/Tokoy]'` is a typo. Falling back to the instant would answer a
602
+ // UTC day for both, silently, which is the defect this branch exists to fix.
603
+ if (hasZoneAnnotation(moment)) {
604
+ // A calendar is judged only where it is applied. With a zone bracket it is: the fields get
605
+ // renumbered, and `toPlainDate()` carries the annotation into daymath's own output. Judged
606
+ // on the string, before the parse, so the message cannot depend on the runtime. A calendar
607
+ // that only relabels the year passes here, exactly as it does through toPlainDate.
608
+ supportedCalendar(moment, 'date')
609
+ if (tz !== undefined && tz !== null) {
610
+ throw new TypeError(
611
+ `daymath: day() got two time zones, ${JSON.stringify(moment)} and ${JSON.stringify(tz)}`,
612
+ )
613
+ }
614
+ try {
615
+ zoned = Temporal.ZonedDateTime.from(moment)
616
+ } catch (err) {
617
+ throw new RangeError(
618
+ `daymath: day() could not read ${JSON.stringify(moment)} in the time zone it names`,
619
+ { cause: err },
620
+ )
621
+ }
622
+ } else {
623
+ try {
624
+ // A timestamp carrying `Z` or an offset names an exact instant, so it
625
+ // reads as a moment. Temporal's own grammar is the definition of that.
626
+ //
627
+ // A calendar annotation here is inert and is ignored. An Instant has no
628
+ // year, month or day for a calendar to renumber, and without a zone
629
+ // bracket Temporal will not build anything that does. Refusing it would
630
+ // reject a right answer for a reason that cannot apply.
631
+ instant = Temporal.Instant.from(moment)
632
+ } catch {
633
+ // Not a moment, so the string must be a zone — decided by shape, before
634
+ // Temporal sees it. Temporal's zone grammar also accepts a whole
635
+ // timestamp and pulls the zone out of it, so letting it decide the role
636
+ // would read a date as a zone and silently answer today.
637
+ if (!ZONE_LIKE.test(moment)) {
638
+ throw new RangeError(
639
+ `daymath: day() got ${JSON.stringify(moment)}, which is neither a moment nor a time zone`,
640
+ )
641
+ }
642
+ if (tz !== undefined && tz !== null) {
643
+ throw new TypeError(
644
+ `daymath: day() got two time zones, ${JSON.stringify(moment)} and ${JSON.stringify(tz)}`,
645
+ )
646
+ }
647
+ zone = moment
648
+ }
649
+ }
650
+ }
651
+ zone ??= 'utc'
652
+
653
+ if (moment instanceof Date && Number.isNaN(moment.getTime())) {
654
+ throw new RangeError('daymath: day() got an Invalid Date')
655
+ }
656
+ if (typeof moment === 'number' && !Number.isFinite(moment)) {
657
+ throw new RangeError(`daymath: day() got a non-finite time value ${moment}`)
658
+ }
659
+
660
+ // Shape first, because implementations disagree past this point. The reasons
661
+ // are on ZONE_LIKE. The typeof test comes first because `.test()` coerces,
662
+ // and a caller-supplied toString could throw an error that is not ours.
663
+ if (typeof zone !== 'string' || !ZONE_LIKE.test(zone)) {
664
+ throw new RangeError(
665
+ `daymath: day() got an unknown time zone ${JSON.stringify(zone)}`,
666
+ )
667
+ }
668
+
669
+ // Checked before anything returns, so a mistyped zone fails the same way
670
+ // whatever the moment is. A caller mapping rows that are sometimes a Date and
671
+ // sometimes a day string would otherwise see the typo only on some rows.
672
+ //
673
+ // `ZonedDateTime.from` accepts exactly what `Temporal.Now` accepts and reads
674
+ // no clock, so a day input stays a pure function.
675
+ try {
676
+ Temporal.ZonedDateTime.from({ timeZone: zone, year: 1970, month: 1, day: 1 })
677
+ } catch (err) {
678
+ throw new RangeError(
679
+ `daymath: day() got an unknown time zone ${JSON.stringify(zone)}`,
680
+ { cause: err },
681
+ )
682
+ }
683
+
684
+ // A day carries no time, so a zone has nothing to shift. Applying one would
685
+ // invent a moment the caller never gave.
686
+ //
687
+ // `isoOf` is what makes day() the normaliser. Every other export carries a
688
+ // `[u-ca=…]` annotation through, because the caller asked for that numbering.
689
+ // day() is the door: something that is not a plain ISO day goes in, and a
690
+ // plain ISO day comes out. Carrying it here also could not be made
691
+ // consistent, because an `Instant` has no fields for a calendar to renumber,
692
+ // so that path has nothing to carry and would answer bare ISO regardless.
693
+ if (isDay) return toDayString(isoOf(toPlainDate(moment)))
694
+
695
+ // The string named its own zone, so that zone decides the day, not the
696
+ // default. This is the one path where `zone` is deliberately not consulted.
697
+ //
698
+ // The result still goes through `toPlainDate`, so the calendar is adjudicated
699
+ // in one place, and then through `isoOf` for the reason above.
700
+ if (zoned !== undefined) {
701
+ const plain = guardRange('day', () => zoned.toPlainDate())
702
+ return toDayString(isoOf(toPlainDate(plain)))
703
+ }
704
+
705
+ if (instant !== undefined) {
706
+ return guardRange('day', () =>
707
+ instant.toZonedDateTimeISO(zone).toPlainDate().toString(),
708
+ )
709
+ }
710
+
711
+ if (!isMoment) return Temporal.Now.plainDateISO(zone).toString()
712
+
713
+ // Truncate, because `new Date(n)` truncates, and the contract here is that a
714
+ // number reads exactly as it does. Verified equal on positive and negative
715
+ // fractions. Sub-millisecond precision cannot change a calendar day anyway.
716
+ // Every other shape returned above, so only a number or a Date reaches here.
717
+ // The cast says what the control flow already guarantees but tsc cannot see.
718
+ const epochMs =
719
+ typeof moment === 'number'
720
+ ? Math.trunc(moment)
721
+ : /** @type {Date} */ (moment).getTime()
722
+ return guardRange('day', () =>
723
+ Temporal.Instant.fromEpochMilliseconds(epochMs)
724
+ .toZonedDateTimeISO(zone)
725
+ .toPlainDate()
726
+ .toString(),
727
+ )
728
+ }
729
+
149
730
  // ─── parse / format / valid ────────────────────────────────────────
150
731
 
151
732
  /**
@@ -454,7 +1035,7 @@ export function startOfWeek(date, options) {
454
1035
 
455
1036
  /**
456
1037
  * How far the day sits past the start of its week.
457
- * @param {Temporal.PlainDate} d
1038
+ * @param {PlainDate} d
458
1039
  * @param {WeekOptions} [options]
459
1040
  * @returns {number}
460
1041
  */
@@ -489,7 +1070,7 @@ export function endOfWeek(date, options) {
489
1070
  export function differenceInDays(dateLeft, dateRight) {
490
1071
  const left = toPlainDate(dateLeft, 'dateLeft')
491
1072
  const right = toPlainDate(dateRight, 'dateRight')
492
- return left.since(right, { largestUnit: 'day' }).days
1073
+ return isoOf(left).since(isoOf(right), { largestUnit: 'day' }).days
493
1074
  }
494
1075
 
495
1076
  /**
@@ -518,8 +1099,12 @@ export function differenceInWeeks(dateLeft, dateRight) {
518
1099
  * @returns {number}
519
1100
  */
520
1101
  export function differenceInMonths(dateLeft, dateRight) {
521
- const left = toPlainDate(dateLeft, 'dateLeft')
522
- const right = toPlainDate(dateRight, 'dateRight')
1102
+ // `isoOf` on both, like differenceInDays. Without it this function read TWO coordinate systems
1103
+ // at once: the sign came from `compare`, which ignores the calendar, and the magnitude came from
1104
+ // `differenceInCalendarMonths`, which does not. A mixed pair then measured 6,513 months for a
1105
+ // 29-day gap, and two days 543 ISO years apart measured 0.
1106
+ const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1107
+ const right = isoOf(toPlainDate(dateRight, 'dateRight'))
523
1108
  const sign = Temporal.PlainDate.compare(left, right)
524
1109
  if (sign === 0) return 0
525
1110
  const diff = Math.abs(differenceInCalendarMonths(left, right))
@@ -541,8 +1126,8 @@ export function differenceInMonths(dateLeft, dateRight) {
541
1126
  * @returns {number}
542
1127
  */
543
1128
  export function differenceInCalendarMonths(dateLeft, dateRight) {
544
- const left = toPlainDate(dateLeft, 'dateLeft')
545
- const right = toPlainDate(dateRight, 'dateRight')
1129
+ const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1130
+ const right = isoOf(toPlainDate(dateRight, 'dateRight'))
546
1131
  return (left.year - right.year) * 12 + (left.month - right.month)
547
1132
  }
548
1133
 
@@ -557,8 +1142,9 @@ export function differenceInCalendarMonths(dateLeft, dateRight) {
557
1142
  * @returns {number}
558
1143
  */
559
1144
  export function differenceInYears(dateLeft, dateRight) {
560
- const left = toPlainDate(dateLeft, 'dateLeft')
561
- const right = toPlainDate(dateRight, 'dateRight')
1145
+ // `isoOf` on both: a measurement, so it ignores the year LABEL. See differenceInMonths.
1146
+ const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1147
+ const right = isoOf(toPlainDate(dateRight, 'dateRight'))
562
1148
  const sign = Temporal.PlainDate.compare(left, right)
563
1149
  if (sign === 0) return 0
564
1150
  const diff = Math.abs(left.year - right.year)
@@ -584,7 +1170,12 @@ export function differenceInYears(dateLeft, dateRight) {
584
1170
  * @returns {number}
585
1171
  */
586
1172
  export function differenceInCalendarYears(dateLeft, dateRight) {
587
- return getYear(dateLeft) - getYear(dateRight)
1173
+ // NOT getYear: that is a field read and honours the label, so a mixed pair subtracted two
1174
+ // different year spaces and answered 0 for days 543 ISO years apart.
1175
+ return (
1176
+ isoOf(toPlainDate(dateLeft, 'dateLeft')).year -
1177
+ isoOf(toPlainDate(dateRight, 'dateRight')).year
1178
+ )
588
1179
  }
589
1180
 
590
1181
  /**
@@ -606,11 +1197,9 @@ export function differenceInQuarters(dateLeft, dateRight) {
606
1197
  * @returns {number}
607
1198
  */
608
1199
  export function differenceInCalendarQuarters(dateLeft, dateRight) {
609
- const left = toPlainDate(dateLeft, 'dateLeft')
610
- const right = toPlainDate(dateRight, 'dateRight')
611
- return (
612
- (left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
613
- )
1200
+ const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1201
+ const right = isoOf(toPlainDate(dateRight, 'dateRight'))
1202
+ return (left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
614
1203
  }
615
1204
 
616
1205
  // ─── compare / equal ───────────────────────────────────────────────
@@ -671,10 +1260,15 @@ export function isSameWeek(dateLeft, dateRight, options) {
671
1260
  const left = toPlainDate(dateLeft, 'dateLeft')
672
1261
  const right = toPlainDate(dateRight, 'dateRight')
673
1262
  // own guard, so a week start below the minimum does not say startOfWeek
674
- return guardRange('isSameWeek', () =>
675
- left.subtract({ days: daysIntoWeek(left, options) }).equals(
676
- right.subtract({ days: daysIntoWeek(right, options) }),
677
- ),
1263
+ // compare, not equals: Temporal's equals compares the calendar as well as the day, so it
1264
+ // answers false for the same week in two calendars. compare reads the ISO fields alone.
1265
+ return guardRange(
1266
+ 'isSameWeek',
1267
+ () =>
1268
+ Temporal.PlainDate.compare(
1269
+ left.subtract({ days: daysIntoWeek(left, options) }),
1270
+ right.subtract({ days: daysIntoWeek(right, options) }),
1271
+ ) === 0,
678
1272
  )
679
1273
  }
680
1274
 
@@ -684,8 +1278,8 @@ export function isSameWeek(dateLeft, dateRight, options) {
684
1278
  * @returns {boolean}
685
1279
  */
686
1280
  export function isSameMonth(dateLeft, dateRight) {
687
- const a = toPlainDate(dateLeft, 'dateLeft')
688
- const b = toPlainDate(dateRight, 'dateRight')
1281
+ const a = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1282
+ const b = isoOf(toPlainDate(dateRight, 'dateRight'))
689
1283
  return a.year === b.year && a.month === b.month
690
1284
  }
691
1285
 
@@ -695,7 +1289,12 @@ export function isSameMonth(dateLeft, dateRight) {
695
1289
  * @returns {boolean}
696
1290
  */
697
1291
  export function isSameYear(dateLeft, dateRight) {
698
- return getYear(dateLeft) === getYear(dateRight)
1292
+ // NOT getYear, for the same reason as differenceInCalendarYears: two days 543 ISO years apart
1293
+ // both read year 2569 once one of them carries a Buddhist label, and this answered true.
1294
+ return (
1295
+ isoOf(toPlainDate(dateLeft, 'dateLeft')).year ===
1296
+ isoOf(toPlainDate(dateRight, 'dateRight')).year
1297
+ )
699
1298
  }
700
1299
 
701
1300
  /**
@@ -704,8 +1303,8 @@ export function isSameYear(dateLeft, dateRight) {
704
1303
  * @returns {boolean}
705
1304
  */
706
1305
  export function isSameQuarter(dateLeft, dateRight) {
707
- const a = toPlainDate(dateLeft, 'dateLeft')
708
- const b = toPlainDate(dateRight, 'dateRight')
1306
+ const a = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1307
+ const b = isoOf(toPlainDate(dateRight, 'dateRight'))
709
1308
  return a.year === b.year && getQuarter(a) === getQuarter(b)
710
1309
  }
711
1310
 
@@ -870,12 +1469,18 @@ export function eachYearOfInterval(interval) {
870
1469
  }
871
1470
  /** @type {string[]} */
872
1471
  const out = []
873
- let y = start.year
874
- // Jan 1 of start's year can sit below the minimum PlainDate
1472
+ // `with` then `add`, never `PlainDate.from({year})`: both keep start's calendar, and the object
1473
+ // form would read `year` as an ISO year, which is 543 years out for a Buddhist day.
1474
+ // Jan 1 of start's year can sit below the minimum PlainDate.
875
1475
  guardRange('eachYearOfInterval', () => {
876
- while (y <= end.year) {
877
- out.push(toDayString(Temporal.PlainDate.from({ year: y, month: 1, day: 1 })))
878
- y += 1
1476
+ // Compare ISO years, then add. Testing the loop condition AFTER the push is what keeps the
1477
+ // top edge working: Jan 1 of +275760 is valid, and adding a year to it is not.
1478
+ const lastIsoYear = isoOf(end).year
1479
+ let cur = start.with({ month: 1, day: 1 })
1480
+ for (;;) {
1481
+ out.push(toDayString(cur))
1482
+ if (isoOf(cur).year >= lastIsoYear) break
1483
+ cur = cur.add({ years: 1 })
879
1484
  }
880
1485
  })
881
1486
  return out
@@ -894,8 +1499,7 @@ export function isWithinInterval(date, interval) {
894
1499
  throw new RangeError('daymath: interval start must not be after end')
895
1500
  }
896
1501
  return (
897
- Temporal.PlainDate.compare(d, start) >= 0 &&
898
- Temporal.PlainDate.compare(d, end) <= 0
1502
+ Temporal.PlainDate.compare(d, start) >= 0 && Temporal.PlainDate.compare(d, end) <= 0
899
1503
  )
900
1504
  }
901
1505
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "daymath",
3
- "version": "0.3.0",
4
- "description": "Calendar date math (ISO 8601 day / PlainDate). date-fns-shaped. No time zones.",
3
+ "version": "0.5.0",
4
+ "description": "Calendar date math (ISO 8601 day / PlainDate). date-fns-shaped. No Date. No time zones.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
7
7
  "types": "./index.d.ts",
@@ -18,21 +18,34 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "test": "node --test test.js",
21
+ "lint": "oxlint -c oxlint.jsonc",
22
+ "lint:fix": "oxlint -c oxlint.jsonc --fix",
23
+ "typecheck": "tsc -p tsconfig.json",
24
+ "format": "oxfmt -c oxfmt.json index.js index.d.ts test.js scripts examples",
25
+ "format:check": "oxfmt -c oxfmt.json --check index.js index.d.ts test.js scripts examples",
21
26
  "test:coverage": "c8 --include=index.js --check-coverage --lines 100 --functions 100 --branches 100 --reporter=text --reporter=lcov node --test test.js",
22
27
  "test:differential": "node scripts/differential.mjs",
23
28
  "test:differential:quick": "node scripts/differential.mjs 2020 2030",
29
+ "test:runtimes": "node scripts/cross-runtime.mjs",
30
+ "test:runtimes:write": "node scripts/cross-runtime.mjs --write",
31
+ "test:intl-day": "node scripts/intl-day.mjs",
32
+ "test:intl-day:sweep": "node scripts/intl-day.mjs --sweep",
33
+ "size": "node scripts/bundle-size.mjs --run",
34
+ "size:check": "node scripts/bundle-size.mjs --check",
35
+ "size:write": "node scripts/bundle-size.mjs --write",
24
36
  "prepublishOnly": "npm run test:coverage",
25
37
  "publish:github": "node scripts/publish-github-packages.mjs"
26
38
  },
27
39
  "keywords": [
28
40
  "date",
29
41
  "calendar",
42
+ "civil-date",
30
43
  "plain-date",
31
- "temporal",
32
- "date-fns",
33
44
  "YYYY-MM-DD",
34
45
  "ISO-8601",
35
- "PlainDate"
46
+ "Temporal",
47
+ "PlainDate",
48
+ "date-fns"
36
49
  ],
37
50
  "author": "leemr",
38
51
  "license": "MIT",
@@ -49,10 +62,14 @@
49
62
  "temporal-polyfill": "^1.0.3"
50
63
  },
51
64
  "engines": {
52
- "node": ">=18"
65
+ "node": ">=20.19.0 <21 || >=22.12.0"
53
66
  },
54
67
  "devDependencies": {
55
68
  "c8": "^12.0.0",
56
- "date-fns": "4.4.0"
69
+ "date-fns": "4.4.0",
70
+ "esbuild": "0.28.1",
71
+ "oxfmt": "0.62.0",
72
+ "oxlint": "1.77.0",
73
+ "typescript": "7.0.2"
57
74
  }
58
75
  }