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.
Files changed (4) hide show
  1. package/README.md +29 -7
  2. package/index.d.ts +10 -5
  3. package/index.js +196 -62
  4. package/package.json +15 -6
package/README.md CHANGED
@@ -1,19 +1,31 @@
1
1
  # daymath
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/daymath.svg)](https://www.npmjs.com/package/daymath)
4
+ [![ci](https://github.com/leemr/daymath/actions/workflows/ci.yml/badge.svg)](https://github.com/leemr/daymath/actions/workflows/ci.yml)
5
+ [![codecov](https://codecov.io/gh/leemr/daymath/graph/badge.svg)](https://codecov.io/gh/leemr/daymath)
4
6
  [![license](https://img.shields.io/npm/l/daymath.svg)](./LICENSE)
5
7
  [![node](https://img.shields.io/node/v/daymath.svg)](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 in the browser →**](https://leemr.github.io/daymath/) · [npm](https://www.npmjs.com/package/daymath) · [FUTURE.md](./FUTURE.md)
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` | **011** (0 = January) |
45
- | `getDay` | **06** (0 = Sunday) |
46
- | `weekStartsOn` | default `0` (Sunday) |
62
+ | `getMonth` / `setMonth` | **112** (1 = January) — ISO, **not** date-fns |
63
+ | `getDay` | **17** (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`: 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
 
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 index like Date/date-fns: 0 = January … 11 = December. */
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 like Date/date-fns: 0 = Sunday6 = Saturday. */
58
+ /** Weekday, ISO 8601: 1 = Monday7 = 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`: 0 = January … 11 = December (date-fns). */
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] 0=Sun6=Sat (date-fns default 0)
27
+ * @property {0|1|2|3|4|5|6|7} [weekStartsOn] ISO 1=Mon7=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 ?? 0
94
- if (!Number.isInteger(w) || w < 0 || w > 6) {
95
- throw new RangeError('daymath: weekStartsOn must be an integer 0…6 (0=Sun)')
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 toDayString(toPlainDate(date).add({ days: amount }))
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 addDays(date, -amount)
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
- return addDays(date, amount * 7)
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
- return addWeeks(date, -amount)
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 toDayString(toPlainDate(date).add({ months: amount }))
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 addMonths(date, -amount)
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 toDayString(toPlainDate(date).add({ years: amount }))
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 addYears(date, -amount)
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
- return addMonths(date, amount * 3)
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
- return addQuarters(date, -amount)
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 index like Date/date-fns: 0 = January … 11 = December.
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 - 1
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 like Date/date-fns: 0 = Sunday6 = Saturday.
330
+ * Weekday, ISO 8601: 1 = Monday7 = 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 isoToJsWeekday(toPlainDate(date).dayOfWeek)
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
- return toDayString(toPlainDate(date).with({ year }))
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 0 = January … 11 = December (date-fns)
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 < 0 || month > 11) {
329
- throw new RangeError('daymath: month must be 011 (0=January)')
378
+ if (month < 1 || month > 12) {
379
+ throw new RangeError('daymath: month must be 112 (1=January)')
330
380
  }
331
- return toDayString(toPlainDate(date).with({ month: month + 1 }))
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
- return toDayString(toPlainDate(date).with({ day: dayOfMonth }))
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
- const mid = d.with({ month, day: 1 })
382
- return toDayString(mid.with({ day: mid.daysInMonth }))
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
- const day = isoToJsWeekday(d.dayOfWeek)
394
- const diff = (day - weekStartsOn + 7) % 7
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
- return addDays(startOfWeek(date, options), 6)
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
- return Math.trunc(differenceInDays(dateLeft, dateRight) / 7)
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), Temporal since with largestUnit month.
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 dur = left.since(right, { largestUnit: 'month' })
441
- return dur.months
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
- return left.since(right, { largestUnit: 'year' }).years
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
- return Math.trunc(differenceInMonths(dateLeft, dateRight) / 3)
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
- return isEqual(startOfWeek(dateLeft, options), startOfWeek(dateRight, options))
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
- return /** @type {-1 | 0 | 1} */ (-compareAsc(dateLeft, dateRight))
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) === 0
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 === 0 || d === 6
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
- while (Temporal.PlainDate.compare(cur, end) <= 0) {
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
- let cur = start.with({ day: 1 })
722
- const last = end.with({ day: 1 })
723
- while (Temporal.PlainDate.compare(cur, last) <= 0) {
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
- while (y <= end.year) {
744
- out.push(toDayString(Temporal.PlainDate.from({ year: y, month: 1, day: 1 })))
745
- y += 1
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] default inclusive true (date-fns default false uses half-open; we default true for plain days)
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.2.2",
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": "node --test --experimental-test-coverage --test-coverage-include=index.js test.js",
22
- "prepublishOnly": "npm run test:coverage"
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.com/leemr/daymath#readme",
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
  }