daymath 0.1.0 → 0.2.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 +54 -22
  2. package/index.d.ts +90 -2
  3. package/index.js +536 -16
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # daymath
2
2
 
3
- Calendar date math for `YYYY-MM-DD` strings. **date-fns-shaped** names. **Temporal.PlainDate** under the hood.
3
+ Calendar date math for **ISO 8601** `YYYY-MM-DD` strings. **date-fns-shaped** names. **Temporal.PlainDate** under the hood.
4
4
 
5
5
  No `Date`. No time zones. No silent “local now.”
6
6
 
@@ -20,34 +20,66 @@ import {
20
20
  addMonths,
21
21
  differenceInDays,
22
22
  isBefore,
23
- parse,
23
+ isSameDay,
24
+ startOfMonth,
25
+ eachDayOfInterval,
24
26
  } from 'daymath'
25
27
 
26
- addDays('2026-08-06', 1) // '2026-08-07'
27
- addMonths('2026-01-31', 1) // '2026-02-28' (constrain)
28
+ addDays('2026-08-06', 1) // '2026-08-07'
29
+ addMonths('2026-01-31', 1) // '2026-02-28' (constrain)
28
30
  differenceInDays('2026-08-06', '2026-08-01') // 5
29
- isBefore('2026-08-05', '2026-08-06') // true
30
- parse('2026-08-06') // '2026-08-06'
31
+ isBefore('2026-08-05', '2026-08-06') // true
32
+ isSameDay('2026-08-06', '2026-08-06') // true (alias of isEqual)
33
+ startOfMonth('2026-08-06') // '2026-08-01'
34
+ eachDayOfInterval({
35
+ start: '2026-08-05',
36
+ end: '2026-08-07',
37
+ }) // ['2026-08-05', '2026-08-06', '2026-08-07']
31
38
  ```
32
39
 
33
- Inputs: `YYYY-MM-DD` string or `Temporal.PlainDate`.
34
- Outputs: always `YYYY-MM-DD` string (for math helpers).
40
+ **Inputs:** ISO 8601 `YYYY-MM-DD` or `Temporal.PlainDate`.
41
+ **Outputs:** always `YYYY-MM-DD` string (for math helpers).
35
42
 
36
- `Date` throws. Time-bearing ISO strings throw. Sloppy forms like `2026-8-6` throw.
43
+ `Date` throws. Time-bearing strings throw. Sloppy forms like `2026-8-6` throw.
37
44
 
38
- ## API
45
+ ## date-fns parity notes
39
46
 
40
- | Function | Notes |
41
- |----------|--------|
42
- | `parse` / `format` / `isValid` | Strict `YYYY-MM-DD` |
43
- | `addDays` / `subDays` | |
44
- | `addWeeks` / `subWeeks` | |
45
- | `addMonths` / `subMonths` | Calendar months; end-of-month constrains |
46
- | `addYears` / `subYears` | Leap day constrains |
47
- | `differenceInDays` | `dateLeft dateRight` (date-fns order) |
48
- | `isBefore` / `isAfter` / `isEqual` | |
49
- | `compareAsc` / `compareDesc` | For `.sort()` |
50
- | `min` / `max` | Non-empty arrays |
47
+ | Topic | daymath |
48
+ |-------|---------|
49
+ | Value type | `YYYY-MM-DD` string (not `Date`) |
50
+ | `isSameDay` | Alias of `isEqual` (same calendar day) |
51
+ | `getMonth` / `setMonth` | **0–11** like Date/date-fns (0 = January) |
52
+ | `getDay` | **0–6** like Date/date-fns (0 = Sunday) |
53
+ | `weekStartsOn` | `0` = Sunday `6` = Saturday (default `0`) |
54
+ | Intervals | `{ start, end }` inclusive for `each*` / `isWithin` / `clamp` |
55
+ | `areIntervalsOverlapping` | Default `{ inclusive: false }` (date-fns); pass `true` for closed |
56
+
57
+ ## API (0.2)
58
+
59
+ ### Parse / format
60
+ `parse` · `format` · `isValid`
61
+
62
+ ### Add / sub
63
+ `addDays` / `subDays` · `addWeeks` / `subWeeks` · `addMonths` / `subMonths` · `addYears` / `subYears` · `addQuarters` / `subQuarters`
64
+
65
+ ### Get / set
66
+ `getYear` · `getMonth` · `getDate` · `getDay` · `getDayOfYear` · `getDaysInMonth` · `getQuarter` · `isLeapYear`
67
+ `setYear` · `setMonth` · `setDate`
68
+
69
+ ### Start / end
70
+ `startOfMonth` / `endOfMonth` · `startOfYear` / `endOfYear` · `startOfQuarter` / `endOfQuarter` · `startOfWeek` / `endOfWeek`
71
+
72
+ ### Differences
73
+ `differenceInDays` · `differenceInWeeks` · `differenceInMonths` · `differenceInCalendarMonths` · `differenceInYears` · `differenceInCalendarYears` · `differenceInQuarters` · `differenceInCalendarQuarters`
74
+
75
+ ### Compare
76
+ `isBefore` · `isAfter` · `isEqual` · `isSameDay` · `isSameWeek` · `isSameMonth` · `isSameYear` · `isSameQuarter` · `compareAsc` · `compareDesc` · `min` · `max`
77
+
78
+ ### Weekday / month edges
79
+ `isSunday`…`isSaturday` · `isWeekend` · `isFirstDayOfMonth` · `isLastDayOfMonth`
80
+
81
+ ### Intervals
82
+ `eachDayOfInterval` · `eachMonthOfInterval` · `eachYearOfInterval` · `isWithinInterval` · `clamp` · `areIntervalsOverlapping`
51
83
 
52
84
  Amounts must be finite integers.
53
85
 
@@ -57,7 +89,7 @@ Uses global `Temporal` when present; otherwise [`temporal-polyfill`](https://www
57
89
 
58
90
  ## Types
59
91
 
60
- Ships `index.d.ts` (no TypeScript compile step for authors). Source is plain JS.
92
+ Ships `index.d.ts` (no TypeScript compile step). Source is plain JS.
61
93
 
62
94
  ## License
63
95
 
package/index.d.ts CHANGED
@@ -1,14 +1,27 @@
1
1
  import type { Temporal } from 'temporal-polyfill'
2
2
 
3
3
  /**
4
- * Calendar day input: `YYYY-MM-DD` string, or a Temporal.PlainDate.
4
+ * Calendar day input: ISO 8601 `YYYY-MM-DD` string, or a Temporal.PlainDate.
5
5
  * `Date` is rejected at runtime.
6
6
  */
7
7
  export type DayInput = string | Temporal.PlainDate
8
8
 
9
+ /** Inclusive calendar-day interval (date-fns shape). */
10
+ export type Interval = {
11
+ start: DayInput
12
+ end: DayInput
13
+ }
14
+
15
+ /**
16
+ * Week options. `weekStartsOn`: 0 = Sunday … 6 = Saturday (date-fns default 0).
17
+ */
18
+ export type WeekOptions = {
19
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
20
+ }
21
+
9
22
  export function isValid(value: unknown): boolean
10
23
 
11
- /** Validate / normalize to `YYYY-MM-DD`. */
24
+ /** Validate / normalize to ISO 8601 `YYYY-MM-DD`. */
12
25
  export function parse(date: DayInput): string
13
26
 
14
27
  /** Format as `YYYY-MM-DD` (only pattern supported). */
@@ -22,16 +35,91 @@ export function addMonths(date: DayInput, amount: number): string
22
35
  export function subMonths(date: DayInput, amount: number): string
23
36
  export function addYears(date: DayInput, amount: number): string
24
37
  export function subYears(date: DayInput, amount: number): string
38
+ export function addQuarters(date: DayInput, amount: number): string
39
+ export function subQuarters(date: DayInput, amount: number): string
40
+
41
+ /** Full year number. */
42
+ export function getYear(date: DayInput): number
43
+ /** Month index like Date/date-fns: 0 = January … 11 = December. */
44
+ export function getMonth(date: DayInput): number
45
+ /** Day of month 1…31. */
46
+ export function getDate(date: DayInput): number
47
+ /** Weekday like Date/date-fns: 0 = Sunday … 6 = Saturday. */
48
+ export function getDay(date: DayInput): number
49
+ export function getDayOfYear(date: DayInput): number
50
+ export function getDaysInMonth(date: DayInput): number
51
+ /** Quarter 1…4. */
52
+ export function getQuarter(date: DayInput): number
53
+ export function isLeapYear(date: DayInput): boolean
54
+
55
+ export function setYear(date: DayInput, year: number): string
56
+ /** `month`: 0 = January … 11 = December (date-fns). */
57
+ export function setMonth(date: DayInput, month: number): string
58
+ export function setDate(date: DayInput, dayOfMonth: number): string
59
+
60
+ export function startOfMonth(date: DayInput): string
61
+ export function endOfMonth(date: DayInput): string
62
+ export function startOfYear(date: DayInput): string
63
+ export function endOfYear(date: DayInput): string
64
+ export function startOfQuarter(date: DayInput): string
65
+ export function endOfQuarter(date: DayInput): string
66
+ export function startOfWeek(date: DayInput, options?: WeekOptions): string
67
+ export function endOfWeek(date: DayInput, options?: WeekOptions): string
25
68
 
26
69
  /** Full days: `dateLeft − dateRight` (date-fns argument order). */
27
70
  export function differenceInDays(dateLeft: DayInput, dateRight: DayInput): number
71
+ export function differenceInWeeks(dateLeft: DayInput, dateRight: DayInput): number
72
+ export function differenceInMonths(dateLeft: DayInput, dateRight: DayInput): number
73
+ export function differenceInCalendarMonths(dateLeft: DayInput, dateRight: DayInput): number
74
+ export function differenceInYears(dateLeft: DayInput, dateRight: DayInput): number
75
+ export function differenceInCalendarYears(dateLeft: DayInput, dateRight: DayInput): number
76
+ export function differenceInQuarters(dateLeft: DayInput, dateRight: DayInput): number
77
+ export function differenceInCalendarQuarters(dateLeft: DayInput, dateRight: DayInput): number
28
78
 
29
79
  export function isBefore(date: DayInput, dateToCompare: DayInput): boolean
30
80
  export function isAfter(date: DayInput, dateToCompare: DayInput): boolean
81
+ /** Same calendar day. */
31
82
  export function isEqual(dateLeft: DayInput, dateRight: DayInput): boolean
83
+ /** Alias of `isEqual` (date-fns name). */
84
+ export const isSameDay: typeof isEqual
85
+
86
+ export function isSameWeek(
87
+ dateLeft: DayInput,
88
+ dateRight: DayInput,
89
+ options?: WeekOptions,
90
+ ): boolean
91
+ export function isSameMonth(dateLeft: DayInput, dateRight: DayInput): boolean
92
+ export function isSameYear(dateLeft: DayInput, dateRight: DayInput): boolean
93
+ export function isSameQuarter(dateLeft: DayInput, dateRight: DayInput): boolean
32
94
 
33
95
  export function compareAsc(dateLeft: DayInput, dateRight: DayInput): -1 | 0 | 1
34
96
  export function compareDesc(dateLeft: DayInput, dateRight: DayInput): -1 | 0 | 1
35
97
 
36
98
  export function min(dates: DayInput[]): string
37
99
  export function max(dates: DayInput[]): string
100
+
101
+ export function isSunday(date: DayInput): boolean
102
+ export function isMonday(date: DayInput): boolean
103
+ export function isTuesday(date: DayInput): boolean
104
+ export function isWednesday(date: DayInput): boolean
105
+ export function isThursday(date: DayInput): boolean
106
+ export function isFriday(date: DayInput): boolean
107
+ export function isSaturday(date: DayInput): boolean
108
+ export function isWeekend(date: DayInput): boolean
109
+ export function isFirstDayOfMonth(date: DayInput): boolean
110
+ export function isLastDayOfMonth(date: DayInput): boolean
111
+
112
+ export function eachDayOfInterval(interval: Interval): string[]
113
+ export function eachMonthOfInterval(interval: Interval): string[]
114
+ export function eachYearOfInterval(interval: Interval): string[]
115
+ export function isWithinInterval(date: DayInput, interval: Interval): boolean
116
+ export function clamp(date: DayInput, interval: Interval): string
117
+ /**
118
+ * date-fns default: `inclusive: false` (touching endpoints only is not overlap).
119
+ * Pass `{ inclusive: true }` for closed intervals.
120
+ */
121
+ export function areIntervalsOverlapping(
122
+ intervalLeft: Interval,
123
+ intervalRight: Interval,
124
+ options?: { inclusive?: boolean },
125
+ ): boolean
package/index.js CHANGED
@@ -4,6 +4,17 @@ import { Temporal as TemporalPolyfill } from 'temporal-polyfill'
4
4
  const Temporal = globalThis.Temporal ?? TemporalPolyfill
5
5
 
6
6
  /** @typedef {string | Temporal.PlainDate} DayInput */
7
+ /**
8
+ * @typedef {object} Interval
9
+ * @property {DayInput} start
10
+ * @property {DayInput} end
11
+ */
12
+ /**
13
+ * @typedef {object} WeekOptions
14
+ * @property {0|1|2|3|4|5|6} [weekStartsOn] 0=Sun … 6=Sat (date-fns default 0)
15
+ */
16
+
17
+ // ─── core conversion ───────────────────────────────────────────────
7
18
 
8
19
  /**
9
20
  * Reject Date and non-calendar values. Accept YYYY-MM-DD string or PlainDate.
@@ -18,7 +29,7 @@ function toPlainDate(value, label = 'date') {
18
29
  )
19
30
  }
20
31
  if (typeof value === 'string') {
21
- // Strict calendar day: no time, no offset, no week dates.
32
+ // Strict ISO 8601 calendar date: YYYY-MM-DD only.
22
33
  if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
23
34
  throw new RangeError(
24
35
  `daymath: ${label} must be YYYY-MM-DD (got ${JSON.stringify(value)})`,
@@ -45,6 +56,55 @@ function toDayString(plain) {
45
56
  return plain.toString()
46
57
  }
47
58
 
59
+ /** Temporal ISO weekday 1=Mon…7=Sun → JS/date-fns 0=Sun…6=Sat */
60
+ function isoToJsWeekday(isoDayOfWeek) {
61
+ return isoDayOfWeek === 7 ? 0 : isoDayOfWeek
62
+ }
63
+
64
+ /** @param {unknown} n @param {string} label */
65
+ function assertFiniteNumber(n, label) {
66
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
67
+ throw new TypeError(`daymath: ${label} must be a finite number`)
68
+ }
69
+ if (!Number.isInteger(n)) {
70
+ throw new RangeError(`daymath: ${label} must be an integer`)
71
+ }
72
+ }
73
+
74
+ /** @param {unknown} dates */
75
+ function assertNonEmptyDates(dates) {
76
+ if (!Array.isArray(dates) || dates.length === 0) {
77
+ throw new RangeError('daymath: expected a non-empty array of dates')
78
+ }
79
+ }
80
+
81
+ /**
82
+ * @param {WeekOptions} [options]
83
+ * @returns {0|1|2|3|4|5|6}
84
+ */
85
+ function weekStartsOnFrom(options) {
86
+ const w = options?.weekStartsOn ?? 0
87
+ if (!Number.isInteger(w) || w < 0 || w > 6) {
88
+ throw new RangeError('daymath: weekStartsOn must be an integer 0…6 (0=Sun)')
89
+ }
90
+ return /** @type {0|1|2|3|4|5|6} */ (w)
91
+ }
92
+
93
+ /**
94
+ * @param {unknown} interval
95
+ * @returns {{ start: Temporal.PlainDate, end: Temporal.PlainDate }}
96
+ */
97
+ function toInterval(interval) {
98
+ if (interval == null || typeof interval !== 'object') {
99
+ throw new TypeError('daymath: interval must be { start, end }')
100
+ }
101
+ const start = toPlainDate(/** @type {Interval} */ (interval).start, 'start')
102
+ const end = toPlainDate(/** @type {Interval} */ (interval).end, 'end')
103
+ return { start, end }
104
+ }
105
+
106
+ // ─── parse / format / valid ────────────────────────────────────────
107
+
48
108
  /** @param {unknown} value */
49
109
  export function isValid(value) {
50
110
  try {
@@ -56,7 +116,7 @@ export function isValid(value) {
56
116
  }
57
117
 
58
118
  /**
59
- * Validate / normalize a calendar day string.
119
+ * Validate / normalize a calendar day string (ISO 8601 YYYY-MM-DD).
60
120
  * @param {DayInput} date
61
121
  * @returns {string}
62
122
  */
@@ -65,7 +125,7 @@ export function parse(date) {
65
125
  }
66
126
 
67
127
  /**
68
- * Format as YYYY-MM-DD (only supported pattern for now).
128
+ * Format as YYYY-MM-DD (only supported pattern).
69
129
  * @param {DayInput} date
70
130
  * @param {string} [pattern='yyyy-MM-dd']
71
131
  * @returns {string}
@@ -79,6 +139,8 @@ export function format(date, pattern = 'yyyy-MM-dd') {
79
139
  return toDayString(toPlainDate(date))
80
140
  }
81
141
 
142
+ // ─── add / sub ─────────────────────────────────────────────────────
143
+
82
144
  /**
83
145
  * @param {DayInput} date
84
146
  * @param {number} amount
@@ -120,7 +182,7 @@ export function subWeeks(date, amount) {
120
182
  }
121
183
 
122
184
  /**
123
- * Calendar months (Temporal overflow: constrain — e.g. Jan 31 + 1 month → Feb 28/29).
185
+ * Calendar months (overflow constrain — Jan 31 + 1 month → Feb 28/29).
124
186
  * @param {DayInput} date
125
187
  * @param {number} amount
126
188
  * @returns {string}
@@ -160,6 +222,174 @@ export function subYears(date, amount) {
160
222
  return addYears(date, -amount)
161
223
  }
162
224
 
225
+ /**
226
+ * @param {DayInput} date
227
+ * @param {number} amount
228
+ * @returns {string}
229
+ */
230
+ export function addQuarters(date, amount) {
231
+ assertFiniteNumber(amount, 'amount')
232
+ return addMonths(date, amount * 3)
233
+ }
234
+
235
+ /**
236
+ * @param {DayInput} date
237
+ * @param {number} amount
238
+ * @returns {string}
239
+ */
240
+ export function subQuarters(date, amount) {
241
+ assertFiniteNumber(amount, 'amount')
242
+ return addQuarters(date, -amount)
243
+ }
244
+
245
+ // ─── getters / setters (date-fns / Date month & weekday indexing) ─
246
+
247
+ /** @param {DayInput} date @returns {number} */
248
+ export function getYear(date) {
249
+ return toPlainDate(date).year
250
+ }
251
+
252
+ /**
253
+ * Month index like Date/date-fns: 0 = January … 11 = December.
254
+ * @param {DayInput} date
255
+ * @returns {number}
256
+ */
257
+ export function getMonth(date) {
258
+ return toPlainDate(date).month - 1
259
+ }
260
+
261
+ /** Day of month 1…31. @param {DayInput} date @returns {number} */
262
+ export function getDate(date) {
263
+ return toPlainDate(date).day
264
+ }
265
+
266
+ /**
267
+ * Weekday like Date/date-fns: 0 = Sunday … 6 = Saturday.
268
+ * @param {DayInput} date
269
+ * @returns {number}
270
+ */
271
+ export function getDay(date) {
272
+ return isoToJsWeekday(toPlainDate(date).dayOfWeek)
273
+ }
274
+
275
+ /** @param {DayInput} date @returns {number} */
276
+ export function getDayOfYear(date) {
277
+ return toPlainDate(date).dayOfYear
278
+ }
279
+
280
+ /** @param {DayInput} date @returns {number} */
281
+ export function getDaysInMonth(date) {
282
+ return toPlainDate(date).daysInMonth
283
+ }
284
+
285
+ /** Quarter 1…4. @param {DayInput} date @returns {number} */
286
+ export function getQuarter(date) {
287
+ return Math.ceil(toPlainDate(date).month / 3)
288
+ }
289
+
290
+ /** @param {DayInput} date @returns {boolean} */
291
+ export function isLeapYear(date) {
292
+ return toPlainDate(date).inLeapYear
293
+ }
294
+
295
+ /**
296
+ * @param {DayInput} date
297
+ * @param {number} year
298
+ * @returns {string}
299
+ */
300
+ export function setYear(date, year) {
301
+ assertFiniteNumber(year, 'year')
302
+ return toDayString(toPlainDate(date).with({ year }))
303
+ }
304
+
305
+ /**
306
+ * @param {DayInput} date
307
+ * @param {number} month 0 = January … 11 = December (date-fns)
308
+ * @returns {string}
309
+ */
310
+ export function setMonth(date, month) {
311
+ assertFiniteNumber(month, 'month')
312
+ if (month < 0 || month > 11) {
313
+ throw new RangeError('daymath: month must be 0…11 (0=January)')
314
+ }
315
+ return toDayString(toPlainDate(date).with({ month: month + 1 }))
316
+ }
317
+
318
+ /**
319
+ * @param {DayInput} date
320
+ * @param {number} dayOfMonth
321
+ * @returns {string}
322
+ */
323
+ export function setDate(date, dayOfMonth) {
324
+ assertFiniteNumber(dayOfMonth, 'day')
325
+ return toDayString(toPlainDate(date).with({ day: dayOfMonth }))
326
+ }
327
+
328
+ // ─── start / end of unit ───────────────────────────────────────────
329
+
330
+ /** @param {DayInput} date @returns {string} */
331
+ export function startOfMonth(date) {
332
+ const d = toPlainDate(date)
333
+ return toDayString(d.with({ day: 1 }))
334
+ }
335
+
336
+ /** @param {DayInput} date @returns {string} */
337
+ export function endOfMonth(date) {
338
+ const d = toPlainDate(date)
339
+ return toDayString(d.with({ day: d.daysInMonth }))
340
+ }
341
+
342
+ /** @param {DayInput} date @returns {string} */
343
+ export function startOfYear(date) {
344
+ const d = toPlainDate(date)
345
+ return toDayString(d.with({ month: 1, day: 1 }))
346
+ }
347
+
348
+ /** @param {DayInput} date @returns {string} */
349
+ export function endOfYear(date) {
350
+ const d = toPlainDate(date)
351
+ return toDayString(d.with({ month: 12, day: 31 }))
352
+ }
353
+
354
+ /** @param {DayInput} date @returns {string} */
355
+ export function startOfQuarter(date) {
356
+ const d = toPlainDate(date)
357
+ const month = (getQuarter(d) - 1) * 3 + 1
358
+ return toDayString(d.with({ month, day: 1 }))
359
+ }
360
+
361
+ /** @param {DayInput} date @returns {string} */
362
+ export function endOfQuarter(date) {
363
+ const d = toPlainDate(date)
364
+ const month = getQuarter(d) * 3
365
+ const mid = d.with({ month, day: 1 })
366
+ return toDayString(mid.with({ day: mid.daysInMonth }))
367
+ }
368
+
369
+ /**
370
+ * @param {DayInput} date
371
+ * @param {WeekOptions} [options]
372
+ * @returns {string}
373
+ */
374
+ export function startOfWeek(date, options) {
375
+ const d = toPlainDate(date)
376
+ const weekStartsOn = weekStartsOnFrom(options)
377
+ const day = isoToJsWeekday(d.dayOfWeek)
378
+ const diff = (day - weekStartsOn + 7) % 7
379
+ return toDayString(d.subtract({ days: diff }))
380
+ }
381
+
382
+ /**
383
+ * @param {DayInput} date
384
+ * @param {WeekOptions} [options]
385
+ * @returns {string}
386
+ */
387
+ export function endOfWeek(date, options) {
388
+ return addDays(startOfWeek(date, options), 6)
389
+ }
390
+
391
+ // ─── differences ───────────────────────────────────────────────────
392
+
163
393
  /**
164
394
  * Full calendar days: dateLeft − dateRight (date-fns order).
165
395
  * @param {DayInput} dateLeft
@@ -172,6 +402,89 @@ export function differenceInDays(dateLeft, dateRight) {
172
402
  return left.since(right, { largestUnit: 'day' }).days
173
403
  }
174
404
 
405
+ /**
406
+ * Full weeks (trunc toward 0), like date-fns.
407
+ * @param {DayInput} dateLeft
408
+ * @param {DayInput} dateRight
409
+ * @returns {number}
410
+ */
411
+ export function differenceInWeeks(dateLeft, dateRight) {
412
+ return Math.trunc(differenceInDays(dateLeft, dateRight) / 7)
413
+ }
414
+
415
+ /**
416
+ * Full months (signed), Temporal since with largestUnit month.
417
+ * @param {DayInput} dateLeft
418
+ * @param {DayInput} dateRight
419
+ * @returns {number}
420
+ */
421
+ export function differenceInMonths(dateLeft, dateRight) {
422
+ const left = toPlainDate(dateLeft, 'dateLeft')
423
+ const right = toPlainDate(dateRight, 'dateRight')
424
+ const dur = left.since(right, { largestUnit: 'month' })
425
+ return dur.months
426
+ }
427
+
428
+ /**
429
+ * Calendar month index diff: (yL−yR)*12 + (mL−mR). Ignores day-of-month.
430
+ * @param {DayInput} dateLeft
431
+ * @param {DayInput} dateRight
432
+ * @returns {number}
433
+ */
434
+ export function differenceInCalendarMonths(dateLeft, dateRight) {
435
+ const left = toPlainDate(dateLeft, 'dateLeft')
436
+ const right = toPlainDate(dateRight, 'dateRight')
437
+ return (left.year - right.year) * 12 + (left.month - right.month)
438
+ }
439
+
440
+ /**
441
+ * Full years (signed).
442
+ * @param {DayInput} dateLeft
443
+ * @param {DayInput} dateRight
444
+ * @returns {number}
445
+ */
446
+ export function differenceInYears(dateLeft, dateRight) {
447
+ const left = toPlainDate(dateLeft, 'dateLeft')
448
+ const right = toPlainDate(dateRight, 'dateRight')
449
+ return left.since(right, { largestUnit: 'year' }).years
450
+ }
451
+
452
+ /**
453
+ * Calendar year number diff. Ignores month/day.
454
+ * @param {DayInput} dateLeft
455
+ * @param {DayInput} dateRight
456
+ * @returns {number}
457
+ */
458
+ export function differenceInCalendarYears(dateLeft, dateRight) {
459
+ return getYear(dateLeft) - getYear(dateRight)
460
+ }
461
+
462
+ /**
463
+ * Full quarters (trunc toward 0 of calendar-month/3 style via months).
464
+ * @param {DayInput} dateLeft
465
+ * @param {DayInput} dateRight
466
+ * @returns {number}
467
+ */
468
+ export function differenceInQuarters(dateLeft, dateRight) {
469
+ return Math.trunc(differenceInMonths(dateLeft, dateRight) / 3)
470
+ }
471
+
472
+ /**
473
+ * Calendar quarter index diff.
474
+ * @param {DayInput} dateLeft
475
+ * @param {DayInput} dateRight
476
+ * @returns {number}
477
+ */
478
+ export function differenceInCalendarQuarters(dateLeft, dateRight) {
479
+ const left = toPlainDate(dateLeft, 'dateLeft')
480
+ const right = toPlainDate(dateRight, 'dateRight')
481
+ return (
482
+ (left.year - right.year) * 4 + (getQuarter(left) - getQuarter(right))
483
+ )
484
+ }
485
+
486
+ // ─── compare / equal ───────────────────────────────────────────────
487
+
175
488
  /**
176
489
  * @param {DayInput} date
177
490
  * @param {DayInput} dateToCompare
@@ -201,6 +514,7 @@ export function isAfter(date, dateToCompare) {
201
514
  }
202
515
 
203
516
  /**
517
+ * Same calendar day.
204
518
  * @param {DayInput} dateLeft
205
519
  * @param {DayInput} dateRight
206
520
  * @returns {boolean}
@@ -214,6 +528,50 @@ export function isEqual(dateLeft, dateRight) {
214
528
  )
215
529
  }
216
530
 
531
+ /** Alias of `isEqual` (date-fns name for same calendar day). */
532
+ export const isSameDay = isEqual
533
+
534
+ /**
535
+ * @param {DayInput} dateLeft
536
+ * @param {DayInput} dateRight
537
+ * @param {WeekOptions} [options]
538
+ * @returns {boolean}
539
+ */
540
+ export function isSameWeek(dateLeft, dateRight, options) {
541
+ return isEqual(startOfWeek(dateLeft, options), startOfWeek(dateRight, options))
542
+ }
543
+
544
+ /**
545
+ * @param {DayInput} dateLeft
546
+ * @param {DayInput} dateRight
547
+ * @returns {boolean}
548
+ */
549
+ export function isSameMonth(dateLeft, dateRight) {
550
+ const a = toPlainDate(dateLeft, 'dateLeft')
551
+ const b = toPlainDate(dateRight, 'dateRight')
552
+ return a.year === b.year && a.month === b.month
553
+ }
554
+
555
+ /**
556
+ * @param {DayInput} dateLeft
557
+ * @param {DayInput} dateRight
558
+ * @returns {boolean}
559
+ */
560
+ export function isSameYear(dateLeft, dateRight) {
561
+ return getYear(dateLeft) === getYear(dateRight)
562
+ }
563
+
564
+ /**
565
+ * @param {DayInput} dateLeft
566
+ * @param {DayInput} dateRight
567
+ * @returns {boolean}
568
+ */
569
+ export function isSameQuarter(dateLeft, dateRight) {
570
+ const a = toPlainDate(dateLeft, 'dateLeft')
571
+ const b = toPlainDate(dateRight, 'dateRight')
572
+ return a.year === b.year && getQuarter(a) === getQuarter(b)
573
+ }
574
+
217
575
  /**
218
576
  * @param {DayInput} dateLeft
219
577
  * @param {DayInput} dateRight
@@ -244,7 +602,9 @@ export function compareDesc(dateLeft, dateRight) {
244
602
  export function min(dates) {
245
603
  assertNonEmptyDates(dates)
246
604
  return toDayString(
247
- dates.map((d) => toPlainDate(d)).reduce((a, b) => (Temporal.PlainDate.compare(a, b) <= 0 ? a : b)),
605
+ dates
606
+ .map((d) => toPlainDate(d))
607
+ .reduce((a, b) => (Temporal.PlainDate.compare(a, b) <= 0 ? a : b)),
248
608
  )
249
609
  }
250
610
 
@@ -255,23 +615,183 @@ export function min(dates) {
255
615
  export function max(dates) {
256
616
  assertNonEmptyDates(dates)
257
617
  return toDayString(
258
- dates.map((d) => toPlainDate(d)).reduce((a, b) => (Temporal.PlainDate.compare(a, b) >= 0 ? a : b)),
618
+ dates
619
+ .map((d) => toPlainDate(d))
620
+ .reduce((a, b) => (Temporal.PlainDate.compare(a, b) >= 0 ? a : b)),
259
621
  )
260
622
  }
261
623
 
262
- /** @param {unknown} n @param {string} label */
263
- function assertFiniteNumber(n, label) {
264
- if (typeof n !== 'number' || !Number.isFinite(n)) {
265
- throw new TypeError(`daymath: ${label} must be a finite number`)
624
+ // ─── weekday predicates ────────────────────────────────────────────
625
+
626
+ /** @param {DayInput} date @returns {boolean} */
627
+ export function isSunday(date) {
628
+ return getDay(date) === 0
629
+ }
630
+ /** @param {DayInput} date @returns {boolean} */
631
+ export function isMonday(date) {
632
+ return getDay(date) === 1
633
+ }
634
+ /** @param {DayInput} date @returns {boolean} */
635
+ export function isTuesday(date) {
636
+ return getDay(date) === 2
637
+ }
638
+ /** @param {DayInput} date @returns {boolean} */
639
+ export function isWednesday(date) {
640
+ return getDay(date) === 3
641
+ }
642
+ /** @param {DayInput} date @returns {boolean} */
643
+ export function isThursday(date) {
644
+ return getDay(date) === 4
645
+ }
646
+ /** @param {DayInput} date @returns {boolean} */
647
+ export function isFriday(date) {
648
+ return getDay(date) === 5
649
+ }
650
+ /** @param {DayInput} date @returns {boolean} */
651
+ export function isSaturday(date) {
652
+ return getDay(date) === 6
653
+ }
654
+ /** @param {DayInput} date @returns {boolean} */
655
+ export function isWeekend(date) {
656
+ const d = getDay(date)
657
+ return d === 0 || d === 6
658
+ }
659
+
660
+ /** @param {DayInput} date @returns {boolean} */
661
+ export function isFirstDayOfMonth(date) {
662
+ return getDate(date) === 1
663
+ }
664
+
665
+ /** @param {DayInput} date @returns {boolean} */
666
+ export function isLastDayOfMonth(date) {
667
+ const d = toPlainDate(date)
668
+ return d.day === d.daysInMonth
669
+ }
670
+
671
+ // ─── intervals ─────────────────────────────────────────────────────
672
+
673
+ /**
674
+ * Inclusive day range. Throws if start > end.
675
+ * @param {Interval} interval
676
+ * @returns {string[]}
677
+ */
678
+ export function eachDayOfInterval(interval) {
679
+ const { start, end } = toInterval(interval)
680
+ if (Temporal.PlainDate.compare(start, end) > 0) {
681
+ throw new RangeError('daymath: interval start must not be after end')
266
682
  }
267
- if (!Number.isInteger(n)) {
268
- throw new RangeError(`daymath: ${label} must be an integer`)
683
+ /** @type {string[]} */
684
+ const out = []
685
+ let cur = start
686
+ while (Temporal.PlainDate.compare(cur, end) <= 0) {
687
+ out.push(toDayString(cur))
688
+ cur = cur.add({ days: 1 })
269
689
  }
690
+ return out
270
691
  }
271
692
 
272
- /** @param {unknown} dates */
273
- function assertNonEmptyDates(dates) {
274
- if (!Array.isArray(dates) || dates.length === 0) {
275
- throw new RangeError('daymath: expected a non-empty array of dates')
693
+ /**
694
+ * First day of each month from start’s month through end’s month (date-fns shape).
695
+ * @param {Interval} interval
696
+ * @returns {string[]}
697
+ */
698
+ export function eachMonthOfInterval(interval) {
699
+ const { start, end } = toInterval(interval)
700
+ if (Temporal.PlainDate.compare(start, end) > 0) {
701
+ throw new RangeError('daymath: interval start must not be after end')
276
702
  }
703
+ /** @type {string[]} */
704
+ const out = []
705
+ let cur = start.with({ day: 1 })
706
+ const last = end.with({ day: 1 })
707
+ while (Temporal.PlainDate.compare(cur, last) <= 0) {
708
+ out.push(toDayString(cur))
709
+ cur = cur.add({ months: 1 })
710
+ }
711
+ return out
712
+ }
713
+
714
+ /**
715
+ * Jan 1 of each year from start’s year through end’s year.
716
+ * @param {Interval} interval
717
+ * @returns {string[]}
718
+ */
719
+ export function eachYearOfInterval(interval) {
720
+ const { start, end } = toInterval(interval)
721
+ if (Temporal.PlainDate.compare(start, end) > 0) {
722
+ throw new RangeError('daymath: interval start must not be after end')
723
+ }
724
+ /** @type {string[]} */
725
+ const out = []
726
+ let y = start.year
727
+ while (y <= end.year) {
728
+ out.push(toDayString(Temporal.PlainDate.from({ year: y, month: 1, day: 1 })))
729
+ y += 1
730
+ }
731
+ return out
732
+ }
733
+
734
+ /**
735
+ * Inclusive: start ≤ date ≤ end.
736
+ * @param {DayInput} date
737
+ * @param {Interval} interval
738
+ * @returns {boolean}
739
+ */
740
+ export function isWithinInterval(date, interval) {
741
+ const d = toPlainDate(date)
742
+ const { start, end } = toInterval(interval)
743
+ if (Temporal.PlainDate.compare(start, end) > 0) {
744
+ throw new RangeError('daymath: interval start must not be after end')
745
+ }
746
+ return (
747
+ Temporal.PlainDate.compare(d, start) >= 0 &&
748
+ Temporal.PlainDate.compare(d, end) <= 0
749
+ )
750
+ }
751
+
752
+ /**
753
+ * Clamp date into [start, end].
754
+ * @param {DayInput} date
755
+ * @param {Interval} interval
756
+ * @returns {string}
757
+ */
758
+ export function clamp(date, interval) {
759
+ const d = toPlainDate(date)
760
+ const { start, end } = toInterval(interval)
761
+ if (Temporal.PlainDate.compare(start, end) > 0) {
762
+ throw new RangeError('daymath: interval start must not be after end')
763
+ }
764
+ if (Temporal.PlainDate.compare(d, start) < 0) return toDayString(start)
765
+ if (Temporal.PlainDate.compare(d, end) > 0) return toDayString(end)
766
+ return toDayString(d)
767
+ }
768
+
769
+ /**
770
+ * Whether two inclusive intervals overlap.
771
+ * @param {Interval} intervalLeft
772
+ * @param {Interval} intervalRight
773
+ * @param {{ inclusive?: boolean }} [options] default inclusive true (date-fns default false uses half-open; we default true for plain days)
774
+ * @returns {boolean}
775
+ */
776
+ export function areIntervalsOverlapping(intervalLeft, intervalRight, options) {
777
+ const a = toInterval(intervalLeft)
778
+ const b = toInterval(intervalRight)
779
+ if (Temporal.PlainDate.compare(a.start, a.end) > 0) {
780
+ throw new RangeError('daymath: intervalLeft start must not be after end')
781
+ }
782
+ if (Temporal.PlainDate.compare(b.start, b.end) > 0) {
783
+ throw new RangeError('daymath: intervalRight start must not be after end')
784
+ }
785
+ const inclusive = options?.inclusive ?? false
786
+ if (inclusive) {
787
+ return (
788
+ Temporal.PlainDate.compare(a.start, b.end) <= 0 &&
789
+ Temporal.PlainDate.compare(b.start, a.end) <= 0
790
+ )
791
+ }
792
+ // date-fns default: touch-at-endpoint is NOT overlap
793
+ return (
794
+ Temporal.PlainDate.compare(a.start, b.end) < 0 &&
795
+ Temporal.PlainDate.compare(b.start, a.end) < 0
796
+ )
277
797
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "daymath",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Calendar date math (YYYY-MM-DD / PlainDate). date-fns-shaped. No time zones.",
5
5
  "type": "module",
6
6
  "main": "./index.js",