daymath 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -9
- package/index.d.ts +43 -7
- package/index.js +575 -93
- package/package.json +21 -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,16 @@ 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.
|
|
119
|
+
|
|
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`.
|
|
52
121
|
|
|
53
122
|
## date-fns parity (names, not `Date`)
|
|
54
123
|
|
|
@@ -57,13 +126,15 @@ node examples/basic.mjs # from a clone
|
|
|
57
126
|
| Values | ISO day **strings**, not `Date` |
|
|
58
127
|
| `isSameDay` | Alias of `isEqual` |
|
|
59
128
|
| `isValid` | Valid daymath day; **`Date` throws** |
|
|
60
|
-
| `getMonth` / `setMonth` | **
|
|
61
|
-
| `getDay` | **
|
|
62
|
-
| `weekStartsOn` | default `
|
|
129
|
+
| `getMonth` / `setMonth` | **1–12** (1 = January) — ISO, **not** date-fns |
|
|
130
|
+
| `getDay` | **1–7** (1 = Monday, 7 = Sunday) — ISO, **not** date-fns |
|
|
131
|
+
| `weekStartsOn` | default `7` (Sunday); `0` also accepted |
|
|
63
132
|
| Intervals | `{ start, end }` |
|
|
64
133
|
|
|
65
134
|
## API
|
|
66
135
|
|
|
136
|
+
**Start here** — `day`
|
|
137
|
+
|
|
67
138
|
**Parse** — `parse` · `format` · `isValid`
|
|
68
139
|
|
|
69
140
|
**Add/sub** — Days · Weeks · Months · Years · Quarters
|
|
@@ -84,11 +155,92 @@ Amounts are finite integers.
|
|
|
84
155
|
|
|
85
156
|
## Temporal
|
|
86
157
|
|
|
87
|
-
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.
|
|
88
238
|
|
|
89
239
|
## Types & tests
|
|
90
240
|
|
|
91
|
-
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.
|
|
92
244
|
|
|
93
245
|
```bash
|
|
94
246
|
npm test
|
package/index.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ 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
|
+
*
|
|
8
|
+
* Usable range is the Temporal `PlainDate` range `-271821-04-19` …
|
|
9
|
+
* `+275760-09-13`; a day outside it throws a `RangeError`.
|
|
7
10
|
* `Date` is rejected at runtime (TypeError).
|
|
8
11
|
*/
|
|
9
12
|
export type DayInput = string | Temporal.PlainDate
|
|
@@ -15,12 +18,39 @@ export type Interval = {
|
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
|
-
* Week options. `weekStartsOn`:
|
|
21
|
+
* Week options. `weekStartsOn`: ISO 1 = Monday … 7 = Sunday (default 7).
|
|
22
|
+
* `0` is also accepted for Sunday — 0 ≡ 7 (mod 7), so pre-0.3.0 callers keep
|
|
23
|
+
* working with no change in behaviour.
|
|
19
24
|
*/
|
|
20
25
|
export type WeekOptions = {
|
|
21
|
-
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
|
|
26
|
+
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
|
|
22
27
|
}
|
|
23
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
|
+
|
|
24
54
|
/**
|
|
25
55
|
* True for a valid daymath day string / PlainDate.
|
|
26
56
|
* Invalid strings → false. `Date` → throws TypeError (not a quiet false).
|
|
@@ -46,11 +76,11 @@ export function subQuarters(date: DayInput, amount: number): string
|
|
|
46
76
|
|
|
47
77
|
/** Full year number. */
|
|
48
78
|
export function getYear(date: DayInput): number
|
|
49
|
-
/** Month
|
|
79
|
+
/** Month number, ISO 8601: 1 = January … 12 = December. Not date-fns's 0-based index. */
|
|
50
80
|
export function getMonth(date: DayInput): number
|
|
51
81
|
/** Day of month 1…31. */
|
|
52
82
|
export function getDate(date: DayInput): number
|
|
53
|
-
/** Weekday
|
|
83
|
+
/** Weekday, ISO 8601: 1 = Monday … 7 = Sunday. Only Sunday differs from date-fns. */
|
|
54
84
|
export function getDay(date: DayInput): number
|
|
55
85
|
export function getDayOfYear(date: DayInput): number
|
|
56
86
|
export function getDaysInMonth(date: DayInput): number
|
|
@@ -59,7 +89,7 @@ export function getQuarter(date: DayInput): number
|
|
|
59
89
|
export function isLeapYear(date: DayInput): boolean
|
|
60
90
|
|
|
61
91
|
export function setYear(date: DayInput, year: number): string
|
|
62
|
-
/** `month`:
|
|
92
|
+
/** `month`: 1 = January … 12 = December (ISO 8601). */
|
|
63
93
|
export function setMonth(date: DayInput, month: number): string
|
|
64
94
|
export function setDate(date: DayInput, dayOfMonth: number): string
|
|
65
95
|
|
|
@@ -76,11 +106,17 @@ export function endOfWeek(date: DayInput, options?: WeekOptions): string
|
|
|
76
106
|
export function differenceInDays(dateLeft: DayInput, dateRight: DayInput): number
|
|
77
107
|
export function differenceInWeeks(dateLeft: DayInput, dateRight: DayInput): number
|
|
78
108
|
export function differenceInMonths(dateLeft: DayInput, dateRight: DayInput): number
|
|
79
|
-
export function differenceInCalendarMonths(
|
|
109
|
+
export function differenceInCalendarMonths(
|
|
110
|
+
dateLeft: DayInput,
|
|
111
|
+
dateRight: DayInput,
|
|
112
|
+
): number
|
|
80
113
|
export function differenceInYears(dateLeft: DayInput, dateRight: DayInput): number
|
|
81
114
|
export function differenceInCalendarYears(dateLeft: DayInput, dateRight: DayInput): number
|
|
82
115
|
export function differenceInQuarters(dateLeft: DayInput, dateRight: DayInput): number
|
|
83
|
-
export function differenceInCalendarQuarters(
|
|
116
|
+
export function differenceInCalendarQuarters(
|
|
117
|
+
dateLeft: DayInput,
|
|
118
|
+
dateRight: DayInput,
|
|
119
|
+
): number
|
|
84
120
|
|
|
85
121
|
export function isBefore(date: DayInput, dateToCompare: DayInput): boolean
|
|
86
122
|
export function isAfter(date: DayInput, dateToCompare: DayInput): boolean
|