daymath 0.0.1 → 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 +83 -6
  2. package/index.d.ts +125 -0
  3. package/index.js +796 -3
  4. package/package.json +20 -4
package/README.md CHANGED
@@ -1,18 +1,95 @@
1
1
  # daymath
2
2
 
3
- Calendar date math (`YYYY-MM-DD` / plain dates). date-fns-shaped. No time zones.
3
+ Calendar date math for **ISO 8601** `YYYY-MM-DD` strings. **date-fns-shaped** names. **Temporal.PlainDate** under the hood.
4
4
 
5
- **Status:** name reserved. API not shipped yet.
5
+ No `Date`. No time zones. No silent “local now.”
6
6
 
7
7
  ```bash
8
8
  npm install daymath
9
9
  ```
10
10
 
11
- ## Planned
11
+ ## Why
12
12
 
13
- - String-first date math (add/sub days, diffs, compare)
14
- - Temporal.PlainDate under the hood
15
- - No `Date`, no silent local-now surprises
13
+ `Date` is a timestamp. Calendar work (add one month”, “days between hire and start”) is not. This package only does plain calendar days.
14
+
15
+ ## Usage
16
+
17
+ ```js
18
+ import {
19
+ addDays,
20
+ addMonths,
21
+ differenceInDays,
22
+ isBefore,
23
+ isSameDay,
24
+ startOfMonth,
25
+ eachDayOfInterval,
26
+ } from 'daymath'
27
+
28
+ addDays('2026-08-06', 1) // '2026-08-07'
29
+ addMonths('2026-01-31', 1) // '2026-02-28' (constrain)
30
+ differenceInDays('2026-08-06', '2026-08-01') // 5
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']
38
+ ```
39
+
40
+ **Inputs:** ISO 8601 `YYYY-MM-DD` or `Temporal.PlainDate`.
41
+ **Outputs:** always `YYYY-MM-DD` string (for math helpers).
42
+
43
+ `Date` throws. Time-bearing strings throw. Sloppy forms like `2026-8-6` throw.
44
+
45
+ ## date-fns parity notes
46
+
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`
83
+
84
+ Amounts must be finite integers.
85
+
86
+ ## Temporal
87
+
88
+ Uses global `Temporal` when present; otherwise [`temporal-polyfill`](https://www.npmjs.com/package/temporal-polyfill).
89
+
90
+ ## Types
91
+
92
+ Ships `index.d.ts` (no TypeScript compile step). Source is plain JS.
16
93
 
17
94
  ## License
18
95
 
package/index.d.ts ADDED
@@ -0,0 +1,125 @@
1
+ import type { Temporal } from 'temporal-polyfill'
2
+
3
+ /**
4
+ * Calendar day input: ISO 8601 `YYYY-MM-DD` string, or a Temporal.PlainDate.
5
+ * `Date` is rejected at runtime.
6
+ */
7
+ export type DayInput = string | Temporal.PlainDate
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
+
22
+ export function isValid(value: unknown): boolean
23
+
24
+ /** Validate / normalize to ISO 8601 `YYYY-MM-DD`. */
25
+ export function parse(date: DayInput): string
26
+
27
+ /** Format as `YYYY-MM-DD` (only pattern supported). */
28
+ export function format(date: DayInput, pattern?: 'yyyy-MM-dd' | 'YYYY-MM-DD'): string
29
+
30
+ export function addDays(date: DayInput, amount: number): string
31
+ export function subDays(date: DayInput, amount: number): string
32
+ export function addWeeks(date: DayInput, amount: number): string
33
+ export function subWeeks(date: DayInput, amount: number): string
34
+ export function addMonths(date: DayInput, amount: number): string
35
+ export function subMonths(date: DayInput, amount: number): string
36
+ export function addYears(date: DayInput, amount: number): string
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
68
+
69
+ /** Full days: `dateLeft − dateRight` (date-fns argument order). */
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
78
+
79
+ export function isBefore(date: DayInput, dateToCompare: DayInput): boolean
80
+ export function isAfter(date: DayInput, dateToCompare: DayInput): boolean
81
+ /** Same calendar day. */
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
94
+
95
+ export function compareAsc(dateLeft: DayInput, dateRight: DayInput): -1 | 0 | 1
96
+ export function compareDesc(dateLeft: DayInput, dateRight: DayInput): -1 | 0 | 1
97
+
98
+ export function min(dates: DayInput[]): string
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
@@ -1,4 +1,797 @@
1
- /** daymath — reserved. Real API coming. */
2
- export function addDays(date, days) {
3
- throw new Error('daymath: not implemented yet (name reserved).')
1
+ /** daymath — calendar date math (YYYY-MM-DD). date-fns-shaped. No Date / time zones. */
2
+ import { Temporal as TemporalPolyfill } from 'temporal-polyfill'
3
+
4
+ const Temporal = globalThis.Temporal ?? TemporalPolyfill
5
+
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 ───────────────────────────────────────────────
18
+
19
+ /**
20
+ * Reject Date and non-calendar values. Accept YYYY-MM-DD string or PlainDate.
21
+ * @param {unknown} value
22
+ * @param {string} label
23
+ * @returns {Temporal.PlainDate}
24
+ */
25
+ function toPlainDate(value, label = 'date') {
26
+ if (value instanceof Date) {
27
+ throw new TypeError(
28
+ `daymath: Date is not allowed for ${label} (pass YYYY-MM-DD string)`,
29
+ )
30
+ }
31
+ if (typeof value === 'string') {
32
+ // Strict ISO 8601 calendar date: YYYY-MM-DD only.
33
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
34
+ throw new RangeError(
35
+ `daymath: ${label} must be YYYY-MM-DD (got ${JSON.stringify(value)})`,
36
+ )
37
+ }
38
+ try {
39
+ return Temporal.PlainDate.from(value)
40
+ } catch (err) {
41
+ throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(value)}`, {
42
+ cause: err,
43
+ })
44
+ }
45
+ }
46
+ if (value instanceof Temporal.PlainDate) {
47
+ return value
48
+ }
49
+ throw new TypeError(
50
+ `daymath: ${label} must be YYYY-MM-DD string or Temporal.PlainDate`,
51
+ )
52
+ }
53
+
54
+ /** @param {Temporal.PlainDate} plain @returns {string} */
55
+ function toDayString(plain) {
56
+ return plain.toString()
57
+ }
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
+
108
+ /** @param {unknown} value */
109
+ export function isValid(value) {
110
+ try {
111
+ toPlainDate(value)
112
+ return true
113
+ } catch {
114
+ return false
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Validate / normalize a calendar day string (ISO 8601 YYYY-MM-DD).
120
+ * @param {DayInput} date
121
+ * @returns {string}
122
+ */
123
+ export function parse(date) {
124
+ return toDayString(toPlainDate(date))
125
+ }
126
+
127
+ /**
128
+ * Format as YYYY-MM-DD (only supported pattern).
129
+ * @param {DayInput} date
130
+ * @param {string} [pattern='yyyy-MM-dd']
131
+ * @returns {string}
132
+ */
133
+ export function format(date, pattern = 'yyyy-MM-dd') {
134
+ if (pattern !== 'yyyy-MM-dd' && pattern !== 'YYYY-MM-DD') {
135
+ throw new RangeError(
136
+ `daymath: only "yyyy-MM-dd" format is supported (got ${JSON.stringify(pattern)})`,
137
+ )
138
+ }
139
+ return toDayString(toPlainDate(date))
140
+ }
141
+
142
+ // ─── add / sub ─────────────────────────────────────────────────────
143
+
144
+ /**
145
+ * @param {DayInput} date
146
+ * @param {number} amount
147
+ * @returns {string}
148
+ */
149
+ export function addDays(date, amount) {
150
+ assertFiniteNumber(amount, 'amount')
151
+ return toDayString(toPlainDate(date).add({ days: amount }))
152
+ }
153
+
154
+ /**
155
+ * @param {DayInput} date
156
+ * @param {number} amount
157
+ * @returns {string}
158
+ */
159
+ export function subDays(date, amount) {
160
+ assertFiniteNumber(amount, 'amount')
161
+ return addDays(date, -amount)
162
+ }
163
+
164
+ /**
165
+ * @param {DayInput} date
166
+ * @param {number} amount
167
+ * @returns {string}
168
+ */
169
+ export function addWeeks(date, amount) {
170
+ assertFiniteNumber(amount, 'amount')
171
+ return addDays(date, amount * 7)
172
+ }
173
+
174
+ /**
175
+ * @param {DayInput} date
176
+ * @param {number} amount
177
+ * @returns {string}
178
+ */
179
+ export function subWeeks(date, amount) {
180
+ assertFiniteNumber(amount, 'amount')
181
+ return addWeeks(date, -amount)
182
+ }
183
+
184
+ /**
185
+ * Calendar months (overflow constrain — Jan 31 + 1 month → Feb 28/29).
186
+ * @param {DayInput} date
187
+ * @param {number} amount
188
+ * @returns {string}
189
+ */
190
+ export function addMonths(date, amount) {
191
+ assertFiniteNumber(amount, 'amount')
192
+ return toDayString(toPlainDate(date).add({ months: amount }))
193
+ }
194
+
195
+ /**
196
+ * @param {DayInput} date
197
+ * @param {number} amount
198
+ * @returns {string}
199
+ */
200
+ export function subMonths(date, amount) {
201
+ assertFiniteNumber(amount, 'amount')
202
+ return addMonths(date, -amount)
203
+ }
204
+
205
+ /**
206
+ * @param {DayInput} date
207
+ * @param {number} amount
208
+ * @returns {string}
209
+ */
210
+ export function addYears(date, amount) {
211
+ assertFiniteNumber(amount, 'amount')
212
+ return toDayString(toPlainDate(date).add({ years: amount }))
213
+ }
214
+
215
+ /**
216
+ * @param {DayInput} date
217
+ * @param {number} amount
218
+ * @returns {string}
219
+ */
220
+ export function subYears(date, amount) {
221
+ assertFiniteNumber(amount, 'amount')
222
+ return addYears(date, -amount)
223
+ }
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
+
393
+ /**
394
+ * Full calendar days: dateLeft − dateRight (date-fns order).
395
+ * @param {DayInput} dateLeft
396
+ * @param {DayInput} dateRight
397
+ * @returns {number}
398
+ */
399
+ export function differenceInDays(dateLeft, dateRight) {
400
+ const left = toPlainDate(dateLeft, 'dateLeft')
401
+ const right = toPlainDate(dateRight, 'dateRight')
402
+ return left.since(right, { largestUnit: 'day' }).days
403
+ }
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
+
488
+ /**
489
+ * @param {DayInput} date
490
+ * @param {DayInput} dateToCompare
491
+ * @returns {boolean}
492
+ */
493
+ export function isBefore(date, dateToCompare) {
494
+ return (
495
+ Temporal.PlainDate.compare(
496
+ toPlainDate(date),
497
+ toPlainDate(dateToCompare, 'dateToCompare'),
498
+ ) < 0
499
+ )
500
+ }
501
+
502
+ /**
503
+ * @param {DayInput} date
504
+ * @param {DayInput} dateToCompare
505
+ * @returns {boolean}
506
+ */
507
+ export function isAfter(date, dateToCompare) {
508
+ return (
509
+ Temporal.PlainDate.compare(
510
+ toPlainDate(date),
511
+ toPlainDate(dateToCompare, 'dateToCompare'),
512
+ ) > 0
513
+ )
514
+ }
515
+
516
+ /**
517
+ * Same calendar day.
518
+ * @param {DayInput} dateLeft
519
+ * @param {DayInput} dateRight
520
+ * @returns {boolean}
521
+ */
522
+ export function isEqual(dateLeft, dateRight) {
523
+ return (
524
+ Temporal.PlainDate.compare(
525
+ toPlainDate(dateLeft, 'dateLeft'),
526
+ toPlainDate(dateRight, 'dateRight'),
527
+ ) === 0
528
+ )
529
+ }
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
+
575
+ /**
576
+ * @param {DayInput} dateLeft
577
+ * @param {DayInput} dateRight
578
+ * @returns {-1 | 0 | 1}
579
+ */
580
+ export function compareAsc(dateLeft, dateRight) {
581
+ return /** @type {-1 | 0 | 1} */ (
582
+ Temporal.PlainDate.compare(
583
+ toPlainDate(dateLeft, 'dateLeft'),
584
+ toPlainDate(dateRight, 'dateRight'),
585
+ )
586
+ )
587
+ }
588
+
589
+ /**
590
+ * @param {DayInput} dateLeft
591
+ * @param {DayInput} dateRight
592
+ * @returns {-1 | 0 | 1}
593
+ */
594
+ export function compareDesc(dateLeft, dateRight) {
595
+ return /** @type {-1 | 0 | 1} */ (-compareAsc(dateLeft, dateRight))
596
+ }
597
+
598
+ /**
599
+ * @param {DayInput[]} dates
600
+ * @returns {string}
601
+ */
602
+ export function min(dates) {
603
+ assertNonEmptyDates(dates)
604
+ return toDayString(
605
+ dates
606
+ .map((d) => toPlainDate(d))
607
+ .reduce((a, b) => (Temporal.PlainDate.compare(a, b) <= 0 ? a : b)),
608
+ )
609
+ }
610
+
611
+ /**
612
+ * @param {DayInput[]} dates
613
+ * @returns {string}
614
+ */
615
+ export function max(dates) {
616
+ assertNonEmptyDates(dates)
617
+ return toDayString(
618
+ dates
619
+ .map((d) => toPlainDate(d))
620
+ .reduce((a, b) => (Temporal.PlainDate.compare(a, b) >= 0 ? a : b)),
621
+ )
622
+ }
623
+
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')
682
+ }
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 })
689
+ }
690
+ return out
691
+ }
692
+
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')
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
+ )
4
797
  }
package/package.json CHANGED
@@ -1,22 +1,32 @@
1
1
  {
2
2
  "name": "daymath",
3
- "version": "0.0.1",
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",
7
+ "types": "./index.d.ts",
7
8
  "exports": {
8
- ".": "./index.js"
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ }
9
13
  },
10
14
  "files": [
11
15
  "index.js",
16
+ "index.d.ts",
12
17
  "README.md"
13
18
  ],
19
+ "scripts": {
20
+ "test": "node --test test.js",
21
+ "prepublishOnly": "npm test"
22
+ },
14
23
  "keywords": [
15
24
  "date",
16
25
  "calendar",
17
26
  "plain-date",
18
27
  "temporal",
19
- "date-fns"
28
+ "date-fns",
29
+ "YYYY-MM-DD"
20
30
  ],
21
31
  "author": "leemr",
22
32
  "license": "MIT",
@@ -27,5 +37,11 @@
27
37
  "bugs": {
28
38
  "url": "https://github.com/leemr/daymath/issues"
29
39
  },
30
- "homepage": "https://github.com/leemr/daymath#readme"
40
+ "homepage": "https://github.com/leemr/daymath#readme",
41
+ "dependencies": {
42
+ "temporal-polyfill": "^1.0.3"
43
+ },
44
+ "engines": {
45
+ "node": ">=18"
46
+ }
31
47
  }