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.
- package/README.md +34 -9
- package/index.d.ts +17 -2
- package/index.js +311 -194
- 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
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
the
|
|
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
|
-
|
|
159
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
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.
|
|
54
|
-
*
|
|
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
|
-
//
|
|
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
|
-
// `
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
11
|
-
// `
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
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
|
-
|
|
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 =
|
|
286
|
-
const dated =
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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 {
|
|
409
|
-
* @returns {
|
|
417
|
+
* @param {PlainDateRecord} plain
|
|
418
|
+
* @returns {PlainDateRecord}
|
|
410
419
|
*/
|
|
411
420
|
function isoOf(plain) {
|
|
412
|
-
return plain.calendarId === 'iso8601'
|
|
421
|
+
return plain.calendarId === 'iso8601'
|
|
422
|
+
? plain
|
|
423
|
+
: PlainDateFns.withCalendar(plain, ISO_CALENDAR)
|
|
413
424
|
}
|
|
414
425
|
|
|
415
|
-
/** @param {
|
|
426
|
+
/** @param {PlainDateRecord} plain @returns {string} */
|
|
416
427
|
function toDayString(plain) {
|
|
417
|
-
return
|
|
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 {
|
|
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,
|
|
483
|
+
function addDuration(date, unit, amount, label) {
|
|
464
484
|
const d = toPlainDate(date)
|
|
465
|
-
|
|
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:
|
|
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
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
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
|
|
579
|
-
|
|
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').
|
|
620
|
+
/** @type {import('temporal-polyfill/fns/Instant').Record | undefined} */
|
|
584
621
|
let instant
|
|
585
|
-
/** @type {import('temporal-polyfill').
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
769
|
+
PlainDateFns.toString(
|
|
770
|
+
ZonedFns.toPlainDate(InstantFns.toZonedDateTimeISO(instant, zone)),
|
|
771
|
+
),
|
|
708
772
|
)
|
|
709
773
|
}
|
|
710
774
|
|
|
711
|
-
if (!isMoment) return
|
|
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
|
-
|
|
724
|
-
.
|
|
725
|
-
|
|
726
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
|
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)
|
|
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(
|
|
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(
|
|
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', () =>
|
|
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', () =>
|
|
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', () =>
|
|
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', () =>
|
|
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', () =>
|
|
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 = (
|
|
1012
|
-
return guardRange('startOfQuarter', () =>
|
|
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 =
|
|
1119
|
+
const month = quarterOf(d) * 3
|
|
1019
1120
|
return guardRange('endOfQuarter', () => {
|
|
1020
|
-
const mid =
|
|
1021
|
-
return toDayString(
|
|
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(
|
|
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 {
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
1230
|
+
const sign = PlainDateFns.compare(left, right)
|
|
1109
1231
|
if (sign === 0) return 0
|
|
1110
|
-
const diff = Math.abs(
|
|
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,
|
|
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
|
|
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 =
|
|
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 && !
|
|
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 + (
|
|
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
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
1229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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 &&
|
|
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
|
-
|
|
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) => (
|
|
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) => (
|
|
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 ===
|
|
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 (
|
|
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 (
|
|
1428
|
-
cur =
|
|
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 (
|
|
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
|
-
|
|
1448
|
-
|
|
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 (
|
|
1455
|
-
cur =
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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 (
|
|
1633
|
+
if (PlainDateFns.compare(start, end) > 0) {
|
|
1516
1634
|
throw new RangeError('daymath: interval start must not be after end')
|
|
1517
1635
|
}
|
|
1518
|
-
if (
|
|
1519
|
-
if (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
1543
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
71
|
-
"oxfmt": "0.
|
|
72
|
-
"oxlint": "1.
|
|
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
|
}
|