daymath 0.2.2 → 0.3.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 +29 -7
- package/index.d.ts +10 -5
- package/index.js +196 -62
- package/package.json +15 -6
package/README.md
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
# daymath
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/daymath)
|
|
4
|
+
[](https://github.com/leemr/daymath/actions/workflows/ci.yml)
|
|
5
|
+
[](https://codecov.io/gh/leemr/daymath)
|
|
4
6
|
[](./LICENSE)
|
|
5
7
|
[](https://www.npmjs.com/package/daymath)
|
|
6
8
|
|
|
7
9
|
**ISO 8601** calendar day math. **date-fns-shaped** names. **Temporal.PlainDate** under the hood.
|
|
8
10
|
|
|
9
|
-
No `Date`. No time zones. No silent “local now.”
|
|
11
|
+
No `Date`. No time zones. No silent “local now.” ISO 8601 ❤️
|
|
10
12
|
|
|
11
|
-
[**Play
|
|
13
|
+
[**Play →**](https://leemr.github.io/daymath/) · [npm](https://www.npmjs.com/package/daymath) · [Changelog](./CHANGELOG.md) · [Contributing](./CONTRIBUTING.md) · [FUTURE](./FUTURE.md)
|
|
12
14
|
|
|
13
15
|
```bash
|
|
14
16
|
npm install daymath
|
|
15
17
|
```
|
|
16
18
|
|
|
19
|
+
Same code also publishes to **GitHub Packages** as `@leemr/daymath` (scoped; see [GitHub npm registry docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry)):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# one-time: map the scope (auth with a PAT that has read:packages, or GITHUB_TOKEN in Actions)
|
|
23
|
+
echo '@leemr:registry=https://npm.pkg.github.com' >> .npmrc
|
|
24
|
+
npm install @leemr/daymath
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Most people should keep using **`daymath` on npmjs**.
|
|
28
|
+
|
|
17
29
|
```js
|
|
18
30
|
import { addDays, addMonths, differenceInDays, isSameDay } from 'daymath'
|
|
19
31
|
|
|
@@ -23,6 +35,10 @@ differenceInDays('2026-08-06', '2026-08-01') // 5
|
|
|
23
35
|
isSameDay('2026-08-06', '2026-08-06') // true
|
|
24
36
|
```
|
|
25
37
|
|
|
38
|
+
```bash
|
|
39
|
+
node examples/basic.mjs # from a clone
|
|
40
|
+
```
|
|
41
|
+
|
|
26
42
|
## Why
|
|
27
43
|
|
|
28
44
|
`Date` is a timestamp. Hire dates, passport expiry, trip days are **calendar** values. daymath only does plain days as ISO strings.
|
|
@@ -34,6 +50,8 @@ isSameDay('2026-08-06', '2026-08-06') // true
|
|
|
34
50
|
|
|
35
51
|
`Date` **throws** (including `isValid`). `isValid('asdf')` → `false`.
|
|
36
52
|
|
|
53
|
+
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
|
+
|
|
37
55
|
## date-fns parity (names, not `Date`)
|
|
38
56
|
|
|
39
57
|
| Topic | daymath |
|
|
@@ -41,9 +59,9 @@ isSameDay('2026-08-06', '2026-08-06') // true
|
|
|
41
59
|
| Values | ISO day **strings**, not `Date` |
|
|
42
60
|
| `isSameDay` | Alias of `isEqual` |
|
|
43
61
|
| `isValid` | Valid daymath day; **`Date` throws** |
|
|
44
|
-
| `getMonth` / `setMonth` | **
|
|
45
|
-
| `getDay` | **
|
|
46
|
-
| `weekStartsOn` | default `
|
|
62
|
+
| `getMonth` / `setMonth` | **1–12** (1 = January) — ISO, **not** date-fns |
|
|
63
|
+
| `getDay` | **1–7** (1 = Monday, 7 = Sunday) — ISO, **not** date-fns |
|
|
64
|
+
| `weekStartsOn` | default `7` (Sunday); `0` also accepted |
|
|
47
65
|
| Intervals | `{ start, end }` |
|
|
48
66
|
|
|
49
67
|
## API
|
|
@@ -72,13 +90,17 @@ Uses global `Temporal` when present; otherwise [`temporal-polyfill`](https://www
|
|
|
72
90
|
|
|
73
91
|
## Types & tests
|
|
74
92
|
|
|
75
|
-
Plain JS + `index.d.ts` (no compile step).
|
|
93
|
+
Plain JS + `index.d.ts` (no compile step). CI runs on Node 18, 20, 22, 24, and 26; the coverage gate runs on 24.
|
|
76
94
|
|
|
77
95
|
```bash
|
|
78
96
|
npm test
|
|
79
|
-
npm run test:coverage # 100% lines on index.js
|
|
97
|
+
npm run test:coverage # c8: 100% lines/funcs/branches on index.js + lcov
|
|
80
98
|
```
|
|
81
99
|
|
|
100
|
+
CI uploads coverage to [Codecov](https://codecov.io/gh/leemr/daymath) (see [CONTRIBUTING.md](./CONTRIBUTING.md) for one-time app/token setup).
|
|
101
|
+
|
|
102
|
+
PRs welcome via fork — see [CONTRIBUTING.md](./CONTRIBUTING.md). Security reports: [SECURITY.md](./SECURITY.md).
|
|
103
|
+
|
|
82
104
|
## License
|
|
83
105
|
|
|
84
106
|
MIT
|
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,10 +18,12 @@ 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
|
|
|
24
29
|
/**
|
|
@@ -46,11 +51,11 @@ export function subQuarters(date: DayInput, amount: number): string
|
|
|
46
51
|
|
|
47
52
|
/** Full year number. */
|
|
48
53
|
export function getYear(date: DayInput): number
|
|
49
|
-
/** Month
|
|
54
|
+
/** Month number, ISO 8601: 1 = January … 12 = December. Not date-fns's 0-based index. */
|
|
50
55
|
export function getMonth(date: DayInput): number
|
|
51
56
|
/** Day of month 1…31. */
|
|
52
57
|
export function getDate(date: DayInput): number
|
|
53
|
-
/** Weekday
|
|
58
|
+
/** Weekday, ISO 8601: 1 = Monday … 7 = Sunday. Only Sunday differs from date-fns. */
|
|
54
59
|
export function getDay(date: DayInput): number
|
|
55
60
|
export function getDayOfYear(date: DayInput): number
|
|
56
61
|
export function getDaysInMonth(date: DayInput): number
|
|
@@ -59,7 +64,7 @@ export function getQuarter(date: DayInput): number
|
|
|
59
64
|
export function isLeapYear(date: DayInput): boolean
|
|
60
65
|
|
|
61
66
|
export function setYear(date: DayInput, year: number): string
|
|
62
|
-
/** `month`:
|
|
67
|
+
/** `month`: 1 = January … 12 = December (ISO 8601). */
|
|
63
68
|
export function setMonth(date: DayInput, month: number): string
|
|
64
69
|
export function setDate(date: DayInput, dayOfMonth: number): string
|
|
65
70
|
|
package/index.js
CHANGED
|
@@ -7,6 +7,11 @@ const Temporal = globalThis.Temporal ?? TemporalPolyfill
|
|
|
7
7
|
* ISO 8601 calendar day string:
|
|
8
8
|
* - `YYYY-MM-DD` (years 0000–9999)
|
|
9
9
|
* - expanded `±YYYYYY-MM-DD` (Temporal form, e.g. `+010000-01-01`)
|
|
10
|
+
*
|
|
11
|
+
* The shape passes this regex; the value must also fall inside the Temporal
|
|
12
|
+
* `PlainDate` range `-271821-04-19` … `+275760-09-13` (roughly ±10^8 days from
|
|
13
|
+
* the epoch). Outside it, Temporal throws and we re-throw with the `daymath:`
|
|
14
|
+
* prefix.
|
|
10
15
|
*/
|
|
11
16
|
const ISO_DAY =
|
|
12
17
|
/^(?:[+-]\d{6}|\d{4})-\d{2}-\d{2}$/
|
|
@@ -19,7 +24,9 @@ const ISO_DAY =
|
|
|
19
24
|
*/
|
|
20
25
|
/**
|
|
21
26
|
* @typedef {object} WeekOptions
|
|
22
|
-
* @property {0|1|2|3|4|5|6} [weekStartsOn]
|
|
27
|
+
* @property {0|1|2|3|4|5|6|7} [weekStartsOn] ISO 1=Mon … 7=Sun (default 7).
|
|
28
|
+
* `0` is also accepted for Sunday, because 0 ≡ 7 (mod 7) and the week-offset
|
|
29
|
+
* arithmetic cannot tell them apart. Pre-0.3.0 callers keep working unchanged.
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
32
|
// ─── core conversion ───────────────────────────────────────────────
|
|
@@ -63,11 +70,6 @@ function toDayString(plain) {
|
|
|
63
70
|
return plain.toString()
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
/** Temporal ISO weekday 1=Mon…7=Sun → JS/date-fns 0=Sun…6=Sat */
|
|
67
|
-
function isoToJsWeekday(isoDayOfWeek) {
|
|
68
|
-
return isoDayOfWeek === 7 ? 0 : isoDayOfWeek
|
|
69
|
-
}
|
|
70
|
-
|
|
71
73
|
/** @param {unknown} n @param {string} label */
|
|
72
74
|
function assertFiniteNumber(n, label) {
|
|
73
75
|
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
@@ -78,6 +80,40 @@ function assertFiniteNumber(n, label) {
|
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Run a Temporal op and keep the `daymath:` message contract when it fails.
|
|
85
|
+
* Temporal throws bare `Out-of-bounds date` / `Non-positive day`; we prefix and
|
|
86
|
+
* keep the original text plus `cause`. Covers both a result past the range and
|
|
87
|
+
* an argument Temporal refuses outright, hence the neutral wording.
|
|
88
|
+
* @template T
|
|
89
|
+
* @param {string} label
|
|
90
|
+
* @param {() => T} op
|
|
91
|
+
* @returns {T}
|
|
92
|
+
*/
|
|
93
|
+
function guardRange(label, op) {
|
|
94
|
+
try {
|
|
95
|
+
return op()
|
|
96
|
+
} catch (err) {
|
|
97
|
+
throw new RangeError(
|
|
98
|
+
`daymath: ${label} could not produce a valid date (${/** @type {Error} */ (err).message})`,
|
|
99
|
+
{ cause: err },
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Shared body for every add/sub function. Each caller passes its own name, so
|
|
106
|
+
* the message never reports a function the caller did not call.
|
|
107
|
+
* @param {DayInput} date
|
|
108
|
+
* @param {{ days?: number, months?: number, years?: number }} delta
|
|
109
|
+
* @param {string} label
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function addDuration(date, delta, label) {
|
|
113
|
+
const d = toPlainDate(date)
|
|
114
|
+
return guardRange(label, () => toDayString(d.add(delta)))
|
|
115
|
+
}
|
|
116
|
+
|
|
81
117
|
/** @param {unknown} dates */
|
|
82
118
|
function assertNonEmptyDates(dates) {
|
|
83
119
|
if (!Array.isArray(dates) || dates.length === 0) {
|
|
@@ -90,11 +126,11 @@ function assertNonEmptyDates(dates) {
|
|
|
90
126
|
* @returns {0|1|2|3|4|5|6}
|
|
91
127
|
*/
|
|
92
128
|
function weekStartsOnFrom(options) {
|
|
93
|
-
const w = options?.weekStartsOn ??
|
|
94
|
-
if (!Number.isInteger(w) || w < 0 || w >
|
|
95
|
-
throw new RangeError('daymath: weekStartsOn must be an integer 0…
|
|
129
|
+
const w = options?.weekStartsOn ?? 7
|
|
130
|
+
if (!Number.isInteger(w) || w < 0 || w > 7) {
|
|
131
|
+
throw new RangeError('daymath: weekStartsOn must be an integer 0…7 (7 or 0 = Sunday)')
|
|
96
132
|
}
|
|
97
|
-
return /** @type {0|1|2|3|4|5|6} */ (w)
|
|
133
|
+
return /** @type {0|1|2|3|4|5|6|7} */ (w)
|
|
98
134
|
}
|
|
99
135
|
|
|
100
136
|
/**
|
|
@@ -116,6 +152,8 @@ function toInterval(interval) {
|
|
|
116
152
|
* True if value is a valid daymath day (ISO day string or PlainDate).
|
|
117
153
|
* Invalid strings → false. `Date` → throws (not a quiet false — swap trap).
|
|
118
154
|
* @param {unknown} value
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
* @throws {TypeError} If `value` is a `Date`
|
|
119
157
|
*/
|
|
120
158
|
export function isValid(value) {
|
|
121
159
|
if (value instanceof Date) {
|
|
@@ -164,7 +202,7 @@ export function format(date, pattern = 'yyyy-MM-dd') {
|
|
|
164
202
|
*/
|
|
165
203
|
export function addDays(date, amount) {
|
|
166
204
|
assertFiniteNumber(amount, 'amount')
|
|
167
|
-
return
|
|
205
|
+
return addDuration(date, { days: amount }, 'addDays')
|
|
168
206
|
}
|
|
169
207
|
|
|
170
208
|
/**
|
|
@@ -174,7 +212,7 @@ export function addDays(date, amount) {
|
|
|
174
212
|
*/
|
|
175
213
|
export function subDays(date, amount) {
|
|
176
214
|
assertFiniteNumber(amount, 'amount')
|
|
177
|
-
return
|
|
215
|
+
return addDuration(date, { days: -amount }, 'subDays')
|
|
178
216
|
}
|
|
179
217
|
|
|
180
218
|
/**
|
|
@@ -184,7 +222,9 @@ export function subDays(date, amount) {
|
|
|
184
222
|
*/
|
|
185
223
|
export function addWeeks(date, amount) {
|
|
186
224
|
assertFiniteNumber(amount, 'amount')
|
|
187
|
-
|
|
225
|
+
const days = amount * 7 // re-check: 7x a finite amount can still reach Infinity
|
|
226
|
+
assertFiniteNumber(days, 'amount')
|
|
227
|
+
return addDuration(date, { days }, 'addWeeks')
|
|
188
228
|
}
|
|
189
229
|
|
|
190
230
|
/**
|
|
@@ -194,7 +234,9 @@ export function addWeeks(date, amount) {
|
|
|
194
234
|
*/
|
|
195
235
|
export function subWeeks(date, amount) {
|
|
196
236
|
assertFiniteNumber(amount, 'amount')
|
|
197
|
-
|
|
237
|
+
const days = -amount * 7
|
|
238
|
+
assertFiniteNumber(days, 'amount')
|
|
239
|
+
return addDuration(date, { days }, 'subWeeks')
|
|
198
240
|
}
|
|
199
241
|
|
|
200
242
|
/**
|
|
@@ -205,7 +247,7 @@ export function subWeeks(date, amount) {
|
|
|
205
247
|
*/
|
|
206
248
|
export function addMonths(date, amount) {
|
|
207
249
|
assertFiniteNumber(amount, 'amount')
|
|
208
|
-
return
|
|
250
|
+
return addDuration(date, { months: amount }, 'addMonths')
|
|
209
251
|
}
|
|
210
252
|
|
|
211
253
|
/**
|
|
@@ -215,7 +257,7 @@ export function addMonths(date, amount) {
|
|
|
215
257
|
*/
|
|
216
258
|
export function subMonths(date, amount) {
|
|
217
259
|
assertFiniteNumber(amount, 'amount')
|
|
218
|
-
return
|
|
260
|
+
return addDuration(date, { months: -amount }, 'subMonths')
|
|
219
261
|
}
|
|
220
262
|
|
|
221
263
|
/**
|
|
@@ -225,7 +267,7 @@ export function subMonths(date, amount) {
|
|
|
225
267
|
*/
|
|
226
268
|
export function addYears(date, amount) {
|
|
227
269
|
assertFiniteNumber(amount, 'amount')
|
|
228
|
-
return
|
|
270
|
+
return addDuration(date, { years: amount }, 'addYears')
|
|
229
271
|
}
|
|
230
272
|
|
|
231
273
|
/**
|
|
@@ -235,7 +277,7 @@ export function addYears(date, amount) {
|
|
|
235
277
|
*/
|
|
236
278
|
export function subYears(date, amount) {
|
|
237
279
|
assertFiniteNumber(amount, 'amount')
|
|
238
|
-
return
|
|
280
|
+
return addDuration(date, { years: -amount }, 'subYears')
|
|
239
281
|
}
|
|
240
282
|
|
|
241
283
|
/**
|
|
@@ -245,7 +287,9 @@ export function subYears(date, amount) {
|
|
|
245
287
|
*/
|
|
246
288
|
export function addQuarters(date, amount) {
|
|
247
289
|
assertFiniteNumber(amount, 'amount')
|
|
248
|
-
|
|
290
|
+
const months = amount * 3
|
|
291
|
+
assertFiniteNumber(months, 'amount')
|
|
292
|
+
return addDuration(date, { months }, 'addQuarters')
|
|
249
293
|
}
|
|
250
294
|
|
|
251
295
|
/**
|
|
@@ -255,7 +299,9 @@ export function addQuarters(date, amount) {
|
|
|
255
299
|
*/
|
|
256
300
|
export function subQuarters(date, amount) {
|
|
257
301
|
assertFiniteNumber(amount, 'amount')
|
|
258
|
-
|
|
302
|
+
const months = -amount * 3
|
|
303
|
+
assertFiniteNumber(months, 'amount')
|
|
304
|
+
return addDuration(date, { months }, 'subQuarters')
|
|
259
305
|
}
|
|
260
306
|
|
|
261
307
|
// ─── getters / setters (date-fns / Date month & weekday indexing) ─
|
|
@@ -266,12 +312,13 @@ export function getYear(date) {
|
|
|
266
312
|
}
|
|
267
313
|
|
|
268
314
|
/**
|
|
269
|
-
* Month
|
|
315
|
+
* Month number, ISO 8601: 1 = January … 12 = December. Matches the `MM` field
|
|
316
|
+
* of the input string, and Temporal. **Not** date-fns, which is 0-based.
|
|
270
317
|
* @param {DayInput} date
|
|
271
318
|
* @returns {number}
|
|
272
319
|
*/
|
|
273
320
|
export function getMonth(date) {
|
|
274
|
-
return toPlainDate(date).month
|
|
321
|
+
return toPlainDate(date).month
|
|
275
322
|
}
|
|
276
323
|
|
|
277
324
|
/** Day of month 1…31. @param {DayInput} date @returns {number} */
|
|
@@ -280,12 +327,14 @@ export function getDate(date) {
|
|
|
280
327
|
}
|
|
281
328
|
|
|
282
329
|
/**
|
|
283
|
-
* Weekday
|
|
330
|
+
* Weekday, ISO 8601: 1 = Monday … 7 = Sunday. Matches Temporal and
|
|
331
|
+
* `Intl.Locale#weekInfo.firstDay`. **Not** date-fns, where Sunday is 0.
|
|
332
|
+
* Only Sunday differs; Monday–Saturday are 1–6 in both.
|
|
284
333
|
* @param {DayInput} date
|
|
285
334
|
* @returns {number}
|
|
286
335
|
*/
|
|
287
336
|
export function getDay(date) {
|
|
288
|
-
return
|
|
337
|
+
return toPlainDate(date).dayOfWeek
|
|
289
338
|
}
|
|
290
339
|
|
|
291
340
|
/** @param {DayInput} date @returns {number} */
|
|
@@ -315,71 +364,81 @@ export function isLeapYear(date) {
|
|
|
315
364
|
*/
|
|
316
365
|
export function setYear(date, year) {
|
|
317
366
|
assertFiniteNumber(year, 'year')
|
|
318
|
-
|
|
367
|
+
const d = toPlainDate(date)
|
|
368
|
+
return guardRange('setYear', () => toDayString(d.with({ year })))
|
|
319
369
|
}
|
|
320
370
|
|
|
321
371
|
/**
|
|
322
372
|
* @param {DayInput} date
|
|
323
|
-
* @param {number} month
|
|
373
|
+
* @param {number} month 1 = January … 12 = December (ISO 8601)
|
|
324
374
|
* @returns {string}
|
|
325
375
|
*/
|
|
326
376
|
export function setMonth(date, month) {
|
|
327
377
|
assertFiniteNumber(month, 'month')
|
|
328
|
-
if (month <
|
|
329
|
-
throw new RangeError('daymath: month must be
|
|
378
|
+
if (month < 1 || month > 12) {
|
|
379
|
+
throw new RangeError('daymath: month must be 1…12 (1=January)')
|
|
330
380
|
}
|
|
331
|
-
|
|
381
|
+
const d = toPlainDate(date)
|
|
382
|
+
return guardRange('setMonth', () => toDayString(d.with({ month })))
|
|
332
383
|
}
|
|
333
384
|
|
|
334
385
|
/**
|
|
335
386
|
* @param {DayInput} date
|
|
336
|
-
* @param {number} dayOfMonth
|
|
387
|
+
* @param {number} dayOfMonth 1…31; a day past the month end constrains to the
|
|
388
|
+
* last day of that month (no roll-over into the next month, unlike date-fns)
|
|
337
389
|
* @returns {string}
|
|
338
390
|
*/
|
|
339
391
|
export function setDate(date, dayOfMonth) {
|
|
340
392
|
assertFiniteNumber(dayOfMonth, 'day')
|
|
341
|
-
|
|
393
|
+
const d = toPlainDate(date)
|
|
394
|
+
return guardRange('setDate', () => toDayString(d.with({ day: dayOfMonth })))
|
|
342
395
|
}
|
|
343
396
|
|
|
344
397
|
// ─── start / end of unit ───────────────────────────────────────────
|
|
345
398
|
|
|
399
|
+
// The first/last day of a unit can fall outside the PlainDate range even when
|
|
400
|
+
// the input is inside it — startOfMonth('-271821-04-19') wants April 1st, which
|
|
401
|
+
// is below the minimum. Throwing is right; guardRange keeps the message ours.
|
|
402
|
+
|
|
346
403
|
/** @param {DayInput} date @returns {string} */
|
|
347
404
|
export function startOfMonth(date) {
|
|
348
405
|
const d = toPlainDate(date)
|
|
349
|
-
return toDayString(d.with({ day: 1 }))
|
|
406
|
+
return guardRange('startOfMonth', () => toDayString(d.with({ day: 1 })))
|
|
350
407
|
}
|
|
351
408
|
|
|
352
409
|
/** @param {DayInput} date @returns {string} */
|
|
353
410
|
export function endOfMonth(date) {
|
|
354
411
|
const d = toPlainDate(date)
|
|
355
|
-
return toDayString(d.with({ day: d.daysInMonth }))
|
|
412
|
+
return guardRange('endOfMonth', () => toDayString(d.with({ day: d.daysInMonth })))
|
|
356
413
|
}
|
|
357
414
|
|
|
358
415
|
/** @param {DayInput} date @returns {string} */
|
|
359
416
|
export function startOfYear(date) {
|
|
360
417
|
const d = toPlainDate(date)
|
|
361
|
-
return toDayString(d.with({ month: 1, day: 1 }))
|
|
418
|
+
return guardRange('startOfYear', () => toDayString(d.with({ month: 1, day: 1 })))
|
|
362
419
|
}
|
|
363
420
|
|
|
364
421
|
/** @param {DayInput} date @returns {string} */
|
|
365
422
|
export function endOfYear(date) {
|
|
366
423
|
const d = toPlainDate(date)
|
|
367
|
-
return toDayString(d.with({ month: 12, day: 31 }))
|
|
424
|
+
return guardRange('endOfYear', () => toDayString(d.with({ month: 12, day: 31 })))
|
|
368
425
|
}
|
|
369
426
|
|
|
370
427
|
/** @param {DayInput} date @returns {string} */
|
|
371
428
|
export function startOfQuarter(date) {
|
|
372
429
|
const d = toPlainDate(date)
|
|
373
430
|
const month = (getQuarter(d) - 1) * 3 + 1
|
|
374
|
-
return toDayString(d.with({ month, day: 1 }))
|
|
431
|
+
return guardRange('startOfQuarter', () => toDayString(d.with({ month, day: 1 })))
|
|
375
432
|
}
|
|
376
433
|
|
|
377
434
|
/** @param {DayInput} date @returns {string} */
|
|
378
435
|
export function endOfQuarter(date) {
|
|
379
436
|
const d = toPlainDate(date)
|
|
380
437
|
const month = getQuarter(d) * 3
|
|
381
|
-
|
|
382
|
-
|
|
438
|
+
return guardRange('endOfQuarter', () => {
|
|
439
|
+
const mid = d.with({ month, day: 1 })
|
|
440
|
+
return toDayString(mid.with({ day: mid.daysInMonth }))
|
|
441
|
+
})
|
|
383
442
|
}
|
|
384
443
|
|
|
385
444
|
/**
|
|
@@ -389,10 +448,20 @@ export function endOfQuarter(date) {
|
|
|
389
448
|
*/
|
|
390
449
|
export function startOfWeek(date, options) {
|
|
391
450
|
const d = toPlainDate(date)
|
|
451
|
+
const diff = daysIntoWeek(d, options)
|
|
452
|
+
return guardRange('startOfWeek', () => toDayString(d.subtract({ days: diff })))
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* How far the day sits past the start of its week.
|
|
457
|
+
* @param {Temporal.PlainDate} d
|
|
458
|
+
* @param {WeekOptions} [options]
|
|
459
|
+
* @returns {number}
|
|
460
|
+
*/
|
|
461
|
+
function daysIntoWeek(d, options) {
|
|
392
462
|
const weekStartsOn = weekStartsOnFrom(options)
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
return toDayString(d.subtract({ days: diff }))
|
|
463
|
+
// mod 7 makes weekStartsOn 0 and 7 identical, so both spellings of Sunday work
|
|
464
|
+
return (d.dayOfWeek - weekStartsOn + 7) % 7
|
|
396
465
|
}
|
|
397
466
|
|
|
398
467
|
/**
|
|
@@ -401,7 +470,12 @@ export function startOfWeek(date, options) {
|
|
|
401
470
|
* @returns {string}
|
|
402
471
|
*/
|
|
403
472
|
export function endOfWeek(date, options) {
|
|
404
|
-
|
|
473
|
+
const d = toPlainDate(date)
|
|
474
|
+
const diff = daysIntoWeek(d, options)
|
|
475
|
+
// one guard for the whole walk, so a failure at either end says endOfWeek
|
|
476
|
+
return guardRange('endOfWeek', () =>
|
|
477
|
+
toDayString(d.subtract({ days: diff }).add({ days: 6 })),
|
|
478
|
+
)
|
|
405
479
|
}
|
|
406
480
|
|
|
407
481
|
// ─── differences ───────────────────────────────────────────────────
|
|
@@ -425,11 +499,20 @@ export function differenceInDays(dateLeft, dateRight) {
|
|
|
425
499
|
* @returns {number}
|
|
426
500
|
*/
|
|
427
501
|
export function differenceInWeeks(dateLeft, dateRight) {
|
|
428
|
-
|
|
502
|
+
// `|| 0` normalises -0: Math.trunc keeps the sign of a negative gap shorter
|
|
503
|
+
// than a week, so a 1..6 day backwards difference returned -0
|
|
504
|
+
return Math.trunc(differenceInDays(dateLeft, dateRight) / 7) || 0
|
|
429
505
|
}
|
|
430
506
|
|
|
431
507
|
/**
|
|
432
|
-
* Full months (signed)
|
|
508
|
+
* Full months (signed). A month counts as full when `addMonths` would carry the
|
|
509
|
+
* earlier date to the later one, so the end of a short month counts: 31 January
|
|
510
|
+
* to 28 February is one month, because `addMonths` clamps 31 February to the
|
|
511
|
+
* 28th. That keeps `differenceInMonths(addMonths(d, n), d) === n`.
|
|
512
|
+
*
|
|
513
|
+
* Temporal's `since` is not used here. It has no overflow option, so it counts
|
|
514
|
+
* 28 days rather than one month for that pair, and the round trip breaks in
|
|
515
|
+
* 21,934 of 1,761,936 cases. `add` clamps, so the measurement has to match.
|
|
433
516
|
* @param {DayInput} dateLeft
|
|
434
517
|
* @param {DayInput} dateRight
|
|
435
518
|
* @returns {number}
|
|
@@ -437,8 +520,18 @@ export function differenceInWeeks(dateLeft, dateRight) {
|
|
|
437
520
|
export function differenceInMonths(dateLeft, dateRight) {
|
|
438
521
|
const left = toPlainDate(dateLeft, 'dateLeft')
|
|
439
522
|
const right = toPlainDate(dateRight, 'dateRight')
|
|
440
|
-
const
|
|
441
|
-
return
|
|
523
|
+
const sign = Temporal.PlainDate.compare(left, right)
|
|
524
|
+
if (sign === 0) return 0
|
|
525
|
+
const diff = Math.abs(differenceInCalendarMonths(left, right))
|
|
526
|
+
if (diff < 1) return 0
|
|
527
|
+
const [earlier, later] = sign > 0 ? [right, left] : [left, right]
|
|
528
|
+
// Where `earlier` lands after `diff` months: same year-month as `later` by
|
|
529
|
+
// construction, on `earlier`'s day clamped to that month's length. Compared
|
|
530
|
+
// as day numbers rather than built as a date, because the landing can sit
|
|
531
|
+
// past the maximum PlainDate even when both operands are inside the range.
|
|
532
|
+
const landingDay = Math.min(earlier.day, later.daysInMonth)
|
|
533
|
+
const isLastMonthNotFull = landingDay > later.day
|
|
534
|
+
return sign * (diff - +isLastMonthNotFull) || 0
|
|
442
535
|
}
|
|
443
536
|
|
|
444
537
|
/**
|
|
@@ -454,7 +547,11 @@ export function differenceInCalendarMonths(dateLeft, dateRight) {
|
|
|
454
547
|
}
|
|
455
548
|
|
|
456
549
|
/**
|
|
457
|
-
* Full years (signed).
|
|
550
|
+
* Full years (signed). Same rule as `differenceInMonths`: a year counts as full
|
|
551
|
+
* when `addYears` would carry the earlier date to the later one, so 29 February
|
|
552
|
+
* to 28 February of a common year is one year, because `addYears` clamps.
|
|
553
|
+
* That keeps `differenceInYears(addYears(d, n), d) === n`, and keeps this
|
|
554
|
+
* function agreeing with `trunc(differenceInMonths(a, b) / 12)`.
|
|
458
555
|
* @param {DayInput} dateLeft
|
|
459
556
|
* @param {DayInput} dateRight
|
|
460
557
|
* @returns {number}
|
|
@@ -462,7 +559,22 @@ export function differenceInCalendarMonths(dateLeft, dateRight) {
|
|
|
462
559
|
export function differenceInYears(dateLeft, dateRight) {
|
|
463
560
|
const left = toPlainDate(dateLeft, 'dateLeft')
|
|
464
561
|
const right = toPlainDate(dateRight, 'dateRight')
|
|
465
|
-
|
|
562
|
+
const sign = Temporal.PlainDate.compare(left, right)
|
|
563
|
+
if (sign === 0) return 0
|
|
564
|
+
const diff = Math.abs(left.year - right.year)
|
|
565
|
+
if (diff < 1) return 0
|
|
566
|
+
const [earlier, later] = sign > 0 ? [right, left] : [left, right]
|
|
567
|
+
// Where `earlier` lands after `diff` years: same year as `later`, same month,
|
|
568
|
+
// and the same day except that 29 February clamps to the 28th in a common
|
|
569
|
+
// year, which is the only day-of-month that changes length year to year.
|
|
570
|
+
// Compared field by field rather than built as a date, because the landing
|
|
571
|
+
// can sit past the maximum PlainDate even with both operands inside the range.
|
|
572
|
+
const landingDay =
|
|
573
|
+
earlier.month === 2 && earlier.day === 29 && !later.inLeapYear ? 28 : earlier.day
|
|
574
|
+
const isLastYearNotFull =
|
|
575
|
+
earlier.month > later.month ||
|
|
576
|
+
(earlier.month === later.month && landingDay > later.day)
|
|
577
|
+
return sign * (diff - +isLastYearNotFull) || 0
|
|
466
578
|
}
|
|
467
579
|
|
|
468
580
|
/**
|
|
@@ -482,7 +594,9 @@ export function differenceInCalendarYears(dateLeft, dateRight) {
|
|
|
482
594
|
* @returns {number}
|
|
483
595
|
*/
|
|
484
596
|
export function differenceInQuarters(dateLeft, dateRight) {
|
|
485
|
-
|
|
597
|
+
// `|| 0` normalises -0, same reason as differenceInWeeks: a backwards gap of
|
|
598
|
+
// one or two months truncates to -0
|
|
599
|
+
return Math.trunc(differenceInMonths(dateLeft, dateRight) / 3) || 0
|
|
486
600
|
}
|
|
487
601
|
|
|
488
602
|
/**
|
|
@@ -554,7 +668,14 @@ export const isSameDay = isEqual
|
|
|
554
668
|
* @returns {boolean}
|
|
555
669
|
*/
|
|
556
670
|
export function isSameWeek(dateLeft, dateRight, options) {
|
|
557
|
-
|
|
671
|
+
const left = toPlainDate(dateLeft, 'dateLeft')
|
|
672
|
+
const right = toPlainDate(dateRight, 'dateRight')
|
|
673
|
+
// own guard, so a week start below the minimum does not say startOfWeek
|
|
674
|
+
return guardRange('isSameWeek', () =>
|
|
675
|
+
left.subtract({ days: daysIntoWeek(left, options) }).equals(
|
|
676
|
+
right.subtract({ days: daysIntoWeek(right, options) }),
|
|
677
|
+
),
|
|
678
|
+
)
|
|
558
679
|
}
|
|
559
680
|
|
|
560
681
|
/**
|
|
@@ -608,7 +729,8 @@ export function compareAsc(dateLeft, dateRight) {
|
|
|
608
729
|
* @returns {-1 | 0 | 1}
|
|
609
730
|
*/
|
|
610
731
|
export function compareDesc(dateLeft, dateRight) {
|
|
611
|
-
|
|
732
|
+
// `|| 0` normalises -0 for equal days
|
|
733
|
+
return /** @type {-1 | 0 | 1} */ (-compareAsc(dateLeft, dateRight) || 0)
|
|
612
734
|
}
|
|
613
735
|
|
|
614
736
|
/**
|
|
@@ -641,7 +763,7 @@ export function max(dates) {
|
|
|
641
763
|
|
|
642
764
|
/** @param {DayInput} date @returns {boolean} */
|
|
643
765
|
export function isSunday(date) {
|
|
644
|
-
return getDay(date) ===
|
|
766
|
+
return getDay(date) === 7
|
|
645
767
|
}
|
|
646
768
|
/** @param {DayInput} date @returns {boolean} */
|
|
647
769
|
export function isMonday(date) {
|
|
@@ -670,7 +792,7 @@ export function isSaturday(date) {
|
|
|
670
792
|
/** @param {DayInput} date @returns {boolean} */
|
|
671
793
|
export function isWeekend(date) {
|
|
672
794
|
const d = getDay(date)
|
|
673
|
-
return d ===
|
|
795
|
+
return d === 6 || d === 7
|
|
674
796
|
}
|
|
675
797
|
|
|
676
798
|
/** @param {DayInput} date @returns {boolean} */
|
|
@@ -699,8 +821,11 @@ export function eachDayOfInterval(interval) {
|
|
|
699
821
|
/** @type {string[]} */
|
|
700
822
|
const out = []
|
|
701
823
|
let cur = start
|
|
702
|
-
|
|
824
|
+
// break on the last day, never step past it — an add beyond the max
|
|
825
|
+
// PlainDate (+275760-09-13) throws
|
|
826
|
+
for (;;) {
|
|
703
827
|
out.push(toDayString(cur))
|
|
828
|
+
if (Temporal.PlainDate.compare(cur, end) >= 0) break
|
|
704
829
|
cur = cur.add({ days: 1 })
|
|
705
830
|
}
|
|
706
831
|
return out
|
|
@@ -718,10 +843,16 @@ export function eachMonthOfInterval(interval) {
|
|
|
718
843
|
}
|
|
719
844
|
/** @type {string[]} */
|
|
720
845
|
const out = []
|
|
721
|
-
|
|
722
|
-
const last =
|
|
723
|
-
|
|
846
|
+
// the 1st of start's month can sit below the minimum PlainDate
|
|
847
|
+
const [cur0, last] = guardRange('eachMonthOfInterval', () => [
|
|
848
|
+
start.with({ day: 1 }),
|
|
849
|
+
end.with({ day: 1 }),
|
|
850
|
+
])
|
|
851
|
+
let cur = cur0
|
|
852
|
+
// same boundary rule as eachDayOfInterval
|
|
853
|
+
for (;;) {
|
|
724
854
|
out.push(toDayString(cur))
|
|
855
|
+
if (Temporal.PlainDate.compare(cur, last) >= 0) break
|
|
725
856
|
cur = cur.add({ months: 1 })
|
|
726
857
|
}
|
|
727
858
|
return out
|
|
@@ -740,10 +871,13 @@ export function eachYearOfInterval(interval) {
|
|
|
740
871
|
/** @type {string[]} */
|
|
741
872
|
const out = []
|
|
742
873
|
let y = start.year
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
y
|
|
746
|
-
|
|
874
|
+
// Jan 1 of start's year can sit below the minimum PlainDate
|
|
875
|
+
guardRange('eachYearOfInterval', () => {
|
|
876
|
+
while (y <= end.year) {
|
|
877
|
+
out.push(toDayString(Temporal.PlainDate.from({ year: y, month: 1, day: 1 })))
|
|
878
|
+
y += 1
|
|
879
|
+
}
|
|
880
|
+
})
|
|
747
881
|
return out
|
|
748
882
|
}
|
|
749
883
|
|
|
@@ -786,7 +920,7 @@ export function clamp(date, interval) {
|
|
|
786
920
|
* Whether two inclusive intervals overlap.
|
|
787
921
|
* @param {Interval} intervalLeft
|
|
788
922
|
* @param {Interval} intervalRight
|
|
789
|
-
* @param {{ inclusive?: boolean }} [options]
|
|
923
|
+
* @param {{ inclusive?: boolean }} [options] `inclusive` defaults to false, like date-fns: intervals that only touch at an endpoint do not overlap
|
|
790
924
|
* @returns {boolean}
|
|
791
925
|
*/
|
|
792
926
|
export function areIntervalsOverlapping(intervalLeft, intervalRight, options) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "daymath",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Calendar date math (ISO 8601 day / PlainDate). date-fns-shaped. No time zones.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -18,17 +18,21 @@
|
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
20
|
"test": "node --test test.js",
|
|
21
|
-
"test:coverage": "
|
|
22
|
-
"
|
|
21
|
+
"test:coverage": "c8 --include=index.js --check-coverage --lines 100 --functions 100 --branches 100 --reporter=text --reporter=lcov node --test test.js",
|
|
22
|
+
"test:differential": "node scripts/differential.mjs",
|
|
23
|
+
"test:differential:quick": "node scripts/differential.mjs 2020 2030",
|
|
24
|
+
"prepublishOnly": "npm run test:coverage",
|
|
25
|
+
"publish:github": "node scripts/publish-github-packages.mjs"
|
|
23
26
|
},
|
|
24
|
-
|
|
25
27
|
"keywords": [
|
|
26
28
|
"date",
|
|
27
29
|
"calendar",
|
|
28
30
|
"plain-date",
|
|
29
31
|
"temporal",
|
|
30
32
|
"date-fns",
|
|
31
|
-
"YYYY-MM-DD"
|
|
33
|
+
"YYYY-MM-DD",
|
|
34
|
+
"ISO-8601",
|
|
35
|
+
"PlainDate"
|
|
32
36
|
],
|
|
33
37
|
"author": "leemr",
|
|
34
38
|
"license": "MIT",
|
|
@@ -39,11 +43,16 @@
|
|
|
39
43
|
"bugs": {
|
|
40
44
|
"url": "https://github.com/leemr/daymath/issues"
|
|
41
45
|
},
|
|
42
|
-
"homepage": "https://github.
|
|
46
|
+
"homepage": "https://leemr.github.io/daymath/",
|
|
47
|
+
"sideEffects": false,
|
|
43
48
|
"dependencies": {
|
|
44
49
|
"temporal-polyfill": "^1.0.3"
|
|
45
50
|
},
|
|
46
51
|
"engines": {
|
|
47
52
|
"node": ">=18"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"c8": "^12.0.0",
|
|
56
|
+
"date-fns": "4.4.0"
|
|
48
57
|
}
|
|
49
58
|
}
|