daymath 0.5.0 → 0.7.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 +34 -9
  2. package/index.d.ts +17 -2
  3. package/index.js +311 -194
  4. package/package.json +4 -4
package/README.md CHANGED
@@ -53,8 +53,25 @@ day(row.createdAt) // '2026-08-07' a Date
53
53
  day(1761616161771) // '2025-10-28' epoch milliseconds
54
54
  day('1999-01-01T00:00:00Z') // '1999-01-01' an ISO timestamp
55
55
  day('2026-05-05') // '2026-05-05' already a day
56
+ day('2026-08-08 12:00:00') // '2026-08-08' a SQLite DATETIME
56
57
  ```
57
58
 
59
+ A **SQLite `DATETIME` goes straight in**, and so does the whole surface — the clock
60
+ is dropped by the same funnel every export shares, so this is not a `day()` feature.
61
+
62
+ ```js
63
+ addDays(row.created_at, 30) // '2026-09-07' from '2026-08-08 12:00:00'
64
+ startOfMonth('2026-08-08 01:57:31.913') // '2026-08-01' strftime('%…%H:%M:%f')
65
+ getYear('2026-08-08t12:00') // 2026 T, t or a space; same rule
66
+ ```
67
+
68
+ `datetime()`, `CURRENT_TIMESTAMP` and `strftime('%Y-%m-%d %H:%M:%f')` all emit that
69
+ shape, so a column value needs no reshaping first. **Pass a zone with one and it
70
+ throws.** Naming a zone means convert, and a clock with no zone gives nothing to
71
+ convert from — and `datetime()` defaults to UTC, so a silent answer would hand the
72
+ UTC day to the one caller who asked for a local one. Put the zone in the string
73
+ (`Z`, an offset, or `[Zone]`) and it converts normally.
74
+
58
75
  Name a zone when the answer depends on one.
59
76
 
60
77
  ```js
@@ -69,7 +86,7 @@ UTC is still not your day for part of every day — it runs ahead of `America/Ne
69
86
  for 16.7% of the day, and behind `Asia/Tokyo` for 37.5% — so name your zone when
70
87
  that matters.
71
88
 
72
- Four rules worth knowing:
89
+ Six rules worth knowing:
73
90
 
74
91
  - A **number is epoch milliseconds**, exactly as `new Date(n)` reads it. A seconds
75
92
  timestamp read as milliseconds lands in 1970, with no error. daymath states the
@@ -84,9 +101,12 @@ Four rules worth knowing:
84
101
  the UTC default never applies. `day(zdt.toString())` equals `zdt.toPlainDate()`.
85
102
  A browser sending `'2026-08-08T20:00:00-04:00[America/New_York]'` gets back the
86
103
  8th, which is the date its user saw. Passing `tz` as well throws.
87
- - A string with **neither** is refused. `'2026-08-08T12:00'` names no instant and
88
- no zone, so daymath would have to pick one, and it will not pick for you. Name
89
- the zone `'2026-08-08T12:00[America/New_York]'` and it is accepted.
104
+ - A **zoneless wall clock is a day.** `'2026-08-08 12:00:00'`, `'2026-08-08T12:00'`
105
+ and the lowercase `t` all answer `'2026-08-08'`; the clock is dropped, never read.
106
+ The **hour is the only bound**, because the hour is the only field that can change
107
+ the date: `24:00` is refused, a leap second and a fraction of any length are not.
108
+ Pass `tz` with one and it **throws**. Name the zone in the string —
109
+ `'2026-08-08T12:00[America/New_York]'` — and it converts.
90
110
  - **`'11/12/2026'` is refused.** Nobody can tell November from December in it.
91
111
 
92
112
  A lone string takes one of four roles, decided in this order: a day, a zoned time,
@@ -155,8 +175,11 @@ Amounts are finite integers.
155
175
 
156
176
  ## Temporal
157
177
 
158
- Uses global `Temporal` when present; otherwise [`temporal-polyfill`](https://www.npmjs.com/package/temporal-polyfill),
159
- which does that resolution itself so on a runtime with native Temporal the polyfill steps aside.
178
+ Built on [`temporal-polyfill/fns`](https://www.npmjs.com/package/temporal-polyfill), the functional
179
+ API, rather than the `Temporal` class. A class is one unit to a bundler, so the class build shipped
180
+ whole for the twenty-odd operations daymath uses; free functions drop what you do not call. It runs
181
+ on native `Temporal` where the runtime has it and on the bundled build elsewhere, and `fns` makes
182
+ that choice itself. Measured on a three-call program: **24.7 kB gzip to 16.3 kB, −34%.**
160
183
 
161
184
  A `Temporal.PlainDate` from *any* implementation is accepted — native, the bundled polyfill,
162
185
  or a second copy of it in the same dependency tree. daymath reads its ISO day and builds its
@@ -312,9 +335,11 @@ Temporal.PlainDate.from({year: 2569, month: 8, day: 8, calendar: 'buddhist'}).to
312
335
  One more trap in the same family: `'2569-08-08[u-ca=buddhist]'` is a valid string, and its
313
336
  `getYear` is **3112**. The date part is ISO 2569, and the annotation adds 543 on top.
314
337
 
315
- Supporting these calendars needs `temporal-polyfill/full`, because the base build cannot construct
316
- them where the runtime has no native Temporal. That costs **4.2 kB gzip**, and it is what makes
317
- the answers identical on every runtime rather than only on the ones with native Temporal.
338
+ Supporting these calendars costs **4.2 kB gzip**, because daymath resolves them with `getAny`, the
339
+ `fns` resolver that carries every calendar's data. The narrower resolvers cannot serve the rule: on
340
+ a runtime without native `Temporal` they drop the annotation instead of refusing it, so the same
341
+ program would answer 2569 on one lane and 2026 on another. `getAny` answers identically everywhere,
342
+ which is the point.
318
343
 
319
344
  Error messages quote no Temporal text, because implementations word the same failure
320
345
  differently. The original error is on `cause`.
package/index.d.ts CHANGED
@@ -4,6 +4,17 @@ import type { Temporal } from 'temporal-polyfill'
4
4
  * Calendar day input: ISO 8601 day string, or a Temporal.PlainDate.
5
5
  * - `YYYY-MM-DD` (years 0000–9999)
6
6
  * - expanded `±YYYYYY-MM-DD` (e.g. `+010000-01-01`)
7
+ * - either of those with a zoneless wall clock on it, `YYYY-MM-DD HH:MM[:SS[.fff]]`,
8
+ * with `T`, `t` or a space between them. The clock is dropped and never read, so
9
+ * `getYear('2026-08-08 12:00:00')` is `2026`. This is the shape SQLite stores:
10
+ * `datetime()`, `CURRENT_TIMESTAMP` and `strftime('%Y-%m-%d %H:%M:%f')` all emit
11
+ * it, so a column value needs no reshaping. The **hour is the only bound**,
12
+ * because the hour is the only field that can change the date: `24:00` is refused
13
+ * because ISO `24:00` starts the next day, while a leap second `23:59:60` and a
14
+ * fraction of any length stay inside their own day and are accepted.
15
+ *
16
+ * A clock naming a zone — `Z`, an offset, or `[Zone]` — is NOT day input. It names
17
+ * an instant, and `day()` is the only door an instant enters by.
7
18
  *
8
19
  * Usable range is the Temporal `PlainDate` range `-271821-04-19` …
9
20
  * `+275760-09-13`; a day outside it throws a `RangeError`.
@@ -50,8 +61,12 @@ export type WeekOptions = {
50
61
  * own civil day and the UTC default never applies. Passing `tz` as well throws.
51
62
  *
52
63
  * `'11/12/2026'` is refused, because nobody can tell November from December in
53
- * it. `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath
54
- * would have to pick one. Name the zone and it is accepted.
64
+ * it.
65
+ *
66
+ * A zoneless wall clock is a day, so `'2026-08-08 12:00:00'` — a SQLite
67
+ * `DATETIME` — answers `'2026-08-08'` and the clock is dropped. Pass `tz` with
68
+ * one and it throws: naming a zone means convert, and a clock with no zone gives
69
+ * nothing to convert from. Put the zone in the string and it converts.
55
70
  *
56
71
  * A lone string takes one of four roles, in this order: a day, a zoned time, an
57
72
  * instant, then a zone. The zone test is by shape — an IANA name, which carries
package/index.js CHANGED
@@ -1,33 +1,34 @@
1
1
  /** daymath — calendar date math (ISO 8601 day). date-fns-shaped. No Date. No time zones. */
2
- // The selection is HERE on purpose, and it must not be removed again.
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.
3
7
  //
4
- // `temporal-polyfill`'s base entry resolves `globalThis.Temporal || bundled` itself, so importing
5
- // it gave native Temporal for free. But that base build can construct only iso8601 and gregory, so
6
- // it cannot serve the calendar rule below. `temporal-polyfill/full` can construct sixteen — and its
7
- // entry is a bare re-export with NO selection, so importing it alone silenced native Temporal on
8
- // every runtime, including Node 26 and Deno. Round 1 of a review caught that.
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.
9
15
  //
10
- // Round 2 then caught the naive repair. A global `Temporal` is NOT necessarily native: an app doing
11
- // `import 'temporal-polyfill/global'` installs the BASE build, and the polyfill's own README calls
12
- // that the most common entry point. Selecting it blindly cost daymath three of its four calendars
13
- // on Node 20 and 22, which have no native Temporal, and the error blamed the caller's calendar
14
- // rather than naming the capability loss. Import order decided it, which is worse than a wrong
15
- // answer. So the candidate is PROBED, not trusted.
16
+ // 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.
16
20
  //
17
- // The probe names no calendar. It asks the runtime for one, which keeps the "method, not a list"
18
- // rule that governs the calendar surface below.
19
- //
20
- // Measured: the selection costs 17 B gzip, because the polyfill ships either way — a bundler cannot
21
- // know at build time whether the runtime has Temporal, which is why FUTURE.md records dynamic
22
- // import as a dead end. Native and the bundled full build agree on every calendar daymath touches:
23
- // 464 date/calendar pairs over the four accepted calendars from 1900 to 2100, plus the eleven
24
- // refused ones, with zero disagreements.
25
- //
26
- // Native matters for three reasons beyond taste. daymath's contract is Temporal's behaviour, and
27
- // native IS Temporal. The deno CI lane exists to prove the polyfill and the standard agree, and it
28
- // cannot prove that if daymath runs the polyfill there. And an engine-level Temporal fix then
29
- // reaches callers with no release from here.
30
- import { Temporal as bundledTemporal } from 'temporal-polyfill/full'
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')
31
32
 
32
33
  /**
33
34
  * Every calendar this runtime names. Read once, lowercased, because BCP-47 keys are
@@ -42,55 +43,6 @@ const RUNTIME_CALENDARS = new Set(
42
43
  Intl.supportedValuesOf('calendar').map((id) => id.toLowerCase()),
43
44
  )
44
45
 
45
- /**
46
- * Can this Temporal build a calendar beyond the two every build has?
47
- *
48
- * The cast is needed because `Temporal` is not declared on `globalThis` in the type space, and
49
- * daymath deliberately installs no global of its own.
50
- * @param {unknown} candidate
51
- * @returns {candidate is typeof bundledTemporal}
52
- */
53
- /*
54
- * Every branch here turns on `globalThis.Temporal`, which is read once at module load. A test in
55
- * this process cannot change it after the fact, so none of these branches is reachable from the
56
- * suite. They are covered three other ways, and each is real:
57
- *
58
- * 1. `test.js` spawns a CHILD process that installs the BASE polyfill global and then imports
59
- * daymath. That is the exact scenario this function exists for, and it asserts the four
60
- * calendars survive. A child's execution does not count toward this file's coverage.
61
- * 2. The Node 20 and bun CI lanes have no native Temporal, so they take the fallback for real.
62
- * 3. `npm run test:runtimes` proves every lane answers identically.
63
- */
64
- /* c8 ignore start */
65
- function buildsExoticCalendars(candidate) {
66
- const temporal = /** @type {typeof bundledTemporal | undefined} */ (candidate)
67
- if (temporal?.PlainDate?.from === undefined) return false
68
- // Ask the runtime for a calendar rather than naming one. The two excluded here are the pair every
69
- // build can construct, so anything else proves the exotic calendar data is present.
70
- const exotic = [...RUNTIME_CALENDARS].find((id) => id !== 'iso8601' && id !== 'gregory')
71
- if (exotic === undefined) return true // nothing to prove it against
72
- try {
73
- temporal.PlainDate.from('2026-01-31').withCalendar(exotic)
74
- return true
75
- } catch {
76
- return false
77
- }
78
- }
79
- /* c8 ignore stop */
80
-
81
- // The fallback needs a runtime whose global Temporal is absent or not calendar-capable, so a process
82
- // with native Temporal cannot reach it. It is covered for real by the Node 20 and bun CI lanes, and
83
- // `npm run test:runtimes` proves those lanes answer identically to the native ones.
84
- // One cast, because `Temporal` is not declared on `globalThis` in the type space and daymath
85
- // deliberately installs no global of its own.
86
- const globalTemporal = /** @type {{Temporal?: unknown}} */ (globalThis).Temporal
87
-
88
- // The fallback needs a runtime whose global Temporal is absent or not calendar-capable, so a process
89
- // with native Temporal cannot reach it. It is covered for real by the Node 20 and bun CI lanes, and
90
- // `npm run test:runtimes` proves those lanes answer identically to the native ones.
91
- /* c8 ignore next */
92
- const Temporal = buildsExoticCalendars(globalTemporal) ? globalTemporal : bundledTemporal
93
-
94
46
  /**
95
47
  * ISO 8601 calendar day string:
96
48
  * - `YYYY-MM-DD` (years 0000–9999)
@@ -103,6 +55,45 @@ const Temporal = buildsExoticCalendars(globalTemporal) ? globalTemporal : bundle
103
55
  */
104
56
  const ISO_DAY = /^(?:[+-]\d{6}|\d{4})-\d{2}-\d{2}$/u
105
57
 
58
+ /**
59
+ * A day with a wall clock and no zone: `YYYY-MM-DD HH:MM[:SS[.fff]]`, with `T`,
60
+ * `t` or a space between them. Group 1 is the day. The clock gets dropped.
61
+ *
62
+ * This is what SQLite hands you — `datetime('now')`, `CURRENT_TIMESTAMP` and
63
+ * `strftime('%Y-%m-%d %H:%M:%f')` all emit a space, seconds and optional
64
+ * fractions — so a column value needs no reshaping before daymath reads it.
65
+ *
66
+ * **All three separators, because a separator is punctuation and never
67
+ * information.** Temporal accepts a space and a lowercase `t` wherever it accepts
68
+ * `T`, and the ZONED forms of all three already answered, so the only hole was a
69
+ * clock naming NO zone.
70
+ *
71
+ * **The hour is the only bound, because the hour is the only field that can
72
+ * change the date. That is the whole rule: daymath drops a clock only when the
73
+ * clock cannot move the day.** A leap second and a fraction of any length stay
74
+ * inside their own day, so both are accepted, matching what `day('…23:59:60Z')`
75
+ * already answered.
76
+ *
77
+ * **`24:00` is refused, and the reason is that SQLite disagrees with itself
78
+ * about it.** ISO `24:00` is midnight starting the NEXT day, and SQLite's own
79
+ * `julianday('2026-08-08 24:00:00')` is 2461261.5 — byte-identical to
80
+ * `julianday('2026-08-09 00:00:00')` — while its `date()` and
81
+ * `strftime('%Y-%m-%d')` on the same string both answer `'2026-08-08'`. So no
82
+ * answer daymath could give agrees with the database: the 9th contradicts its
83
+ * `date()`, the 8th contradicts its `julianday()`. Refusing is the only option
84
+ * that never silently disagrees. Temporal refuses `24:00` on every path too, so
85
+ * accepting would also mean doing carry arithmetic the engine will not do.
86
+ *
87
+ * That also separates it from the two cases above. `t` and `:60` were holes
88
+ * because the ZONED twin already answered; every `24:00` spelling is refused
89
+ * today, zoned and zoneless, so refusing it is the consistent choice.
90
+ *
91
+ * `Z`, an offset and a `[Zone]` cannot reach this pattern — it is anchored — so
92
+ * a string that names a real instant still takes the instant path in `day()`.
93
+ */
94
+ const ISO_DAY_TIME =
95
+ /^((?:[+-]\d{6}|\d{4})-\d{2}-\d{2})[Tt ](?:[01]\d|2[0-3]):[0-5]\d(?::(?:[0-5]\d|60)(?:\.\d+)?)?$/u
96
+
106
97
  /**
107
98
  * Temporal's calendar annotation, `[u-ca=…]` or the critical `[!u-ca=…]`.
108
99
  * Returns what it is attached to, and the calendar it names, or `null`.
@@ -169,9 +160,12 @@ function hasZoneAnnotation(text) {
169
160
  const ZONE_LIKE =
170
161
  /^(?:(?![Tt]\d)[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)*|[+-]\d{2}(?::?\d{2})?)$/u
171
162
 
172
- // `Temporal` is a const now, not an imported namespace, so it cannot carry types. `PlainDate` has
173
- // seven use sites, so a typedef earns itself. `Instant` and `ZonedDateTime` have one each and are
174
- // inlined, because measured the typedef plus its use is longer than the inline form.
163
+ // Two different things wear the name PlainDate here, and keeping them apart is the whole type
164
+ // story of this file. `PlainDateRecord` is daymath's INTERNAL value, and the name is the polyfill's
165
+ // own for it. `PlainDate` is the public INPUT type, a real `Temporal.PlainDate` object from any
166
+ // implementation, which callers still pass and `isPlainDate` still recognises by brand. daymath
167
+ // never returns one.
168
+ /** @typedef {import('temporal-polyfill/fns/PlainDate').Record} PlainDateRecord */
175
169
  /** @typedef {import('temporal-polyfill').Temporal.PlainDate} PlainDate */
176
170
  /** @typedef {string | PlainDate} DayInput */
177
171
  /**
@@ -198,13 +192,19 @@ const ZONE_LIKE =
198
192
  * One predicate, because `toPlainDate` and `day()` both ask this question. They
199
193
  * asked it separately once, and day() alone then refused a string that every
200
194
  * other export accepted.
195
+ *
196
+ * `clock` reports whether a zoneless wall clock came off the string. Every export ignores it and
197
+ * answers the same day either way; only `day()` reads it, because `day()` is the only one that
198
+ * takes a zone and so the only one that can be asked to convert what it just discarded.
201
199
  * @param {string} text
202
- * @returns {string | null}
200
+ * @returns {{ day: string, clock: boolean } | null}
203
201
  */
204
202
  function bareDay(text) {
205
203
  const annotated = calendarAnnotation(text)
206
204
  const bare = annotated ? annotated.head : text
207
- return ISO_DAY.test(bare) ? bare : null
205
+ if (ISO_DAY.test(bare)) return { day: bare, clock: false }
206
+ const clocked = ISO_DAY_TIME.exec(bare)
207
+ return clocked === null ? null : { day: clocked[1], clock: true }
208
208
  }
209
209
 
210
210
  /**
@@ -281,9 +281,12 @@ function calendarRule(calendar) {
281
281
  let verdict = { reason: 'renumbers' }
282
282
  try {
283
283
  const offsets = new Set()
284
+ // `getAny(calendar)` throws for an id the polyfill cannot build, which is the `unknown` case
285
+ // below. It is resolved once rather than per probe.
286
+ const calendarRecord = getAny(calendar)
284
287
  for (const probe of CALENDAR_PROBES) {
285
- const iso = Temporal.PlainDate.from(probe)
286
- const dated = iso.withCalendar(calendar)
288
+ const iso = PlainDateFns.fromString(probe, getAny)
289
+ const dated = PlainDateFns.withCalendar(iso, calendarRecord)
287
290
  if (dated.month !== iso.month || dated.day !== iso.day) {
288
291
  offsets.clear()
289
292
  break
@@ -363,7 +366,7 @@ function isPlainDate(value) {
363
366
  * reasons live on `supportedCalendar`; this function only applies the verdict.
364
367
  * @param {unknown} value
365
368
  * @param {string} label
366
- * @returns {PlainDate}
369
+ * @returns {PlainDateRecord}
367
370
  */
368
371
  function toPlainDate(value, label = 'date') {
369
372
  if (value instanceof Date) {
@@ -383,14 +386,20 @@ function toPlainDate(value, label = 'date') {
383
386
  const bare = bareDay(text)
384
387
  if (bare === null) {
385
388
  throw new RangeError(
386
- `daymath: ${label} must be ISO 8601 day YYYY-MM-DD or ±YYYYYY-MM-DD (got ${JSON.stringify(text)})`,
389
+ `daymath: ${label} must be ISO 8601 day YYYY-MM-DD or ±YYYYYY-MM-DD, optionally with a zoneless clock HH:MM[:SS[.fff]] after a T, t or space, where only the hour is bounded (00-23) (got ${JSON.stringify(text)})`,
387
390
  )
388
391
  }
389
392
  try {
390
- const plain = Temporal.PlainDate.from(bare)
393
+ // The bare day is parsed with the ISO resolver's own entry point. `getAny` is passed as the
394
+ // resolver rather than called, because `fromString` calls it with whatever the string names,
395
+ // and a bare day names nothing. A wall clock is already off the string by this line, so no
396
+ // export ever parses one and every answer stays a day.
397
+ const plain = PlainDateFns.fromString(bare.day, getAny)
391
398
  // The annotation rides along, so getYear answers 2569 for a Buddhist day and every
392
399
  // returned string keeps the calendar the caller named.
393
- return calendar === undefined ? plain : plain.withCalendar(calendar)
400
+ return calendar === undefined
401
+ ? plain
402
+ : PlainDateFns.withCalendar(plain, getAny(calendar))
394
403
  } catch (err) {
395
404
  throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(text)}`, {
396
405
  cause: err,
@@ -405,16 +414,18 @@ function toPlainDate(value, label = 'date') {
405
414
  * `equals` compares the calendar as well as the day. Neither matters to a day count: a day is the
406
415
  * same day whatever the year is labelled, so daymath normalises and answers. Only the exports that
407
416
  * *read* or *write* a field honour the label.
408
- * @param {PlainDate} plain
409
- * @returns {PlainDate}
417
+ * @param {PlainDateRecord} plain
418
+ * @returns {PlainDateRecord}
410
419
  */
411
420
  function isoOf(plain) {
412
- return plain.calendarId === 'iso8601' ? plain : plain.withCalendar('iso8601')
421
+ return plain.calendarId === 'iso8601'
422
+ ? plain
423
+ : PlainDateFns.withCalendar(plain, ISO_CALENDAR)
413
424
  }
414
425
 
415
- /** @param {PlainDate} plain @returns {string} */
426
+ /** @param {PlainDateRecord} plain @returns {string} */
416
427
  function toDayString(plain) {
417
- return plain.toString()
428
+ return PlainDateFns.toString(plain)
418
429
  }
419
430
 
420
431
  /** @param {unknown} n @param {string} label */
@@ -455,14 +466,29 @@ function guardRange(label, op) {
455
466
  /**
456
467
  * Shared body for every add/sub function. Each caller passes its own name, so
457
468
  * the message never reports a function the caller did not call.
469
+ *
470
+ * The unit is a parameter rather than a duration object on purpose. `PlainDateFns.add` would need a
471
+ * `DurationRecord`, which means importing `fns/Duration`, and every daymath caller moves exactly
472
+ * one unit. `addDays` / `addMonths` / `addYears` take a plain number and keep that subpath out of
473
+ * the bundle. Subtraction is a negative amount, exactly as it was when this passed `-amount`.
474
+ *
475
+ * `addMonths` and `addYears` clamp by default, which is daymath's one overflow rule and the same
476
+ * behaviour the class API gave: 31 January plus one month is 28 February.
458
477
  * @param {DayInput} date
459
- * @param {{ days?: number, months?: number, years?: number }} delta
478
+ * @param {'days'|'months'|'years'} unit
479
+ * @param {number} amount
460
480
  * @param {string} label
461
481
  * @returns {string}
462
482
  */
463
- function addDuration(date, delta, label) {
483
+ function addDuration(date, unit, amount, label) {
464
484
  const d = toPlainDate(date)
465
- return guardRange(label, () => toDayString(d.add(delta)))
485
+ const move =
486
+ unit === 'days'
487
+ ? PlainDateFns.addDays
488
+ : unit === 'months'
489
+ ? PlainDateFns.addMonths
490
+ : PlainDateFns.addYears
491
+ return guardRange(label, () => toDayString(move(d, amount)))
466
492
  }
467
493
 
468
494
  /** @param {unknown} dates */
@@ -486,7 +512,7 @@ function weekStartsOnFrom(options) {
486
512
 
487
513
  /**
488
514
  * @param {unknown} interval
489
- * @returns {{ start: PlainDate, end: PlainDate }}
515
+ * @returns {{ start: PlainDateRecord, end: PlainDateRecord }}
490
516
  */
491
517
  function toInterval(interval) {
492
518
  // `typeof x === 'object'` alone let a Date, an array or a PlainDate through,
@@ -522,6 +548,9 @@ function toInterval(interval) {
522
548
  * truncated the same way, so a fractional value is not an error
523
549
  * - an ISO 8601 day string, or a `Temporal.PlainDate` from any implementation,
524
550
  * both of which are already a day
551
+ * - a day carrying a zoneless wall clock, `'YYYY-MM-DD HH:MM[:SS[.fff]]'`, with
552
+ * `T`, `t` or a space between them: a day with noise on it, and the clock is
553
+ * dropped. Only the hour is bounded, at 00-23
525
554
  * - an ISO 8601 timestamp carrying `Z` or an offset, which names an exact
526
555
  * instant, so there is nothing left to guess
527
556
  * - a string carrying a `[Zone]` annotation, which names its own zone, so it
@@ -538,9 +567,15 @@ function toInterval(interval) {
538
567
  * ways in one function.
539
568
  *
540
569
  * `'11/12/2026'` is refused. Nobody can tell November from December in it.
541
- * `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath would
542
- * have to pick one, and it will not pick on the caller's behalf. Name the zone
543
- * `'2026-08-08T12:00[America/New_York]'` and it is accepted.
570
+ *
571
+ * A **zoneless wall clock is a day**, so `'2026-08-08 12:00:00'` and
572
+ * `'2026-08-08T12:00'` both answer `'2026-08-08'`. The clock is dropped, never
573
+ * read. That is what SQLite hands you from `datetime()` and `CURRENT_TIMESTAMP`,
574
+ * and it takes the day path here exactly as a bare day does.
575
+ *
576
+ * **Pass `tz` with one and it throws.** Naming a zone means convert, and a clock
577
+ * with no zone gives nothing to convert from. Name the zone in the string —
578
+ * `'2026-08-08T12:00[America/New_York]'`, or `Z`, or an offset — and it converts.
544
579
  *
545
580
  * A lone string takes one of four roles, decided in this order: a day, then a
546
581
  * zoned time, then an instant, then a zone. The zone test is by **shape**, and
@@ -570,19 +605,21 @@ function toInterval(interval) {
570
605
  * @example day(1761616161771) // '2025-10-28' epoch ms
571
606
  * @example day('1999-01-01T00:00:00Z') // '1999-01-01' an ISO timestamp
572
607
  * @example day(zdt.toString()) // the zone in the string wins
608
+ * @example day(row.created_at) // '2026-08-08' a SQLite DATETIME
609
+ * @example day('2026-08-08 12:00:00', 'utc') // throws: the clock names no zone
573
610
  * @example addDays(day(), 2) // '2026-08-10'
574
611
  */
575
612
  export function day(moment, tz) {
576
613
  // Already a day, in every accepted spelling. `bareDay` is the same predicate
577
614
  // toPlainDate uses, so day() cannot drift from the other 68 exports.
578
- const isDay =
579
- isPlainDate(moment) || (typeof moment === 'string' && bareDay(moment) !== null)
615
+ const bare = typeof moment === 'string' ? bareDay(moment) : null
616
+ const isDay = isPlainDate(moment) || bare !== null
580
617
  const isMoment = isDay || moment instanceof Date || typeof moment === 'number'
581
618
 
582
619
  let zone = tz
583
- /** @type {import('temporal-polyfill').Temporal.Instant | undefined} */
620
+ /** @type {import('temporal-polyfill/fns/Instant').Record | undefined} */
584
621
  let instant
585
- /** @type {import('temporal-polyfill').Temporal.ZonedDateTime | undefined} */
622
+ /** @type {import('temporal-polyfill/fns/ZonedDateTime').Record | undefined} */
586
623
  let zoned
587
624
  if (!isMoment && moment !== undefined && moment !== null) {
588
625
  if (typeof moment !== 'string') {
@@ -612,7 +649,9 @@ export function day(moment, tz) {
612
649
  )
613
650
  }
614
651
  try {
615
- zoned = Temporal.ZonedDateTime.from(moment)
652
+ // `getAny` again, for the same reason it is used everywhere else: a zoned string can carry
653
+ // a calendar annotation, and the shim funcApi would drop it under `getISO`.
654
+ zoned = ZonedFns.fromString(moment, getAny)
616
655
  } catch (err) {
617
656
  throw new RangeError(
618
657
  `daymath: day() could not read ${JSON.stringify(moment)} in the time zone it names`,
@@ -628,7 +667,7 @@ export function day(moment, tz) {
628
667
  // year, month or day for a calendar to renumber, and without a zone
629
668
  // bracket Temporal will not build anything that does. Refusing it would
630
669
  // reject a right answer for a reason that cannot apply.
631
- instant = Temporal.Instant.from(moment)
670
+ instant = InstantFns.fromString(moment)
632
671
  } catch {
633
672
  // Not a moment, so the string must be a zone — decided by shape, before
634
673
  // Temporal sees it. Temporal's zone grammar also accepts a whole
@@ -670,10 +709,10 @@ export function day(moment, tz) {
670
709
  // whatever the moment is. A caller mapping rows that are sometimes a Date and
671
710
  // sometimes a day string would otherwise see the typo only on some rows.
672
711
  //
673
- // `ZonedDateTime.from` accepts exactly what `Temporal.Now` accepts and reads
712
+ // `ZonedDateTime.fromFields` accepts exactly the zone ids `Now` accepts and reads
674
713
  // no clock, so a day input stays a pure function.
675
714
  try {
676
- Temporal.ZonedDateTime.from({ timeZone: zone, year: 1970, month: 1, day: 1 })
715
+ ZonedFns.fromFields({ timeZone: zone, year: 1970, month: 1, day: 1 })
677
716
  } catch (err) {
678
717
  throw new RangeError(
679
718
  `daymath: day() got an unknown time zone ${JSON.stringify(zone)}`,
@@ -690,7 +729,25 @@ export function day(moment, tz) {
690
729
  // plain ISO day comes out. Carrying it here also could not be made
691
730
  // consistent, because an `Instant` has no fields for a calendar to renumber,
692
731
  // so that path has nothing to carry and would answer bare ISO regardless.
693
- if (isDay) return toDayString(isoOf(toPlainDate(moment)))
732
+ if (isDay) {
733
+ // The value is judged FIRST, so a day that does not exist reports itself rather than the zone.
734
+ // The guard below is about the argument PAIR, so it must never pre-empt a fault in either half.
735
+ const answer = toDayString(isoOf(toPlainDate(moment)))
736
+ // The one argument pair daymath refuses rather than answers. On 2026-08-24 SQLite's
737
+ // `datetime('now')` read 2026-08-24 01:57:31 and `datetime('now','localtime')` read
738
+ // 2026-08-23 21:57:31 — different days — because `datetime()` defaults to UTC. So the caller
739
+ // who stores the default and asks for a local day is the one a silent answer would mislead.
740
+ //
741
+ // Only this pair. A bare day plus a zone still answers, because no information was discarded
742
+ // there. Sibling of the `two time zones` refusals above, which a wall clock never reaches:
743
+ // both of those sit inside `if (!isMoment …)`.
744
+ if (bare?.clock && tz !== undefined && tz !== null) {
745
+ throw new TypeError(
746
+ `daymath: day() cannot apply the zone ${JSON.stringify(tz)} to ${JSON.stringify(moment)}, because that clock names no zone (add Z or an offset to it, or drop the zone)`,
747
+ )
748
+ }
749
+ return answer
750
+ }
694
751
 
695
752
  // The string named its own zone, so that zone decides the day, not the
696
753
  // default. This is the one path where `zone` is deliberately not consulted.
@@ -698,17 +755,24 @@ export function day(moment, tz) {
698
755
  // The result still goes through `toPlainDate`, so the calendar is adjudicated
699
756
  // in one place, and then through `isoOf` for the reason above.
700
757
  if (zoned !== undefined) {
701
- const plain = guardRange('day', () => zoned.toPlainDate())
758
+ // `toPlainDate` is re-entered with the STRING, not the record, because daymath's own funnel
759
+ // takes a day string or a `Temporal.PlainDate`, and an fns record is neither. `PlainDateFns.toString`
760
+ // writes the same day the class API's `toPlainDate()` produced, annotation and all.
761
+ const plain = guardRange('day', () =>
762
+ PlainDateFns.toString(ZonedFns.toPlainDate(zoned)),
763
+ )
702
764
  return toDayString(isoOf(toPlainDate(plain)))
703
765
  }
704
766
 
705
767
  if (instant !== undefined) {
706
768
  return guardRange('day', () =>
707
- instant.toZonedDateTimeISO(zone).toPlainDate().toString(),
769
+ PlainDateFns.toString(
770
+ ZonedFns.toPlainDate(InstantFns.toZonedDateTimeISO(instant, zone)),
771
+ ),
708
772
  )
709
773
  }
710
774
 
711
- if (!isMoment) return Temporal.Now.plainDateISO(zone).toString()
775
+ if (!isMoment) return PlainDateFns.toString(NowFns.plainDateISO(zone))
712
776
 
713
777
  // Truncate, because `new Date(n)` truncates, and the contract here is that a
714
778
  // number reads exactly as it does. Verified equal on positive and negative
@@ -720,10 +784,11 @@ export function day(moment, tz) {
720
784
  ? Math.trunc(moment)
721
785
  : /** @type {Date} */ (moment).getTime()
722
786
  return guardRange('day', () =>
723
- Temporal.Instant.fromEpochMilliseconds(epochMs)
724
- .toZonedDateTimeISO(zone)
725
- .toPlainDate()
726
- .toString(),
787
+ PlainDateFns.toString(
788
+ ZonedFns.toPlainDate(
789
+ InstantFns.toZonedDateTimeISO(InstantFns.fromEpochMilliseconds(epochMs), zone),
790
+ ),
791
+ ),
727
792
  )
728
793
  }
729
794
 
@@ -783,7 +848,7 @@ export function format(date, pattern = 'yyyy-MM-dd') {
783
848
  */
784
849
  export function addDays(date, amount) {
785
850
  assertFiniteNumber(amount, 'amount')
786
- return addDuration(date, { days: amount }, 'addDays')
851
+ return addDuration(date, 'days', amount, 'addDays')
787
852
  }
788
853
 
789
854
  /**
@@ -793,7 +858,7 @@ export function addDays(date, amount) {
793
858
  */
794
859
  export function subDays(date, amount) {
795
860
  assertFiniteNumber(amount, 'amount')
796
- return addDuration(date, { days: -amount }, 'subDays')
861
+ return addDuration(date, 'days', -amount, 'subDays')
797
862
  }
798
863
 
799
864
  /**
@@ -805,7 +870,7 @@ export function addWeeks(date, amount) {
805
870
  assertFiniteNumber(amount, 'amount')
806
871
  const days = amount * 7 // re-check: 7x a finite amount can still reach Infinity
807
872
  assertFiniteNumber(days, 'amount')
808
- return addDuration(date, { days }, 'addWeeks')
873
+ return addDuration(date, 'days', days, 'addWeeks')
809
874
  }
810
875
 
811
876
  /**
@@ -817,7 +882,7 @@ export function subWeeks(date, amount) {
817
882
  assertFiniteNumber(amount, 'amount')
818
883
  const days = -amount * 7
819
884
  assertFiniteNumber(days, 'amount')
820
- return addDuration(date, { days }, 'subWeeks')
885
+ return addDuration(date, 'days', days, 'subWeeks')
821
886
  }
822
887
 
823
888
  /**
@@ -828,7 +893,7 @@ export function subWeeks(date, amount) {
828
893
  */
829
894
  export function addMonths(date, amount) {
830
895
  assertFiniteNumber(amount, 'amount')
831
- return addDuration(date, { months: amount }, 'addMonths')
896
+ return addDuration(date, 'months', amount, 'addMonths')
832
897
  }
833
898
 
834
899
  /**
@@ -838,7 +903,7 @@ export function addMonths(date, amount) {
838
903
  */
839
904
  export function subMonths(date, amount) {
840
905
  assertFiniteNumber(amount, 'amount')
841
- return addDuration(date, { months: -amount }, 'subMonths')
906
+ return addDuration(date, 'months', -amount, 'subMonths')
842
907
  }
843
908
 
844
909
  /**
@@ -848,7 +913,7 @@ export function subMonths(date, amount) {
848
913
  */
849
914
  export function addYears(date, amount) {
850
915
  assertFiniteNumber(amount, 'amount')
851
- return addDuration(date, { years: amount }, 'addYears')
916
+ return addDuration(date, 'years', amount, 'addYears')
852
917
  }
853
918
 
854
919
  /**
@@ -858,7 +923,7 @@ export function addYears(date, amount) {
858
923
  */
859
924
  export function subYears(date, amount) {
860
925
  assertFiniteNumber(amount, 'amount')
861
- return addDuration(date, { years: -amount }, 'subYears')
926
+ return addDuration(date, 'years', -amount, 'subYears')
862
927
  }
863
928
 
864
929
  /**
@@ -870,7 +935,7 @@ export function addQuarters(date, amount) {
870
935
  assertFiniteNumber(amount, 'amount')
871
936
  const months = amount * 3
872
937
  assertFiniteNumber(months, 'amount')
873
- return addDuration(date, { months }, 'addQuarters')
938
+ return addDuration(date, 'months', months, 'addQuarters')
874
939
  }
875
940
 
876
941
  /**
@@ -882,7 +947,7 @@ export function subQuarters(date, amount) {
882
947
  assertFiniteNumber(amount, 'amount')
883
948
  const months = -amount * 3
884
949
  assertFiniteNumber(months, 'amount')
885
- return addDuration(date, { months }, 'subQuarters')
950
+ return addDuration(date, 'months', months, 'subQuarters')
886
951
  }
887
952
 
888
953
  // ─── getters / setters (date-fns / Date month & weekday indexing) ─
@@ -915,27 +980,51 @@ export function getDate(date) {
915
980
  * @returns {number}
916
981
  */
917
982
  export function getDay(date) {
918
- return toPlainDate(date).dayOfWeek
983
+ return PlainDateFns.dayOfWeek(toPlainDate(date))
919
984
  }
920
985
 
921
986
  /** @param {DayInput} date @returns {number} */
922
987
  export function getDayOfYear(date) {
923
- return toPlainDate(date).dayOfYear
988
+ return PlainDateFns.dayOfYear(toPlainDate(date))
924
989
  }
925
990
 
926
991
  /** @param {DayInput} date @returns {number} */
927
992
  export function getDaysInMonth(date) {
928
- return toPlainDate(date).daysInMonth
993
+ return PlainDateFns.daysInMonth(toPlainDate(date))
994
+ }
995
+
996
+ /**
997
+ * Quarter of a record daymath already owns.
998
+ *
999
+ * The class API needed no such helper, because a `Temporal.PlainDate` passed back into
1000
+ * `toPlainDate` was recognised by `isPlainDate` and survived the round trip. An fns record is not
1001
+ * a `Temporal.PlainDate` and is correctly refused, so every internal caller now works on the
1002
+ * record. That was always the honest shape: re-validating a value daymath just built is waste.
1003
+ * @param {PlainDateRecord} plain
1004
+ * @returns {number}
1005
+ */
1006
+ function quarterOf(plain) {
1007
+ return Math.ceil(plain.month / 3)
1008
+ }
1009
+
1010
+ /**
1011
+ * Calendar month index between two records already normalised to ISO.
1012
+ * @param {PlainDateRecord} left
1013
+ * @param {PlainDateRecord} right
1014
+ * @returns {number}
1015
+ */
1016
+ function calendarMonthsBetween(left, right) {
1017
+ return (left.year - right.year) * 12 + (left.month - right.month)
929
1018
  }
930
1019
 
931
1020
  /** Quarter 1…4. @param {DayInput} date @returns {number} */
932
1021
  export function getQuarter(date) {
933
- return Math.ceil(toPlainDate(date).month / 3)
1022
+ return quarterOf(toPlainDate(date))
934
1023
  }
935
1024
 
936
1025
  /** @param {DayInput} date @returns {boolean} */
937
1026
  export function isLeapYear(date) {
938
- return toPlainDate(date).inLeapYear
1027
+ return PlainDateFns.inLeapYear(toPlainDate(date))
939
1028
  }
940
1029
 
941
1030
  /**
@@ -946,7 +1035,7 @@ export function isLeapYear(date) {
946
1035
  export function setYear(date, year) {
947
1036
  assertFiniteNumber(year, 'year')
948
1037
  const d = toPlainDate(date)
949
- return guardRange('setYear', () => toDayString(d.with({ year })))
1038
+ return guardRange('setYear', () => toDayString(PlainDateFns.withFields(d, { year })))
950
1039
  }
951
1040
 
952
1041
  /**
@@ -960,7 +1049,7 @@ export function setMonth(date, month) {
960
1049
  throw new RangeError('daymath: month must be 1…12 (1=January)')
961
1050
  }
962
1051
  const d = toPlainDate(date)
963
- return guardRange('setMonth', () => toDayString(d.with({ month })))
1052
+ return guardRange('setMonth', () => toDayString(PlainDateFns.withFields(d, { month })))
964
1053
  }
965
1054
 
966
1055
  /**
@@ -972,7 +1061,9 @@ export function setMonth(date, month) {
972
1061
  export function setDate(date, dayOfMonth) {
973
1062
  assertFiniteNumber(dayOfMonth, 'day')
974
1063
  const d = toPlainDate(date)
975
- return guardRange('setDate', () => toDayString(d.with({ day: dayOfMonth })))
1064
+ return guardRange('setDate', () =>
1065
+ toDayString(PlainDateFns.withFields(d, { day: dayOfMonth })),
1066
+ )
976
1067
  }
977
1068
 
978
1069
  // ─── start / end of unit ───────────────────────────────────────────
@@ -984,41 +1075,53 @@ export function setDate(date, dayOfMonth) {
984
1075
  /** @param {DayInput} date @returns {string} */
985
1076
  export function startOfMonth(date) {
986
1077
  const d = toPlainDate(date)
987
- return guardRange('startOfMonth', () => toDayString(d.with({ day: 1 })))
1078
+ return guardRange('startOfMonth', () =>
1079
+ toDayString(PlainDateFns.withFields(d, { day: 1 })),
1080
+ )
988
1081
  }
989
1082
 
990
1083
  /** @param {DayInput} date @returns {string} */
991
1084
  export function endOfMonth(date) {
992
1085
  const d = toPlainDate(date)
993
- return guardRange('endOfMonth', () => toDayString(d.with({ day: d.daysInMonth })))
1086
+ return guardRange('endOfMonth', () =>
1087
+ toDayString(PlainDateFns.withFields(d, { day: PlainDateFns.daysInMonth(d) })),
1088
+ )
994
1089
  }
995
1090
 
996
1091
  /** @param {DayInput} date @returns {string} */
997
1092
  export function startOfYear(date) {
998
1093
  const d = toPlainDate(date)
999
- return guardRange('startOfYear', () => toDayString(d.with({ month: 1, day: 1 })))
1094
+ return guardRange('startOfYear', () =>
1095
+ toDayString(PlainDateFns.withFields(d, { month: 1, day: 1 })),
1096
+ )
1000
1097
  }
1001
1098
 
1002
1099
  /** @param {DayInput} date @returns {string} */
1003
1100
  export function endOfYear(date) {
1004
1101
  const d = toPlainDate(date)
1005
- return guardRange('endOfYear', () => toDayString(d.with({ month: 12, day: 31 })))
1102
+ return guardRange('endOfYear', () =>
1103
+ toDayString(PlainDateFns.withFields(d, { month: 12, day: 31 })),
1104
+ )
1006
1105
  }
1007
1106
 
1008
1107
  /** @param {DayInput} date @returns {string} */
1009
1108
  export function startOfQuarter(date) {
1010
1109
  const d = toPlainDate(date)
1011
- const month = (getQuarter(d) - 1) * 3 + 1
1012
- return guardRange('startOfQuarter', () => toDayString(d.with({ month, day: 1 })))
1110
+ const month = (quarterOf(d) - 1) * 3 + 1
1111
+ return guardRange('startOfQuarter', () =>
1112
+ toDayString(PlainDateFns.withFields(d, { month, day: 1 })),
1113
+ )
1013
1114
  }
1014
1115
 
1015
1116
  /** @param {DayInput} date @returns {string} */
1016
1117
  export function endOfQuarter(date) {
1017
1118
  const d = toPlainDate(date)
1018
- const month = getQuarter(d) * 3
1119
+ const month = quarterOf(d) * 3
1019
1120
  return guardRange('endOfQuarter', () => {
1020
- const mid = d.with({ month, day: 1 })
1021
- return toDayString(mid.with({ day: mid.daysInMonth }))
1121
+ const mid = PlainDateFns.withFields(d, { month, day: 1 })
1122
+ return toDayString(
1123
+ PlainDateFns.withFields(mid, { day: PlainDateFns.daysInMonth(mid) }),
1124
+ )
1022
1125
  })
1023
1126
  }
1024
1127
 
@@ -1030,19 +1133,19 @@ export function endOfQuarter(date) {
1030
1133
  export function startOfWeek(date, options) {
1031
1134
  const d = toPlainDate(date)
1032
1135
  const diff = daysIntoWeek(d, options)
1033
- return guardRange('startOfWeek', () => toDayString(d.subtract({ days: diff })))
1136
+ return guardRange('startOfWeek', () => toDayString(PlainDateFns.subtractDays(d, diff)))
1034
1137
  }
1035
1138
 
1036
1139
  /**
1037
1140
  * How far the day sits past the start of its week.
1038
- * @param {PlainDate} d
1141
+ * @param {PlainDateRecord} d
1039
1142
  * @param {WeekOptions} [options]
1040
1143
  * @returns {number}
1041
1144
  */
1042
1145
  function daysIntoWeek(d, options) {
1043
1146
  const weekStartsOn = weekStartsOnFrom(options)
1044
1147
  // mod 7 makes weekStartsOn 0 and 7 identical, so both spellings of Sunday work
1045
- return (d.dayOfWeek - weekStartsOn + 7) % 7
1148
+ return (PlainDateFns.dayOfWeek(d) - weekStartsOn + 7) % 7
1046
1149
  }
1047
1150
 
1048
1151
  /**
@@ -1055,7 +1158,7 @@ export function endOfWeek(date, options) {
1055
1158
  const diff = daysIntoWeek(d, options)
1056
1159
  // one guard for the whole walk, so a failure at either end says endOfWeek
1057
1160
  return guardRange('endOfWeek', () =>
1058
- toDayString(d.subtract({ days: diff }).add({ days: 6 })),
1161
+ toDayString(PlainDateFns.addDays(PlainDateFns.subtractDays(d, diff), 6)),
1059
1162
  )
1060
1163
  }
1061
1164
 
@@ -1070,7 +1173,26 @@ export function endOfWeek(date, options) {
1070
1173
  export function differenceInDays(dateLeft, dateRight) {
1071
1174
  const left = toPlainDate(dateLeft, 'dateLeft')
1072
1175
  const right = toPlainDate(dateRight, 'dateRight')
1073
- return isoOf(left).since(isoOf(right), { largestUnit: 'day' }).days
1176
+ // `diff` IS Temporal: it is the `fns` spelling of `PlainDate.prototype.until`, which is what
1177
+ // `since` was here with the operands the other way round. The choice below is between two
1178
+ // Temporal functions, not between Temporal and anything else.
1179
+ //
1180
+ // `diff`, NOT `diffDays`, and the reason is the one day where the two Temporal ranges disagree.
1181
+ //
1182
+ // `PlainDate`'s minimum is `-271821-04-19`, one day BELOW `PlainDateTime`'s, because midnight on
1183
+ // that day is out of bounds while the same day is reachable in a positive-offset zone. The
1184
+ // maximum is not widened, so the asymmetry is real and only the low edge has it. Measured:
1185
+ // `PlainDate.from('-271821-04-19').toPlainDateTime()` throws `Out-of-bounds date`, and
1186
+ // `-271821-04-20` does not.
1187
+ //
1188
+ // `diffDays` converts to a `PlainDateTime` and inherits that, so it throws on exactly one
1189
+ // operand. `diff` with `largestUnit: 'day'` does not, and answers what the class API's `since`
1190
+ // answered. The cross-runtime baseline caught it, because no test in the suite covers a pair
1191
+ // that wide; `differenceInDays` and `differenceInWeeks` both went red.
1192
+ //
1193
+ // The argument order is `(record, other)` for `other − record`, so the operands are swapped to
1194
+ // keep daymath's date-fns order of `dateLeft − dateRight`.
1195
+ return PlainDateFns.diff(isoOf(right), isoOf(left), { largestUnit: 'day' }).days
1074
1196
  }
1075
1197
 
1076
1198
  /**
@@ -1105,16 +1227,16 @@ export function differenceInMonths(dateLeft, dateRight) {
1105
1227
  // 29-day gap, and two days 543 ISO years apart measured 0.
1106
1228
  const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1107
1229
  const right = isoOf(toPlainDate(dateRight, 'dateRight'))
1108
- const sign = Temporal.PlainDate.compare(left, right)
1230
+ const sign = PlainDateFns.compare(left, right)
1109
1231
  if (sign === 0) return 0
1110
- const diff = Math.abs(differenceInCalendarMonths(left, right))
1232
+ const diff = Math.abs(calendarMonthsBetween(left, right))
1111
1233
  if (diff < 1) return 0
1112
1234
  const [earlier, later] = sign > 0 ? [right, left] : [left, right]
1113
1235
  // Where `earlier` lands after `diff` months: same year-month as `later` by
1114
1236
  // construction, on `earlier`'s day clamped to that month's length. Compared
1115
1237
  // as day numbers rather than built as a date, because the landing can sit
1116
1238
  // past the maximum PlainDate even when both operands are inside the range.
1117
- const landingDay = Math.min(earlier.day, later.daysInMonth)
1239
+ const landingDay = Math.min(earlier.day, PlainDateFns.daysInMonth(later))
1118
1240
  const isLastMonthNotFull = landingDay > later.day
1119
1241
  return sign * (diff - +isLastMonthNotFull) || 0
1120
1242
  }
@@ -1128,7 +1250,7 @@ export function differenceInMonths(dateLeft, dateRight) {
1128
1250
  export function differenceInCalendarMonths(dateLeft, dateRight) {
1129
1251
  const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1130
1252
  const right = isoOf(toPlainDate(dateRight, 'dateRight'))
1131
- return (left.year - right.year) * 12 + (left.month - right.month)
1253
+ return calendarMonthsBetween(left, right)
1132
1254
  }
1133
1255
 
1134
1256
  /**
@@ -1145,7 +1267,7 @@ export function differenceInYears(dateLeft, dateRight) {
1145
1267
  // `isoOf` on both: a measurement, so it ignores the year LABEL. See differenceInMonths.
1146
1268
  const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1147
1269
  const right = isoOf(toPlainDate(dateRight, 'dateRight'))
1148
- const sign = Temporal.PlainDate.compare(left, right)
1270
+ const sign = PlainDateFns.compare(left, right)
1149
1271
  if (sign === 0) return 0
1150
1272
  const diff = Math.abs(left.year - right.year)
1151
1273
  if (diff < 1) return 0
@@ -1156,7 +1278,9 @@ export function differenceInYears(dateLeft, dateRight) {
1156
1278
  // Compared field by field rather than built as a date, because the landing
1157
1279
  // can sit past the maximum PlainDate even with both operands inside the range.
1158
1280
  const landingDay =
1159
- earlier.month === 2 && earlier.day === 29 && !later.inLeapYear ? 28 : earlier.day
1281
+ earlier.month === 2 && earlier.day === 29 && !PlainDateFns.inLeapYear(later)
1282
+ ? 28
1283
+ : earlier.day
1160
1284
  const isLastYearNotFull =
1161
1285
  earlier.month > later.month ||
1162
1286
  (earlier.month === later.month && landingDay > later.day)
@@ -1199,7 +1323,7 @@ export function differenceInQuarters(dateLeft, dateRight) {
1199
1323
  export function differenceInCalendarQuarters(dateLeft, dateRight) {
1200
1324
  const left = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1201
1325
  const right = isoOf(toPlainDate(dateRight, 'dateRight'))
1202
- return (left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
1326
+ return (left.year - right.year) * 4 + (quarterOf(left) - quarterOf(right))
1203
1327
  }
1204
1328
 
1205
1329
  // ─── compare / equal ───────────────────────────────────────────────
@@ -1211,10 +1335,8 @@ export function differenceInCalendarQuarters(dateLeft, dateRight) {
1211
1335
  */
1212
1336
  export function isBefore(date, dateToCompare) {
1213
1337
  return (
1214
- Temporal.PlainDate.compare(
1215
- toPlainDate(date),
1216
- toPlainDate(dateToCompare, 'dateToCompare'),
1217
- ) < 0
1338
+ PlainDateFns.compare(toPlainDate(date), toPlainDate(dateToCompare, 'dateToCompare')) <
1339
+ 0
1218
1340
  )
1219
1341
  }
1220
1342
 
@@ -1225,10 +1347,8 @@ export function isBefore(date, dateToCompare) {
1225
1347
  */
1226
1348
  export function isAfter(date, dateToCompare) {
1227
1349
  return (
1228
- Temporal.PlainDate.compare(
1229
- toPlainDate(date),
1230
- toPlainDate(dateToCompare, 'dateToCompare'),
1231
- ) > 0
1350
+ PlainDateFns.compare(toPlainDate(date), toPlainDate(dateToCompare, 'dateToCompare')) >
1351
+ 0
1232
1352
  )
1233
1353
  }
1234
1354
 
@@ -1240,7 +1360,7 @@ export function isAfter(date, dateToCompare) {
1240
1360
  */
1241
1361
  export function isEqual(dateLeft, dateRight) {
1242
1362
  return (
1243
- Temporal.PlainDate.compare(
1363
+ PlainDateFns.compare(
1244
1364
  toPlainDate(dateLeft, 'dateLeft'),
1245
1365
  toPlainDate(dateRight, 'dateRight'),
1246
1366
  ) === 0
@@ -1265,9 +1385,9 @@ export function isSameWeek(dateLeft, dateRight, options) {
1265
1385
  return guardRange(
1266
1386
  'isSameWeek',
1267
1387
  () =>
1268
- Temporal.PlainDate.compare(
1269
- left.subtract({ days: daysIntoWeek(left, options) }),
1270
- right.subtract({ days: daysIntoWeek(right, options) }),
1388
+ PlainDateFns.compare(
1389
+ PlainDateFns.subtractDays(left, daysIntoWeek(left, options)),
1390
+ PlainDateFns.subtractDays(right, daysIntoWeek(right, options)),
1271
1391
  ) === 0,
1272
1392
  )
1273
1393
  }
@@ -1305,7 +1425,7 @@ export function isSameYear(dateLeft, dateRight) {
1305
1425
  export function isSameQuarter(dateLeft, dateRight) {
1306
1426
  const a = isoOf(toPlainDate(dateLeft, 'dateLeft'))
1307
1427
  const b = isoOf(toPlainDate(dateRight, 'dateRight'))
1308
- return a.year === b.year && getQuarter(a) === getQuarter(b)
1428
+ return a.year === b.year && quarterOf(a) === quarterOf(b)
1309
1429
  }
1310
1430
 
1311
1431
  /**
@@ -1315,7 +1435,7 @@ export function isSameQuarter(dateLeft, dateRight) {
1315
1435
  */
1316
1436
  export function compareAsc(dateLeft, dateRight) {
1317
1437
  return /** @type {-1 | 0 | 1} */ (
1318
- Temporal.PlainDate.compare(
1438
+ PlainDateFns.compare(
1319
1439
  toPlainDate(dateLeft, 'dateLeft'),
1320
1440
  toPlainDate(dateRight, 'dateRight'),
1321
1441
  )
@@ -1341,7 +1461,7 @@ export function min(dates) {
1341
1461
  return toDayString(
1342
1462
  dates
1343
1463
  .map((d) => toPlainDate(d))
1344
- .reduce((a, b) => (Temporal.PlainDate.compare(a, b) <= 0 ? a : b)),
1464
+ .reduce((a, b) => (PlainDateFns.compare(a, b) <= 0 ? a : b)),
1345
1465
  )
1346
1466
  }
1347
1467
 
@@ -1354,7 +1474,7 @@ export function max(dates) {
1354
1474
  return toDayString(
1355
1475
  dates
1356
1476
  .map((d) => toPlainDate(d))
1357
- .reduce((a, b) => (Temporal.PlainDate.compare(a, b) >= 0 ? a : b)),
1477
+ .reduce((a, b) => (PlainDateFns.compare(a, b) >= 0 ? a : b)),
1358
1478
  )
1359
1479
  }
1360
1480
 
@@ -1402,7 +1522,7 @@ export function isFirstDayOfMonth(date) {
1402
1522
  /** @param {DayInput} date @returns {boolean} */
1403
1523
  export function isLastDayOfMonth(date) {
1404
1524
  const d = toPlainDate(date)
1405
- return d.day === d.daysInMonth
1525
+ return d.day === PlainDateFns.daysInMonth(d)
1406
1526
  }
1407
1527
 
1408
1528
  // ─── intervals ─────────────────────────────────────────────────────
@@ -1414,7 +1534,7 @@ export function isLastDayOfMonth(date) {
1414
1534
  */
1415
1535
  export function eachDayOfInterval(interval) {
1416
1536
  const { start, end } = toInterval(interval)
1417
- if (Temporal.PlainDate.compare(start, end) > 0) {
1537
+ if (PlainDateFns.compare(start, end) > 0) {
1418
1538
  throw new RangeError('daymath: interval start must not be after end')
1419
1539
  }
1420
1540
  /** @type {string[]} */
@@ -1424,8 +1544,8 @@ export function eachDayOfInterval(interval) {
1424
1544
  // PlainDate (+275760-09-13) throws
1425
1545
  for (;;) {
1426
1546
  out.push(toDayString(cur))
1427
- if (Temporal.PlainDate.compare(cur, end) >= 0) break
1428
- cur = cur.add({ days: 1 })
1547
+ if (PlainDateFns.compare(cur, end) >= 0) break
1548
+ cur = PlainDateFns.addDays(cur, 1)
1429
1549
  }
1430
1550
  return out
1431
1551
  }
@@ -1437,22 +1557,22 @@ export function eachDayOfInterval(interval) {
1437
1557
  */
1438
1558
  export function eachMonthOfInterval(interval) {
1439
1559
  const { start, end } = toInterval(interval)
1440
- if (Temporal.PlainDate.compare(start, end) > 0) {
1560
+ if (PlainDateFns.compare(start, end) > 0) {
1441
1561
  throw new RangeError('daymath: interval start must not be after end')
1442
1562
  }
1443
1563
  /** @type {string[]} */
1444
1564
  const out = []
1445
1565
  // the 1st of start's month can sit below the minimum PlainDate
1446
1566
  const [cur0, last] = guardRange('eachMonthOfInterval', () => [
1447
- start.with({ day: 1 }),
1448
- end.with({ day: 1 }),
1567
+ PlainDateFns.withFields(start, { day: 1 }),
1568
+ PlainDateFns.withFields(end, { day: 1 }),
1449
1569
  ])
1450
1570
  let cur = cur0
1451
1571
  // same boundary rule as eachDayOfInterval
1452
1572
  for (;;) {
1453
1573
  out.push(toDayString(cur))
1454
- if (Temporal.PlainDate.compare(cur, last) >= 0) break
1455
- cur = cur.add({ months: 1 })
1574
+ if (PlainDateFns.compare(cur, last) >= 0) break
1575
+ cur = PlainDateFns.addMonths(cur, 1)
1456
1576
  }
1457
1577
  return out
1458
1578
  }
@@ -1464,7 +1584,7 @@ export function eachMonthOfInterval(interval) {
1464
1584
  */
1465
1585
  export function eachYearOfInterval(interval) {
1466
1586
  const { start, end } = toInterval(interval)
1467
- if (Temporal.PlainDate.compare(start, end) > 0) {
1587
+ if (PlainDateFns.compare(start, end) > 0) {
1468
1588
  throw new RangeError('daymath: interval start must not be after end')
1469
1589
  }
1470
1590
  /** @type {string[]} */
@@ -1476,11 +1596,11 @@ export function eachYearOfInterval(interval) {
1476
1596
  // Compare ISO years, then add. Testing the loop condition AFTER the push is what keeps the
1477
1597
  // top edge working: Jan 1 of +275760 is valid, and adding a year to it is not.
1478
1598
  const lastIsoYear = isoOf(end).year
1479
- let cur = start.with({ month: 1, day: 1 })
1599
+ let cur = PlainDateFns.withFields(start, { month: 1, day: 1 })
1480
1600
  for (;;) {
1481
1601
  out.push(toDayString(cur))
1482
1602
  if (isoOf(cur).year >= lastIsoYear) break
1483
- cur = cur.add({ years: 1 })
1603
+ cur = PlainDateFns.addYears(cur, 1)
1484
1604
  }
1485
1605
  })
1486
1606
  return out
@@ -1495,12 +1615,10 @@ export function eachYearOfInterval(interval) {
1495
1615
  export function isWithinInterval(date, interval) {
1496
1616
  const d = toPlainDate(date)
1497
1617
  const { start, end } = toInterval(interval)
1498
- if (Temporal.PlainDate.compare(start, end) > 0) {
1618
+ if (PlainDateFns.compare(start, end) > 0) {
1499
1619
  throw new RangeError('daymath: interval start must not be after end')
1500
1620
  }
1501
- return (
1502
- Temporal.PlainDate.compare(d, start) >= 0 && Temporal.PlainDate.compare(d, end) <= 0
1503
- )
1621
+ return PlainDateFns.compare(d, start) >= 0 && PlainDateFns.compare(d, end) <= 0
1504
1622
  }
1505
1623
 
1506
1624
  /**
@@ -1512,11 +1630,11 @@ export function isWithinInterval(date, interval) {
1512
1630
  export function clamp(date, interval) {
1513
1631
  const d = toPlainDate(date)
1514
1632
  const { start, end } = toInterval(interval)
1515
- if (Temporal.PlainDate.compare(start, end) > 0) {
1633
+ if (PlainDateFns.compare(start, end) > 0) {
1516
1634
  throw new RangeError('daymath: interval start must not be after end')
1517
1635
  }
1518
- if (Temporal.PlainDate.compare(d, start) < 0) return toDayString(start)
1519
- if (Temporal.PlainDate.compare(d, end) > 0) return toDayString(end)
1636
+ if (PlainDateFns.compare(d, start) < 0) return toDayString(start)
1637
+ if (PlainDateFns.compare(d, end) > 0) return toDayString(end)
1520
1638
  return toDayString(d)
1521
1639
  }
1522
1640
 
@@ -1530,22 +1648,21 @@ export function clamp(date, interval) {
1530
1648
  export function areIntervalsOverlapping(intervalLeft, intervalRight, options) {
1531
1649
  const a = toInterval(intervalLeft)
1532
1650
  const b = toInterval(intervalRight)
1533
- if (Temporal.PlainDate.compare(a.start, a.end) > 0) {
1651
+ if (PlainDateFns.compare(a.start, a.end) > 0) {
1534
1652
  throw new RangeError('daymath: intervalLeft start must not be after end')
1535
1653
  }
1536
- if (Temporal.PlainDate.compare(b.start, b.end) > 0) {
1654
+ if (PlainDateFns.compare(b.start, b.end) > 0) {
1537
1655
  throw new RangeError('daymath: intervalRight start must not be after end')
1538
1656
  }
1539
1657
  const inclusive = options?.inclusive ?? false
1540
1658
  if (inclusive) {
1541
1659
  return (
1542
- Temporal.PlainDate.compare(a.start, b.end) <= 0 &&
1543
- Temporal.PlainDate.compare(b.start, a.end) <= 0
1660
+ PlainDateFns.compare(a.start, b.end) <= 0 &&
1661
+ PlainDateFns.compare(b.start, a.end) <= 0
1544
1662
  )
1545
1663
  }
1546
1664
  // date-fns default: touch-at-endpoint is NOT overlap
1547
1665
  return (
1548
- Temporal.PlainDate.compare(a.start, b.end) < 0 &&
1549
- Temporal.PlainDate.compare(b.start, a.end) < 0
1666
+ PlainDateFns.compare(a.start, b.end) < 0 && PlainDateFns.compare(b.start, a.end) < 0
1550
1667
  )
1551
1668
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "daymath",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Calendar date math (ISO 8601 day / PlainDate). date-fns-shaped. No Date. No time zones.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -67,9 +67,9 @@
67
67
  "devDependencies": {
68
68
  "c8": "^12.0.0",
69
69
  "date-fns": "4.4.0",
70
- "esbuild": "0.28.1",
71
- "oxfmt": "0.62.0",
72
- "oxlint": "1.77.0",
70
+ "esbuild": "0.28.2",
71
+ "oxfmt": "0.64.0",
72
+ "oxlint": "1.78.0",
73
73
  "typescript": "7.0.2"
74
74
  }
75
75
  }