daymath 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +161 -9
  2. package/index.d.ts +43 -7
  3. package/index.js +575 -93
  4. package/package.json +21 -7
package/README.md CHANGED
@@ -5,10 +5,11 @@
5
5
  [![codecov](https://codecov.io/gh/leemr/daymath/graph/badge.svg)](https://codecov.io/gh/leemr/daymath)
6
6
  [![license](https://img.shields.io/npm/l/daymath.svg)](./LICENSE)
7
7
  [![node](https://img.shields.io/node/v/daymath.svg)](https://www.npmjs.com/package/daymath)
8
+ [![bundle](https://img.shields.io/bundlejs/size/daymath)](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` | string |
111
+ | or `Temporal.PlainDate`, from any implementation | string |
50
112
 
51
- `Date` **throws** (including `isValid`). `isValid('asdf')` `false`.
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` | **011** (0 = January) |
61
- | `getDay` | **06** (0 = Sunday) |
62
- | `weekStartsOn` | default `0` (Sunday) |
129
+ | `getMonth` / `setMonth` | **112** (1 = January) — ISO, **not** date-fns |
130
+ | `getDay` | **17** (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 18, 20, and 22.
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`: 0 = Sunday6 = Saturday (date-fns default 0).
21
+ * Week options. `weekStartsOn`: ISO 1 = Monday7 = 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 index like Date/date-fns: 0 = January … 11 = December. */
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 like Date/date-fns: 0 = Sunday6 = Saturday. */
83
+ /** Weekday, ISO 8601: 1 = Monday7 = 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`: 0 = January … 11 = December (date-fns). */
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(dateLeft: DayInput, dateRight: DayInput): number
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(dateLeft: DayInput, dateRight: DayInput): number
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