daymath 0.1.0 → 0.2.1

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