daymath 0.2.3 → 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 +7 -5
  2. package/index.d.ts +10 -5
  3. package/index.js +194 -62
  4. package/package.json +5 -2
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  **ISO 8601** calendar day math. **date-fns-shaped** names. **Temporal.PlainDate** under the hood.
10
10
 
11
- No `Date`. No time zones. No silent “local now.”
11
+ No `Date`. No time zones. No silent “local now.” ISO 8601 ❤️
12
12
 
13
13
  [**Play →**](https://leemr.github.io/daymath/) · [npm](https://www.npmjs.com/package/daymath) · [Changelog](./CHANGELOG.md) · [Contributing](./CONTRIBUTING.md) · [FUTURE](./FUTURE.md)
14
14
 
@@ -50,6 +50,8 @@ node examples/basic.mjs # from a clone
50
50
 
51
51
  `Date` **throws** (including `isValid`). `isValid('asdf')` → `false`.
52
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
+
53
55
  ## date-fns parity (names, not `Date`)
54
56
 
55
57
  | Topic | daymath |
@@ -57,9 +59,9 @@ node examples/basic.mjs # from a clone
57
59
  | Values | ISO day **strings**, not `Date` |
58
60
  | `isSameDay` | Alias of `isEqual` |
59
61
  | `isValid` | Valid daymath day; **`Date` throws** |
60
- | `getMonth` / `setMonth` | **011** (0 = January) |
61
- | `getDay` | **06** (0 = Sunday) |
62
- | `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 |
63
65
  | Intervals | `{ start, end }` |
64
66
 
65
67
  ## API
@@ -88,7 +90,7 @@ Uses global `Temporal` when present; otherwise [`temporal-polyfill`](https://www
88
90
 
89
91
  ## Types & tests
90
92
 
91
- Plain JS + `index.d.ts` (no compile step). CI runs on Node 18, 20, and 22.
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.
92
94
 
93
95
  ```bash
94
96
  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,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
  /**
@@ -166,7 +202,7 @@ export function format(date, pattern = 'yyyy-MM-dd') {
166
202
  */
167
203
  export function addDays(date, amount) {
168
204
  assertFiniteNumber(amount, 'amount')
169
- return toDayString(toPlainDate(date).add({ days: amount }))
205
+ return addDuration(date, { days: amount }, 'addDays')
170
206
  }
171
207
 
172
208
  /**
@@ -176,7 +212,7 @@ export function addDays(date, amount) {
176
212
  */
177
213
  export function subDays(date, amount) {
178
214
  assertFiniteNumber(amount, 'amount')
179
- return addDays(date, -amount)
215
+ return addDuration(date, { days: -amount }, 'subDays')
180
216
  }
181
217
 
182
218
  /**
@@ -186,7 +222,9 @@ export function subDays(date, amount) {
186
222
  */
187
223
  export function addWeeks(date, amount) {
188
224
  assertFiniteNumber(amount, 'amount')
189
- 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')
190
228
  }
191
229
 
192
230
  /**
@@ -196,7 +234,9 @@ export function addWeeks(date, amount) {
196
234
  */
197
235
  export function subWeeks(date, amount) {
198
236
  assertFiniteNumber(amount, 'amount')
199
- return addWeeks(date, -amount)
237
+ const days = -amount * 7
238
+ assertFiniteNumber(days, 'amount')
239
+ return addDuration(date, { days }, 'subWeeks')
200
240
  }
201
241
 
202
242
  /**
@@ -207,7 +247,7 @@ export function subWeeks(date, amount) {
207
247
  */
208
248
  export function addMonths(date, amount) {
209
249
  assertFiniteNumber(amount, 'amount')
210
- return toDayString(toPlainDate(date).add({ months: amount }))
250
+ return addDuration(date, { months: amount }, 'addMonths')
211
251
  }
212
252
 
213
253
  /**
@@ -217,7 +257,7 @@ export function addMonths(date, amount) {
217
257
  */
218
258
  export function subMonths(date, amount) {
219
259
  assertFiniteNumber(amount, 'amount')
220
- return addMonths(date, -amount)
260
+ return addDuration(date, { months: -amount }, 'subMonths')
221
261
  }
222
262
 
223
263
  /**
@@ -227,7 +267,7 @@ export function subMonths(date, amount) {
227
267
  */
228
268
  export function addYears(date, amount) {
229
269
  assertFiniteNumber(amount, 'amount')
230
- return toDayString(toPlainDate(date).add({ years: amount }))
270
+ return addDuration(date, { years: amount }, 'addYears')
231
271
  }
232
272
 
233
273
  /**
@@ -237,7 +277,7 @@ export function addYears(date, amount) {
237
277
  */
238
278
  export function subYears(date, amount) {
239
279
  assertFiniteNumber(amount, 'amount')
240
- return addYears(date, -amount)
280
+ return addDuration(date, { years: -amount }, 'subYears')
241
281
  }
242
282
 
243
283
  /**
@@ -247,7 +287,9 @@ export function subYears(date, amount) {
247
287
  */
248
288
  export function addQuarters(date, amount) {
249
289
  assertFiniteNumber(amount, 'amount')
250
- return addMonths(date, amount * 3)
290
+ const months = amount * 3
291
+ assertFiniteNumber(months, 'amount')
292
+ return addDuration(date, { months }, 'addQuarters')
251
293
  }
252
294
 
253
295
  /**
@@ -257,7 +299,9 @@ export function addQuarters(date, amount) {
257
299
  */
258
300
  export function subQuarters(date, amount) {
259
301
  assertFiniteNumber(amount, 'amount')
260
- return addQuarters(date, -amount)
302
+ const months = -amount * 3
303
+ assertFiniteNumber(months, 'amount')
304
+ return addDuration(date, { months }, 'subQuarters')
261
305
  }
262
306
 
263
307
  // ─── getters / setters (date-fns / Date month & weekday indexing) ─
@@ -268,12 +312,13 @@ export function getYear(date) {
268
312
  }
269
313
 
270
314
  /**
271
- * 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.
272
317
  * @param {DayInput} date
273
318
  * @returns {number}
274
319
  */
275
320
  export function getMonth(date) {
276
- return toPlainDate(date).month - 1
321
+ return toPlainDate(date).month
277
322
  }
278
323
 
279
324
  /** Day of month 1…31. @param {DayInput} date @returns {number} */
@@ -282,12 +327,14 @@ export function getDate(date) {
282
327
  }
283
328
 
284
329
  /**
285
- * 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.
286
333
  * @param {DayInput} date
287
334
  * @returns {number}
288
335
  */
289
336
  export function getDay(date) {
290
- return isoToJsWeekday(toPlainDate(date).dayOfWeek)
337
+ return toPlainDate(date).dayOfWeek
291
338
  }
292
339
 
293
340
  /** @param {DayInput} date @returns {number} */
@@ -317,71 +364,81 @@ export function isLeapYear(date) {
317
364
  */
318
365
  export function setYear(date, year) {
319
366
  assertFiniteNumber(year, 'year')
320
- return toDayString(toPlainDate(date).with({ year }))
367
+ const d = toPlainDate(date)
368
+ return guardRange('setYear', () => toDayString(d.with({ year })))
321
369
  }
322
370
 
323
371
  /**
324
372
  * @param {DayInput} date
325
- * @param {number} month 0 = January … 11 = December (date-fns)
373
+ * @param {number} month 1 = January … 12 = December (ISO 8601)
326
374
  * @returns {string}
327
375
  */
328
376
  export function setMonth(date, month) {
329
377
  assertFiniteNumber(month, 'month')
330
- if (month < 0 || month > 11) {
331
- 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)')
332
380
  }
333
- return toDayString(toPlainDate(date).with({ month: month + 1 }))
381
+ const d = toPlainDate(date)
382
+ return guardRange('setMonth', () => toDayString(d.with({ month })))
334
383
  }
335
384
 
336
385
  /**
337
386
  * @param {DayInput} date
338
- * @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)
339
389
  * @returns {string}
340
390
  */
341
391
  export function setDate(date, dayOfMonth) {
342
392
  assertFiniteNumber(dayOfMonth, 'day')
343
- return toDayString(toPlainDate(date).with({ day: dayOfMonth }))
393
+ const d = toPlainDate(date)
394
+ return guardRange('setDate', () => toDayString(d.with({ day: dayOfMonth })))
344
395
  }
345
396
 
346
397
  // ─── start / end of unit ───────────────────────────────────────────
347
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
+
348
403
  /** @param {DayInput} date @returns {string} */
349
404
  export function startOfMonth(date) {
350
405
  const d = toPlainDate(date)
351
- return toDayString(d.with({ day: 1 }))
406
+ return guardRange('startOfMonth', () => toDayString(d.with({ day: 1 })))
352
407
  }
353
408
 
354
409
  /** @param {DayInput} date @returns {string} */
355
410
  export function endOfMonth(date) {
356
411
  const d = toPlainDate(date)
357
- return toDayString(d.with({ day: d.daysInMonth }))
412
+ return guardRange('endOfMonth', () => toDayString(d.with({ day: d.daysInMonth })))
358
413
  }
359
414
 
360
415
  /** @param {DayInput} date @returns {string} */
361
416
  export function startOfYear(date) {
362
417
  const d = toPlainDate(date)
363
- return toDayString(d.with({ month: 1, day: 1 }))
418
+ return guardRange('startOfYear', () => toDayString(d.with({ month: 1, day: 1 })))
364
419
  }
365
420
 
366
421
  /** @param {DayInput} date @returns {string} */
367
422
  export function endOfYear(date) {
368
423
  const d = toPlainDate(date)
369
- return toDayString(d.with({ month: 12, day: 31 }))
424
+ return guardRange('endOfYear', () => toDayString(d.with({ month: 12, day: 31 })))
370
425
  }
371
426
 
372
427
  /** @param {DayInput} date @returns {string} */
373
428
  export function startOfQuarter(date) {
374
429
  const d = toPlainDate(date)
375
430
  const month = (getQuarter(d) - 1) * 3 + 1
376
- return toDayString(d.with({ month, day: 1 }))
431
+ return guardRange('startOfQuarter', () => toDayString(d.with({ month, day: 1 })))
377
432
  }
378
433
 
379
434
  /** @param {DayInput} date @returns {string} */
380
435
  export function endOfQuarter(date) {
381
436
  const d = toPlainDate(date)
382
437
  const month = getQuarter(d) * 3
383
- const mid = d.with({ month, day: 1 })
384
- 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
+ })
385
442
  }
386
443
 
387
444
  /**
@@ -391,10 +448,20 @@ export function endOfQuarter(date) {
391
448
  */
392
449
  export function startOfWeek(date, options) {
393
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) {
394
462
  const weekStartsOn = weekStartsOnFrom(options)
395
- const day = isoToJsWeekday(d.dayOfWeek)
396
- const diff = (day - weekStartsOn + 7) % 7
397
- 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
398
465
  }
399
466
 
400
467
  /**
@@ -403,7 +470,12 @@ export function startOfWeek(date, options) {
403
470
  * @returns {string}
404
471
  */
405
472
  export function endOfWeek(date, options) {
406
- 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
+ )
407
479
  }
408
480
 
409
481
  // ─── differences ───────────────────────────────────────────────────
@@ -427,11 +499,20 @@ export function differenceInDays(dateLeft, dateRight) {
427
499
  * @returns {number}
428
500
  */
429
501
  export function differenceInWeeks(dateLeft, dateRight) {
430
- 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
431
505
  }
432
506
 
433
507
  /**
434
- * 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.
435
516
  * @param {DayInput} dateLeft
436
517
  * @param {DayInput} dateRight
437
518
  * @returns {number}
@@ -439,8 +520,18 @@ export function differenceInWeeks(dateLeft, dateRight) {
439
520
  export function differenceInMonths(dateLeft, dateRight) {
440
521
  const left = toPlainDate(dateLeft, 'dateLeft')
441
522
  const right = toPlainDate(dateRight, 'dateRight')
442
- const dur = left.since(right, { largestUnit: 'month' })
443
- 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
444
535
  }
445
536
 
446
537
  /**
@@ -456,7 +547,11 @@ export function differenceInCalendarMonths(dateLeft, dateRight) {
456
547
  }
457
548
 
458
549
  /**
459
- * 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)`.
460
555
  * @param {DayInput} dateLeft
461
556
  * @param {DayInput} dateRight
462
557
  * @returns {number}
@@ -464,7 +559,22 @@ export function differenceInCalendarMonths(dateLeft, dateRight) {
464
559
  export function differenceInYears(dateLeft, dateRight) {
465
560
  const left = toPlainDate(dateLeft, 'dateLeft')
466
561
  const right = toPlainDate(dateRight, 'dateRight')
467
- 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
468
578
  }
469
579
 
470
580
  /**
@@ -484,7 +594,9 @@ export function differenceInCalendarYears(dateLeft, dateRight) {
484
594
  * @returns {number}
485
595
  */
486
596
  export function differenceInQuarters(dateLeft, dateRight) {
487
- 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
488
600
  }
489
601
 
490
602
  /**
@@ -556,7 +668,14 @@ export const isSameDay = isEqual
556
668
  * @returns {boolean}
557
669
  */
558
670
  export function isSameWeek(dateLeft, dateRight, options) {
559
- 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
+ )
560
679
  }
561
680
 
562
681
  /**
@@ -610,7 +729,8 @@ export function compareAsc(dateLeft, dateRight) {
610
729
  * @returns {-1 | 0 | 1}
611
730
  */
612
731
  export function compareDesc(dateLeft, dateRight) {
613
- 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)
614
734
  }
615
735
 
616
736
  /**
@@ -643,7 +763,7 @@ export function max(dates) {
643
763
 
644
764
  /** @param {DayInput} date @returns {boolean} */
645
765
  export function isSunday(date) {
646
- return getDay(date) === 0
766
+ return getDay(date) === 7
647
767
  }
648
768
  /** @param {DayInput} date @returns {boolean} */
649
769
  export function isMonday(date) {
@@ -672,7 +792,7 @@ export function isSaturday(date) {
672
792
  /** @param {DayInput} date @returns {boolean} */
673
793
  export function isWeekend(date) {
674
794
  const d = getDay(date)
675
- return d === 0 || d === 6
795
+ return d === 6 || d === 7
676
796
  }
677
797
 
678
798
  /** @param {DayInput} date @returns {boolean} */
@@ -701,8 +821,11 @@ export function eachDayOfInterval(interval) {
701
821
  /** @type {string[]} */
702
822
  const out = []
703
823
  let cur = start
704
- 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 (;;) {
705
827
  out.push(toDayString(cur))
828
+ if (Temporal.PlainDate.compare(cur, end) >= 0) break
706
829
  cur = cur.add({ days: 1 })
707
830
  }
708
831
  return out
@@ -720,10 +843,16 @@ export function eachMonthOfInterval(interval) {
720
843
  }
721
844
  /** @type {string[]} */
722
845
  const out = []
723
- let cur = start.with({ day: 1 })
724
- const last = end.with({ day: 1 })
725
- 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 (;;) {
726
854
  out.push(toDayString(cur))
855
+ if (Temporal.PlainDate.compare(cur, last) >= 0) break
727
856
  cur = cur.add({ months: 1 })
728
857
  }
729
858
  return out
@@ -742,10 +871,13 @@ export function eachYearOfInterval(interval) {
742
871
  /** @type {string[]} */
743
872
  const out = []
744
873
  let y = start.year
745
- while (y <= end.year) {
746
- out.push(toDayString(Temporal.PlainDate.from({ year: y, month: 1, day: 1 })))
747
- y += 1
748
- }
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
+ })
749
881
  return out
750
882
  }
751
883
 
@@ -788,7 +920,7 @@ export function clamp(date, interval) {
788
920
  * Whether two inclusive intervals overlap.
789
921
  * @param {Interval} intervalLeft
790
922
  * @param {Interval} intervalRight
791
- * @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
792
924
  * @returns {boolean}
793
925
  */
794
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.3",
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",
@@ -19,6 +19,8 @@
19
19
  "scripts": {
20
20
  "test": "node --test test.js",
21
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",
22
24
  "prepublishOnly": "npm run test:coverage",
23
25
  "publish:github": "node scripts/publish-github-packages.mjs"
24
26
  },
@@ -50,6 +52,7 @@
50
52
  "node": ">=18"
51
53
  },
52
54
  "devDependencies": {
53
- "c8": "^12.0.0"
55
+ "c8": "^12.0.0",
56
+ "date-fns": "4.4.0"
54
57
  }
55
58
  }