daymath 0.4.0 → 0.6.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 +136 -32
  2. package/index.d.ts +21 -0
  3. package/index.js +463 -167
  4. package/package.json +7 -1
package/index.js CHANGED
@@ -1,8 +1,47 @@
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
+ // daymath reaches Temporal through `temporal-polyfill/fns`, the tree-shakable functional API, not
3
+ // through the `Temporal` class. A class is one unit to a bundler, because it cannot prove a method
4
+ // unreachable, so the class API shipped the whole polyfill for the ~20 operations used here.
5
+ // Measured on a three-call program by `npm run size`, shape A: 24,735 B gzip to 16,295 B, −34%.
6
+ // Nothing a caller can observe changes.
7
+ //
8
+ // **`getAny` is required, and `getISO` would be a defect.** `fromString` takes the calendar
9
+ // resolver as a REQUIRED second argument, and it is the set of calendars the bundle admits. With
10
+ // `getISO` the same program answers two ways: the native funcApi keeps `[u-ca=buddhist]` and reads
11
+ // 2569, the shim funcApi drops the annotation and reads 2026. Node 20 and bun are the shim lanes in
12
+ // CI. `getAny` carries the calendar data itself and answers identically on every path, measured
13
+ // with no global, with a BASE polyfill global, with a FULL one, and on native. That costs 4.2 kB
14
+ // and it is what keeps the calendar rule below a MEASUREMENT rather than a build-time list.
15
+ //
16
+ // It also retires the selection block this file used to carry. daymath no longer reads
17
+ // `globalThis.Temporal` at all, so the 0.5.0 defect class — a base polyfill global silently costing
18
+ // three of four calendars, decided by import order — cannot recur here. `fns` picks its own funcApi
19
+ // and `getAny` makes that choice unobservable.
20
+ //
21
+ // `Duration` is deliberately not imported. `add(record, durationRecord)` needs one, but every
22
+ // daymath call moves a single unit, so `addDays` / `addMonths` / `addYears` serve instead and that
23
+ // whole subpath stays out of the bundle.
24
+ import * as PlainDateFns from 'temporal-polyfill/fns/PlainDate'
25
+ import { getAny } from 'temporal-polyfill/fns/Calendar'
26
+ import * as InstantFns from 'temporal-polyfill/fns/Instant'
27
+ import * as ZonedFns from 'temporal-polyfill/fns/ZonedDateTime'
28
+ import * as NowFns from 'temporal-polyfill/fns/Now'
29
+
30
+ /** The ISO calendar record, resolved once. `isoOf` runs on every two-date export. */
31
+ const ISO_CALENDAR = getAny('iso8601')
32
+
33
+ /**
34
+ * Every calendar this runtime names. Read once, lowercased, because BCP-47 keys are
35
+ * case-insensitive while a `Map` key is not.
36
+ *
37
+ * This set is also what bounds the verdict cache below. It is closed and small — 18 ids on Node 26
38
+ * — so an id the runtime does not name is refused without ever being stored.
39
+ *
40
+ * `Intl.supportedValuesOf` is ES2022 and present on every runtime in `engines`.
41
+ */
42
+ const RUNTIME_CALENDARS = new Set(
43
+ Intl.supportedValuesOf('calendar').map((id) => id.toLowerCase()),
44
+ )
6
45
 
7
46
  /**
8
47
  * ISO 8601 calendar day string:
@@ -82,7 +121,14 @@ function hasZoneAnnotation(text) {
82
121
  const ZONE_LIKE =
83
122
  /^(?:(?![Tt]\d)[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)*|[+-]\d{2}(?::?\d{2})?)$/u
84
123
 
85
- /** @typedef {string | Temporal.PlainDate} DayInput */
124
+ // Two different things wear the name PlainDate here, and keeping them apart is the whole type
125
+ // story of this file. `PlainDateRecord` is daymath's INTERNAL value, and the name is the polyfill's
126
+ // own for it. `PlainDate` is the public INPUT type, a real `Temporal.PlainDate` object from any
127
+ // implementation, which callers still pass and `isPlainDate` still recognises by brand. daymath
128
+ // never returns one.
129
+ /** @typedef {import('temporal-polyfill/fns/PlainDate').Record} PlainDateRecord */
130
+ /** @typedef {import('temporal-polyfill').Temporal.PlainDate} PlainDate */
131
+ /** @typedef {string | PlainDate} DayInput */
86
132
  /**
87
133
  * @typedef {object} Interval
88
134
  * @property {DayInput} start
@@ -100,11 +146,9 @@ const ZONE_LIKE =
100
146
  /**
101
147
  * The bare ISO day inside a string, or `null` if there is not one.
102
148
  *
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.
149
+ * An annotation is dropped here, never judged. `supportedCalendar` owns that rule, and the two do
150
+ * not run in a fixed order: `toPlainDate` judges first, and `day()` calls this first. That is safe
151
+ * precisely because this function strips the annotation without reading it.
108
152
  *
109
153
  * One predicate, because `toPlainDate` and `day()` both ask this question. They
110
154
  * asked it separately once, and day() alone then refused a string that every
@@ -119,21 +163,139 @@ function bareDay(text) {
119
163
  }
120
164
 
121
165
  /**
122
- * Refuse a non-ISO calendar, wherever the annotation is attached.
166
+ * Nine probe dates for the calendar rule below.
167
+ *
168
+ * They span 1900 to 2100, they sit in different months, and two pairs straddle a Japanese era
169
+ * boundary: 1989-01-07/08 is Showa 64 into Heisei 1, and 2019-04-30/05-01 is Heisei 31 into
170
+ * Reiwa 1. An era change *inside* one ISO year is the case a single probe cannot see, and it is
171
+ * exactly what separates a year label from a year renumbering.
172
+ */
173
+ const CALENDAR_PROBES = [
174
+ '1900-01-01',
175
+ '1900-07-01',
176
+ '1989-01-07',
177
+ '1989-01-08',
178
+ '2019-04-30',
179
+ '2019-05-01',
180
+ '2026-01-31',
181
+ '2026-12-31',
182
+ '2100-06-15',
183
+ ]
184
+
185
+ /** @type {Map<string, {offset: number} | {reason: 'unknown' | 'renumbers'}>} */
186
+ const calendarRuleCache = new Map()
187
+
188
+ /**
189
+ * Is this a calendar the runtime actually names?
190
+ *
191
+ * **This is what bounds `calendarRuleCache`, and it is a memory-exhaustion fix.** The cache keys on
192
+ * whatever sits between `[u-ca=` and `]`, which is caller input.
193
+ *
194
+ * A shape guard was tried first and was only half a fix. It bounded the key LENGTH and not the
195
+ * entry COUNT, so any BCP-47-shaped string still bought a permanent entry: 800,000 distinct
196
+ * guard-passing ids retained 76.8 MB, growing linearly and never released. Case made it worse,
197
+ * because the shape test ignored case while the `Map` key did not, so one name had 256 keys.
198
+ *
199
+ * A closed set fixes both at once. `RUNTIME_CALENDARS` holds 18 ids on Node 26, so entries can
200
+ * never exceed what the runtime has, and a lowercased key collapses the case variants. It also
201
+ * needs no length cap and no pattern, which removes the last quadratic-regex risk from this file.
202
+ *
203
+ * The quiet path was `isValid`, which swallows the `RangeError`, so a loop raised nothing and
204
+ * logged nothing.
205
+ * @param {string} lowerId
206
+ */
207
+ function runtimeKnowsCalendar(lowerId) {
208
+ return RUNTIME_CALENDARS.has(lowerId)
209
+ }
210
+
211
+ /**
212
+ * Measure whether a calendar only relabels the year.
123
213
  *
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.
214
+ * **This is a method, not a list.** Nothing here names a calendar, so a calendar CLDR adds later
215
+ * is admitted or refused by the same measurement, with no code change. Today it admits
216
+ * `buddhist` (+543), `roc` (−1911), `japanese` (0) and `gregory` (0), and refuses the other
217
+ * eleven the runtime knows.
218
+ *
219
+ * Two conditions, both required on every probe:
220
+ *
221
+ * 1. **Month and day equal the ISO fields.** A month-count test would be wrong: `hebrew` is
222
+ * lunisolar, so `monthsInYear` is 13 in 2024, 12 in 2025 and 13 in 2027. Comparing the fields
223
+ * is correct in every year.
224
+ * 2. **The year offset is constant.** `japanese` and `roc` pass condition 1, and Temporal reports
225
+ * a continuous year for both, so both are pure labels — `roc` 1900 is −11, not "12 before".
226
+ * Reading the year from `Intl` instead would fail here, because `Intl` reports the *era* year:
227
+ * Reiwa 8 and B.R.O.C. 12. Temporal is the reference, so Temporal is what this asks.
228
+ *
229
+ * The verdict is cached per calendar. It cannot change while the process runs.
230
+ * @param {string} calendar
231
+ */
232
+ function calendarRule(calendar) {
233
+ const cached = calendarRuleCache.get(calendar)
234
+ if (cached !== undefined) return cached
235
+ /** @type {{offset: number} | {reason: 'unknown' | 'renumbers'}} */
236
+ let verdict = { reason: 'renumbers' }
237
+ try {
238
+ const offsets = new Set()
239
+ // `getAny(calendar)` throws for an id the polyfill cannot build, which is the `unknown` case
240
+ // below. It is resolved once rather than per probe.
241
+ const calendarRecord = getAny(calendar)
242
+ for (const probe of CALENDAR_PROBES) {
243
+ const iso = PlainDateFns.fromString(probe, getAny)
244
+ const dated = PlainDateFns.withCalendar(iso, calendarRecord)
245
+ if (dated.month !== iso.month || dated.day !== iso.day) {
246
+ offsets.clear()
247
+ break
248
+ }
249
+ offsets.add(dated.year - iso.year)
250
+ }
251
+ // The offset is the discriminant — `'offset' in verdict` is what accepts — and the number
252
+ // itself is kept unread on purpose, so a debugger shows WHY a calendar passed.
253
+ if (offsets.size === 1) verdict = { offset: [...offsets][0] }
254
+ } catch {
255
+ // The runtime cannot build this calendar at all, which is a different message for the caller.
256
+ verdict = { reason: 'unknown' }
257
+ }
258
+ calendarRuleCache.set(calendar, verdict)
259
+ return verdict
260
+ }
261
+
262
+ /**
263
+ * Accept a calendar that only relabels the year, and refuse one that renumbers.
264
+ *
265
+ * Checked on the *string*, before any parse, so the outcome cannot depend on where the annotation
266
+ * sits. Returns the calendar for `toPlainDate` to carry, or `undefined` for plain ISO.
267
+ *
268
+ * `[u-ca=iso8601]` returns `undefined` rather than carrying: Temporal writes it itself for
269
+ * `toString({ calendarName: 'always' })`, and daymath already answers in it, so there is nothing
270
+ * to relabel.
127
271
  * @param {string} text
128
272
  * @param {string} label
273
+ * @returns {string | undefined}
129
274
  */
130
- function assertIsoCalendar(text, label) {
275
+ function supportedCalendar(text, label) {
131
276
  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
- }
277
+ // A bare `return` rather than `return undefined`: oxlint's no-useless-undefined forbids the
278
+ // explicit spelling, and "nothing to carry" is what both of these mean.
279
+ if (annotated === null) return
280
+ const { calendar } = annotated
281
+ // Lowercased once. BCP-47 keys are case-insensitive, and this is also the cache key, so the case
282
+ // variants of one name must collapse to one entry rather than 256.
283
+ const lowerId = calendar.toLowerCase()
284
+ if (lowerId === 'iso8601') return
285
+ // Membership FIRST, so an id the runtime does not name is never stored. See runtimeKnowsCalendar.
286
+ // One throw, not two: an unknown id and a renumbering one differ only in the reason.
287
+ const verdict = runtimeKnowsCalendar(lowerId)
288
+ ? calendarRule(lowerId)
289
+ : /** @type {const} */ ({ reason: 'unknown' })
290
+ if ('offset' in verdict) return calendar
291
+ const why =
292
+ verdict.reason === 'unknown'
293
+ ? 'is not a calendar this runtime knows'
294
+ : 'renumbers months or days, so daymath cannot answer a day in it'
295
+ // The id is sliced because it is caller input, so a megabyte in cannot become a megabyte of error.
296
+ throw new RangeError(
297
+ `daymath: ${label} calendar ${JSON.stringify(calendar.slice(0, 40))} ${why} (convert with withCalendar('iso8601'))`,
298
+ )
137
299
  }
138
300
 
139
301
  /**
@@ -142,7 +304,7 @@ function assertIsoCalendar(text, label) {
142
304
  * `instanceof` recognises only one of those. Every Temporal puts this tag on
143
305
  * `PlainDate.prototype` as a non-writable property, so it is the portable brand.
144
306
  * @param {unknown} value
145
- * @returns {value is Temporal.PlainDate} a predicate, so callers narrow
307
+ * @returns {value is PlainDate} a predicate, so callers narrow
146
308
  */
147
309
  function isPlainDate(value) {
148
310
  return Object.prototype.toString.call(value) === '[object Temporal.PlainDate]'
@@ -154,21 +316,12 @@ function isPlainDate(value) {
154
316
  * A PlainDate becomes its ISO day string first, so every input reaches one
155
317
  * validation path and daymath owns the resulting instance.
156
318
  *
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.
319
+ * A calendar annotation is judged by `supportedCalendar` and then CARRIED, so `getYear` answers
320
+ * the caller's own number: `2026-01-31[u-ca=buddhist]` reads 2569, not 2026. The rule and the
321
+ * reasons live on `supportedCalendar`; this function only applies the verdict.
169
322
  * @param {unknown} value
170
323
  * @param {string} label
171
- * @returns {Temporal.PlainDate}
324
+ * @returns {PlainDateRecord}
172
325
  */
173
326
  function toPlainDate(value, label = 'date') {
174
327
  if (value instanceof Date) {
@@ -184,7 +337,7 @@ function toPlainDate(value, label = 'date') {
184
337
  )
185
338
  }
186
339
  // Before the shape check: an annotated day is well formed, not malformed.
187
- assertIsoCalendar(text, label)
340
+ const calendar = supportedCalendar(text, label)
188
341
  const bare = bareDay(text)
189
342
  if (bare === null) {
190
343
  throw new RangeError(
@@ -192,7 +345,15 @@ function toPlainDate(value, label = 'date') {
192
345
  )
193
346
  }
194
347
  try {
195
- return Temporal.PlainDate.from(bare)
348
+ // The bare day is parsed with the ISO resolver's own entry point. `getAny` is passed as the
349
+ // resolver rather than called, because `fromString` calls it with whatever the string names,
350
+ // and a bare day names nothing.
351
+ const plain = PlainDateFns.fromString(bare, getAny)
352
+ // The annotation rides along, so getYear answers 2569 for a Buddhist day and every
353
+ // returned string keeps the calendar the caller named.
354
+ return calendar === undefined
355
+ ? plain
356
+ : PlainDateFns.withCalendar(plain, getAny(calendar))
196
357
  } catch (err) {
197
358
  throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(text)}`, {
198
359
  cause: err,
@@ -200,9 +361,25 @@ function toPlainDate(value, label = 'date') {
200
361
  }
201
362
  }
202
363
 
203
- /** @param {Temporal.PlainDate} plain @returns {string} */
364
+ /**
365
+ * The same day in ISO, for measurement only.
366
+ *
367
+ * Temporal refuses `since` and `until` across two calendars with `Mismatched calendars`, and its
368
+ * `equals` compares the calendar as well as the day. Neither matters to a day count: a day is the
369
+ * same day whatever the year is labelled, so daymath normalises and answers. Only the exports that
370
+ * *read* or *write* a field honour the label.
371
+ * @param {PlainDateRecord} plain
372
+ * @returns {PlainDateRecord}
373
+ */
374
+ function isoOf(plain) {
375
+ return plain.calendarId === 'iso8601'
376
+ ? plain
377
+ : PlainDateFns.withCalendar(plain, ISO_CALENDAR)
378
+ }
379
+
380
+ /** @param {PlainDateRecord} plain @returns {string} */
204
381
  function toDayString(plain) {
205
- return plain.toString()
382
+ return PlainDateFns.toString(plain)
206
383
  }
207
384
 
208
385
  /** @param {unknown} n @param {string} label */
@@ -243,14 +420,29 @@ function guardRange(label, op) {
243
420
  /**
244
421
  * Shared body for every add/sub function. Each caller passes its own name, so
245
422
  * the message never reports a function the caller did not call.
423
+ *
424
+ * The unit is a parameter rather than a duration object on purpose. `PlainDateFns.add` would need a
425
+ * `DurationRecord`, which means importing `fns/Duration`, and every daymath caller moves exactly
426
+ * one unit. `addDays` / `addMonths` / `addYears` take a plain number and keep that subpath out of
427
+ * the bundle. Subtraction is a negative amount, exactly as it was when this passed `-amount`.
428
+ *
429
+ * `addMonths` and `addYears` clamp by default, which is daymath's one overflow rule and the same
430
+ * behaviour the class API gave: 31 January plus one month is 28 February.
246
431
  * @param {DayInput} date
247
- * @param {{ days?: number, months?: number, years?: number }} delta
432
+ * @param {'days'|'months'|'years'} unit
433
+ * @param {number} amount
248
434
  * @param {string} label
249
435
  * @returns {string}
250
436
  */
251
- function addDuration(date, delta, label) {
437
+ function addDuration(date, unit, amount, label) {
252
438
  const d = toPlainDate(date)
253
- return guardRange(label, () => toDayString(d.add(delta)))
439
+ const move =
440
+ unit === 'days'
441
+ ? PlainDateFns.addDays
442
+ : unit === 'months'
443
+ ? PlainDateFns.addMonths
444
+ : PlainDateFns.addYears
445
+ return guardRange(label, () => toDayString(move(d, amount)))
254
446
  }
255
447
 
256
448
  /** @param {unknown} dates */
@@ -274,7 +466,7 @@ function weekStartsOnFrom(options) {
274
466
 
275
467
  /**
276
468
  * @param {unknown} interval
277
- * @returns {{ start: Temporal.PlainDate, end: Temporal.PlainDate }}
469
+ * @returns {{ start: PlainDateRecord, end: PlainDateRecord }}
278
470
  */
279
471
  function toInterval(interval) {
280
472
  // `typeof x === 'object'` alone let a Date, an array or a PlainDate through,
@@ -315,6 +507,16 @@ function toInterval(interval) {
315
507
  * - a string carrying a `[Zone]` annotation, which names its own zone, so it
316
508
  * answers its own civil day and the `tz` default never applies
317
509
  *
510
+ * **day() is the normaliser, and that is the one rule to hold: a moment
511
+ * converts to a plain ISO day, and so does a day.** A `[u-ca=…]` annotation is
512
+ * accepted wherever the other 68 exports accept it, and then dropped from the
513
+ * result. Those exports carry it, because the caller asked for that numbering;
514
+ * day() exists to hand back the canonical form. It cannot do both, because an
515
+ * `Instant` has no fields for a calendar to renumber, so an annotation on
516
+ * `'…T20:00:00Z[u-ca=buddhist]'` is inert and that path has nothing to carry.
517
+ * Carrying only on the `[Zone]` path would make the same annotation behave two
518
+ * ways in one function.
519
+ *
318
520
  * `'11/12/2026'` is refused. Nobody can tell November from December in it.
319
521
  * `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath would
320
522
  * have to pick one, and it will not pick on the caller's behalf. Name the zone
@@ -335,7 +537,10 @@ function toInterval(interval) {
335
537
  *
336
538
  * @param {Date | number | DayInput | null} [moment] instant, epoch ms, day, or a zone
337
539
  * @param {string} [tz] IANA time zone id, e.g. `'utc'`, `'Asia/Tokyo'`
338
- * @returns {string} `YYYY-MM-DD`
540
+ * @returns {string} a plain ISO day. `YYYY-MM-DD`, or expanded `±YYYYYY-MM-DD` outside years
541
+ * 0000-9999 — the annotation said `YYYY-MM-DD` alone, which was already wrong for
542
+ * `day('+010000-01-01')`. **Never carries `[u-ca=…]`.** day() is the normaliser, so a calendar
543
+ * annotation is accepted on input and dropped from the result. Every other export carries it.
339
544
  * @throws {TypeError} If `moment` is not one of the accepted shapes
340
545
  * @throws {RangeError} On an Invalid Date, a non-finite number, or an unknown zone
341
546
  * @example day() // '2026-08-08' now, UTC
@@ -355,9 +560,9 @@ export function day(moment, tz) {
355
560
  const isMoment = isDay || moment instanceof Date || typeof moment === 'number'
356
561
 
357
562
  let zone = tz
358
- /** @type {Temporal.Instant | undefined} */
563
+ /** @type {import('temporal-polyfill/fns/Instant').Record | undefined} */
359
564
  let instant
360
- /** @type {Temporal.ZonedDateTime | undefined} */
565
+ /** @type {import('temporal-polyfill/fns/ZonedDateTime').Record | undefined} */
361
566
  let zoned
362
567
  if (!isMoment && moment !== undefined && moment !== null) {
363
568
  if (typeof moment !== 'string') {
@@ -376,20 +581,20 @@ export function day(moment, tz) {
376
581
  // `'…[Asia/Tokoy]'` is a typo. Falling back to the instant would answer a
377
582
  // UTC day for both, silently, which is the defect this branch exists to fix.
378
583
  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')
584
+ // A calendar is judged only where it is applied. With a zone bracket it is: the fields get
585
+ // renumbered, and `toPlainDate()` carries the annotation into daymath's own output. Judged
586
+ // on the string, before the parse, so the message cannot depend on the runtime. A calendar
587
+ // that only relabels the year passes here, exactly as it does through toPlainDate.
588
+ supportedCalendar(moment, 'date')
386
589
  if (tz !== undefined && tz !== null) {
387
590
  throw new TypeError(
388
591
  `daymath: day() got two time zones, ${JSON.stringify(moment)} and ${JSON.stringify(tz)}`,
389
592
  )
390
593
  }
391
594
  try {
392
- zoned = Temporal.ZonedDateTime.from(moment)
595
+ // `getAny` again, for the same reason it is used everywhere else: a zoned string can carry
596
+ // a calendar annotation, and the shim funcApi would drop it under `getISO`.
597
+ zoned = ZonedFns.fromString(moment, getAny)
393
598
  } catch (err) {
394
599
  throw new RangeError(
395
600
  `daymath: day() could not read ${JSON.stringify(moment)} in the time zone it names`,
@@ -405,7 +610,7 @@ export function day(moment, tz) {
405
610
  // year, month or day for a calendar to renumber, and without a zone
406
611
  // bracket Temporal will not build anything that does. Refusing it would
407
612
  // reject a right answer for a reason that cannot apply.
408
- instant = Temporal.Instant.from(moment)
613
+ instant = InstantFns.fromString(moment)
409
614
  } catch {
410
615
  // Not a moment, so the string must be a zone — decided by shape, before
411
616
  // Temporal sees it. Temporal's zone grammar also accepts a whole
@@ -447,10 +652,10 @@ export function day(moment, tz) {
447
652
  // whatever the moment is. A caller mapping rows that are sometimes a Date and
448
653
  // sometimes a day string would otherwise see the typo only on some rows.
449
654
  //
450
- // `ZonedDateTime.from` accepts exactly what `Temporal.Now` accepts and reads
655
+ // `ZonedDateTime.fromFields` accepts exactly the zone ids `Now` accepts and reads
451
656
  // no clock, so a day input stays a pure function.
452
657
  try {
453
- Temporal.ZonedDateTime.from({ timeZone: zone, year: 1970, month: 1, day: 1 })
658
+ ZonedFns.fromFields({ timeZone: zone, year: 1970, month: 1, day: 1 })
454
659
  } catch (err) {
455
660
  throw new RangeError(
456
661
  `daymath: day() got an unknown time zone ${JSON.stringify(zone)}`,
@@ -460,27 +665,39 @@ export function day(moment, tz) {
460
665
 
461
666
  // A day carries no time, so a zone has nothing to shift. Applying one would
462
667
  // invent a moment the caller never gave.
463
- if (isDay) return toDayString(toPlainDate(moment))
668
+ //
669
+ // `isoOf` is what makes day() the normaliser. Every other export carries a
670
+ // `[u-ca=…]` annotation through, because the caller asked for that numbering.
671
+ // day() is the door: something that is not a plain ISO day goes in, and a
672
+ // plain ISO day comes out. Carrying it here also could not be made
673
+ // consistent, because an `Instant` has no fields for a calendar to renumber,
674
+ // so that path has nothing to carry and would answer bare ISO regardless.
675
+ if (isDay) return toDayString(isoOf(toPlainDate(moment)))
464
676
 
465
677
  // The string named its own zone, so that zone decides the day, not the
466
678
  // default. This is the one path where `zone` is deliberately not consulted.
467
679
  //
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.
680
+ // The result still goes through `toPlainDate`, so the calendar is adjudicated
681
+ // in one place, and then through `isoOf` for the reason above.
472
682
  if (zoned !== undefined) {
473
- const plain = guardRange('day', () => zoned.toPlainDate())
474
- return toDayString(toPlainDate(plain))
683
+ // `toPlainDate` is re-entered with the STRING, not the record, because daymath's own funnel
684
+ // takes a day string or a `Temporal.PlainDate`, and an fns record is neither. `PlainDateFns.toString`
685
+ // writes the same day the class API's `toPlainDate()` produced, annotation and all.
686
+ const plain = guardRange('day', () =>
687
+ PlainDateFns.toString(ZonedFns.toPlainDate(zoned)),
688
+ )
689
+ return toDayString(isoOf(toPlainDate(plain)))
475
690
  }
476
691
 
477
692
  if (instant !== undefined) {
478
693
  return guardRange('day', () =>
479
- instant.toZonedDateTimeISO(zone).toPlainDate().toString(),
694
+ PlainDateFns.toString(
695
+ ZonedFns.toPlainDate(InstantFns.toZonedDateTimeISO(instant, zone)),
696
+ ),
480
697
  )
481
698
  }
482
699
 
483
- if (!isMoment) return Temporal.Now.plainDateISO(zone).toString()
700
+ if (!isMoment) return PlainDateFns.toString(NowFns.plainDateISO(zone))
484
701
 
485
702
  // Truncate, because `new Date(n)` truncates, and the contract here is that a
486
703
  // number reads exactly as it does. Verified equal on positive and negative
@@ -492,10 +709,11 @@ export function day(moment, tz) {
492
709
  ? Math.trunc(moment)
493
710
  : /** @type {Date} */ (moment).getTime()
494
711
  return guardRange('day', () =>
495
- Temporal.Instant.fromEpochMilliseconds(epochMs)
496
- .toZonedDateTimeISO(zone)
497
- .toPlainDate()
498
- .toString(),
712
+ PlainDateFns.toString(
713
+ ZonedFns.toPlainDate(
714
+ InstantFns.toZonedDateTimeISO(InstantFns.fromEpochMilliseconds(epochMs), zone),
715
+ ),
716
+ ),
499
717
  )
500
718
  }
501
719
 
@@ -555,7 +773,7 @@ export function format(date, pattern = 'yyyy-MM-dd') {
555
773
  */
556
774
  export function addDays(date, amount) {
557
775
  assertFiniteNumber(amount, 'amount')
558
- return addDuration(date, { days: amount }, 'addDays')
776
+ return addDuration(date, 'days', amount, 'addDays')
559
777
  }
560
778
 
561
779
  /**
@@ -565,7 +783,7 @@ export function addDays(date, amount) {
565
783
  */
566
784
  export function subDays(date, amount) {
567
785
  assertFiniteNumber(amount, 'amount')
568
- return addDuration(date, { days: -amount }, 'subDays')
786
+ return addDuration(date, 'days', -amount, 'subDays')
569
787
  }
570
788
 
571
789
  /**
@@ -577,7 +795,7 @@ export function addWeeks(date, amount) {
577
795
  assertFiniteNumber(amount, 'amount')
578
796
  const days = amount * 7 // re-check: 7x a finite amount can still reach Infinity
579
797
  assertFiniteNumber(days, 'amount')
580
- return addDuration(date, { days }, 'addWeeks')
798
+ return addDuration(date, 'days', days, 'addWeeks')
581
799
  }
582
800
 
583
801
  /**
@@ -589,7 +807,7 @@ export function subWeeks(date, amount) {
589
807
  assertFiniteNumber(amount, 'amount')
590
808
  const days = -amount * 7
591
809
  assertFiniteNumber(days, 'amount')
592
- return addDuration(date, { days }, 'subWeeks')
810
+ return addDuration(date, 'days', days, 'subWeeks')
593
811
  }
594
812
 
595
813
  /**
@@ -600,7 +818,7 @@ export function subWeeks(date, amount) {
600
818
  */
601
819
  export function addMonths(date, amount) {
602
820
  assertFiniteNumber(amount, 'amount')
603
- return addDuration(date, { months: amount }, 'addMonths')
821
+ return addDuration(date, 'months', amount, 'addMonths')
604
822
  }
605
823
 
606
824
  /**
@@ -610,7 +828,7 @@ export function addMonths(date, amount) {
610
828
  */
611
829
  export function subMonths(date, amount) {
612
830
  assertFiniteNumber(amount, 'amount')
613
- return addDuration(date, { months: -amount }, 'subMonths')
831
+ return addDuration(date, 'months', -amount, 'subMonths')
614
832
  }
615
833
 
616
834
  /**
@@ -620,7 +838,7 @@ export function subMonths(date, amount) {
620
838
  */
621
839
  export function addYears(date, amount) {
622
840
  assertFiniteNumber(amount, 'amount')
623
- return addDuration(date, { years: amount }, 'addYears')
841
+ return addDuration(date, 'years', amount, 'addYears')
624
842
  }
625
843
 
626
844
  /**
@@ -630,7 +848,7 @@ export function addYears(date, amount) {
630
848
  */
631
849
  export function subYears(date, amount) {
632
850
  assertFiniteNumber(amount, 'amount')
633
- return addDuration(date, { years: -amount }, 'subYears')
851
+ return addDuration(date, 'years', -amount, 'subYears')
634
852
  }
635
853
 
636
854
  /**
@@ -642,7 +860,7 @@ export function addQuarters(date, amount) {
642
860
  assertFiniteNumber(amount, 'amount')
643
861
  const months = amount * 3
644
862
  assertFiniteNumber(months, 'amount')
645
- return addDuration(date, { months }, 'addQuarters')
863
+ return addDuration(date, 'months', months, 'addQuarters')
646
864
  }
647
865
 
648
866
  /**
@@ -654,7 +872,7 @@ export function subQuarters(date, amount) {
654
872
  assertFiniteNumber(amount, 'amount')
655
873
  const months = -amount * 3
656
874
  assertFiniteNumber(months, 'amount')
657
- return addDuration(date, { months }, 'subQuarters')
875
+ return addDuration(date, 'months', months, 'subQuarters')
658
876
  }
659
877
 
660
878
  // ─── getters / setters (date-fns / Date month & weekday indexing) ─
@@ -687,27 +905,51 @@ export function getDate(date) {
687
905
  * @returns {number}
688
906
  */
689
907
  export function getDay(date) {
690
- return toPlainDate(date).dayOfWeek
908
+ return PlainDateFns.dayOfWeek(toPlainDate(date))
691
909
  }
692
910
 
693
911
  /** @param {DayInput} date @returns {number} */
694
912
  export function getDayOfYear(date) {
695
- return toPlainDate(date).dayOfYear
913
+ return PlainDateFns.dayOfYear(toPlainDate(date))
696
914
  }
697
915
 
698
916
  /** @param {DayInput} date @returns {number} */
699
917
  export function getDaysInMonth(date) {
700
- return toPlainDate(date).daysInMonth
918
+ return PlainDateFns.daysInMonth(toPlainDate(date))
919
+ }
920
+
921
+ /**
922
+ * Quarter of a record daymath already owns.
923
+ *
924
+ * The class API needed no such helper, because a `Temporal.PlainDate` passed back into
925
+ * `toPlainDate` was recognised by `isPlainDate` and survived the round trip. An fns record is not
926
+ * a `Temporal.PlainDate` and is correctly refused, so every internal caller now works on the
927
+ * record. That was always the honest shape: re-validating a value daymath just built is waste.
928
+ * @param {PlainDateRecord} plain
929
+ * @returns {number}
930
+ */
931
+ function quarterOf(plain) {
932
+ return Math.ceil(plain.month / 3)
933
+ }
934
+
935
+ /**
936
+ * Calendar month index between two records already normalised to ISO.
937
+ * @param {PlainDateRecord} left
938
+ * @param {PlainDateRecord} right
939
+ * @returns {number}
940
+ */
941
+ function calendarMonthsBetween(left, right) {
942
+ return (left.year - right.year) * 12 + (left.month - right.month)
701
943
  }
702
944
 
703
945
  /** Quarter 1…4. @param {DayInput} date @returns {number} */
704
946
  export function getQuarter(date) {
705
- return Math.ceil(toPlainDate(date).month / 3)
947
+ return quarterOf(toPlainDate(date))
706
948
  }
707
949
 
708
950
  /** @param {DayInput} date @returns {boolean} */
709
951
  export function isLeapYear(date) {
710
- return toPlainDate(date).inLeapYear
952
+ return PlainDateFns.inLeapYear(toPlainDate(date))
711
953
  }
712
954
 
713
955
  /**
@@ -718,7 +960,7 @@ export function isLeapYear(date) {
718
960
  export function setYear(date, year) {
719
961
  assertFiniteNumber(year, 'year')
720
962
  const d = toPlainDate(date)
721
- return guardRange('setYear', () => toDayString(d.with({ year })))
963
+ return guardRange('setYear', () => toDayString(PlainDateFns.withFields(d, { year })))
722
964
  }
723
965
 
724
966
  /**
@@ -732,7 +974,7 @@ export function setMonth(date, month) {
732
974
  throw new RangeError('daymath: month must be 1…12 (1=January)')
733
975
  }
734
976
  const d = toPlainDate(date)
735
- return guardRange('setMonth', () => toDayString(d.with({ month })))
977
+ return guardRange('setMonth', () => toDayString(PlainDateFns.withFields(d, { month })))
736
978
  }
737
979
 
738
980
  /**
@@ -744,7 +986,9 @@ export function setMonth(date, month) {
744
986
  export function setDate(date, dayOfMonth) {
745
987
  assertFiniteNumber(dayOfMonth, 'day')
746
988
  const d = toPlainDate(date)
747
- return guardRange('setDate', () => toDayString(d.with({ day: dayOfMonth })))
989
+ return guardRange('setDate', () =>
990
+ toDayString(PlainDateFns.withFields(d, { day: dayOfMonth })),
991
+ )
748
992
  }
749
993
 
750
994
  // ─── start / end of unit ───────────────────────────────────────────
@@ -756,41 +1000,53 @@ export function setDate(date, dayOfMonth) {
756
1000
  /** @param {DayInput} date @returns {string} */
757
1001
  export function startOfMonth(date) {
758
1002
  const d = toPlainDate(date)
759
- return guardRange('startOfMonth', () => toDayString(d.with({ day: 1 })))
1003
+ return guardRange('startOfMonth', () =>
1004
+ toDayString(PlainDateFns.withFields(d, { day: 1 })),
1005
+ )
760
1006
  }
761
1007
 
762
1008
  /** @param {DayInput} date @returns {string} */
763
1009
  export function endOfMonth(date) {
764
1010
  const d = toPlainDate(date)
765
- return guardRange('endOfMonth', () => toDayString(d.with({ day: d.daysInMonth })))
1011
+ return guardRange('endOfMonth', () =>
1012
+ toDayString(PlainDateFns.withFields(d, { day: PlainDateFns.daysInMonth(d) })),
1013
+ )
766
1014
  }
767
1015
 
768
1016
  /** @param {DayInput} date @returns {string} */
769
1017
  export function startOfYear(date) {
770
1018
  const d = toPlainDate(date)
771
- return guardRange('startOfYear', () => toDayString(d.with({ month: 1, day: 1 })))
1019
+ return guardRange('startOfYear', () =>
1020
+ toDayString(PlainDateFns.withFields(d, { month: 1, day: 1 })),
1021
+ )
772
1022
  }
773
1023
 
774
1024
  /** @param {DayInput} date @returns {string} */
775
1025
  export function endOfYear(date) {
776
1026
  const d = toPlainDate(date)
777
- return guardRange('endOfYear', () => toDayString(d.with({ month: 12, day: 31 })))
1027
+ return guardRange('endOfYear', () =>
1028
+ toDayString(PlainDateFns.withFields(d, { month: 12, day: 31 })),
1029
+ )
778
1030
  }
779
1031
 
780
1032
  /** @param {DayInput} date @returns {string} */
781
1033
  export function startOfQuarter(date) {
782
1034
  const d = toPlainDate(date)
783
- const month = (getQuarter(d) - 1) * 3 + 1
784
- return guardRange('startOfQuarter', () => toDayString(d.with({ month, day: 1 })))
1035
+ const month = (quarterOf(d) - 1) * 3 + 1
1036
+ return guardRange('startOfQuarter', () =>
1037
+ toDayString(PlainDateFns.withFields(d, { month, day: 1 })),
1038
+ )
785
1039
  }
786
1040
 
787
1041
  /** @param {DayInput} date @returns {string} */
788
1042
  export function endOfQuarter(date) {
789
1043
  const d = toPlainDate(date)
790
- const month = getQuarter(d) * 3
1044
+ const month = quarterOf(d) * 3
791
1045
  return guardRange('endOfQuarter', () => {
792
- const mid = d.with({ month, day: 1 })
793
- return toDayString(mid.with({ day: mid.daysInMonth }))
1046
+ const mid = PlainDateFns.withFields(d, { month, day: 1 })
1047
+ return toDayString(
1048
+ PlainDateFns.withFields(mid, { day: PlainDateFns.daysInMonth(mid) }),
1049
+ )
794
1050
  })
795
1051
  }
796
1052
 
@@ -802,19 +1058,19 @@ export function endOfQuarter(date) {
802
1058
  export function startOfWeek(date, options) {
803
1059
  const d = toPlainDate(date)
804
1060
  const diff = daysIntoWeek(d, options)
805
- return guardRange('startOfWeek', () => toDayString(d.subtract({ days: diff })))
1061
+ return guardRange('startOfWeek', () => toDayString(PlainDateFns.subtractDays(d, diff)))
806
1062
  }
807
1063
 
808
1064
  /**
809
1065
  * How far the day sits past the start of its week.
810
- * @param {Temporal.PlainDate} d
1066
+ * @param {PlainDateRecord} d
811
1067
  * @param {WeekOptions} [options]
812
1068
  * @returns {number}
813
1069
  */
814
1070
  function daysIntoWeek(d, options) {
815
1071
  const weekStartsOn = weekStartsOnFrom(options)
816
1072
  // mod 7 makes weekStartsOn 0 and 7 identical, so both spellings of Sunday work
817
- return (d.dayOfWeek - weekStartsOn + 7) % 7
1073
+ return (PlainDateFns.dayOfWeek(d) - weekStartsOn + 7) % 7
818
1074
  }
819
1075
 
820
1076
  /**
@@ -827,7 +1083,7 @@ export function endOfWeek(date, options) {
827
1083
  const diff = daysIntoWeek(d, options)
828
1084
  // one guard for the whole walk, so a failure at either end says endOfWeek
829
1085
  return guardRange('endOfWeek', () =>
830
- toDayString(d.subtract({ days: diff }).add({ days: 6 })),
1086
+ toDayString(PlainDateFns.addDays(PlainDateFns.subtractDays(d, diff), 6)),
831
1087
  )
832
1088
  }
833
1089
 
@@ -842,7 +1098,26 @@ export function endOfWeek(date, options) {
842
1098
  export function differenceInDays(dateLeft, dateRight) {
843
1099
  const left = toPlainDate(dateLeft, 'dateLeft')
844
1100
  const right = toPlainDate(dateRight, 'dateRight')
845
- return left.since(right, { largestUnit: 'day' }).days
1101
+ // `diff` IS Temporal: it is the `fns` spelling of `PlainDate.prototype.until`, which is what
1102
+ // `since` was here with the operands the other way round. The choice below is between two
1103
+ // Temporal functions, not between Temporal and anything else.
1104
+ //
1105
+ // `diff`, NOT `diffDays`, and the reason is the one day where the two Temporal ranges disagree.
1106
+ //
1107
+ // `PlainDate`'s minimum is `-271821-04-19`, one day BELOW `PlainDateTime`'s, because midnight on
1108
+ // that day is out of bounds while the same day is reachable in a positive-offset zone. The
1109
+ // maximum is not widened, so the asymmetry is real and only the low edge has it. Measured:
1110
+ // `PlainDate.from('-271821-04-19').toPlainDateTime()` throws `Out-of-bounds date`, and
1111
+ // `-271821-04-20` does not.
1112
+ //
1113
+ // `diffDays` converts to a `PlainDateTime` and inherits that, so it throws on exactly one
1114
+ // operand. `diff` with `largestUnit: 'day'` does not, and answers what the class API's `since`
1115
+ // answered. The cross-runtime baseline caught it, because no test in the suite covers a pair
1116
+ // that wide; `differenceInDays` and `differenceInWeeks` both went red.
1117
+ //
1118
+ // The argument order is `(record, other)` for `other − record`, so the operands are swapped to
1119
+ // keep daymath's date-fns order of `dateLeft − dateRight`.
1120
+ return PlainDateFns.diff(isoOf(right), isoOf(left), { largestUnit: 'day' }).days
846
1121
  }
847
1122
 
848
1123
  /**
@@ -871,18 +1146,22 @@ export function differenceInWeeks(dateLeft, dateRight) {
871
1146
  * @returns {number}
872
1147
  */
873
1148
  export function differenceInMonths(dateLeft, dateRight) {
874
- const left = toPlainDate(dateLeft, 'dateLeft')
875
- const right = toPlainDate(dateRight, 'dateRight')
876
- const sign = Temporal.PlainDate.compare(left, right)
1149
+ // `isoOf` on both, like differenceInDays. Without it this function read TWO coordinate systems
1150
+ // at once: the sign came from `compare`, which ignores the calendar, and the magnitude came from
1151
+ // `differenceInCalendarMonths`, which does not. A mixed pair then measured 6,513 months for a
1152
+ // 29-day gap, and two days 543 ISO years apart measured 0.
1153
+ const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1154
+ const right = isoOf(toPlainDate(dateRight, 'dateRight'))
1155
+ const sign = PlainDateFns.compare(left, right)
877
1156
  if (sign === 0) return 0
878
- const diff = Math.abs(differenceInCalendarMonths(left, right))
1157
+ const diff = Math.abs(calendarMonthsBetween(left, right))
879
1158
  if (diff < 1) return 0
880
1159
  const [earlier, later] = sign > 0 ? [right, left] : [left, right]
881
1160
  // Where `earlier` lands after `diff` months: same year-month as `later` by
882
1161
  // construction, on `earlier`'s day clamped to that month's length. Compared
883
1162
  // as day numbers rather than built as a date, because the landing can sit
884
1163
  // past the maximum PlainDate even when both operands are inside the range.
885
- const landingDay = Math.min(earlier.day, later.daysInMonth)
1164
+ const landingDay = Math.min(earlier.day, PlainDateFns.daysInMonth(later))
886
1165
  const isLastMonthNotFull = landingDay > later.day
887
1166
  return sign * (diff - +isLastMonthNotFull) || 0
888
1167
  }
@@ -894,9 +1173,9 @@ export function differenceInMonths(dateLeft, dateRight) {
894
1173
  * @returns {number}
895
1174
  */
896
1175
  export function differenceInCalendarMonths(dateLeft, dateRight) {
897
- const left = toPlainDate(dateLeft, 'dateLeft')
898
- const right = toPlainDate(dateRight, 'dateRight')
899
- return (left.year - right.year) * 12 + (left.month - right.month)
1176
+ const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1177
+ const right = isoOf(toPlainDate(dateRight, 'dateRight'))
1178
+ return calendarMonthsBetween(left, right)
900
1179
  }
901
1180
 
902
1181
  /**
@@ -910,9 +1189,10 @@ export function differenceInCalendarMonths(dateLeft, dateRight) {
910
1189
  * @returns {number}
911
1190
  */
912
1191
  export function differenceInYears(dateLeft, dateRight) {
913
- const left = toPlainDate(dateLeft, 'dateLeft')
914
- const right = toPlainDate(dateRight, 'dateRight')
915
- const sign = Temporal.PlainDate.compare(left, right)
1192
+ // `isoOf` on both: a measurement, so it ignores the year LABEL. See differenceInMonths.
1193
+ const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1194
+ const right = isoOf(toPlainDate(dateRight, 'dateRight'))
1195
+ const sign = PlainDateFns.compare(left, right)
916
1196
  if (sign === 0) return 0
917
1197
  const diff = Math.abs(left.year - right.year)
918
1198
  if (diff < 1) return 0
@@ -923,7 +1203,9 @@ export function differenceInYears(dateLeft, dateRight) {
923
1203
  // Compared field by field rather than built as a date, because the landing
924
1204
  // can sit past the maximum PlainDate even with both operands inside the range.
925
1205
  const landingDay =
926
- earlier.month === 2 && earlier.day === 29 && !later.inLeapYear ? 28 : earlier.day
1206
+ earlier.month === 2 && earlier.day === 29 && !PlainDateFns.inLeapYear(later)
1207
+ ? 28
1208
+ : earlier.day
927
1209
  const isLastYearNotFull =
928
1210
  earlier.month > later.month ||
929
1211
  (earlier.month === later.month && landingDay > later.day)
@@ -937,7 +1219,12 @@ export function differenceInYears(dateLeft, dateRight) {
937
1219
  * @returns {number}
938
1220
  */
939
1221
  export function differenceInCalendarYears(dateLeft, dateRight) {
940
- return getYear(dateLeft) - getYear(dateRight)
1222
+ // NOT getYear: that is a field read and honours the label, so a mixed pair subtracted two
1223
+ // different year spaces and answered 0 for days 543 ISO years apart.
1224
+ return (
1225
+ isoOf(toPlainDate(dateLeft, 'dateLeft')).year -
1226
+ isoOf(toPlainDate(dateRight, 'dateRight')).year
1227
+ )
941
1228
  }
942
1229
 
943
1230
  /**
@@ -959,9 +1246,9 @@ export function differenceInQuarters(dateLeft, dateRight) {
959
1246
  * @returns {number}
960
1247
  */
961
1248
  export function differenceInCalendarQuarters(dateLeft, dateRight) {
962
- const left = toPlainDate(dateLeft, 'dateLeft')
963
- const right = toPlainDate(dateRight, 'dateRight')
964
- return (left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
1249
+ const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1250
+ const right = isoOf(toPlainDate(dateRight, 'dateRight'))
1251
+ return (left.year - right.year) * 4 + (quarterOf(left) - quarterOf(right))
965
1252
  }
966
1253
 
967
1254
  // ─── compare / equal ───────────────────────────────────────────────
@@ -973,10 +1260,8 @@ export function differenceInCalendarQuarters(dateLeft, dateRight) {
973
1260
  */
974
1261
  export function isBefore(date, dateToCompare) {
975
1262
  return (
976
- Temporal.PlainDate.compare(
977
- toPlainDate(date),
978
- toPlainDate(dateToCompare, 'dateToCompare'),
979
- ) < 0
1263
+ PlainDateFns.compare(toPlainDate(date), toPlainDate(dateToCompare, 'dateToCompare')) <
1264
+ 0
980
1265
  )
981
1266
  }
982
1267
 
@@ -987,10 +1272,8 @@ export function isBefore(date, dateToCompare) {
987
1272
  */
988
1273
  export function isAfter(date, dateToCompare) {
989
1274
  return (
990
- Temporal.PlainDate.compare(
991
- toPlainDate(date),
992
- toPlainDate(dateToCompare, 'dateToCompare'),
993
- ) > 0
1275
+ PlainDateFns.compare(toPlainDate(date), toPlainDate(dateToCompare, 'dateToCompare')) >
1276
+ 0
994
1277
  )
995
1278
  }
996
1279
 
@@ -1002,7 +1285,7 @@ export function isAfter(date, dateToCompare) {
1002
1285
  */
1003
1286
  export function isEqual(dateLeft, dateRight) {
1004
1287
  return (
1005
- Temporal.PlainDate.compare(
1288
+ PlainDateFns.compare(
1006
1289
  toPlainDate(dateLeft, 'dateLeft'),
1007
1290
  toPlainDate(dateRight, 'dateRight'),
1008
1291
  ) === 0
@@ -1022,10 +1305,15 @@ export function isSameWeek(dateLeft, dateRight, options) {
1022
1305
  const left = toPlainDate(dateLeft, 'dateLeft')
1023
1306
  const right = toPlainDate(dateRight, 'dateRight')
1024
1307
  // 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) })),
1308
+ // compare, not equals: Temporal's equals compares the calendar as well as the day, so it
1309
+ // answers false for the same week in two calendars. compare reads the ISO fields alone.
1310
+ return guardRange(
1311
+ 'isSameWeek',
1312
+ () =>
1313
+ PlainDateFns.compare(
1314
+ PlainDateFns.subtractDays(left, daysIntoWeek(left, options)),
1315
+ PlainDateFns.subtractDays(right, daysIntoWeek(right, options)),
1316
+ ) === 0,
1029
1317
  )
1030
1318
  }
1031
1319
 
@@ -1035,8 +1323,8 @@ export function isSameWeek(dateLeft, dateRight, options) {
1035
1323
  * @returns {boolean}
1036
1324
  */
1037
1325
  export function isSameMonth(dateLeft, dateRight) {
1038
- const a = toPlainDate(dateLeft, 'dateLeft')
1039
- const b = toPlainDate(dateRight, 'dateRight')
1326
+ const a = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1327
+ const b = isoOf(toPlainDate(dateRight, 'dateRight'))
1040
1328
  return a.year === b.year && a.month === b.month
1041
1329
  }
1042
1330
 
@@ -1046,7 +1334,12 @@ export function isSameMonth(dateLeft, dateRight) {
1046
1334
  * @returns {boolean}
1047
1335
  */
1048
1336
  export function isSameYear(dateLeft, dateRight) {
1049
- return getYear(dateLeft) === getYear(dateRight)
1337
+ // NOT getYear, for the same reason as differenceInCalendarYears: two days 543 ISO years apart
1338
+ // both read year 2569 once one of them carries a Buddhist label, and this answered true.
1339
+ return (
1340
+ isoOf(toPlainDate(dateLeft, 'dateLeft')).year ===
1341
+ isoOf(toPlainDate(dateRight, 'dateRight')).year
1342
+ )
1050
1343
  }
1051
1344
 
1052
1345
  /**
@@ -1055,9 +1348,9 @@ export function isSameYear(dateLeft, dateRight) {
1055
1348
  * @returns {boolean}
1056
1349
  */
1057
1350
  export function isSameQuarter(dateLeft, dateRight) {
1058
- const a = toPlainDate(dateLeft, 'dateLeft')
1059
- const b = toPlainDate(dateRight, 'dateRight')
1060
- return a.year === b.year && getQuarter(a) === getQuarter(b)
1351
+ const a = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1352
+ const b = isoOf(toPlainDate(dateRight, 'dateRight'))
1353
+ return a.year === b.year && quarterOf(a) === quarterOf(b)
1061
1354
  }
1062
1355
 
1063
1356
  /**
@@ -1067,7 +1360,7 @@ export function isSameQuarter(dateLeft, dateRight) {
1067
1360
  */
1068
1361
  export function compareAsc(dateLeft, dateRight) {
1069
1362
  return /** @type {-1 | 0 | 1} */ (
1070
- Temporal.PlainDate.compare(
1363
+ PlainDateFns.compare(
1071
1364
  toPlainDate(dateLeft, 'dateLeft'),
1072
1365
  toPlainDate(dateRight, 'dateRight'),
1073
1366
  )
@@ -1093,7 +1386,7 @@ export function min(dates) {
1093
1386
  return toDayString(
1094
1387
  dates
1095
1388
  .map((d) => toPlainDate(d))
1096
- .reduce((a, b) => (Temporal.PlainDate.compare(a, b) <= 0 ? a : b)),
1389
+ .reduce((a, b) => (PlainDateFns.compare(a, b) <= 0 ? a : b)),
1097
1390
  )
1098
1391
  }
1099
1392
 
@@ -1106,7 +1399,7 @@ export function max(dates) {
1106
1399
  return toDayString(
1107
1400
  dates
1108
1401
  .map((d) => toPlainDate(d))
1109
- .reduce((a, b) => (Temporal.PlainDate.compare(a, b) >= 0 ? a : b)),
1402
+ .reduce((a, b) => (PlainDateFns.compare(a, b) >= 0 ? a : b)),
1110
1403
  )
1111
1404
  }
1112
1405
 
@@ -1154,7 +1447,7 @@ export function isFirstDayOfMonth(date) {
1154
1447
  /** @param {DayInput} date @returns {boolean} */
1155
1448
  export function isLastDayOfMonth(date) {
1156
1449
  const d = toPlainDate(date)
1157
- return d.day === d.daysInMonth
1450
+ return d.day === PlainDateFns.daysInMonth(d)
1158
1451
  }
1159
1452
 
1160
1453
  // ─── intervals ─────────────────────────────────────────────────────
@@ -1166,7 +1459,7 @@ export function isLastDayOfMonth(date) {
1166
1459
  */
1167
1460
  export function eachDayOfInterval(interval) {
1168
1461
  const { start, end } = toInterval(interval)
1169
- if (Temporal.PlainDate.compare(start, end) > 0) {
1462
+ if (PlainDateFns.compare(start, end) > 0) {
1170
1463
  throw new RangeError('daymath: interval start must not be after end')
1171
1464
  }
1172
1465
  /** @type {string[]} */
@@ -1176,8 +1469,8 @@ export function eachDayOfInterval(interval) {
1176
1469
  // PlainDate (+275760-09-13) throws
1177
1470
  for (;;) {
1178
1471
  out.push(toDayString(cur))
1179
- if (Temporal.PlainDate.compare(cur, end) >= 0) break
1180
- cur = cur.add({ days: 1 })
1472
+ if (PlainDateFns.compare(cur, end) >= 0) break
1473
+ cur = PlainDateFns.addDays(cur, 1)
1181
1474
  }
1182
1475
  return out
1183
1476
  }
@@ -1189,22 +1482,22 @@ export function eachDayOfInterval(interval) {
1189
1482
  */
1190
1483
  export function eachMonthOfInterval(interval) {
1191
1484
  const { start, end } = toInterval(interval)
1192
- if (Temporal.PlainDate.compare(start, end) > 0) {
1485
+ if (PlainDateFns.compare(start, end) > 0) {
1193
1486
  throw new RangeError('daymath: interval start must not be after end')
1194
1487
  }
1195
1488
  /** @type {string[]} */
1196
1489
  const out = []
1197
1490
  // the 1st of start's month can sit below the minimum PlainDate
1198
1491
  const [cur0, last] = guardRange('eachMonthOfInterval', () => [
1199
- start.with({ day: 1 }),
1200
- end.with({ day: 1 }),
1492
+ PlainDateFns.withFields(start, { day: 1 }),
1493
+ PlainDateFns.withFields(end, { day: 1 }),
1201
1494
  ])
1202
1495
  let cur = cur0
1203
1496
  // same boundary rule as eachDayOfInterval
1204
1497
  for (;;) {
1205
1498
  out.push(toDayString(cur))
1206
- if (Temporal.PlainDate.compare(cur, last) >= 0) break
1207
- cur = cur.add({ months: 1 })
1499
+ if (PlainDateFns.compare(cur, last) >= 0) break
1500
+ cur = PlainDateFns.addMonths(cur, 1)
1208
1501
  }
1209
1502
  return out
1210
1503
  }
@@ -1216,17 +1509,23 @@ export function eachMonthOfInterval(interval) {
1216
1509
  */
1217
1510
  export function eachYearOfInterval(interval) {
1218
1511
  const { start, end } = toInterval(interval)
1219
- if (Temporal.PlainDate.compare(start, end) > 0) {
1512
+ if (PlainDateFns.compare(start, end) > 0) {
1220
1513
  throw new RangeError('daymath: interval start must not be after end')
1221
1514
  }
1222
1515
  /** @type {string[]} */
1223
1516
  const out = []
1224
- let y = start.year
1225
- // Jan 1 of start's year can sit below the minimum PlainDate
1517
+ // `with` then `add`, never `PlainDate.from({year})`: both keep start's calendar, and the object
1518
+ // form would read `year` as an ISO year, which is 543 years out for a Buddhist day.
1519
+ // Jan 1 of start's year can sit below the minimum PlainDate.
1226
1520
  guardRange('eachYearOfInterval', () => {
1227
- while (y <= end.year) {
1228
- out.push(toDayString(Temporal.PlainDate.from({ year: y, month: 1, day: 1 })))
1229
- y += 1
1521
+ // Compare ISO years, then add. Testing the loop condition AFTER the push is what keeps the
1522
+ // top edge working: Jan 1 of +275760 is valid, and adding a year to it is not.
1523
+ const lastIsoYear = isoOf(end).year
1524
+ let cur = PlainDateFns.withFields(start, { month: 1, day: 1 })
1525
+ for (;;) {
1526
+ out.push(toDayString(cur))
1527
+ if (isoOf(cur).year >= lastIsoYear) break
1528
+ cur = PlainDateFns.addYears(cur, 1)
1230
1529
  }
1231
1530
  })
1232
1531
  return out
@@ -1241,12 +1540,10 @@ export function eachYearOfInterval(interval) {
1241
1540
  export function isWithinInterval(date, interval) {
1242
1541
  const d = toPlainDate(date)
1243
1542
  const { start, end } = toInterval(interval)
1244
- if (Temporal.PlainDate.compare(start, end) > 0) {
1543
+ if (PlainDateFns.compare(start, end) > 0) {
1245
1544
  throw new RangeError('daymath: interval start must not be after end')
1246
1545
  }
1247
- return (
1248
- Temporal.PlainDate.compare(d, start) >= 0 && Temporal.PlainDate.compare(d, end) <= 0
1249
- )
1546
+ return PlainDateFns.compare(d, start) >= 0 && PlainDateFns.compare(d, end) <= 0
1250
1547
  }
1251
1548
 
1252
1549
  /**
@@ -1258,11 +1555,11 @@ export function isWithinInterval(date, interval) {
1258
1555
  export function clamp(date, interval) {
1259
1556
  const d = toPlainDate(date)
1260
1557
  const { start, end } = toInterval(interval)
1261
- if (Temporal.PlainDate.compare(start, end) > 0) {
1558
+ if (PlainDateFns.compare(start, end) > 0) {
1262
1559
  throw new RangeError('daymath: interval start must not be after end')
1263
1560
  }
1264
- if (Temporal.PlainDate.compare(d, start) < 0) return toDayString(start)
1265
- if (Temporal.PlainDate.compare(d, end) > 0) return toDayString(end)
1561
+ if (PlainDateFns.compare(d, start) < 0) return toDayString(start)
1562
+ if (PlainDateFns.compare(d, end) > 0) return toDayString(end)
1266
1563
  return toDayString(d)
1267
1564
  }
1268
1565
 
@@ -1276,22 +1573,21 @@ export function clamp(date, interval) {
1276
1573
  export function areIntervalsOverlapping(intervalLeft, intervalRight, options) {
1277
1574
  const a = toInterval(intervalLeft)
1278
1575
  const b = toInterval(intervalRight)
1279
- if (Temporal.PlainDate.compare(a.start, a.end) > 0) {
1576
+ if (PlainDateFns.compare(a.start, a.end) > 0) {
1280
1577
  throw new RangeError('daymath: intervalLeft start must not be after end')
1281
1578
  }
1282
- if (Temporal.PlainDate.compare(b.start, b.end) > 0) {
1579
+ if (PlainDateFns.compare(b.start, b.end) > 0) {
1283
1580
  throw new RangeError('daymath: intervalRight start must not be after end')
1284
1581
  }
1285
1582
  const inclusive = options?.inclusive ?? false
1286
1583
  if (inclusive) {
1287
1584
  return (
1288
- Temporal.PlainDate.compare(a.start, b.end) <= 0 &&
1289
- Temporal.PlainDate.compare(b.start, a.end) <= 0
1585
+ PlainDateFns.compare(a.start, b.end) <= 0 &&
1586
+ PlainDateFns.compare(b.start, a.end) <= 0
1290
1587
  )
1291
1588
  }
1292
1589
  // date-fns default: touch-at-endpoint is NOT overlap
1293
1590
  return (
1294
- Temporal.PlainDate.compare(a.start, b.end) < 0 &&
1295
- Temporal.PlainDate.compare(b.start, a.end) < 0
1591
+ PlainDateFns.compare(a.start, b.end) < 0 && PlainDateFns.compare(b.start, a.end) < 0
1296
1592
  )
1297
1593
  }