daymath 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -6
- package/index.d.ts +33 -2
- package/index.js +391 -41
- package/package.json +18 -7
package/README.md
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
[](https://codecov.io/gh/leemr/daymath)
|
|
6
6
|
[](./LICENSE)
|
|
7
7
|
[](https://www.npmjs.com/package/daymath)
|
|
8
|
+
[](https://bundlejs.com/?q=daymath)
|
|
8
9
|
|
|
9
10
|
**ISO 8601** calendar day math. **date-fns-shaped** names. **Temporal.PlainDate** under the hood.
|
|
10
11
|
|
|
11
|
-
No `Date`. No time zones. No silent “local now
|
|
12
|
+
No `Date`. No time zones. No silent “local now”. ISO 8601 ❤️
|
|
12
13
|
|
|
13
14
|
[**Play →**](https://leemr.github.io/daymath/) · [npm](https://www.npmjs.com/package/daymath) · [Changelog](./CHANGELOG.md) · [Contributing](./CONTRIBUTING.md) · [FUTURE](./FUTURE.md)
|
|
14
15
|
|
|
@@ -27,7 +28,7 @@ npm install @leemr/daymath
|
|
|
27
28
|
Most people should keep using **`daymath` on npmjs**.
|
|
28
29
|
|
|
29
30
|
```js
|
|
30
|
-
import { addDays, addMonths, differenceInDays, isSameDay } from 'daymath'
|
|
31
|
+
import { day, addDays, addMonths, differenceInDays, isSameDay } from 'daymath'
|
|
31
32
|
|
|
32
33
|
addDays('2026-08-06', 1) // '2026-08-07'
|
|
33
34
|
addMonths('2026-01-31', 1) // '2026-02-28'
|
|
@@ -35,6 +36,67 @@ differenceInDays('2026-08-06', '2026-08-01') // 5
|
|
|
35
36
|
isSameDay('2026-08-06', '2026-08-06') // true
|
|
36
37
|
```
|
|
37
38
|
|
|
39
|
+
`day` is the way in. It turns a moment into a day, and it is the only function
|
|
40
|
+
that reads a clock.
|
|
41
|
+
|
|
42
|
+
Start here. No arguments means today, in UTC.
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
day() // '2026-08-08'
|
|
46
|
+
addDays(day(), 2) // '2026-08-10'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then hand it whatever you already have. It converts; it never stores what you gave it.
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
day(row.createdAt) // '2026-08-07' a Date
|
|
53
|
+
day(1761616161771) // '2025-10-28' epoch milliseconds
|
|
54
|
+
day('1999-01-01T00:00:00Z') // '1999-01-01' an ISO timestamp
|
|
55
|
+
day('2026-05-05') // '2026-05-05' already a day
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Name a zone when the answer depends on one.
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
day('Asia/Tokyo') // '2026-08-09' today in Tokyo
|
|
62
|
+
day(row.createdAt, 'America/New_York') // '2026-08-06' the evening before
|
|
63
|
+
day('2026-08-08T23:00:00Z', 'Asia/Tokyo') // '2026-08-09' same instant, next day
|
|
64
|
+
day(zdt.toString()) // the zone in the string wins
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Both defaults are **stated**, not assumed.
|
|
68
|
+
UTC is still not your day for part of every day — it runs ahead of `America/New_York`
|
|
69
|
+
for 16.7% of the day, and behind `Asia/Tokyo` for 37.5% — so name your zone when
|
|
70
|
+
that matters.
|
|
71
|
+
|
|
72
|
+
Four rules worth knowing:
|
|
73
|
+
|
|
74
|
+
- A **number is epoch milliseconds**, exactly as `new Date(n)` reads it. A seconds
|
|
75
|
+
timestamp read as milliseconds lands in 1970, with no error. daymath states the
|
|
76
|
+
unit rather than sniffing it, because no rule can separate the two: 13 digits
|
|
77
|
+
means milliseconds for 2001–2286 and seconds for the year 275760, and both are
|
|
78
|
+
inside the supported range.
|
|
79
|
+
- A **day carries no time**, so a zone does not apply to one.
|
|
80
|
+
`day('2026-05-05', 'Asia/Tokyo')` is `'2026-05-05'`.
|
|
81
|
+
- A **timestamp carrying `Z` or an offset** names an exact instant, so it is read
|
|
82
|
+
as a moment.
|
|
83
|
+
- A **string carrying a `[Zone]`** names its own zone, so it keeps its own day and
|
|
84
|
+
the UTC default never applies. `day(zdt.toString())` equals `zdt.toPlainDate()`.
|
|
85
|
+
A browser sending `'2026-08-08T20:00:00-04:00[America/New_York]'` gets back the
|
|
86
|
+
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.
|
|
90
|
+
- **`'11/12/2026'` is refused.** Nobody can tell November from December in it.
|
|
91
|
+
|
|
92
|
+
A lone string takes one of four roles, decided in this order: a day, a zoned time,
|
|
93
|
+
an instant, then a zone. The zone test is by **shape** — an IANA name, which carries no `:`, or
|
|
94
|
+
a bare offset such as `+05:30`. Temporal's own zone grammar cannot decide the role:
|
|
95
|
+
it accepts a whole timestamp and reads the zone out of it, so
|
|
96
|
+
`day('1999-01-01T00:00:00Z')` would answer today. That grammar also differs between
|
|
97
|
+
implementations — `'T12:00:00Z'` is a zone to native Temporal and not to the
|
|
98
|
+
polyfill — which would make the answer depend on the runtime.
|
|
99
|
+
|
|
38
100
|
```bash
|
|
39
101
|
node examples/basic.mjs # from a clone
|
|
40
102
|
```
|
|
@@ -46,9 +108,14 @@ node examples/basic.mjs # from a clone
|
|
|
46
108
|
| In | Out |
|
|
47
109
|
|----|-----|
|
|
48
110
|
| `YYYY-MM-DD` or expanded `±YYYYYY-MM-DD` | same forms (Temporal `toString`) |
|
|
49
|
-
| or `Temporal.PlainDate
|
|
111
|
+
| or `Temporal.PlainDate`, from any implementation | string |
|
|
50
112
|
|
|
51
|
-
`Date` **throws**
|
|
113
|
+
A `Date` **throws** everywhere except `day()`, including in `isValid`.
|
|
114
|
+
`isValid('asdf')` → `false`.
|
|
115
|
+
|
|
116
|
+
`day()` is the single door a `Date` comes through, and it makes you name the zone
|
|
117
|
+
or take the stated UTC default. Nothing carries a zone past that point, and
|
|
118
|
+
nothing gives you a `Date` back.
|
|
52
119
|
|
|
53
120
|
Range: `-271821-04-19` … `+275760-09-13` — the `Temporal.PlainDate` limit, roughly ±10⁸ days from the epoch. A day outside it throws a `RangeError`.
|
|
54
121
|
|
|
@@ -66,6 +133,8 @@ Range: `-271821-04-19` … `+275760-09-13` — the `Temporal.PlainDate` limit, r
|
|
|
66
133
|
|
|
67
134
|
## API
|
|
68
135
|
|
|
136
|
+
**Start here** — `day`
|
|
137
|
+
|
|
69
138
|
**Parse** — `parse` · `format` · `isValid`
|
|
70
139
|
|
|
71
140
|
**Add/sub** — Days · Weeks · Months · Years · Quarters
|
|
@@ -86,11 +155,92 @@ Amounts are finite integers.
|
|
|
86
155
|
|
|
87
156
|
## Temporal
|
|
88
157
|
|
|
89
|
-
Uses global `Temporal` when present; otherwise [`temporal-polyfill`](https://www.npmjs.com/package/temporal-polyfill)
|
|
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.
|
|
160
|
+
|
|
161
|
+
A `Temporal.PlainDate` from *any* implementation is accepted — native, the bundled polyfill,
|
|
162
|
+
or a second copy of it in the same dependency tree. daymath reads its ISO day and builds its
|
|
163
|
+
own instance, so it never depends on `instanceof` agreeing across copies. The common case is
|
|
164
|
+
`Temporal.Now.plainDateISO()`: daymath has no `today()` on purpose, so that is where a caller
|
|
165
|
+
gets one.
|
|
166
|
+
|
|
167
|
+
A non-ISO calendar is refused, as an object and as a string alike. Temporal can put one on a
|
|
168
|
+
`PlainDate`, and it names the same *day* with different numbers:
|
|
169
|
+
|
|
170
|
+
| calendar | year | month | day | `toString()` |
|
|
171
|
+
|---|---|---|---|---|
|
|
172
|
+
| `iso8601` | 2026 | 1 | 31 | `2026-01-31` |
|
|
173
|
+
| `buddhist` | **2569** | 1 | 31 | `2026-01-31[u-ca=buddhist]` |
|
|
174
|
+
| `hebrew` | 5786 | 5 | 13 | `2026-01-31[u-ca=hebrew]` |
|
|
175
|
+
| `chinese` | 2025 | 13 | 13 | `2026-01-31[u-ca=chinese]` |
|
|
176
|
+
|
|
177
|
+
Thai Buddhist years run 543 ahead, so the same day is 2569. Worse, the same number means two
|
|
178
|
+
different days depending on where you write it:
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
Temporal.PlainDate.from('2026-01-31[u-ca=buddhist]') // ISO 2026-01-31, .year 2569
|
|
182
|
+
Temporal.PlainDate.from({year: 2026, month: 1, day: 31,
|
|
183
|
+
calendar: 'buddhist'}) // ISO 1483-01-31, .year 2026
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
543 years apart. The date part of a string is always ISO; the annotation changes how fields are
|
|
187
|
+
*read*, never how the string *parses*. daymath could strip the annotation and answer
|
|
188
|
+
`2026-01-31`, which is the right day — but `getYear` would then return `2026` where your own
|
|
189
|
+
object says `2569`. So it throws, naming the calendar and the way out:
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
getYear(buddhistDate)
|
|
193
|
+
// RangeError: daymath: date must use the ISO 8601 calendar, not "buddhist"
|
|
194
|
+
// (convert with withCalendar('iso8601'))
|
|
195
|
+
|
|
196
|
+
format(buddhistDate.withCalendar('iso8601')) // '2026-01-31'
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`[u-ca=iso8601]` is the one annotation daymath accepts, and it drops it. Temporal writes it
|
|
200
|
+
itself for `toString({ calendarName: 'always' })`, and it names the very calendar daymath
|
|
201
|
+
reads, so refusing your own round-trip would be arbitrary.
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
const written = plainDate.toString({ calendarName: 'always' }) // '2026-01-31[u-ca=iso8601]'
|
|
205
|
+
getYear(written) // 2026
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
A calendar is refused where it is **applied**. On a day string it is, and with a `[Zone]`
|
|
209
|
+
bracket it is, because the fields get renumbered. Without a bracket the string names an
|
|
210
|
+
`Instant`, which has no year, month or day for a calendar to renumber, so the annotation is
|
|
211
|
+
inert and `day()` answers:
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
day('2026-08-08T20:00:00Z[u-ca=buddhist]') // '2026-08-08'
|
|
215
|
+
day('2026-08-08T12:00[America/New_York][u-ca=buddhist]') // throws
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Accepting `buddhist` and `roc` is planned; see `FUTURE.md` for the rule that would allow it.
|
|
219
|
+
|
|
220
|
+
Error messages quote no Temporal text, because implementations word the same failure
|
|
221
|
+
differently. The original error is on `cause`.
|
|
222
|
+
|
|
223
|
+
Behaviour is checked against a recorded baseline on Node, Deno (which ships **native**
|
|
224
|
+
Temporal) and Bun — every export, `npm run test:runtimes`. The exact call count lives
|
|
225
|
+
in `scripts/cross-runtime.baseline.json`, which is the only place it cannot go stale.
|
|
226
|
+
|
|
227
|
+
## Requirements
|
|
228
|
+
|
|
229
|
+
ESM only. **Node 20.19+, or 22.12+.**
|
|
230
|
+
|
|
231
|
+
That floor is `require()`, not `import`. Node's `require(esm)` landed in 20.19 and 22.12, so
|
|
232
|
+
those are the versions where `require('daymath')` works. `engines` states the range exactly,
|
|
233
|
+
including the gap at 22.0–22.11. `temporal-polyfill` is ESM-only too, so a CJS build of daymath
|
|
234
|
+
would not escape this. Bundlers and browsers are unaffected. A Jest consumer needs a
|
|
235
|
+
`transformIgnorePatterns` entry, because Jest does not use Node's resolution.
|
|
236
|
+
|
|
237
|
+
Node 18 was supported through 0.3.0 and is dropped here. It went end-of-life in April 2025.
|
|
90
238
|
|
|
91
239
|
## Types & tests
|
|
92
240
|
|
|
93
|
-
Plain JS + `index.d.ts` (no compile step). CI runs on Node
|
|
241
|
+
Plain JS + `index.d.ts` (no compile step). CI runs on Node 20, 22, 24, and 26; the
|
|
242
|
+
coverage gate runs on 26, which is also the version in `.node-version` and the one used
|
|
243
|
+
to publish. The matrix still proves the floor.
|
|
94
244
|
|
|
95
245
|
```bash
|
|
96
246
|
npm test
|
package/index.d.ts
CHANGED
|
@@ -26,6 +26,31 @@ export type WeekOptions = {
|
|
|
26
26
|
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The calendar day of a moment, in a zone. The way in.
|
|
31
|
+
*
|
|
32
|
+
* Both defaults are stated: the moment is now, the zone is UTC. A number is
|
|
33
|
+
* read as epoch **milliseconds**, exactly as `new Date(n)` reads it. An ISO day
|
|
34
|
+
* string is already a day, so a zone does not apply to it. An ISO timestamp
|
|
35
|
+
* carrying `Z` or an offset names an exact instant, so it is read as a moment.
|
|
36
|
+
*
|
|
37
|
+
* A string carrying a `[Zone]` annotation names its own zone, so it answers its
|
|
38
|
+
* own civil day and the UTC default never applies. Passing `tz` as well throws.
|
|
39
|
+
*
|
|
40
|
+
* `'11/12/2026'` is refused, because nobody can tell November from December in
|
|
41
|
+
* it. `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath
|
|
42
|
+
* would have to pick one. Name the zone and it is accepted.
|
|
43
|
+
*
|
|
44
|
+
* A lone string takes one of four roles, in this order: a day, a zoned time, an
|
|
45
|
+
* instant, then a zone. The zone test is by shape — an IANA name, which carries
|
|
46
|
+
* no `:`, or a bare offset — so a timestamp can never be read as a zone.
|
|
47
|
+
*/
|
|
48
|
+
export function day(tz?: string): string
|
|
49
|
+
export function day(
|
|
50
|
+
moment: Date | number | DayInput | null | undefined,
|
|
51
|
+
tz?: string,
|
|
52
|
+
): string
|
|
53
|
+
|
|
29
54
|
/**
|
|
30
55
|
* True for a valid daymath day string / PlainDate.
|
|
31
56
|
* Invalid strings → false. `Date` → throws TypeError (not a quiet false).
|
|
@@ -81,11 +106,17 @@ export function endOfWeek(date: DayInput, options?: WeekOptions): string
|
|
|
81
106
|
export function differenceInDays(dateLeft: DayInput, dateRight: DayInput): number
|
|
82
107
|
export function differenceInWeeks(dateLeft: DayInput, dateRight: DayInput): number
|
|
83
108
|
export function differenceInMonths(dateLeft: DayInput, dateRight: DayInput): number
|
|
84
|
-
export function differenceInCalendarMonths(
|
|
109
|
+
export function differenceInCalendarMonths(
|
|
110
|
+
dateLeft: DayInput,
|
|
111
|
+
dateRight: DayInput,
|
|
112
|
+
): number
|
|
85
113
|
export function differenceInYears(dateLeft: DayInput, dateRight: DayInput): number
|
|
86
114
|
export function differenceInCalendarYears(dateLeft: DayInput, dateRight: DayInput): number
|
|
87
115
|
export function differenceInQuarters(dateLeft: DayInput, dateRight: DayInput): number
|
|
88
|
-
export function differenceInCalendarQuarters(
|
|
116
|
+
export function differenceInCalendarQuarters(
|
|
117
|
+
dateLeft: DayInput,
|
|
118
|
+
dateRight: DayInput,
|
|
119
|
+
): number
|
|
89
120
|
|
|
90
121
|
export function isBefore(date: DayInput, dateToCompare: DayInput): boolean
|
|
91
122
|
export function isAfter(date: DayInput, dateToCompare: DayInput): boolean
|
package/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
/** daymath — calendar date math (ISO 8601 day). date-fns-shaped. No Date
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/** daymath — calendar date math (ISO 8601 day). date-fns-shaped. No Date. No time zones. */
|
|
2
|
+
// temporal-polyfill already resolves this: its entry is `globalThis.Temporal ||
|
|
3
|
+
// bundled`, so a runtime with native Temporal gets native. Re-reading
|
|
4
|
+
// globalThis here only duplicated that check and hid where it happens.
|
|
5
|
+
import { Temporal } from 'temporal-polyfill'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* ISO 8601 calendar day string:
|
|
@@ -13,8 +14,73 @@ const Temporal = globalThis.Temporal ?? TemporalPolyfill
|
|
|
13
14
|
* the epoch). Outside it, Temporal throws and we re-throw with the `daymath:`
|
|
14
15
|
* prefix.
|
|
15
16
|
*/
|
|
16
|
-
const ISO_DAY =
|
|
17
|
-
|
|
17
|
+
const ISO_DAY = /^(?:[+-]\d{6}|\d{4})-\d{2}-\d{2}$/u
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Temporal's calendar annotation, `[u-ca=…]` or the critical `[!u-ca=…]`.
|
|
21
|
+
* Returns what it is attached to, and the calendar it names, or `null`.
|
|
22
|
+
*
|
|
23
|
+
* String operations, not a regex, and the reason is measured. The annotation can
|
|
24
|
+
* sit behind another one — `'…[America/New_York][u-ca=buddhist]'` — so the head
|
|
25
|
+
* may itself contain `[`. A pattern that allows that needs `^(.*)\[…\]$`, whose
|
|
26
|
+
* `.*` backtracks: `'[u-ca='.repeat(64000)` cost 9.0 s, growing with the square
|
|
27
|
+
* of the input. `lastIndexOf` answers the same question in one pass. CodeQL
|
|
28
|
+
* `js/polynomial-redos` caught the regex form; timing it confirmed the report.
|
|
29
|
+
* @param {string} text
|
|
30
|
+
* @returns {{ head: string, calendar: string } | null}
|
|
31
|
+
*/
|
|
32
|
+
function calendarAnnotation(text) {
|
|
33
|
+
if (!text.endsWith(']')) return null
|
|
34
|
+
const open = text.lastIndexOf('[')
|
|
35
|
+
if (open === -1) return null
|
|
36
|
+
const inner = text.slice(open + 1, -1)
|
|
37
|
+
const body = inner.startsWith('!') ? inner.slice(1) : inner
|
|
38
|
+
if (!body.startsWith('u-ca=')) return null
|
|
39
|
+
const calendar = body.slice(5)
|
|
40
|
+
// A `]` inside means the brackets do not nest as they appear, so this is not
|
|
41
|
+
// an annotation. `'[u-ca=[u-ca=[u-ca=x]]]'` reads as the calendar `x]]` here
|
|
42
|
+
// and as a malformed day everywhere else, which is what it is.
|
|
43
|
+
if (calendar === '' || calendar.includes(']')) return null
|
|
44
|
+
return { head: text.slice(0, open), calendar }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True when the string carries a time-zone annotation, `[Zone]` or `[!Zone]`.
|
|
49
|
+
* It is the one annotation with no `=`, which separates it from `[u-ca=…]`, and
|
|
50
|
+
* Temporal writes it first, so only the leading bracket can be one.
|
|
51
|
+
*
|
|
52
|
+
* Asking "does the string name a zone?" is a different question from "does it
|
|
53
|
+
* resolve?". A string can name one and still fail: an offset that disagrees with
|
|
54
|
+
* the zone, or a misspelled name. Both must be errors, never a silent fallback
|
|
55
|
+
* to the UTC default.
|
|
56
|
+
*
|
|
57
|
+
* String operations again. An unanchored `/\[!?[^\]=]+\]/` restarts at every
|
|
58
|
+
* position: `'[!'.repeat(64000)` cost 6.8 s, and it is the same defect an
|
|
59
|
+
* earlier commit removed from the calendar pattern.
|
|
60
|
+
* @param {string} text
|
|
61
|
+
*/
|
|
62
|
+
function hasZoneAnnotation(text) {
|
|
63
|
+
const open = text.indexOf('[')
|
|
64
|
+
if (open === -1) return false
|
|
65
|
+
const close = text.indexOf(']', open)
|
|
66
|
+
if (close === -1) return false
|
|
67
|
+
return !text.slice(open + 1, close).includes('=')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A time zone, by shape: an IANA name, or a bare offset.
|
|
72
|
+
*
|
|
73
|
+
* A name is letter-led, slash-separated segments of letters, digits, `_`, `+`,
|
|
74
|
+
* `-` and `.`. It carries no `:`, which is what keeps an ISO time out. The
|
|
75
|
+
* `(?![Tt]\d)` guard rejects the compact spelling `T120000Z`, which has no `:`
|
|
76
|
+
* to catch it. Both matter: `T12:00:00Z` is a zone to native Temporal and not
|
|
77
|
+
* to temporal-polyfill, so letting either through splits the answer by runtime.
|
|
78
|
+
*
|
|
79
|
+
* Verified against every zone this runtime knows: 0 of 418 rejected, plus the
|
|
80
|
+
* aliases `UTC`, `GMT`, `US/Eastern`, `Asia/Calcutta` and `Etc/GMT+5`.
|
|
81
|
+
*/
|
|
82
|
+
const ZONE_LIKE =
|
|
83
|
+
/^(?:(?![Tt]\d)[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)*|[+-]\d{2}(?::?\d{2})?)$/u
|
|
18
84
|
|
|
19
85
|
/** @typedef {string | Temporal.PlainDate} DayInput */
|
|
20
86
|
/**
|
|
@@ -31,8 +97,75 @@ const ISO_DAY =
|
|
|
31
97
|
|
|
32
98
|
// ─── core conversion ───────────────────────────────────────────────
|
|
33
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The bare ISO day inside a string, or `null` if there is not one.
|
|
102
|
+
*
|
|
103
|
+
* An annotation is dropped, not judged: `assertIsoCalendar` owns that rule and
|
|
104
|
+
* runs before both callers, so by here the only calendar left is ISO. Temporal
|
|
105
|
+
* writes `[u-ca=iso8601]` itself for `toString({ calendarName: 'always' })`, and
|
|
106
|
+
* it names the very calendar daymath reads, so refusing a caller's own
|
|
107
|
+
* round-trip would be arbitrary.
|
|
108
|
+
*
|
|
109
|
+
* One predicate, because `toPlainDate` and `day()` both ask this question. They
|
|
110
|
+
* asked it separately once, and day() alone then refused a string that every
|
|
111
|
+
* other export accepted.
|
|
112
|
+
* @param {string} text
|
|
113
|
+
* @returns {string | null}
|
|
114
|
+
*/
|
|
115
|
+
function bareDay(text) {
|
|
116
|
+
const annotated = calendarAnnotation(text)
|
|
117
|
+
const bare = annotated ? annotated.head : text
|
|
118
|
+
return ISO_DAY.test(bare) ? bare : null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Refuse a non-ISO calendar, wherever the annotation is attached.
|
|
123
|
+
*
|
|
124
|
+
* Checked on the *string*, before any parse. Implementations disagree past this
|
|
125
|
+
* point: native Temporal builds a `[u-ca=buddhist]` ZonedDateTime and the
|
|
126
|
+
* polyfill refuses to, so parsing first made the error depend on the runtime.
|
|
127
|
+
* @param {string} text
|
|
128
|
+
* @param {string} label
|
|
129
|
+
*/
|
|
130
|
+
function assertIsoCalendar(text, label) {
|
|
131
|
+
const annotated = calendarAnnotation(text)
|
|
132
|
+
if (annotated && annotated.calendar.toLowerCase() !== 'iso8601') {
|
|
133
|
+
throw new RangeError(
|
|
134
|
+
`daymath: ${label} must use the ISO 8601 calendar, not ${JSON.stringify(annotated.calendar)} (convert with withCalendar('iso8601'))`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* True for a `Temporal.PlainDate` from *any* implementation: native, the
|
|
141
|
+
* bundled polyfill, or a second copy of it in the same dependency tree.
|
|
142
|
+
* `instanceof` recognises only one of those. Every Temporal puts this tag on
|
|
143
|
+
* `PlainDate.prototype` as a non-writable property, so it is the portable brand.
|
|
144
|
+
* @param {unknown} value
|
|
145
|
+
* @returns {value is Temporal.PlainDate} a predicate, so callers narrow
|
|
146
|
+
*/
|
|
147
|
+
function isPlainDate(value) {
|
|
148
|
+
return Object.prototype.toString.call(value) === '[object Temporal.PlainDate]'
|
|
149
|
+
}
|
|
150
|
+
|
|
34
151
|
/**
|
|
35
152
|
* Reject Date and non-calendar values. Accept ISO day string or PlainDate.
|
|
153
|
+
*
|
|
154
|
+
* A PlainDate becomes its ISO day string first, so every input reaches one
|
|
155
|
+
* validation path and daymath owns the resulting instance.
|
|
156
|
+
*
|
|
157
|
+
* A non-ISO calendar is refused rather than reinterpreted. `toString()` writes
|
|
158
|
+
* the ISO date then an optional `[u-ca=…]`, so the *day* would survive — but
|
|
159
|
+
* the field numbers would not. Thai Buddhist years run 543 ahead, so
|
|
160
|
+
* `2026-01-31[u-ca=buddhist]` is year 2569, and `getYear` would answer `2026`
|
|
161
|
+
* where the caller's own object says `2569`. A string carrying the annotation is
|
|
162
|
+
* refused for the same reason, and the error names both the calendar it found
|
|
163
|
+
* and the way through, `withCalendar('iso8601')`.
|
|
164
|
+
*
|
|
165
|
+
* `[u-ca=iso8601]` is the exception: it is accepted and dropped. Temporal writes
|
|
166
|
+
* it itself for `toString({ calendarName: 'always' })`, and it names the very
|
|
167
|
+
* calendar daymath reads, so refusing a caller's own round-trip would be
|
|
168
|
+
* arbitrary.
|
|
36
169
|
* @param {unknown} value
|
|
37
170
|
* @param {string} label
|
|
38
171
|
* @returns {Temporal.PlainDate}
|
|
@@ -43,26 +176,28 @@ function toPlainDate(value, label = 'date') {
|
|
|
43
176
|
`daymath: Date is not allowed for ${label} (pass ISO 8601 day string)`,
|
|
44
177
|
)
|
|
45
178
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
return Temporal.PlainDate.from(value)
|
|
54
|
-
} catch (err) {
|
|
55
|
-
throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(value)}`, {
|
|
56
|
-
cause: err,
|
|
57
|
-
})
|
|
58
|
-
}
|
|
179
|
+
// Named `text`, not `day`: a local `day` would shadow the exported day().
|
|
180
|
+
const text = isPlainDate(value) ? value.toString() : value
|
|
181
|
+
if (typeof text !== 'string') {
|
|
182
|
+
throw new TypeError(
|
|
183
|
+
`daymath: ${label} must be ISO 8601 day string or Temporal.PlainDate`,
|
|
184
|
+
)
|
|
59
185
|
}
|
|
60
|
-
|
|
61
|
-
|
|
186
|
+
// Before the shape check: an annotated day is well formed, not malformed.
|
|
187
|
+
assertIsoCalendar(text, label)
|
|
188
|
+
const bare = bareDay(text)
|
|
189
|
+
if (bare === null) {
|
|
190
|
+
throw new RangeError(
|
|
191
|
+
`daymath: ${label} must be ISO 8601 day YYYY-MM-DD or ±YYYYYY-MM-DD (got ${JSON.stringify(text)})`,
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
return Temporal.PlainDate.from(bare)
|
|
196
|
+
} catch (err) {
|
|
197
|
+
throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(text)}`, {
|
|
198
|
+
cause: err,
|
|
199
|
+
})
|
|
62
200
|
}
|
|
63
|
-
throw new TypeError(
|
|
64
|
-
`daymath: ${label} must be ISO 8601 day string or Temporal.PlainDate`,
|
|
65
|
-
)
|
|
66
201
|
}
|
|
67
202
|
|
|
68
203
|
/** @param {Temporal.PlainDate} plain @returns {string} */
|
|
@@ -82,9 +217,14 @@ function assertFiniteNumber(n, label) {
|
|
|
82
217
|
|
|
83
218
|
/**
|
|
84
219
|
* Run a Temporal op and keep the `daymath:` message contract when it fails.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
220
|
+
* Covers both a result past the range and an argument Temporal refuses
|
|
221
|
+
* outright, hence the neutral wording.
|
|
222
|
+
*
|
|
223
|
+
* The message carries no Temporal text. Implementations word the same failure
|
|
224
|
+
* differently — the polyfill says `Out-of-bounds date` where native V8 says
|
|
225
|
+
* `Temporal error: epoch days exceed maximum range.` — so quoting it made
|
|
226
|
+
* daymath's own message vary by runtime. `cause` still holds the original
|
|
227
|
+
* error, which is where the detail belongs.
|
|
88
228
|
* @template T
|
|
89
229
|
* @param {string} label
|
|
90
230
|
* @param {() => T} op
|
|
@@ -94,10 +234,9 @@ function guardRange(label, op) {
|
|
|
94
234
|
try {
|
|
95
235
|
return op()
|
|
96
236
|
} catch (err) {
|
|
97
|
-
throw new RangeError(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
237
|
+
throw new RangeError(`daymath: ${label} could not produce a valid date`, {
|
|
238
|
+
cause: err,
|
|
239
|
+
})
|
|
101
240
|
}
|
|
102
241
|
}
|
|
103
242
|
|
|
@@ -123,7 +262,7 @@ function assertNonEmptyDates(dates) {
|
|
|
123
262
|
|
|
124
263
|
/**
|
|
125
264
|
* @param {WeekOptions} [options]
|
|
126
|
-
* @returns {0|1|2|3|4|5|6}
|
|
265
|
+
* @returns {0|1|2|3|4|5|6|7} 7 is the default, so 0-6 was never the real range
|
|
127
266
|
*/
|
|
128
267
|
function weekStartsOnFrom(options) {
|
|
129
268
|
const w = options?.weekStartsOn ?? 7
|
|
@@ -138,7 +277,15 @@ function weekStartsOnFrom(options) {
|
|
|
138
277
|
* @returns {{ start: Temporal.PlainDate, end: Temporal.PlainDate }}
|
|
139
278
|
*/
|
|
140
279
|
function toInterval(interval) {
|
|
141
|
-
|
|
280
|
+
// `typeof x === 'object'` alone let a Date, an array or a PlainDate through,
|
|
281
|
+
// and the failure then surfaced as "start must be ISO 8601 day string" —
|
|
282
|
+
// naming a property the caller never meant to pass.
|
|
283
|
+
if (
|
|
284
|
+
interval === null || // typeof null is 'object', so it needs its own test
|
|
285
|
+
typeof interval !== 'object' || // and this already catches undefined
|
|
286
|
+
!('start' in interval) ||
|
|
287
|
+
!('end' in interval)
|
|
288
|
+
) {
|
|
142
289
|
throw new TypeError('daymath: interval must be { start, end }')
|
|
143
290
|
}
|
|
144
291
|
const start = toPlainDate(/** @type {Interval} */ (interval).start, 'start')
|
|
@@ -146,6 +293,212 @@ function toInterval(interval) {
|
|
|
146
293
|
return { start, end }
|
|
147
294
|
}
|
|
148
295
|
|
|
296
|
+
// ─── the clock ─────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The calendar day of a moment, in a zone. The way in.
|
|
300
|
+
*
|
|
301
|
+
* Both arguments have a **stated** default: the moment is now, and the zone is
|
|
302
|
+
* UTC. A default that is written down is not a guess; a default that is assumed
|
|
303
|
+
* is. UTC is still not your day for part of every day — it runs ahead of
|
|
304
|
+
* America/New_York for 16.7% of the day, and behind Asia/Tokyo for 37.5% — so
|
|
305
|
+
* name your zone when that matters.
|
|
306
|
+
*
|
|
307
|
+
* `moment` accepts what the world actually hands you:
|
|
308
|
+
* - a `Date`, the only carrier of an instant JavaScript has
|
|
309
|
+
* - a number, read as **epoch milliseconds**, exactly as `new Date(n)` reads it,
|
|
310
|
+
* truncated the same way, so a fractional value is not an error
|
|
311
|
+
* - an ISO 8601 day string, or a `Temporal.PlainDate` from any implementation,
|
|
312
|
+
* both of which are already a day
|
|
313
|
+
* - an ISO 8601 timestamp carrying `Z` or an offset, which names an exact
|
|
314
|
+
* instant, so there is nothing left to guess
|
|
315
|
+
* - a string carrying a `[Zone]` annotation, which names its own zone, so it
|
|
316
|
+
* answers its own civil day and the `tz` default never applies
|
|
317
|
+
*
|
|
318
|
+
* `'11/12/2026'` is refused. Nobody can tell November from December in it.
|
|
319
|
+
* `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath would
|
|
320
|
+
* have to pick one, and it will not pick on the caller's behalf. Name the zone
|
|
321
|
+
* — `'2026-08-08T12:00[America/New_York]'` — and it is accepted.
|
|
322
|
+
*
|
|
323
|
+
* A lone string takes one of four roles, decided in this order: a day, then a
|
|
324
|
+
* zoned time, then an instant, then a zone. The zone test is by **shape**, and
|
|
325
|
+
* the shape is on `ZONE_LIKE`. Temporal's own zone grammar cannot decide the role, because it
|
|
326
|
+
* accepts a whole timestamp and reads the zone out of it, so
|
|
327
|
+
* `day('1999-01-01T00:00:00Z')` would answer today. The grammar also differs
|
|
328
|
+
* between implementations: `'2026-08-08T25:00:00Z'` and `'T12:00:00Z'` are both
|
|
329
|
+
* zones to native Temporal and neither is one to `temporal-polyfill`. Shape
|
|
330
|
+
* settles the role and the runtime split together.
|
|
331
|
+
*
|
|
332
|
+
* With `day()` this is the only export that reads a clock, so the only one
|
|
333
|
+
* whose answer depends on when you call it. Give it a moment and it becomes a
|
|
334
|
+
* pure function, which is how the cross-runtime battery covers it.
|
|
335
|
+
*
|
|
336
|
+
* @param {Date | number | DayInput | null} [moment] instant, epoch ms, day, or a zone
|
|
337
|
+
* @param {string} [tz] IANA time zone id, e.g. `'utc'`, `'Asia/Tokyo'`
|
|
338
|
+
* @returns {string} `YYYY-MM-DD`
|
|
339
|
+
* @throws {TypeError} If `moment` is not one of the accepted shapes
|
|
340
|
+
* @throws {RangeError} On an Invalid Date, a non-finite number, or an unknown zone
|
|
341
|
+
* @example day() // '2026-08-08' now, UTC
|
|
342
|
+
* @example day('Asia/Tokyo') // '2026-08-09' today in Tokyo
|
|
343
|
+
* @example day(row.createdAt) // '2026-08-07' a Date, UTC
|
|
344
|
+
* @example day(row.createdAt, 'America/New_York') // '2026-08-06'
|
|
345
|
+
* @example day(1761616161771) // '2025-10-28' epoch ms
|
|
346
|
+
* @example day('1999-01-01T00:00:00Z') // '1999-01-01' an ISO timestamp
|
|
347
|
+
* @example day(zdt.toString()) // the zone in the string wins
|
|
348
|
+
* @example addDays(day(), 2) // '2026-08-10'
|
|
349
|
+
*/
|
|
350
|
+
export function day(moment, tz) {
|
|
351
|
+
// Already a day, in every accepted spelling. `bareDay` is the same predicate
|
|
352
|
+
// toPlainDate uses, so day() cannot drift from the other 68 exports.
|
|
353
|
+
const isDay =
|
|
354
|
+
isPlainDate(moment) || (typeof moment === 'string' && bareDay(moment) !== null)
|
|
355
|
+
const isMoment = isDay || moment instanceof Date || typeof moment === 'number'
|
|
356
|
+
|
|
357
|
+
let zone = tz
|
|
358
|
+
/** @type {Temporal.Instant | undefined} */
|
|
359
|
+
let instant
|
|
360
|
+
/** @type {Temporal.ZonedDateTime | undefined} */
|
|
361
|
+
let zoned
|
|
362
|
+
if (!isMoment && moment !== undefined && moment !== null) {
|
|
363
|
+
if (typeof moment !== 'string') {
|
|
364
|
+
throw new TypeError(
|
|
365
|
+
'daymath: day() takes a Date, epoch milliseconds, or an ISO 8601 day',
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
// A `[Zone]` annotation makes the string a ZonedDateTime, and Temporal will
|
|
369
|
+
// not build one without it: a bare offset is refused. So the bracket is the
|
|
370
|
+
// caller naming a zone, and the string carries its own civil day. Reading
|
|
371
|
+
// the instant instead and applying UTC would move a browser's date by one.
|
|
372
|
+
//
|
|
373
|
+
// Naming a zone and resolving as one are different questions, so the
|
|
374
|
+
// annotation is detected first. A string can name a zone and still fail:
|
|
375
|
+
// `'…-05:00[America/New_York]'` has an offset the zone contradicts, and
|
|
376
|
+
// `'…[Asia/Tokoy]'` is a typo. Falling back to the instant would answer a
|
|
377
|
+
// UTC day for both, silently, which is the defect this branch exists to fix.
|
|
378
|
+
if (hasZoneAnnotation(moment)) {
|
|
379
|
+
// A calendar is refused only where it is applied. With a zone bracket it
|
|
380
|
+
// is: the fields get renumbered, and `toPlainDate()` carries the
|
|
381
|
+
// annotation into daymath's own output. Refused on the string, before the
|
|
382
|
+
// parse, because native Temporal builds a `[u-ca=buddhist]` ZonedDateTime
|
|
383
|
+
// and the polyfill refuses to — judging after the parse would make the
|
|
384
|
+
// message depend on the runtime.
|
|
385
|
+
assertIsoCalendar(moment, 'date')
|
|
386
|
+
if (tz !== undefined && tz !== null) {
|
|
387
|
+
throw new TypeError(
|
|
388
|
+
`daymath: day() got two time zones, ${JSON.stringify(moment)} and ${JSON.stringify(tz)}`,
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
zoned = Temporal.ZonedDateTime.from(moment)
|
|
393
|
+
} catch (err) {
|
|
394
|
+
throw new RangeError(
|
|
395
|
+
`daymath: day() could not read ${JSON.stringify(moment)} in the time zone it names`,
|
|
396
|
+
{ cause: err },
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
try {
|
|
401
|
+
// A timestamp carrying `Z` or an offset names an exact instant, so it
|
|
402
|
+
// reads as a moment. Temporal's own grammar is the definition of that.
|
|
403
|
+
//
|
|
404
|
+
// A calendar annotation here is inert and is ignored. An Instant has no
|
|
405
|
+
// year, month or day for a calendar to renumber, and without a zone
|
|
406
|
+
// bracket Temporal will not build anything that does. Refusing it would
|
|
407
|
+
// reject a right answer for a reason that cannot apply.
|
|
408
|
+
instant = Temporal.Instant.from(moment)
|
|
409
|
+
} catch {
|
|
410
|
+
// Not a moment, so the string must be a zone — decided by shape, before
|
|
411
|
+
// Temporal sees it. Temporal's zone grammar also accepts a whole
|
|
412
|
+
// timestamp and pulls the zone out of it, so letting it decide the role
|
|
413
|
+
// would read a date as a zone and silently answer today.
|
|
414
|
+
if (!ZONE_LIKE.test(moment)) {
|
|
415
|
+
throw new RangeError(
|
|
416
|
+
`daymath: day() got ${JSON.stringify(moment)}, which is neither a moment nor a time zone`,
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
if (tz !== undefined && tz !== null) {
|
|
420
|
+
throw new TypeError(
|
|
421
|
+
`daymath: day() got two time zones, ${JSON.stringify(moment)} and ${JSON.stringify(tz)}`,
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
zone = moment
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
zone ??= 'utc'
|
|
429
|
+
|
|
430
|
+
if (moment instanceof Date && Number.isNaN(moment.getTime())) {
|
|
431
|
+
throw new RangeError('daymath: day() got an Invalid Date')
|
|
432
|
+
}
|
|
433
|
+
if (typeof moment === 'number' && !Number.isFinite(moment)) {
|
|
434
|
+
throw new RangeError(`daymath: day() got a non-finite time value ${moment}`)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Shape first, because implementations disagree past this point. The reasons
|
|
438
|
+
// are on ZONE_LIKE. The typeof test comes first because `.test()` coerces,
|
|
439
|
+
// and a caller-supplied toString could throw an error that is not ours.
|
|
440
|
+
if (typeof zone !== 'string' || !ZONE_LIKE.test(zone)) {
|
|
441
|
+
throw new RangeError(
|
|
442
|
+
`daymath: day() got an unknown time zone ${JSON.stringify(zone)}`,
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Checked before anything returns, so a mistyped zone fails the same way
|
|
447
|
+
// whatever the moment is. A caller mapping rows that are sometimes a Date and
|
|
448
|
+
// sometimes a day string would otherwise see the typo only on some rows.
|
|
449
|
+
//
|
|
450
|
+
// `ZonedDateTime.from` accepts exactly what `Temporal.Now` accepts and reads
|
|
451
|
+
// no clock, so a day input stays a pure function.
|
|
452
|
+
try {
|
|
453
|
+
Temporal.ZonedDateTime.from({ timeZone: zone, year: 1970, month: 1, day: 1 })
|
|
454
|
+
} catch (err) {
|
|
455
|
+
throw new RangeError(
|
|
456
|
+
`daymath: day() got an unknown time zone ${JSON.stringify(zone)}`,
|
|
457
|
+
{ cause: err },
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// A day carries no time, so a zone has nothing to shift. Applying one would
|
|
462
|
+
// invent a moment the caller never gave.
|
|
463
|
+
if (isDay) return toDayString(toPlainDate(moment))
|
|
464
|
+
|
|
465
|
+
// The string named its own zone, so that zone decides the day, not the
|
|
466
|
+
// default. This is the one path where `zone` is deliberately not consulted.
|
|
467
|
+
//
|
|
468
|
+
// The result still goes through `toPlainDate`. A ZonedDateTime keeps a
|
|
469
|
+
// `[u-ca=…]` annotation, and `PlainDate.toString()` writes it back out, so
|
|
470
|
+
// returning directly emitted `'2026-08-08[u-ca=buddhist]'` — a value daymath
|
|
471
|
+
// itself refuses. Every path adjudicates the calendar in one place or none.
|
|
472
|
+
if (zoned !== undefined) {
|
|
473
|
+
const plain = guardRange('day', () => zoned.toPlainDate())
|
|
474
|
+
return toDayString(toPlainDate(plain))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (instant !== undefined) {
|
|
478
|
+
return guardRange('day', () =>
|
|
479
|
+
instant.toZonedDateTimeISO(zone).toPlainDate().toString(),
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!isMoment) return Temporal.Now.plainDateISO(zone).toString()
|
|
484
|
+
|
|
485
|
+
// Truncate, because `new Date(n)` truncates, and the contract here is that a
|
|
486
|
+
// number reads exactly as it does. Verified equal on positive and negative
|
|
487
|
+
// fractions. Sub-millisecond precision cannot change a calendar day anyway.
|
|
488
|
+
// Every other shape returned above, so only a number or a Date reaches here.
|
|
489
|
+
// The cast says what the control flow already guarantees but tsc cannot see.
|
|
490
|
+
const epochMs =
|
|
491
|
+
typeof moment === 'number'
|
|
492
|
+
? Math.trunc(moment)
|
|
493
|
+
: /** @type {Date} */ (moment).getTime()
|
|
494
|
+
return guardRange('day', () =>
|
|
495
|
+
Temporal.Instant.fromEpochMilliseconds(epochMs)
|
|
496
|
+
.toZonedDateTimeISO(zone)
|
|
497
|
+
.toPlainDate()
|
|
498
|
+
.toString(),
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
149
502
|
// ─── parse / format / valid ────────────────────────────────────────
|
|
150
503
|
|
|
151
504
|
/**
|
|
@@ -608,9 +961,7 @@ export function differenceInQuarters(dateLeft, dateRight) {
|
|
|
608
961
|
export function differenceInCalendarQuarters(dateLeft, dateRight) {
|
|
609
962
|
const left = toPlainDate(dateLeft, 'dateLeft')
|
|
610
963
|
const right = toPlainDate(dateRight, 'dateRight')
|
|
611
|
-
return (
|
|
612
|
-
(left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
|
|
613
|
-
)
|
|
964
|
+
return (left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
|
|
614
965
|
}
|
|
615
966
|
|
|
616
967
|
// ─── compare / equal ───────────────────────────────────────────────
|
|
@@ -672,9 +1023,9 @@ export function isSameWeek(dateLeft, dateRight, options) {
|
|
|
672
1023
|
const right = toPlainDate(dateRight, 'dateRight')
|
|
673
1024
|
// own guard, so a week start below the minimum does not say startOfWeek
|
|
674
1025
|
return guardRange('isSameWeek', () =>
|
|
675
|
-
left
|
|
676
|
-
|
|
677
|
-
|
|
1026
|
+
left
|
|
1027
|
+
.subtract({ days: daysIntoWeek(left, options) })
|
|
1028
|
+
.equals(right.subtract({ days: daysIntoWeek(right, options) })),
|
|
678
1029
|
)
|
|
679
1030
|
}
|
|
680
1031
|
|
|
@@ -894,8 +1245,7 @@ export function isWithinInterval(date, interval) {
|
|
|
894
1245
|
throw new RangeError('daymath: interval start must not be after end')
|
|
895
1246
|
}
|
|
896
1247
|
return (
|
|
897
|
-
Temporal.PlainDate.compare(d, start) >= 0 &&
|
|
898
|
-
Temporal.PlainDate.compare(d, end) <= 0
|
|
1248
|
+
Temporal.PlainDate.compare(d, start) >= 0 && Temporal.PlainDate.compare(d, end) <= 0
|
|
899
1249
|
)
|
|
900
1250
|
}
|
|
901
1251
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "daymath",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Calendar date math (ISO 8601 day / PlainDate). date-fns-shaped. No time zones.",
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
7
7
|
"types": "./index.d.ts",
|
|
@@ -18,21 +18,29 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"test": "node --test test.js",
|
|
21
|
+
"lint": "oxlint -c oxlint.jsonc",
|
|
22
|
+
"lint:fix": "oxlint -c oxlint.jsonc --fix",
|
|
23
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
24
|
+
"format": "oxfmt -c oxfmt.json index.js index.d.ts test.js scripts examples",
|
|
25
|
+
"format:check": "oxfmt -c oxfmt.json --check index.js index.d.ts test.js scripts examples",
|
|
21
26
|
"test:coverage": "c8 --include=index.js --check-coverage --lines 100 --functions 100 --branches 100 --reporter=text --reporter=lcov node --test test.js",
|
|
22
27
|
"test:differential": "node scripts/differential.mjs",
|
|
23
28
|
"test:differential:quick": "node scripts/differential.mjs 2020 2030",
|
|
29
|
+
"test:runtimes": "node scripts/cross-runtime.mjs",
|
|
30
|
+
"test:runtimes:write": "node scripts/cross-runtime.mjs --write",
|
|
24
31
|
"prepublishOnly": "npm run test:coverage",
|
|
25
32
|
"publish:github": "node scripts/publish-github-packages.mjs"
|
|
26
33
|
},
|
|
27
34
|
"keywords": [
|
|
28
35
|
"date",
|
|
29
36
|
"calendar",
|
|
37
|
+
"civil-date",
|
|
30
38
|
"plain-date",
|
|
31
|
-
"temporal",
|
|
32
|
-
"date-fns",
|
|
33
39
|
"YYYY-MM-DD",
|
|
34
40
|
"ISO-8601",
|
|
35
|
-
"
|
|
41
|
+
"Temporal",
|
|
42
|
+
"PlainDate",
|
|
43
|
+
"date-fns"
|
|
36
44
|
],
|
|
37
45
|
"author": "leemr",
|
|
38
46
|
"license": "MIT",
|
|
@@ -49,10 +57,13 @@
|
|
|
49
57
|
"temporal-polyfill": "^1.0.3"
|
|
50
58
|
},
|
|
51
59
|
"engines": {
|
|
52
|
-
"node": ">=
|
|
60
|
+
"node": ">=20.19.0 <21 || >=22.12.0"
|
|
53
61
|
},
|
|
54
62
|
"devDependencies": {
|
|
55
63
|
"c8": "^12.0.0",
|
|
56
|
-
"date-fns": "4.4.0"
|
|
64
|
+
"date-fns": "4.4.0",
|
|
65
|
+
"oxfmt": "0.62.0",
|
|
66
|
+
"oxlint": "1.77.0",
|
|
67
|
+
"typescript": "7.0.2"
|
|
57
68
|
}
|
|
58
69
|
}
|