daymath 0.4.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 +129 -30
  2. package/index.d.ts +21 -0
  3. package/index.js +333 -79
  4. package/package.json +7 -1
package/README.md CHANGED
@@ -164,58 +164,157 @@ own instance, so it never depends on `instanceof` agreeing across copies. The co
164
164
  `Temporal.Now.plainDateISO()`: daymath has no `today()` on purpose, so that is where a caller
165
165
  gets one.
166
166
 
167
- A non-ISO calendar is refused, as an object and as a string alike. Temporal can put one on a
168
- `PlainDate`, and it names the same *day* with different numbers:
167
+ ### Calendars
169
168
 
170
- | calendar | year | month | day | `toString()` |
171
- |---|---|---|---|---|
172
- | `iso8601` | 2026 | 1 | 31 | `2026-01-31` |
173
- | `buddhist` | **2569** | 1 | 31 | `2026-01-31[u-ca=buddhist]` |
174
- | `hebrew` | 5786 | 5 | 13 | `2026-01-31[u-ca=hebrew]` |
175
- | `chinese` | 2025 | 13 | 13 | `2026-01-31[u-ca=chinese]` |
169
+ Temporal can put a calendar on a `PlainDate`. The same *day* then carries different numbers:
176
170
 
177
- Thai Buddhist years run 543 ahead, so the same day is 2569. Worse, the same number means two
178
- different days depending on where you write it:
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:
179
192
 
180
193
  ```js
181
- Temporal.PlainDate.from('2026-01-31[u-ca=buddhist]') // ISO 2026-01-31, .year 2569
182
- Temporal.PlainDate.from({year: 2026, month: 1, day: 31,
183
- calendar: 'buddhist'}) // ISO 1483-01-31, .year 2026
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
184
200
  ```
185
201
 
186
- 543 years apart. The date part of a string is always ISO; the annotation changes how fields are
187
- *read*, never how the string *parses*. daymath could strip the annotation and answer
188
- `2026-01-31`, which is the right day — but `getYear` would then return `2026` where your own
189
- object says `2569`. So it throws, naming the calendar and the way out:
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:
190
205
 
191
206
  ```js
192
- getYear(buddhistDate)
193
- // RangeError: daymath: date must use the ISO 8601 calendar, not "buddhist"
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
194
213
  // (convert with withCalendar('iso8601'))
195
214
 
196
- format(buddhistDate.withCalendar('iso8601')) // '2026-01-31'
215
+ format(hebrewDate.withCalendar('iso8601')) // '2026-01-31'
197
216
  ```
198
217
 
199
- `[u-ca=iso8601]` is the one annotation daymath accepts, and it drops it. Temporal writes it
200
- itself for `toString({ calendarName: 'always' })`, and it names the very calendar daymath
201
- reads, so refusing your own round-trip would be arbitrary.
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:
202
243
 
203
244
  ```js
204
245
  const written = plainDate.toString({ calendarName: 'always' }) // '2026-01-31[u-ca=iso8601]'
205
246
  getYear(written) // 2026
206
247
  ```
207
248
 
208
- A calendar is refused where it is **applied**. On a day string it is, and with a `[Zone]`
209
- bracket it is, because the fields get renumbered. Without a bracket the string names an
210
- `Instant`, which has no year, month or day for a calendar to renumber, so the annotation is
211
- inert and `day()` answers:
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:
212
256
 
213
257
  ```js
214
- day('2026-08-08T20:00:00Z[u-ca=buddhist]') // '2026-08-08'
215
- day('2026-08-08T12:00[America/New_York][u-ca=buddhist]') // throws
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
216
264
  ```
217
265
 
218
- Accepting `buddhist` and `roc` is planned; see `FUTURE.md` for the rule that would allow it.
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.
219
318
 
220
319
  Error messages quote no Temporal text, because implementations word the same failure
221
320
  differently. The original error is on `cause`.
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
 
@@ -44,6 +56,15 @@ export type WeekOptions = {
44
56
  * A lone string takes one of four roles, in this order: a day, a zoned time, an
45
57
  * instant, then a zone. The zone test is by shape — an IANA name, which carries
46
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.
47
68
  */
48
69
  export function day(tz?: string): string
49
70
  export function day(
package/index.js CHANGED
@@ -1,8 +1,95 @@
1
1
  /** daymath — calendar date math (ISO 8601 day). date-fns-shaped. No Date. No time zones. */
2
- // temporal-polyfill already resolves this: its entry is `globalThis.Temporal ||
3
- // bundled`, so a runtime with native Temporal gets native. Re-reading
4
- // globalThis here only duplicated that check and hid where it happens.
5
- import { Temporal } from 'temporal-polyfill'
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'
31
+
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
6
93
 
7
94
  /**
8
95
  * ISO 8601 calendar day string:
@@ -82,7 +169,11 @@ function hasZoneAnnotation(text) {
82
169
  const ZONE_LIKE =
83
170
  /^(?:(?![Tt]\d)[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)*|[+-]\d{2}(?::?\d{2})?)$/u
84
171
 
85
- /** @typedef {string | Temporal.PlainDate} DayInput */
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 */
86
177
  /**
87
178
  * @typedef {object} Interval
88
179
  * @property {DayInput} start
@@ -100,11 +191,9 @@ const ZONE_LIKE =
100
191
  /**
101
192
  * The bare ISO day inside a string, or `null` if there is not one.
102
193
  *
103
- * An annotation is dropped, not judged: `assertIsoCalendar` owns that rule and
104
- * runs before both callers, so by here the only calendar left is ISO. Temporal
105
- * writes `[u-ca=iso8601]` itself for `toString({ calendarName: 'always' })`, and
106
- * it names the very calendar daymath reads, so refusing a caller's own
107
- * round-trip would be arbitrary.
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.
108
197
  *
109
198
  * One predicate, because `toPlainDate` and `day()` both ask this question. They
110
199
  * asked it separately once, and day() alone then refused a string that every
@@ -119,21 +208,136 @@ function bareDay(text) {
119
208
  }
120
209
 
121
210
  /**
122
- * Refuse a non-ISO calendar, wherever the annotation is attached.
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?
123
235
  *
124
- * Checked on the *string*, before any parse. Implementations disagree past this
125
- * point: native Temporal builds a `[u-ca=buddhist]` ZonedDateTime and the
126
- * polyfill refuses to, so parsing first made the error depend on the runtime.
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.
127
313
  * @param {string} text
128
314
  * @param {string} label
315
+ * @returns {string | undefined}
129
316
  */
130
- function assertIsoCalendar(text, label) {
317
+ function supportedCalendar(text, label) {
131
318
  const annotated = calendarAnnotation(text)
132
- if (annotated && annotated.calendar.toLowerCase() !== 'iso8601') {
133
- throw new RangeError(
134
- `daymath: ${label} must use the ISO 8601 calendar, not ${JSON.stringify(annotated.calendar)} (convert with withCalendar('iso8601'))`,
135
- )
136
- }
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
+ )
137
341
  }
138
342
 
139
343
  /**
@@ -142,7 +346,7 @@ function assertIsoCalendar(text, label) {
142
346
  * `instanceof` recognises only one of those. Every Temporal puts this tag on
143
347
  * `PlainDate.prototype` as a non-writable property, so it is the portable brand.
144
348
  * @param {unknown} value
145
- * @returns {value is Temporal.PlainDate} a predicate, so callers narrow
349
+ * @returns {value is PlainDate} a predicate, so callers narrow
146
350
  */
147
351
  function isPlainDate(value) {
148
352
  return Object.prototype.toString.call(value) === '[object Temporal.PlainDate]'
@@ -154,21 +358,12 @@ function isPlainDate(value) {
154
358
  * A PlainDate becomes its ISO day string first, so every input reaches one
155
359
  * validation path and daymath owns the resulting instance.
156
360
  *
157
- * A non-ISO calendar is refused rather than reinterpreted. `toString()` writes
158
- * the ISO date then an optional `[u-ca=…]`, so the *day* would survive but
159
- * the field numbers would not. Thai Buddhist years run 543 ahead, so
160
- * `2026-01-31[u-ca=buddhist]` is year 2569, and `getYear` would answer `2026`
161
- * where the caller's own object says `2569`. A string carrying the annotation is
162
- * refused for the same reason, and the error names both the calendar it found
163
- * and the way through, `withCalendar('iso8601')`.
164
- *
165
- * `[u-ca=iso8601]` is the exception: it is accepted and dropped. Temporal writes
166
- * it itself for `toString({ calendarName: 'always' })`, and it names the very
167
- * calendar daymath reads, so refusing a caller's own round-trip would be
168
- * arbitrary.
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.
169
364
  * @param {unknown} value
170
365
  * @param {string} label
171
- * @returns {Temporal.PlainDate}
366
+ * @returns {PlainDate}
172
367
  */
173
368
  function toPlainDate(value, label = 'date') {
174
369
  if (value instanceof Date) {
@@ -184,7 +379,7 @@ function toPlainDate(value, label = 'date') {
184
379
  )
185
380
  }
186
381
  // Before the shape check: an annotated day is well formed, not malformed.
187
- assertIsoCalendar(text, label)
382
+ const calendar = supportedCalendar(text, label)
188
383
  const bare = bareDay(text)
189
384
  if (bare === null) {
190
385
  throw new RangeError(
@@ -192,7 +387,10 @@ function toPlainDate(value, label = 'date') {
192
387
  )
193
388
  }
194
389
  try {
195
- return Temporal.PlainDate.from(bare)
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)
196
394
  } catch (err) {
197
395
  throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(text)}`, {
198
396
  cause: err,
@@ -200,7 +398,21 @@ function toPlainDate(value, label = 'date') {
200
398
  }
201
399
  }
202
400
 
203
- /** @param {Temporal.PlainDate} plain @returns {string} */
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')
413
+ }
414
+
415
+ /** @param {PlainDate} plain @returns {string} */
204
416
  function toDayString(plain) {
205
417
  return plain.toString()
206
418
  }
@@ -274,7 +486,7 @@ function weekStartsOnFrom(options) {
274
486
 
275
487
  /**
276
488
  * @param {unknown} interval
277
- * @returns {{ start: Temporal.PlainDate, end: Temporal.PlainDate }}
489
+ * @returns {{ start: PlainDate, end: PlainDate }}
278
490
  */
279
491
  function toInterval(interval) {
280
492
  // `typeof x === 'object'` alone let a Date, an array or a PlainDate through,
@@ -315,6 +527,16 @@ function toInterval(interval) {
315
527
  * - a string carrying a `[Zone]` annotation, which names its own zone, so it
316
528
  * answers its own civil day and the `tz` default never applies
317
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
+ *
318
540
  * `'11/12/2026'` is refused. Nobody can tell November from December in it.
319
541
  * `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath would
320
542
  * have to pick one, and it will not pick on the caller's behalf. Name the zone
@@ -335,7 +557,10 @@ function toInterval(interval) {
335
557
  *
336
558
  * @param {Date | number | DayInput | null} [moment] instant, epoch ms, day, or a zone
337
559
  * @param {string} [tz] IANA time zone id, e.g. `'utc'`, `'Asia/Tokyo'`
338
- * @returns {string} `YYYY-MM-DD`
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.
339
564
  * @throws {TypeError} If `moment` is not one of the accepted shapes
340
565
  * @throws {RangeError} On an Invalid Date, a non-finite number, or an unknown zone
341
566
  * @example day() // '2026-08-08' now, UTC
@@ -355,9 +580,9 @@ export function day(moment, tz) {
355
580
  const isMoment = isDay || moment instanceof Date || typeof moment === 'number'
356
581
 
357
582
  let zone = tz
358
- /** @type {Temporal.Instant | undefined} */
583
+ /** @type {import('temporal-polyfill').Temporal.Instant | undefined} */
359
584
  let instant
360
- /** @type {Temporal.ZonedDateTime | undefined} */
585
+ /** @type {import('temporal-polyfill').Temporal.ZonedDateTime | undefined} */
361
586
  let zoned
362
587
  if (!isMoment && moment !== undefined && moment !== null) {
363
588
  if (typeof moment !== 'string') {
@@ -376,13 +601,11 @@ export function day(moment, tz) {
376
601
  // `'…[Asia/Tokoy]'` is a typo. Falling back to the instant would answer a
377
602
  // UTC day for both, silently, which is the defect this branch exists to fix.
378
603
  if (hasZoneAnnotation(moment)) {
379
- // A calendar is refused only where it is applied. With a zone bracket it
380
- // is: the fields get renumbered, and `toPlainDate()` carries the
381
- // annotation into daymath's own output. Refused on the string, before the
382
- // parse, because native Temporal builds a `[u-ca=buddhist]` ZonedDateTime
383
- // and the polyfill refuses to — judging after the parse would make the
384
- // message depend on the runtime.
385
- assertIsoCalendar(moment, 'date')
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')
386
609
  if (tz !== undefined && tz !== null) {
387
610
  throw new TypeError(
388
611
  `daymath: day() got two time zones, ${JSON.stringify(moment)} and ${JSON.stringify(tz)}`,
@@ -460,18 +683,23 @@ export function day(moment, tz) {
460
683
 
461
684
  // A day carries no time, so a zone has nothing to shift. Applying one would
462
685
  // invent a moment the caller never gave.
463
- if (isDay) return toDayString(toPlainDate(moment))
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)))
464
694
 
465
695
  // The string named its own zone, so that zone decides the day, not the
466
696
  // default. This is the one path where `zone` is deliberately not consulted.
467
697
  //
468
- // The result still goes through `toPlainDate`. A ZonedDateTime keeps a
469
- // `[u-ca=…]` annotation, and `PlainDate.toString()` writes it back out, so
470
- // returning directly emitted `'2026-08-08[u-ca=buddhist]'` — a value daymath
471
- // itself refuses. Every path adjudicates the calendar in one place or none.
698
+ // The result still goes through `toPlainDate`, so the calendar is adjudicated
699
+ // in one place, and then through `isoOf` for the reason above.
472
700
  if (zoned !== undefined) {
473
701
  const plain = guardRange('day', () => zoned.toPlainDate())
474
- return toDayString(toPlainDate(plain))
702
+ return toDayString(isoOf(toPlainDate(plain)))
475
703
  }
476
704
 
477
705
  if (instant !== undefined) {
@@ -807,7 +1035,7 @@ export function startOfWeek(date, options) {
807
1035
 
808
1036
  /**
809
1037
  * How far the day sits past the start of its week.
810
- * @param {Temporal.PlainDate} d
1038
+ * @param {PlainDate} d
811
1039
  * @param {WeekOptions} [options]
812
1040
  * @returns {number}
813
1041
  */
@@ -842,7 +1070,7 @@ export function endOfWeek(date, options) {
842
1070
  export function differenceInDays(dateLeft, dateRight) {
843
1071
  const left = toPlainDate(dateLeft, 'dateLeft')
844
1072
  const right = toPlainDate(dateRight, 'dateRight')
845
- return left.since(right, { largestUnit: 'day' }).days
1073
+ return isoOf(left).since(isoOf(right), { largestUnit: 'day' }).days
846
1074
  }
847
1075
 
848
1076
  /**
@@ -871,8 +1099,12 @@ export function differenceInWeeks(dateLeft, dateRight) {
871
1099
  * @returns {number}
872
1100
  */
873
1101
  export function differenceInMonths(dateLeft, dateRight) {
874
- const left = toPlainDate(dateLeft, 'dateLeft')
875
- 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'))
876
1108
  const sign = Temporal.PlainDate.compare(left, right)
877
1109
  if (sign === 0) return 0
878
1110
  const diff = Math.abs(differenceInCalendarMonths(left, right))
@@ -894,8 +1126,8 @@ export function differenceInMonths(dateLeft, dateRight) {
894
1126
  * @returns {number}
895
1127
  */
896
1128
  export function differenceInCalendarMonths(dateLeft, dateRight) {
897
- const left = toPlainDate(dateLeft, 'dateLeft')
898
- const right = toPlainDate(dateRight, 'dateRight')
1129
+ const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1130
+ const right = isoOf(toPlainDate(dateRight, 'dateRight'))
899
1131
  return (left.year - right.year) * 12 + (left.month - right.month)
900
1132
  }
901
1133
 
@@ -910,8 +1142,9 @@ export function differenceInCalendarMonths(dateLeft, dateRight) {
910
1142
  * @returns {number}
911
1143
  */
912
1144
  export function differenceInYears(dateLeft, dateRight) {
913
- const left = toPlainDate(dateLeft, 'dateLeft')
914
- 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'))
915
1148
  const sign = Temporal.PlainDate.compare(left, right)
916
1149
  if (sign === 0) return 0
917
1150
  const diff = Math.abs(left.year - right.year)
@@ -937,7 +1170,12 @@ export function differenceInYears(dateLeft, dateRight) {
937
1170
  * @returns {number}
938
1171
  */
939
1172
  export function differenceInCalendarYears(dateLeft, dateRight) {
940
- 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
+ )
941
1179
  }
942
1180
 
943
1181
  /**
@@ -959,8 +1197,8 @@ export function differenceInQuarters(dateLeft, dateRight) {
959
1197
  * @returns {number}
960
1198
  */
961
1199
  export function differenceInCalendarQuarters(dateLeft, dateRight) {
962
- const left = toPlainDate(dateLeft, 'dateLeft')
963
- const right = toPlainDate(dateRight, 'dateRight')
1200
+ const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1201
+ const right = isoOf(toPlainDate(dateRight, 'dateRight'))
964
1202
  return (left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
965
1203
  }
966
1204
 
@@ -1022,10 +1260,15 @@ export function isSameWeek(dateLeft, dateRight, options) {
1022
1260
  const left = toPlainDate(dateLeft, 'dateLeft')
1023
1261
  const right = toPlainDate(dateRight, 'dateRight')
1024
1262
  // own guard, so a week start below the minimum does not say startOfWeek
1025
- return guardRange('isSameWeek', () =>
1026
- left
1027
- .subtract({ days: daysIntoWeek(left, options) })
1028
- .equals(right.subtract({ days: daysIntoWeek(right, options) })),
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,
1029
1272
  )
1030
1273
  }
1031
1274
 
@@ -1035,8 +1278,8 @@ export function isSameWeek(dateLeft, dateRight, options) {
1035
1278
  * @returns {boolean}
1036
1279
  */
1037
1280
  export function isSameMonth(dateLeft, dateRight) {
1038
- const a = toPlainDate(dateLeft, 'dateLeft')
1039
- const b = toPlainDate(dateRight, 'dateRight')
1281
+ const a = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1282
+ const b = isoOf(toPlainDate(dateRight, 'dateRight'))
1040
1283
  return a.year === b.year && a.month === b.month
1041
1284
  }
1042
1285
 
@@ -1046,7 +1289,12 @@ export function isSameMonth(dateLeft, dateRight) {
1046
1289
  * @returns {boolean}
1047
1290
  */
1048
1291
  export function isSameYear(dateLeft, dateRight) {
1049
- 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
+ )
1050
1298
  }
1051
1299
 
1052
1300
  /**
@@ -1055,8 +1303,8 @@ export function isSameYear(dateLeft, dateRight) {
1055
1303
  * @returns {boolean}
1056
1304
  */
1057
1305
  export function isSameQuarter(dateLeft, dateRight) {
1058
- const a = toPlainDate(dateLeft, 'dateLeft')
1059
- const b = toPlainDate(dateRight, 'dateRight')
1306
+ const a = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1307
+ const b = isoOf(toPlainDate(dateRight, 'dateRight'))
1060
1308
  return a.year === b.year && getQuarter(a) === getQuarter(b)
1061
1309
  }
1062
1310
 
@@ -1221,12 +1469,18 @@ export function eachYearOfInterval(interval) {
1221
1469
  }
1222
1470
  /** @type {string[]} */
1223
1471
  const out = []
1224
- let y = start.year
1225
- // 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.
1226
1475
  guardRange('eachYearOfInterval', () => {
1227
- while (y <= end.year) {
1228
- out.push(toDayString(Temporal.PlainDate.from({ year: y, month: 1, day: 1 })))
1229
- 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 })
1230
1484
  }
1231
1485
  })
1232
1486
  return out
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "daymath",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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",
@@ -28,6 +28,11 @@
28
28
  "test:differential:quick": "node scripts/differential.mjs 2020 2030",
29
29
  "test:runtimes": "node scripts/cross-runtime.mjs",
30
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",
31
36
  "prepublishOnly": "npm run test:coverage",
32
37
  "publish:github": "node scripts/publish-github-packages.mjs"
33
38
  },
@@ -62,6 +67,7 @@
62
67
  "devDependencies": {
63
68
  "c8": "^12.0.0",
64
69
  "date-fns": "4.4.0",
70
+ "esbuild": "0.28.1",
65
71
  "oxfmt": "0.62.0",
66
72
  "oxlint": "1.77.0",
67
73
  "typescript": "7.0.2"