daymath 0.0.1 → 0.1.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 +51 -6
  2. package/index.d.ts +37 -0
  3. package/index.js +276 -3
  4. package/package.json +20 -4
package/README.md CHANGED
@@ -1,18 +1,63 @@
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 `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
+ parse,
24
+ } from 'daymath'
25
+
26
+ addDays('2026-08-06', 1) // '2026-08-07'
27
+ addMonths('2026-01-31', 1) // '2026-02-28' (constrain)
28
+ 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
+ ```
32
+
33
+ Inputs: `YYYY-MM-DD` string or `Temporal.PlainDate`.
34
+ Outputs: always `YYYY-MM-DD` string (for math helpers).
35
+
36
+ `Date` throws. Time-bearing ISO strings throw. Sloppy forms like `2026-8-6` throw.
37
+
38
+ ## API
39
+
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 |
51
+
52
+ Amounts must be finite integers.
53
+
54
+ ## Temporal
55
+
56
+ Uses global `Temporal` when present; otherwise [`temporal-polyfill`](https://www.npmjs.com/package/temporal-polyfill).
57
+
58
+ ## Types
59
+
60
+ Ships `index.d.ts` (no TypeScript compile step for authors). Source is plain JS.
16
61
 
17
62
  ## License
18
63
 
package/index.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { Temporal } from 'temporal-polyfill'
2
+
3
+ /**
4
+ * Calendar day input: `YYYY-MM-DD` string, or a Temporal.PlainDate.
5
+ * `Date` is rejected at runtime.
6
+ */
7
+ export type DayInput = string | Temporal.PlainDate
8
+
9
+ export function isValid(value: unknown): boolean
10
+
11
+ /** Validate / normalize to `YYYY-MM-DD`. */
12
+ export function parse(date: DayInput): string
13
+
14
+ /** Format as `YYYY-MM-DD` (only pattern supported). */
15
+ export function format(date: DayInput, pattern?: 'yyyy-MM-dd' | 'YYYY-MM-DD'): string
16
+
17
+ export function addDays(date: DayInput, amount: number): string
18
+ export function subDays(date: DayInput, amount: number): string
19
+ export function addWeeks(date: DayInput, amount: number): string
20
+ export function subWeeks(date: DayInput, amount: number): string
21
+ export function addMonths(date: DayInput, amount: number): string
22
+ export function subMonths(date: DayInput, amount: number): string
23
+ export function addYears(date: DayInput, amount: number): string
24
+ export function subYears(date: DayInput, amount: number): string
25
+
26
+ /** Full days: `dateLeft − dateRight` (date-fns argument order). */
27
+ export function differenceInDays(dateLeft: DayInput, dateRight: DayInput): number
28
+
29
+ export function isBefore(date: DayInput, dateToCompare: DayInput): boolean
30
+ export function isAfter(date: DayInput, dateToCompare: DayInput): boolean
31
+ export function isEqual(dateLeft: DayInput, dateRight: DayInput): boolean
32
+
33
+ export function compareAsc(dateLeft: DayInput, dateRight: DayInput): -1 | 0 | 1
34
+ export function compareDesc(dateLeft: DayInput, dateRight: DayInput): -1 | 0 | 1
35
+
36
+ export function min(dates: DayInput[]): string
37
+ export function max(dates: DayInput[]): string
package/index.js CHANGED
@@ -1,4 +1,277 @@
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
+ /**
9
+ * Reject Date and non-calendar values. Accept YYYY-MM-DD string or PlainDate.
10
+ * @param {unknown} value
11
+ * @param {string} label
12
+ * @returns {Temporal.PlainDate}
13
+ */
14
+ function toPlainDate(value, label = 'date') {
15
+ if (value instanceof Date) {
16
+ throw new TypeError(
17
+ `daymath: Date is not allowed for ${label} (pass YYYY-MM-DD string)`,
18
+ )
19
+ }
20
+ 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)) {
23
+ throw new RangeError(
24
+ `daymath: ${label} must be YYYY-MM-DD (got ${JSON.stringify(value)})`,
25
+ )
26
+ }
27
+ try {
28
+ return Temporal.PlainDate.from(value)
29
+ } catch (err) {
30
+ throw new RangeError(`daymath: invalid ${label} ${JSON.stringify(value)}`, {
31
+ cause: err,
32
+ })
33
+ }
34
+ }
35
+ if (value instanceof Temporal.PlainDate) {
36
+ return value
37
+ }
38
+ throw new TypeError(
39
+ `daymath: ${label} must be YYYY-MM-DD string or Temporal.PlainDate`,
40
+ )
41
+ }
42
+
43
+ /** @param {Temporal.PlainDate} plain @returns {string} */
44
+ function toDayString(plain) {
45
+ return plain.toString()
46
+ }
47
+
48
+ /** @param {unknown} value */
49
+ export function isValid(value) {
50
+ try {
51
+ toPlainDate(value)
52
+ return true
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Validate / normalize a calendar day string.
60
+ * @param {DayInput} date
61
+ * @returns {string}
62
+ */
63
+ export function parse(date) {
64
+ return toDayString(toPlainDate(date))
65
+ }
66
+
67
+ /**
68
+ * Format as YYYY-MM-DD (only supported pattern for now).
69
+ * @param {DayInput} date
70
+ * @param {string} [pattern='yyyy-MM-dd']
71
+ * @returns {string}
72
+ */
73
+ export function format(date, pattern = 'yyyy-MM-dd') {
74
+ if (pattern !== 'yyyy-MM-dd' && pattern !== 'YYYY-MM-DD') {
75
+ throw new RangeError(
76
+ `daymath: only "yyyy-MM-dd" format is supported (got ${JSON.stringify(pattern)})`,
77
+ )
78
+ }
79
+ return toDayString(toPlainDate(date))
80
+ }
81
+
82
+ /**
83
+ * @param {DayInput} date
84
+ * @param {number} amount
85
+ * @returns {string}
86
+ */
87
+ export function addDays(date, amount) {
88
+ assertFiniteNumber(amount, 'amount')
89
+ return toDayString(toPlainDate(date).add({ days: amount }))
90
+ }
91
+
92
+ /**
93
+ * @param {DayInput} date
94
+ * @param {number} amount
95
+ * @returns {string}
96
+ */
97
+ export function subDays(date, amount) {
98
+ assertFiniteNumber(amount, 'amount')
99
+ return addDays(date, -amount)
100
+ }
101
+
102
+ /**
103
+ * @param {DayInput} date
104
+ * @param {number} amount
105
+ * @returns {string}
106
+ */
107
+ export function addWeeks(date, amount) {
108
+ assertFiniteNumber(amount, 'amount')
109
+ return addDays(date, amount * 7)
110
+ }
111
+
112
+ /**
113
+ * @param {DayInput} date
114
+ * @param {number} amount
115
+ * @returns {string}
116
+ */
117
+ export function subWeeks(date, amount) {
118
+ assertFiniteNumber(amount, 'amount')
119
+ return addWeeks(date, -amount)
120
+ }
121
+
122
+ /**
123
+ * Calendar months (Temporal overflow: constrain — e.g. Jan 31 + 1 month → Feb 28/29).
124
+ * @param {DayInput} date
125
+ * @param {number} amount
126
+ * @returns {string}
127
+ */
128
+ export function addMonths(date, amount) {
129
+ assertFiniteNumber(amount, 'amount')
130
+ return toDayString(toPlainDate(date).add({ months: amount }))
131
+ }
132
+
133
+ /**
134
+ * @param {DayInput} date
135
+ * @param {number} amount
136
+ * @returns {string}
137
+ */
138
+ export function subMonths(date, amount) {
139
+ assertFiniteNumber(amount, 'amount')
140
+ return addMonths(date, -amount)
141
+ }
142
+
143
+ /**
144
+ * @param {DayInput} date
145
+ * @param {number} amount
146
+ * @returns {string}
147
+ */
148
+ export function addYears(date, amount) {
149
+ assertFiniteNumber(amount, 'amount')
150
+ return toDayString(toPlainDate(date).add({ years: amount }))
151
+ }
152
+
153
+ /**
154
+ * @param {DayInput} date
155
+ * @param {number} amount
156
+ * @returns {string}
157
+ */
158
+ export function subYears(date, amount) {
159
+ assertFiniteNumber(amount, 'amount')
160
+ return addYears(date, -amount)
161
+ }
162
+
163
+ /**
164
+ * Full calendar days: dateLeft − dateRight (date-fns order).
165
+ * @param {DayInput} dateLeft
166
+ * @param {DayInput} dateRight
167
+ * @returns {number}
168
+ */
169
+ export function differenceInDays(dateLeft, dateRight) {
170
+ const left = toPlainDate(dateLeft, 'dateLeft')
171
+ const right = toPlainDate(dateRight, 'dateRight')
172
+ return left.since(right, { largestUnit: 'day' }).days
173
+ }
174
+
175
+ /**
176
+ * @param {DayInput} date
177
+ * @param {DayInput} dateToCompare
178
+ * @returns {boolean}
179
+ */
180
+ export function isBefore(date, dateToCompare) {
181
+ return (
182
+ Temporal.PlainDate.compare(
183
+ toPlainDate(date),
184
+ toPlainDate(dateToCompare, 'dateToCompare'),
185
+ ) < 0
186
+ )
187
+ }
188
+
189
+ /**
190
+ * @param {DayInput} date
191
+ * @param {DayInput} dateToCompare
192
+ * @returns {boolean}
193
+ */
194
+ export function isAfter(date, dateToCompare) {
195
+ return (
196
+ Temporal.PlainDate.compare(
197
+ toPlainDate(date),
198
+ toPlainDate(dateToCompare, 'dateToCompare'),
199
+ ) > 0
200
+ )
201
+ }
202
+
203
+ /**
204
+ * @param {DayInput} dateLeft
205
+ * @param {DayInput} dateRight
206
+ * @returns {boolean}
207
+ */
208
+ export function isEqual(dateLeft, dateRight) {
209
+ return (
210
+ Temporal.PlainDate.compare(
211
+ toPlainDate(dateLeft, 'dateLeft'),
212
+ toPlainDate(dateRight, 'dateRight'),
213
+ ) === 0
214
+ )
215
+ }
216
+
217
+ /**
218
+ * @param {DayInput} dateLeft
219
+ * @param {DayInput} dateRight
220
+ * @returns {-1 | 0 | 1}
221
+ */
222
+ export function compareAsc(dateLeft, dateRight) {
223
+ return /** @type {-1 | 0 | 1} */ (
224
+ Temporal.PlainDate.compare(
225
+ toPlainDate(dateLeft, 'dateLeft'),
226
+ toPlainDate(dateRight, 'dateRight'),
227
+ )
228
+ )
229
+ }
230
+
231
+ /**
232
+ * @param {DayInput} dateLeft
233
+ * @param {DayInput} dateRight
234
+ * @returns {-1 | 0 | 1}
235
+ */
236
+ export function compareDesc(dateLeft, dateRight) {
237
+ return /** @type {-1 | 0 | 1} */ (-compareAsc(dateLeft, dateRight))
238
+ }
239
+
240
+ /**
241
+ * @param {DayInput[]} dates
242
+ * @returns {string}
243
+ */
244
+ export function min(dates) {
245
+ assertNonEmptyDates(dates)
246
+ return toDayString(
247
+ dates.map((d) => toPlainDate(d)).reduce((a, b) => (Temporal.PlainDate.compare(a, b) <= 0 ? a : b)),
248
+ )
249
+ }
250
+
251
+ /**
252
+ * @param {DayInput[]} dates
253
+ * @returns {string}
254
+ */
255
+ export function max(dates) {
256
+ assertNonEmptyDates(dates)
257
+ return toDayString(
258
+ dates.map((d) => toPlainDate(d)).reduce((a, b) => (Temporal.PlainDate.compare(a, b) >= 0 ? a : b)),
259
+ )
260
+ }
261
+
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`)
266
+ }
267
+ if (!Number.isInteger(n)) {
268
+ throw new RangeError(`daymath: ${label} must be an integer`)
269
+ }
270
+ }
271
+
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')
276
+ }
4
277
  }
package/package.json CHANGED
@@ -1,22 +1,32 @@
1
1
  {
2
2
  "name": "daymath",
3
- "version": "0.0.1",
3
+ "version": "0.1.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
  }