daymath 0.2.3 → 0.4.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 +161 -9
  2. package/index.d.ts +43 -7
  3. package/index.js +575 -93
  4. package/package.json +21 -7
package/index.js CHANGED
@@ -1,15 +1,86 @@
1
- /** daymath — calendar date math (ISO 8601 day). date-fns-shaped. No Date / time zones. */
2
- import { Temporal as TemporalPolyfill } from 'temporal-polyfill'
3
-
4
- const Temporal = globalThis.Temporal ?? TemporalPolyfill
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'
5
6
 
6
7
  /**
7
8
  * ISO 8601 calendar day string:
8
9
  * - `YYYY-MM-DD` (years 0000–9999)
9
10
  * - expanded `±YYYYYY-MM-DD` (Temporal form, e.g. `+010000-01-01`)
11
+ *
12
+ * The shape passes this regex; the value must also fall inside the Temporal
13
+ * `PlainDate` range `-271821-04-19` … `+275760-09-13` (roughly ±10^8 days from
14
+ * the epoch). Outside it, Temporal throws and we re-throw with the `daymath:`
15
+ * prefix.
10
16
  */
11
- const ISO_DAY =
12
- /^(?:[+-]\d{6}|\d{4})-\d{2}-\d{2}$/
17
+ const ISO_DAY = /^(?:[+-]\d{6}|\d{4})-\d{2}-\d{2}$/u
18
+
19
+ /**
20
+ * Temporal's calendar annotation, `[u-ca=…]` or the critical `[!u-ca=…]`.
21
+ * Returns what it is attached to, and the calendar it names, or `null`.
22
+ *
23
+ * String operations, not a regex, and the reason is measured. The annotation can
24
+ * sit behind another one — `'…[America/New_York][u-ca=buddhist]'` — so the head
25
+ * may itself contain `[`. A pattern that allows that needs `^(.*)\[…\]$`, whose
26
+ * `.*` backtracks: `'[u-ca='.repeat(64000)` cost 9.0 s, growing with the square
27
+ * of the input. `lastIndexOf` answers the same question in one pass. CodeQL
28
+ * `js/polynomial-redos` caught the regex form; timing it confirmed the report.
29
+ * @param {string} text
30
+ * @returns {{ head: string, calendar: string } | null}
31
+ */
32
+ function calendarAnnotation(text) {
33
+ if (!text.endsWith(']')) return null
34
+ const open = text.lastIndexOf('[')
35
+ if (open === -1) return null
36
+ const inner = text.slice(open + 1, -1)
37
+ const body = inner.startsWith('!') ? inner.slice(1) : inner
38
+ if (!body.startsWith('u-ca=')) return null
39
+ const calendar = body.slice(5)
40
+ // A `]` inside means the brackets do not nest as they appear, so this is not
41
+ // an annotation. `'[u-ca=[u-ca=[u-ca=x]]]'` reads as the calendar `x]]` here
42
+ // and as a malformed day everywhere else, which is what it is.
43
+ if (calendar === '' || calendar.includes(']')) return null
44
+ return { head: text.slice(0, open), calendar }
45
+ }
46
+
47
+ /**
48
+ * True when the string carries a time-zone annotation, `[Zone]` or `[!Zone]`.
49
+ * It is the one annotation with no `=`, which separates it from `[u-ca=…]`, and
50
+ * Temporal writes it first, so only the leading bracket can be one.
51
+ *
52
+ * Asking "does the string name a zone?" is a different question from "does it
53
+ * resolve?". A string can name one and still fail: an offset that disagrees with
54
+ * the zone, or a misspelled name. Both must be errors, never a silent fallback
55
+ * to the UTC default.
56
+ *
57
+ * String operations again. An unanchored `/\[!?[^\]=]+\]/` restarts at every
58
+ * position: `'[!'.repeat(64000)` cost 6.8 s, and it is the same defect an
59
+ * earlier commit removed from the calendar pattern.
60
+ * @param {string} text
61
+ */
62
+ function hasZoneAnnotation(text) {
63
+ const open = text.indexOf('[')
64
+ if (open === -1) return false
65
+ const close = text.indexOf(']', open)
66
+ if (close === -1) return false
67
+ return !text.slice(open + 1, close).includes('=')
68
+ }
69
+
70
+ /**
71
+ * A time zone, by shape: an IANA name, or a bare offset.
72
+ *
73
+ * A name is letter-led, slash-separated segments of letters, digits, `_`, `+`,
74
+ * `-` and `.`. It carries no `:`, which is what keeps an ISO time out. The
75
+ * `(?![Tt]\d)` guard rejects the compact spelling `T120000Z`, which has no `:`
76
+ * to catch it. Both matter: `T12:00:00Z` is a zone to native Temporal and not
77
+ * to temporal-polyfill, so letting either through splits the answer by runtime.
78
+ *
79
+ * Verified against every zone this runtime knows: 0 of 418 rejected, plus the
80
+ * aliases `UTC`, `GMT`, `US/Eastern`, `Asia/Calcutta` and `Etc/GMT+5`.
81
+ */
82
+ const ZONE_LIKE =
83
+ /^(?:(?![Tt]\d)[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)*|[+-]\d{2}(?::?\d{2})?)$/u
13
84
 
14
85
  /** @typedef {string | Temporal.PlainDate} DayInput */
15
86
  /**
@@ -19,13 +90,82 @@ const ISO_DAY =
19
90
  */
20
91
  /**
21
92
  * @typedef {object} WeekOptions
22
- * @property {0|1|2|3|4|5|6} [weekStartsOn] 0=Sun6=Sat (date-fns default 0)
93
+ * @property {0|1|2|3|4|5|6|7} [weekStartsOn] ISO 1=Mon7=Sun (default 7).
94
+ * `0` is also accepted for Sunday, because 0 ≡ 7 (mod 7) and the week-offset
95
+ * arithmetic cannot tell them apart. Pre-0.3.0 callers keep working unchanged.
23
96
  */
24
97
 
25
98
  // ─── core conversion ───────────────────────────────────────────────
26
99
 
100
+ /**
101
+ * The bare ISO day inside a string, or `null` if there is not one.
102
+ *
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.
108
+ *
109
+ * One predicate, because `toPlainDate` and `day()` both ask this question. They
110
+ * asked it separately once, and day() alone then refused a string that every
111
+ * other export accepted.
112
+ * @param {string} text
113
+ * @returns {string | null}
114
+ */
115
+ function bareDay(text) {
116
+ const annotated = calendarAnnotation(text)
117
+ const bare = annotated ? annotated.head : text
118
+ return ISO_DAY.test(bare) ? bare : null
119
+ }
120
+
121
+ /**
122
+ * Refuse a non-ISO calendar, wherever the annotation is attached.
123
+ *
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.
127
+ * @param {string} text
128
+ * @param {string} label
129
+ */
130
+ function assertIsoCalendar(text, label) {
131
+ 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
+ }
137
+ }
138
+
139
+ /**
140
+ * True for a `Temporal.PlainDate` from *any* implementation: native, the
141
+ * bundled polyfill, or a second copy of it in the same dependency tree.
142
+ * `instanceof` recognises only one of those. Every Temporal puts this tag on
143
+ * `PlainDate.prototype` as a non-writable property, so it is the portable brand.
144
+ * @param {unknown} value
145
+ * @returns {value is Temporal.PlainDate} a predicate, so callers narrow
146
+ */
147
+ function isPlainDate(value) {
148
+ return Object.prototype.toString.call(value) === '[object Temporal.PlainDate]'
149
+ }
150
+
27
151
  /**
28
152
  * Reject Date and non-calendar values. Accept ISO day string or PlainDate.
153
+ *
154
+ * A PlainDate becomes its ISO day string first, so every input reaches one
155
+ * validation path and daymath owns the resulting instance.
156
+ *
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.
29
169
  * @param {unknown} value
30
170
  * @param {string} label
31
171
  * @returns {Temporal.PlainDate}
@@ -36,26 +176,28 @@ function toPlainDate(value, label = 'date') {
36
176
  `daymath: Date is not allowed for ${label} (pass ISO 8601 day string)`,
37
177
  )
38
178
  }
39
- if (typeof value === 'string') {
40
- if (!ISO_DAY.test(value)) {
41
- throw new RangeError(
42
- `daymath: ${label} must be ISO 8601 day YYYY-MM-DD or ±YYYYYY-MM-DD (got ${JSON.stringify(value)})`,
43
- )
44
- }
45
- try {
46
- return Temporal.PlainDate.from(value)
47
- } catch (err) {
48
- throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(value)}`, {
49
- cause: err,
50
- })
51
- }
179
+ // Named `text`, not `day`: a local `day` would shadow the exported day().
180
+ const text = isPlainDate(value) ? value.toString() : value
181
+ if (typeof text !== 'string') {
182
+ throw new TypeError(
183
+ `daymath: ${label} must be ISO 8601 day string or Temporal.PlainDate`,
184
+ )
185
+ }
186
+ // Before the shape check: an annotated day is well formed, not malformed.
187
+ assertIsoCalendar(text, label)
188
+ const bare = bareDay(text)
189
+ if (bare === null) {
190
+ throw new RangeError(
191
+ `daymath: ${label} must be ISO 8601 day YYYY-MM-DD or ±YYYYYY-MM-DD (got ${JSON.stringify(text)})`,
192
+ )
52
193
  }
53
- if (value instanceof Temporal.PlainDate) {
54
- return value
194
+ try {
195
+ return Temporal.PlainDate.from(bare)
196
+ } catch (err) {
197
+ throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(text)}`, {
198
+ cause: err,
199
+ })
55
200
  }
56
- throw new TypeError(
57
- `daymath: ${label} must be ISO 8601 day string or Temporal.PlainDate`,
58
- )
59
201
  }
60
202
 
61
203
  /** @param {Temporal.PlainDate} plain @returns {string} */
@@ -63,11 +205,6 @@ function toDayString(plain) {
63
205
  return plain.toString()
64
206
  }
65
207
 
66
- /** Temporal ISO weekday 1=Mon…7=Sun → JS/date-fns 0=Sun…6=Sat */
67
- function isoToJsWeekday(isoDayOfWeek) {
68
- return isoDayOfWeek === 7 ? 0 : isoDayOfWeek
69
- }
70
-
71
208
  /** @param {unknown} n @param {string} label */
72
209
  function assertFiniteNumber(n, label) {
73
210
  if (typeof n !== 'number' || !Number.isFinite(n)) {
@@ -78,6 +215,44 @@ function assertFiniteNumber(n, label) {
78
215
  }
79
216
  }
80
217
 
218
+ /**
219
+ * Run a Temporal op and keep the `daymath:` message contract when it fails.
220
+ * Covers both a result past the range and an argument Temporal refuses
221
+ * outright, hence the neutral wording.
222
+ *
223
+ * The message carries no Temporal text. Implementations word the same failure
224
+ * differently — the polyfill says `Out-of-bounds date` where native V8 says
225
+ * `Temporal error: epoch days exceed maximum range.` — so quoting it made
226
+ * daymath's own message vary by runtime. `cause` still holds the original
227
+ * error, which is where the detail belongs.
228
+ * @template T
229
+ * @param {string} label
230
+ * @param {() => T} op
231
+ * @returns {T}
232
+ */
233
+ function guardRange(label, op) {
234
+ try {
235
+ return op()
236
+ } catch (err) {
237
+ throw new RangeError(`daymath: ${label} could not produce a valid date`, {
238
+ cause: err,
239
+ })
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Shared body for every add/sub function. Each caller passes its own name, so
245
+ * the message never reports a function the caller did not call.
246
+ * @param {DayInput} date
247
+ * @param {{ days?: number, months?: number, years?: number }} delta
248
+ * @param {string} label
249
+ * @returns {string}
250
+ */
251
+ function addDuration(date, delta, label) {
252
+ const d = toPlainDate(date)
253
+ return guardRange(label, () => toDayString(d.add(delta)))
254
+ }
255
+
81
256
  /** @param {unknown} dates */
82
257
  function assertNonEmptyDates(dates) {
83
258
  if (!Array.isArray(dates) || dates.length === 0) {
@@ -87,14 +262,14 @@ function assertNonEmptyDates(dates) {
87
262
 
88
263
  /**
89
264
  * @param {WeekOptions} [options]
90
- * @returns {0|1|2|3|4|5|6}
265
+ * @returns {0|1|2|3|4|5|6|7} 7 is the default, so 0-6 was never the real range
91
266
  */
92
267
  function weekStartsOnFrom(options) {
93
- const w = options?.weekStartsOn ?? 0
94
- if (!Number.isInteger(w) || w < 0 || w > 6) {
95
- throw new RangeError('daymath: weekStartsOn must be an integer 0…6 (0=Sun)')
268
+ const w = options?.weekStartsOn ?? 7
269
+ if (!Number.isInteger(w) || w < 0 || w > 7) {
270
+ throw new RangeError('daymath: weekStartsOn must be an integer 0…7 (7 or 0 = Sunday)')
96
271
  }
97
- return /** @type {0|1|2|3|4|5|6} */ (w)
272
+ return /** @type {0|1|2|3|4|5|6|7} */ (w)
98
273
  }
99
274
 
100
275
  /**
@@ -102,7 +277,15 @@ function weekStartsOnFrom(options) {
102
277
  * @returns {{ start: Temporal.PlainDate, end: Temporal.PlainDate }}
103
278
  */
104
279
  function toInterval(interval) {
105
- if (interval == null || typeof interval !== 'object') {
280
+ // `typeof x === 'object'` alone let a Date, an array or a PlainDate through,
281
+ // and the failure then surfaced as "start must be ISO 8601 day string" —
282
+ // naming a property the caller never meant to pass.
283
+ if (
284
+ interval === null || // typeof null is 'object', so it needs its own test
285
+ typeof interval !== 'object' || // and this already catches undefined
286
+ !('start' in interval) ||
287
+ !('end' in interval)
288
+ ) {
106
289
  throw new TypeError('daymath: interval must be { start, end }')
107
290
  }
108
291
  const start = toPlainDate(/** @type {Interval} */ (interval).start, 'start')
@@ -110,6 +293,212 @@ function toInterval(interval) {
110
293
  return { start, end }
111
294
  }
112
295
 
296
+ // ─── the clock ─────────────────────────────────────────────────────
297
+
298
+ /**
299
+ * The calendar day of a moment, in a zone. The way in.
300
+ *
301
+ * Both arguments have a **stated** default: the moment is now, and the zone is
302
+ * UTC. A default that is written down is not a guess; a default that is assumed
303
+ * is. UTC is still not your day for part of every day — it runs ahead of
304
+ * America/New_York for 16.7% of the day, and behind Asia/Tokyo for 37.5% — so
305
+ * name your zone when that matters.
306
+ *
307
+ * `moment` accepts what the world actually hands you:
308
+ * - a `Date`, the only carrier of an instant JavaScript has
309
+ * - a number, read as **epoch milliseconds**, exactly as `new Date(n)` reads it,
310
+ * truncated the same way, so a fractional value is not an error
311
+ * - an ISO 8601 day string, or a `Temporal.PlainDate` from any implementation,
312
+ * both of which are already a day
313
+ * - an ISO 8601 timestamp carrying `Z` or an offset, which names an exact
314
+ * instant, so there is nothing left to guess
315
+ * - a string carrying a `[Zone]` annotation, which names its own zone, so it
316
+ * answers its own civil day and the `tz` default never applies
317
+ *
318
+ * `'11/12/2026'` is refused. Nobody can tell November from December in it.
319
+ * `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath would
320
+ * have to pick one, and it will not pick on the caller's behalf. Name the zone
321
+ * — `'2026-08-08T12:00[America/New_York]'` — and it is accepted.
322
+ *
323
+ * A lone string takes one of four roles, decided in this order: a day, then a
324
+ * zoned time, then an instant, then a zone. The zone test is by **shape**, and
325
+ * the shape is on `ZONE_LIKE`. Temporal's own zone grammar cannot decide the role, because it
326
+ * accepts a whole timestamp and reads the zone out of it, so
327
+ * `day('1999-01-01T00:00:00Z')` would answer today. The grammar also differs
328
+ * between implementations: `'2026-08-08T25:00:00Z'` and `'T12:00:00Z'` are both
329
+ * zones to native Temporal and neither is one to `temporal-polyfill`. Shape
330
+ * settles the role and the runtime split together.
331
+ *
332
+ * With `day()` this is the only export that reads a clock, so the only one
333
+ * whose answer depends on when you call it. Give it a moment and it becomes a
334
+ * pure function, which is how the cross-runtime battery covers it.
335
+ *
336
+ * @param {Date | number | DayInput | null} [moment] instant, epoch ms, day, or a zone
337
+ * @param {string} [tz] IANA time zone id, e.g. `'utc'`, `'Asia/Tokyo'`
338
+ * @returns {string} `YYYY-MM-DD`
339
+ * @throws {TypeError} If `moment` is not one of the accepted shapes
340
+ * @throws {RangeError} On an Invalid Date, a non-finite number, or an unknown zone
341
+ * @example day() // '2026-08-08' now, UTC
342
+ * @example day('Asia/Tokyo') // '2026-08-09' today in Tokyo
343
+ * @example day(row.createdAt) // '2026-08-07' a Date, UTC
344
+ * @example day(row.createdAt, 'America/New_York') // '2026-08-06'
345
+ * @example day(1761616161771) // '2025-10-28' epoch ms
346
+ * @example day('1999-01-01T00:00:00Z') // '1999-01-01' an ISO timestamp
347
+ * @example day(zdt.toString()) // the zone in the string wins
348
+ * @example addDays(day(), 2) // '2026-08-10'
349
+ */
350
+ export function day(moment, tz) {
351
+ // Already a day, in every accepted spelling. `bareDay` is the same predicate
352
+ // toPlainDate uses, so day() cannot drift from the other 68 exports.
353
+ const isDay =
354
+ isPlainDate(moment) || (typeof moment === 'string' && bareDay(moment) !== null)
355
+ const isMoment = isDay || moment instanceof Date || typeof moment === 'number'
356
+
357
+ let zone = tz
358
+ /** @type {Temporal.Instant | undefined} */
359
+ let instant
360
+ /** @type {Temporal.ZonedDateTime | undefined} */
361
+ let zoned
362
+ if (!isMoment && moment !== undefined && moment !== null) {
363
+ if (typeof moment !== 'string') {
364
+ throw new TypeError(
365
+ 'daymath: day() takes a Date, epoch milliseconds, or an ISO 8601 day',
366
+ )
367
+ }
368
+ // A `[Zone]` annotation makes the string a ZonedDateTime, and Temporal will
369
+ // not build one without it: a bare offset is refused. So the bracket is the
370
+ // caller naming a zone, and the string carries its own civil day. Reading
371
+ // the instant instead and applying UTC would move a browser's date by one.
372
+ //
373
+ // Naming a zone and resolving as one are different questions, so the
374
+ // annotation is detected first. A string can name a zone and still fail:
375
+ // `'…-05:00[America/New_York]'` has an offset the zone contradicts, and
376
+ // `'…[Asia/Tokoy]'` is a typo. Falling back to the instant would answer a
377
+ // UTC day for both, silently, which is the defect this branch exists to fix.
378
+ 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')
386
+ if (tz !== undefined && tz !== null) {
387
+ throw new TypeError(
388
+ `daymath: day() got two time zones, ${JSON.stringify(moment)} and ${JSON.stringify(tz)}`,
389
+ )
390
+ }
391
+ try {
392
+ zoned = Temporal.ZonedDateTime.from(moment)
393
+ } catch (err) {
394
+ throw new RangeError(
395
+ `daymath: day() could not read ${JSON.stringify(moment)} in the time zone it names`,
396
+ { cause: err },
397
+ )
398
+ }
399
+ } else {
400
+ try {
401
+ // A timestamp carrying `Z` or an offset names an exact instant, so it
402
+ // reads as a moment. Temporal's own grammar is the definition of that.
403
+ //
404
+ // A calendar annotation here is inert and is ignored. An Instant has no
405
+ // year, month or day for a calendar to renumber, and without a zone
406
+ // bracket Temporal will not build anything that does. Refusing it would
407
+ // reject a right answer for a reason that cannot apply.
408
+ instant = Temporal.Instant.from(moment)
409
+ } catch {
410
+ // Not a moment, so the string must be a zone — decided by shape, before
411
+ // Temporal sees it. Temporal's zone grammar also accepts a whole
412
+ // timestamp and pulls the zone out of it, so letting it decide the role
413
+ // would read a date as a zone and silently answer today.
414
+ if (!ZONE_LIKE.test(moment)) {
415
+ throw new RangeError(
416
+ `daymath: day() got ${JSON.stringify(moment)}, which is neither a moment nor a time zone`,
417
+ )
418
+ }
419
+ if (tz !== undefined && tz !== null) {
420
+ throw new TypeError(
421
+ `daymath: day() got two time zones, ${JSON.stringify(moment)} and ${JSON.stringify(tz)}`,
422
+ )
423
+ }
424
+ zone = moment
425
+ }
426
+ }
427
+ }
428
+ zone ??= 'utc'
429
+
430
+ if (moment instanceof Date && Number.isNaN(moment.getTime())) {
431
+ throw new RangeError('daymath: day() got an Invalid Date')
432
+ }
433
+ if (typeof moment === 'number' && !Number.isFinite(moment)) {
434
+ throw new RangeError(`daymath: day() got a non-finite time value ${moment}`)
435
+ }
436
+
437
+ // Shape first, because implementations disagree past this point. The reasons
438
+ // are on ZONE_LIKE. The typeof test comes first because `.test()` coerces,
439
+ // and a caller-supplied toString could throw an error that is not ours.
440
+ if (typeof zone !== 'string' || !ZONE_LIKE.test(zone)) {
441
+ throw new RangeError(
442
+ `daymath: day() got an unknown time zone ${JSON.stringify(zone)}`,
443
+ )
444
+ }
445
+
446
+ // Checked before anything returns, so a mistyped zone fails the same way
447
+ // whatever the moment is. A caller mapping rows that are sometimes a Date and
448
+ // sometimes a day string would otherwise see the typo only on some rows.
449
+ //
450
+ // `ZonedDateTime.from` accepts exactly what `Temporal.Now` accepts and reads
451
+ // no clock, so a day input stays a pure function.
452
+ try {
453
+ Temporal.ZonedDateTime.from({ timeZone: zone, year: 1970, month: 1, day: 1 })
454
+ } catch (err) {
455
+ throw new RangeError(
456
+ `daymath: day() got an unknown time zone ${JSON.stringify(zone)}`,
457
+ { cause: err },
458
+ )
459
+ }
460
+
461
+ // A day carries no time, so a zone has nothing to shift. Applying one would
462
+ // invent a moment the caller never gave.
463
+ if (isDay) return toDayString(toPlainDate(moment))
464
+
465
+ // The string named its own zone, so that zone decides the day, not the
466
+ // default. This is the one path where `zone` is deliberately not consulted.
467
+ //
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.
472
+ if (zoned !== undefined) {
473
+ const plain = guardRange('day', () => zoned.toPlainDate())
474
+ return toDayString(toPlainDate(plain))
475
+ }
476
+
477
+ if (instant !== undefined) {
478
+ return guardRange('day', () =>
479
+ instant.toZonedDateTimeISO(zone).toPlainDate().toString(),
480
+ )
481
+ }
482
+
483
+ if (!isMoment) return Temporal.Now.plainDateISO(zone).toString()
484
+
485
+ // Truncate, because `new Date(n)` truncates, and the contract here is that a
486
+ // number reads exactly as it does. Verified equal on positive and negative
487
+ // fractions. Sub-millisecond precision cannot change a calendar day anyway.
488
+ // Every other shape returned above, so only a number or a Date reaches here.
489
+ // The cast says what the control flow already guarantees but tsc cannot see.
490
+ const epochMs =
491
+ typeof moment === 'number'
492
+ ? Math.trunc(moment)
493
+ : /** @type {Date} */ (moment).getTime()
494
+ return guardRange('day', () =>
495
+ Temporal.Instant.fromEpochMilliseconds(epochMs)
496
+ .toZonedDateTimeISO(zone)
497
+ .toPlainDate()
498
+ .toString(),
499
+ )
500
+ }
501
+
113
502
  // ─── parse / format / valid ────────────────────────────────────────
114
503
 
115
504
  /**
@@ -166,7 +555,7 @@ export function format(date, pattern = 'yyyy-MM-dd') {
166
555
  */
167
556
  export function addDays(date, amount) {
168
557
  assertFiniteNumber(amount, 'amount')
169
- return toDayString(toPlainDate(date).add({ days: amount }))
558
+ return addDuration(date, { days: amount }, 'addDays')
170
559
  }
171
560
 
172
561
  /**
@@ -176,7 +565,7 @@ export function addDays(date, amount) {
176
565
  */
177
566
  export function subDays(date, amount) {
178
567
  assertFiniteNumber(amount, 'amount')
179
- return addDays(date, -amount)
568
+ return addDuration(date, { days: -amount }, 'subDays')
180
569
  }
181
570
 
182
571
  /**
@@ -186,7 +575,9 @@ export function subDays(date, amount) {
186
575
  */
187
576
  export function addWeeks(date, amount) {
188
577
  assertFiniteNumber(amount, 'amount')
189
- return addDays(date, amount * 7)
578
+ const days = amount * 7 // re-check: 7x a finite amount can still reach Infinity
579
+ assertFiniteNumber(days, 'amount')
580
+ return addDuration(date, { days }, 'addWeeks')
190
581
  }
191
582
 
192
583
  /**
@@ -196,7 +587,9 @@ export function addWeeks(date, amount) {
196
587
  */
197
588
  export function subWeeks(date, amount) {
198
589
  assertFiniteNumber(amount, 'amount')
199
- return addWeeks(date, -amount)
590
+ const days = -amount * 7
591
+ assertFiniteNumber(days, 'amount')
592
+ return addDuration(date, { days }, 'subWeeks')
200
593
  }
201
594
 
202
595
  /**
@@ -207,7 +600,7 @@ export function subWeeks(date, amount) {
207
600
  */
208
601
  export function addMonths(date, amount) {
209
602
  assertFiniteNumber(amount, 'amount')
210
- return toDayString(toPlainDate(date).add({ months: amount }))
603
+ return addDuration(date, { months: amount }, 'addMonths')
211
604
  }
212
605
 
213
606
  /**
@@ -217,7 +610,7 @@ export function addMonths(date, amount) {
217
610
  */
218
611
  export function subMonths(date, amount) {
219
612
  assertFiniteNumber(amount, 'amount')
220
- return addMonths(date, -amount)
613
+ return addDuration(date, { months: -amount }, 'subMonths')
221
614
  }
222
615
 
223
616
  /**
@@ -227,7 +620,7 @@ export function subMonths(date, amount) {
227
620
  */
228
621
  export function addYears(date, amount) {
229
622
  assertFiniteNumber(amount, 'amount')
230
- return toDayString(toPlainDate(date).add({ years: amount }))
623
+ return addDuration(date, { years: amount }, 'addYears')
231
624
  }
232
625
 
233
626
  /**
@@ -237,7 +630,7 @@ export function addYears(date, amount) {
237
630
  */
238
631
  export function subYears(date, amount) {
239
632
  assertFiniteNumber(amount, 'amount')
240
- return addYears(date, -amount)
633
+ return addDuration(date, { years: -amount }, 'subYears')
241
634
  }
242
635
 
243
636
  /**
@@ -247,7 +640,9 @@ export function subYears(date, amount) {
247
640
  */
248
641
  export function addQuarters(date, amount) {
249
642
  assertFiniteNumber(amount, 'amount')
250
- return addMonths(date, amount * 3)
643
+ const months = amount * 3
644
+ assertFiniteNumber(months, 'amount')
645
+ return addDuration(date, { months }, 'addQuarters')
251
646
  }
252
647
 
253
648
  /**
@@ -257,7 +652,9 @@ export function addQuarters(date, amount) {
257
652
  */
258
653
  export function subQuarters(date, amount) {
259
654
  assertFiniteNumber(amount, 'amount')
260
- return addQuarters(date, -amount)
655
+ const months = -amount * 3
656
+ assertFiniteNumber(months, 'amount')
657
+ return addDuration(date, { months }, 'subQuarters')
261
658
  }
262
659
 
263
660
  // ─── getters / setters (date-fns / Date month & weekday indexing) ─
@@ -268,12 +665,13 @@ export function getYear(date) {
268
665
  }
269
666
 
270
667
  /**
271
- * Month index like Date/date-fns: 0 = January … 11 = December.
668
+ * Month number, ISO 8601: 1 = January … 12 = December. Matches the `MM` field
669
+ * of the input string, and Temporal. **Not** date-fns, which is 0-based.
272
670
  * @param {DayInput} date
273
671
  * @returns {number}
274
672
  */
275
673
  export function getMonth(date) {
276
- return toPlainDate(date).month - 1
674
+ return toPlainDate(date).month
277
675
  }
278
676
 
279
677
  /** Day of month 1…31. @param {DayInput} date @returns {number} */
@@ -282,12 +680,14 @@ export function getDate(date) {
282
680
  }
283
681
 
284
682
  /**
285
- * Weekday like Date/date-fns: 0 = Sunday6 = Saturday.
683
+ * Weekday, ISO 8601: 1 = Monday7 = Sunday. Matches Temporal and
684
+ * `Intl.Locale#weekInfo.firstDay`. **Not** date-fns, where Sunday is 0.
685
+ * Only Sunday differs; Monday–Saturday are 1–6 in both.
286
686
  * @param {DayInput} date
287
687
  * @returns {number}
288
688
  */
289
689
  export function getDay(date) {
290
- return isoToJsWeekday(toPlainDate(date).dayOfWeek)
690
+ return toPlainDate(date).dayOfWeek
291
691
  }
292
692
 
293
693
  /** @param {DayInput} date @returns {number} */
@@ -317,71 +717,81 @@ export function isLeapYear(date) {
317
717
  */
318
718
  export function setYear(date, year) {
319
719
  assertFiniteNumber(year, 'year')
320
- return toDayString(toPlainDate(date).with({ year }))
720
+ const d = toPlainDate(date)
721
+ return guardRange('setYear', () => toDayString(d.with({ year })))
321
722
  }
322
723
 
323
724
  /**
324
725
  * @param {DayInput} date
325
- * @param {number} month 0 = January … 11 = December (date-fns)
726
+ * @param {number} month 1 = January … 12 = December (ISO 8601)
326
727
  * @returns {string}
327
728
  */
328
729
  export function setMonth(date, month) {
329
730
  assertFiniteNumber(month, 'month')
330
- if (month < 0 || month > 11) {
331
- throw new RangeError('daymath: month must be 011 (0=January)')
731
+ if (month < 1 || month > 12) {
732
+ throw new RangeError('daymath: month must be 112 (1=January)')
332
733
  }
333
- return toDayString(toPlainDate(date).with({ month: month + 1 }))
734
+ const d = toPlainDate(date)
735
+ return guardRange('setMonth', () => toDayString(d.with({ month })))
334
736
  }
335
737
 
336
738
  /**
337
739
  * @param {DayInput} date
338
- * @param {number} dayOfMonth
740
+ * @param {number} dayOfMonth 1…31; a day past the month end constrains to the
741
+ * last day of that month (no roll-over into the next month, unlike date-fns)
339
742
  * @returns {string}
340
743
  */
341
744
  export function setDate(date, dayOfMonth) {
342
745
  assertFiniteNumber(dayOfMonth, 'day')
343
- return toDayString(toPlainDate(date).with({ day: dayOfMonth }))
746
+ const d = toPlainDate(date)
747
+ return guardRange('setDate', () => toDayString(d.with({ day: dayOfMonth })))
344
748
  }
345
749
 
346
750
  // ─── start / end of unit ───────────────────────────────────────────
347
751
 
752
+ // The first/last day of a unit can fall outside the PlainDate range even when
753
+ // the input is inside it — startOfMonth('-271821-04-19') wants April 1st, which
754
+ // is below the minimum. Throwing is right; guardRange keeps the message ours.
755
+
348
756
  /** @param {DayInput} date @returns {string} */
349
757
  export function startOfMonth(date) {
350
758
  const d = toPlainDate(date)
351
- return toDayString(d.with({ day: 1 }))
759
+ return guardRange('startOfMonth', () => toDayString(d.with({ day: 1 })))
352
760
  }
353
761
 
354
762
  /** @param {DayInput} date @returns {string} */
355
763
  export function endOfMonth(date) {
356
764
  const d = toPlainDate(date)
357
- return toDayString(d.with({ day: d.daysInMonth }))
765
+ return guardRange('endOfMonth', () => toDayString(d.with({ day: d.daysInMonth })))
358
766
  }
359
767
 
360
768
  /** @param {DayInput} date @returns {string} */
361
769
  export function startOfYear(date) {
362
770
  const d = toPlainDate(date)
363
- return toDayString(d.with({ month: 1, day: 1 }))
771
+ return guardRange('startOfYear', () => toDayString(d.with({ month: 1, day: 1 })))
364
772
  }
365
773
 
366
774
  /** @param {DayInput} date @returns {string} */
367
775
  export function endOfYear(date) {
368
776
  const d = toPlainDate(date)
369
- return toDayString(d.with({ month: 12, day: 31 }))
777
+ return guardRange('endOfYear', () => toDayString(d.with({ month: 12, day: 31 })))
370
778
  }
371
779
 
372
780
  /** @param {DayInput} date @returns {string} */
373
781
  export function startOfQuarter(date) {
374
782
  const d = toPlainDate(date)
375
783
  const month = (getQuarter(d) - 1) * 3 + 1
376
- return toDayString(d.with({ month, day: 1 }))
784
+ return guardRange('startOfQuarter', () => toDayString(d.with({ month, day: 1 })))
377
785
  }
378
786
 
379
787
  /** @param {DayInput} date @returns {string} */
380
788
  export function endOfQuarter(date) {
381
789
  const d = toPlainDate(date)
382
790
  const month = getQuarter(d) * 3
383
- const mid = d.with({ month, day: 1 })
384
- return toDayString(mid.with({ day: mid.daysInMonth }))
791
+ return guardRange('endOfQuarter', () => {
792
+ const mid = d.with({ month, day: 1 })
793
+ return toDayString(mid.with({ day: mid.daysInMonth }))
794
+ })
385
795
  }
386
796
 
387
797
  /**
@@ -391,10 +801,20 @@ export function endOfQuarter(date) {
391
801
  */
392
802
  export function startOfWeek(date, options) {
393
803
  const d = toPlainDate(date)
804
+ const diff = daysIntoWeek(d, options)
805
+ return guardRange('startOfWeek', () => toDayString(d.subtract({ days: diff })))
806
+ }
807
+
808
+ /**
809
+ * How far the day sits past the start of its week.
810
+ * @param {Temporal.PlainDate} d
811
+ * @param {WeekOptions} [options]
812
+ * @returns {number}
813
+ */
814
+ function daysIntoWeek(d, options) {
394
815
  const weekStartsOn = weekStartsOnFrom(options)
395
- const day = isoToJsWeekday(d.dayOfWeek)
396
- const diff = (day - weekStartsOn + 7) % 7
397
- return toDayString(d.subtract({ days: diff }))
816
+ // mod 7 makes weekStartsOn 0 and 7 identical, so both spellings of Sunday work
817
+ return (d.dayOfWeek - weekStartsOn + 7) % 7
398
818
  }
399
819
 
400
820
  /**
@@ -403,7 +823,12 @@ export function startOfWeek(date, options) {
403
823
  * @returns {string}
404
824
  */
405
825
  export function endOfWeek(date, options) {
406
- return addDays(startOfWeek(date, options), 6)
826
+ const d = toPlainDate(date)
827
+ const diff = daysIntoWeek(d, options)
828
+ // one guard for the whole walk, so a failure at either end says endOfWeek
829
+ return guardRange('endOfWeek', () =>
830
+ toDayString(d.subtract({ days: diff }).add({ days: 6 })),
831
+ )
407
832
  }
408
833
 
409
834
  // ─── differences ───────────────────────────────────────────────────
@@ -427,11 +852,20 @@ export function differenceInDays(dateLeft, dateRight) {
427
852
  * @returns {number}
428
853
  */
429
854
  export function differenceInWeeks(dateLeft, dateRight) {
430
- return Math.trunc(differenceInDays(dateLeft, dateRight) / 7)
855
+ // `|| 0` normalises -0: Math.trunc keeps the sign of a negative gap shorter
856
+ // than a week, so a 1..6 day backwards difference returned -0
857
+ return Math.trunc(differenceInDays(dateLeft, dateRight) / 7) || 0
431
858
  }
432
859
 
433
860
  /**
434
- * Full months (signed), Temporal since with largestUnit month.
861
+ * Full months (signed). A month counts as full when `addMonths` would carry the
862
+ * earlier date to the later one, so the end of a short month counts: 31 January
863
+ * to 28 February is one month, because `addMonths` clamps 31 February to the
864
+ * 28th. That keeps `differenceInMonths(addMonths(d, n), d) === n`.
865
+ *
866
+ * Temporal's `since` is not used here. It has no overflow option, so it counts
867
+ * 28 days rather than one month for that pair, and the round trip breaks in
868
+ * 21,934 of 1,761,936 cases. `add` clamps, so the measurement has to match.
435
869
  * @param {DayInput} dateLeft
436
870
  * @param {DayInput} dateRight
437
871
  * @returns {number}
@@ -439,8 +873,18 @@ export function differenceInWeeks(dateLeft, dateRight) {
439
873
  export function differenceInMonths(dateLeft, dateRight) {
440
874
  const left = toPlainDate(dateLeft, 'dateLeft')
441
875
  const right = toPlainDate(dateRight, 'dateRight')
442
- const dur = left.since(right, { largestUnit: 'month' })
443
- return dur.months
876
+ const sign = Temporal.PlainDate.compare(left, right)
877
+ if (sign === 0) return 0
878
+ const diff = Math.abs(differenceInCalendarMonths(left, right))
879
+ if (diff < 1) return 0
880
+ const [earlier, later] = sign > 0 ? [right, left] : [left, right]
881
+ // Where `earlier` lands after `diff` months: same year-month as `later` by
882
+ // construction, on `earlier`'s day clamped to that month's length. Compared
883
+ // as day numbers rather than built as a date, because the landing can sit
884
+ // past the maximum PlainDate even when both operands are inside the range.
885
+ const landingDay = Math.min(earlier.day, later.daysInMonth)
886
+ const isLastMonthNotFull = landingDay > later.day
887
+ return sign * (diff - +isLastMonthNotFull) || 0
444
888
  }
445
889
 
446
890
  /**
@@ -456,7 +900,11 @@ export function differenceInCalendarMonths(dateLeft, dateRight) {
456
900
  }
457
901
 
458
902
  /**
459
- * Full years (signed).
903
+ * Full years (signed). Same rule as `differenceInMonths`: a year counts as full
904
+ * when `addYears` would carry the earlier date to the later one, so 29 February
905
+ * to 28 February of a common year is one year, because `addYears` clamps.
906
+ * That keeps `differenceInYears(addYears(d, n), d) === n`, and keeps this
907
+ * function agreeing with `trunc(differenceInMonths(a, b) / 12)`.
460
908
  * @param {DayInput} dateLeft
461
909
  * @param {DayInput} dateRight
462
910
  * @returns {number}
@@ -464,7 +912,22 @@ export function differenceInCalendarMonths(dateLeft, dateRight) {
464
912
  export function differenceInYears(dateLeft, dateRight) {
465
913
  const left = toPlainDate(dateLeft, 'dateLeft')
466
914
  const right = toPlainDate(dateRight, 'dateRight')
467
- return left.since(right, { largestUnit: 'year' }).years
915
+ const sign = Temporal.PlainDate.compare(left, right)
916
+ if (sign === 0) return 0
917
+ const diff = Math.abs(left.year - right.year)
918
+ if (diff < 1) return 0
919
+ const [earlier, later] = sign > 0 ? [right, left] : [left, right]
920
+ // Where `earlier` lands after `diff` years: same year as `later`, same month,
921
+ // and the same day except that 29 February clamps to the 28th in a common
922
+ // year, which is the only day-of-month that changes length year to year.
923
+ // Compared field by field rather than built as a date, because the landing
924
+ // can sit past the maximum PlainDate even with both operands inside the range.
925
+ const landingDay =
926
+ earlier.month === 2 && earlier.day === 29 && !later.inLeapYear ? 28 : earlier.day
927
+ const isLastYearNotFull =
928
+ earlier.month > later.month ||
929
+ (earlier.month === later.month && landingDay > later.day)
930
+ return sign * (diff - +isLastYearNotFull) || 0
468
931
  }
469
932
 
470
933
  /**
@@ -484,7 +947,9 @@ export function differenceInCalendarYears(dateLeft, dateRight) {
484
947
  * @returns {number}
485
948
  */
486
949
  export function differenceInQuarters(dateLeft, dateRight) {
487
- return Math.trunc(differenceInMonths(dateLeft, dateRight) / 3)
950
+ // `|| 0` normalises -0, same reason as differenceInWeeks: a backwards gap of
951
+ // one or two months truncates to -0
952
+ return Math.trunc(differenceInMonths(dateLeft, dateRight) / 3) || 0
488
953
  }
489
954
 
490
955
  /**
@@ -496,9 +961,7 @@ export function differenceInQuarters(dateLeft, dateRight) {
496
961
  export function differenceInCalendarQuarters(dateLeft, dateRight) {
497
962
  const left = toPlainDate(dateLeft, 'dateLeft')
498
963
  const right = toPlainDate(dateRight, 'dateRight')
499
- return (
500
- (left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
501
- )
964
+ return (left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
502
965
  }
503
966
 
504
967
  // ─── compare / equal ───────────────────────────────────────────────
@@ -556,7 +1019,14 @@ export const isSameDay = isEqual
556
1019
  * @returns {boolean}
557
1020
  */
558
1021
  export function isSameWeek(dateLeft, dateRight, options) {
559
- return isEqual(startOfWeek(dateLeft, options), startOfWeek(dateRight, options))
1022
+ const left = toPlainDate(dateLeft, 'dateLeft')
1023
+ const right = toPlainDate(dateRight, 'dateRight')
1024
+ // 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) })),
1029
+ )
560
1030
  }
561
1031
 
562
1032
  /**
@@ -610,7 +1080,8 @@ export function compareAsc(dateLeft, dateRight) {
610
1080
  * @returns {-1 | 0 | 1}
611
1081
  */
612
1082
  export function compareDesc(dateLeft, dateRight) {
613
- return /** @type {-1 | 0 | 1} */ (-compareAsc(dateLeft, dateRight))
1083
+ // `|| 0` normalises -0 for equal days
1084
+ return /** @type {-1 | 0 | 1} */ (-compareAsc(dateLeft, dateRight) || 0)
614
1085
  }
615
1086
 
616
1087
  /**
@@ -643,7 +1114,7 @@ export function max(dates) {
643
1114
 
644
1115
  /** @param {DayInput} date @returns {boolean} */
645
1116
  export function isSunday(date) {
646
- return getDay(date) === 0
1117
+ return getDay(date) === 7
647
1118
  }
648
1119
  /** @param {DayInput} date @returns {boolean} */
649
1120
  export function isMonday(date) {
@@ -672,7 +1143,7 @@ export function isSaturday(date) {
672
1143
  /** @param {DayInput} date @returns {boolean} */
673
1144
  export function isWeekend(date) {
674
1145
  const d = getDay(date)
675
- return d === 0 || d === 6
1146
+ return d === 6 || d === 7
676
1147
  }
677
1148
 
678
1149
  /** @param {DayInput} date @returns {boolean} */
@@ -701,8 +1172,11 @@ export function eachDayOfInterval(interval) {
701
1172
  /** @type {string[]} */
702
1173
  const out = []
703
1174
  let cur = start
704
- while (Temporal.PlainDate.compare(cur, end) <= 0) {
1175
+ // break on the last day, never step past it — an add beyond the max
1176
+ // PlainDate (+275760-09-13) throws
1177
+ for (;;) {
705
1178
  out.push(toDayString(cur))
1179
+ if (Temporal.PlainDate.compare(cur, end) >= 0) break
706
1180
  cur = cur.add({ days: 1 })
707
1181
  }
708
1182
  return out
@@ -720,10 +1194,16 @@ export function eachMonthOfInterval(interval) {
720
1194
  }
721
1195
  /** @type {string[]} */
722
1196
  const out = []
723
- let cur = start.with({ day: 1 })
724
- const last = end.with({ day: 1 })
725
- while (Temporal.PlainDate.compare(cur, last) <= 0) {
1197
+ // the 1st of start's month can sit below the minimum PlainDate
1198
+ const [cur0, last] = guardRange('eachMonthOfInterval', () => [
1199
+ start.with({ day: 1 }),
1200
+ end.with({ day: 1 }),
1201
+ ])
1202
+ let cur = cur0
1203
+ // same boundary rule as eachDayOfInterval
1204
+ for (;;) {
726
1205
  out.push(toDayString(cur))
1206
+ if (Temporal.PlainDate.compare(cur, last) >= 0) break
727
1207
  cur = cur.add({ months: 1 })
728
1208
  }
729
1209
  return out
@@ -742,10 +1222,13 @@ export function eachYearOfInterval(interval) {
742
1222
  /** @type {string[]} */
743
1223
  const out = []
744
1224
  let y = start.year
745
- while (y <= end.year) {
746
- out.push(toDayString(Temporal.PlainDate.from({ year: y, month: 1, day: 1 })))
747
- y += 1
748
- }
1225
+ // Jan 1 of start's year can sit below the minimum PlainDate
1226
+ guardRange('eachYearOfInterval', () => {
1227
+ while (y <= end.year) {
1228
+ out.push(toDayString(Temporal.PlainDate.from({ year: y, month: 1, day: 1 })))
1229
+ y += 1
1230
+ }
1231
+ })
749
1232
  return out
750
1233
  }
751
1234
 
@@ -762,8 +1245,7 @@ export function isWithinInterval(date, interval) {
762
1245
  throw new RangeError('daymath: interval start must not be after end')
763
1246
  }
764
1247
  return (
765
- Temporal.PlainDate.compare(d, start) >= 0 &&
766
- Temporal.PlainDate.compare(d, end) <= 0
1248
+ Temporal.PlainDate.compare(d, start) >= 0 && Temporal.PlainDate.compare(d, end) <= 0
767
1249
  )
768
1250
  }
769
1251
 
@@ -788,7 +1270,7 @@ export function clamp(date, interval) {
788
1270
  * Whether two inclusive intervals overlap.
789
1271
  * @param {Interval} intervalLeft
790
1272
  * @param {Interval} intervalRight
791
- * @param {{ inclusive?: boolean }} [options] default inclusive true (date-fns default false uses half-open; we default true for plain days)
1273
+ * @param {{ inclusive?: boolean }} [options] `inclusive` defaults to false, like date-fns: intervals that only touch at an endpoint do not overlap
792
1274
  * @returns {boolean}
793
1275
  */
794
1276
  export function areIntervalsOverlapping(intervalLeft, intervalRight, options) {