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.
- package/README.md +7 -5
- package/index.d.ts +10 -5
- package/index.js +194 -62
- 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` | **
|
|
61
|
-
| `getDay` | **
|
|
62
|
-
| `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 |
|
|
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
|
|
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`:
|
|
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
|
/**
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
286
333
|
* @param {DayInput} date
|
|
287
334
|
* @returns {number}
|
|
288
335
|
*/
|
|
289
336
|
export function getDay(date) {
|
|
290
|
-
return
|
|
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
|
-
|
|
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
|
|
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 <
|
|
331
|
-
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)')
|
|
332
380
|
}
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
443
|
-
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) ===
|
|
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 ===
|
|
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
|
-
|
|
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
|
-
|
|
724
|
-
const last =
|
|
725
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
y
|
|
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]
|
|
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.
|
|
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
|
}
|