daymath 0.6.0 → 0.7.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 +28 -8
  2. package/index.d.ts +259 -14
  3. package/index.js +91 -16
  4. package/package.json +7 -5
package/README.md CHANGED
@@ -42,8 +42,8 @@ that reads a clock.
42
42
  Start here. No arguments means today, in UTC.
43
43
 
44
44
  ```js
45
- day() // '2026-08-08'
46
- addDays(day(), 2) // '2026-08-10'
45
+ day() // today, in UTC
46
+ addDays(day(), 2) // two days from today
47
47
  ```
48
48
 
49
49
  Then hand it whatever you already have. It converts; it never stores what you gave it.
@@ -53,12 +53,29 @@ day(row.createdAt) // '2026-08-07' a Date
53
53
  day(1761616161771) // '2025-10-28' epoch milliseconds
54
54
  day('1999-01-01T00:00:00Z') // '1999-01-01' an ISO timestamp
55
55
  day('2026-05-05') // '2026-05-05' already a day
56
+ day('2026-08-08 12:00:00') // '2026-08-08' a SQLite DATETIME
56
57
  ```
57
58
 
59
+ A **SQLite `DATETIME` goes straight in**, and so does the whole surface — the clock
60
+ is dropped by the same funnel every export shares, so this is not a `day()` feature.
61
+
62
+ ```js
63
+ addDays(row.created_at, 30) // '2026-09-07' from '2026-08-08 12:00:00'
64
+ startOfMonth('2026-08-08 01:57:31.913') // '2026-08-01' strftime('%…%H:%M:%f')
65
+ getYear('2026-08-08t12:00') // 2026 T, t or a space; same rule
66
+ ```
67
+
68
+ `datetime()`, `CURRENT_TIMESTAMP` and `strftime('%Y-%m-%d %H:%M:%f')` all emit that
69
+ shape, so a column value needs no reshaping first. **Pass a zone with one and it
70
+ throws.** Naming a zone means convert, and a clock with no zone gives nothing to
71
+ convert from — and `datetime()` defaults to UTC, so a silent answer would hand the
72
+ UTC day to the one caller who asked for a local one. Put the zone in the string
73
+ (`Z`, an offset, or `[Zone]`) and it converts normally.
74
+
58
75
  Name a zone when the answer depends on one.
59
76
 
60
77
  ```js
61
- day('Asia/Tokyo') // '2026-08-09' today in Tokyo
78
+ day('Asia/Tokyo') // today in Tokyo
62
79
  day(row.createdAt, 'America/New_York') // '2026-08-06' the evening before
63
80
  day('2026-08-08T23:00:00Z', 'Asia/Tokyo') // '2026-08-09' same instant, next day
64
81
  day(zdt.toString()) // the zone in the string wins
@@ -69,7 +86,7 @@ UTC is still not your day for part of every day — it runs ahead of `America/Ne
69
86
  for 16.7% of the day, and behind `Asia/Tokyo` for 37.5% — so name your zone when
70
87
  that matters.
71
88
 
72
- Four rules worth knowing:
89
+ Six rules worth knowing:
73
90
 
74
91
  - A **number is epoch milliseconds**, exactly as `new Date(n)` reads it. A seconds
75
92
  timestamp read as milliseconds lands in 1970, with no error. daymath states the
@@ -84,9 +101,12 @@ Four rules worth knowing:
84
101
  the UTC default never applies. `day(zdt.toString())` equals `zdt.toPlainDate()`.
85
102
  A browser sending `'2026-08-08T20:00:00-04:00[America/New_York]'` gets back the
86
103
  8th, which is the date its user saw. Passing `tz` as well throws.
87
- - A string with **neither** is refused. `'2026-08-08T12:00'` names no instant and
88
- no zone, so daymath would have to pick one, and it will not pick for you. Name
89
- the zone `'2026-08-08T12:00[America/New_York]'` and it is accepted.
104
+ - A **zoneless wall clock is a day.** `'2026-08-08 12:00:00'`, `'2026-08-08T12:00'`
105
+ and the lowercase `t` all answer `'2026-08-08'`; the clock is dropped, never read.
106
+ The **hour is the only bound**, because the hour is the only field that can change
107
+ the date: `24:00` is refused, a leap second and a fraction of any length are not.
108
+ Pass `tz` with one and it **throws**. Name the zone in the string —
109
+ `'2026-08-08T12:00[America/New_York]'` — and it converts.
90
110
  - **`'11/12/2026'` is refused.** Nobody can tell November from December in it.
91
111
 
92
112
  A lone string takes one of four roles, decided in this order: a day, a zoned time,
@@ -194,7 +214,7 @@ For the accepting family only the year label moves, so every export still answer
194
214
  the annotation rides along:
195
215
 
196
216
  ```js
197
- getYear('2026-01-31[u-ca=buddhist]') // 2569, not 2026
217
+ getYear('2026-01-31[u-ca=buddhist]') // 2569 not 2026
198
218
  getMonth('2026-01-31[u-ca=buddhist]') // 1
199
219
  addDays('2026-01-31[u-ca=buddhist]', 1) // '2026-02-01[u-ca=buddhist]'
200
220
  setYear('2026-01-31[u-ca=buddhist]', 2570) // '2027-01-31[u-ca=buddhist]'
package/index.d.ts CHANGED
@@ -4,6 +4,17 @@ import type { Temporal } from 'temporal-polyfill'
4
4
  * Calendar day input: ISO 8601 day string, or a Temporal.PlainDate.
5
5
  * - `YYYY-MM-DD` (years 0000–9999)
6
6
  * - expanded `±YYYYYY-MM-DD` (e.g. `+010000-01-01`)
7
+ * - either of those with a zoneless wall clock on it, `YYYY-MM-DD HH:MM[:SS[.fff]]`,
8
+ * with `T`, `t` or a space between them. The clock is dropped and never read, so
9
+ * `getYear('2026-08-08 12:00:00')` is `2026`. This is the shape SQLite stores:
10
+ * `datetime()`, `CURRENT_TIMESTAMP` and `strftime('%Y-%m-%d %H:%M:%f')` all emit
11
+ * it, so a column value needs no reshaping. The **hour is the only bound**,
12
+ * because the hour is the only field that can change the date: `24:00` is refused
13
+ * because ISO `24:00` starts the next day, while a leap second `23:59:60` and a
14
+ * fraction of any length stay inside their own day and are accepted.
15
+ *
16
+ * A clock naming a zone — `Z`, an offset, or `[Zone]` — is NOT day input. It names
17
+ * an instant, and `day()` is the only door an instant enters by.
7
18
  *
8
19
  * Usable range is the Temporal `PlainDate` range `-271821-04-19` …
9
20
  * `+275760-09-13`; a day outside it throws a `RangeError`.
@@ -50,8 +61,12 @@ export type WeekOptions = {
50
61
  * own civil day and the UTC default never applies. Passing `tz` as well throws.
51
62
  *
52
63
  * `'11/12/2026'` is refused, because nobody can tell November from December in
53
- * it. `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath
54
- * would have to pick one. Name the zone and it is accepted.
64
+ * it.
65
+ *
66
+ * A zoneless wall clock is a day, so `'2026-08-08 12:00:00'` — a SQLite
67
+ * `DATETIME` — answers `'2026-08-08'` and the clock is dropped. Pass `tz` with
68
+ * one and it throws: naming a zone means convert, and a clock with no zone gives
69
+ * nothing to convert from. Put the zone in the string and it converts.
55
70
  *
56
71
  * A lone string takes one of four roles, in this order: a day, a zoned time, an
57
72
  * instant, then a zone. The zone test is by shape — an IANA name, which carries
@@ -65,121 +80,351 @@ export type WeekOptions = {
65
80
  *
66
81
  * A calendar that renumbers months or days is still refused where it is
67
82
  * applied, which means with a `[Zone]` bracket and on a day string.
83
+ * @example day(row.createdAt) // a Date, read in UTC
84
+ * @example day(row.createdAt, 'Asia/Tokyo') // the same instant, Tokyo's day
85
+ * @example day('1999-01-01T00:00:00Z') // '1999-01-01'
86
+ * @example day('2026-08-08 12:00:00') // '2026-08-08' a SQLite DATETIME
87
+ * @example day('2026-05-05', 'Asia/Tokyo') // '2026-05-05' a day carries no time
68
88
  */
69
- export function day(tz?: string): string
70
89
  export function day(
71
90
  moment: Date | number | DayInput | null | undefined,
72
91
  tz?: string,
73
92
  ): string
93
+ /**
94
+ * Today's calendar day, in a zone. **This is the one overload that reads a clock**, so it is the
95
+ * only call in daymath that is not a pure function. Give the other overload a moment and it is.
96
+ *
97
+ * The zone defaults to UTC, and the default is STATED rather than assumed: daymath has no silent
98
+ * local now. UTC is not your day for part of every day — it runs ahead of `America/New_York` for
99
+ * 16.7% of the day and behind `Asia/Tokyo` for 37.5% — so name your zone when that matters.
100
+ *
101
+ * A lone string here is read as a zone only after it fails to be a day, a zoned time and an
102
+ * instant, in that order. The zone test is by shape, so a timestamp can never be read as a zone.
103
+ * @example day() // today, in UTC
104
+ * @example day('Asia/Tokyo') // today in Tokyo
105
+ * @example day('+05:30') // a bare offset is a zone too
106
+ */
107
+ export function day(tz?: string): string
74
108
 
75
109
  /**
76
110
  * True for a valid daymath day string / PlainDate.
77
111
  * Invalid strings → false. `Date` → throws TypeError (not a quiet false).
112
+ * @example isValid('2026-08-08') // true
113
+ * @example isValid('2026-02-30') // false
114
+ * @example isValid('2026-08-08 12:00:00') // true clock dropped
115
+ * @example isValid(new Date()) // throws TypeError, not false
78
116
  */
79
117
  export function isValid(value: unknown): boolean
80
118
 
81
- /** Validate / normalize to ISO 8601 day string (Temporal `toString` form). */
119
+ /**
120
+ * Validate / normalize to ISO 8601 day string (Temporal `toString` form).
121
+ *
122
+ * `parse` preserves what it is given; `day()` normalizes. So a calendar
123
+ * annotation survives here and is dropped by `day()`.
124
+ * @example parse('2026-08-08') // '2026-08-08'
125
+ * @example parse('2026-08-08 12:00:00') // '2026-08-08' clock dropped
126
+ * @example parse('2026-01-31[u-ca=buddhist]') // '2026-01-31[u-ca=buddhist]'
127
+ * @example parse('2026-02-30') // throws RangeError
128
+ */
82
129
  export function parse(date: DayInput): string
83
130
 
84
- /** Format as ISO day (only `yyyy-MM-dd` / `YYYY-MM-DD` patterns supported). */
131
+ /**
132
+ * Format as ISO day (only `yyyy-MM-dd` / `YYYY-MM-DD` patterns supported).
133
+ *
134
+ * Not locale display. For that, use `Intl` or a date-fns formatter.
135
+ * @example format('2026-08-08') // '2026-08-08'
136
+ * @example format('2026-08-08', 'dd/MM/yyyy') // throws RangeError
137
+ */
85
138
  export function format(date: DayInput, pattern?: 'yyyy-MM-dd' | 'YYYY-MM-DD'): string
86
139
 
140
+ /**
141
+ * @example addDays('2026-08-08', 30) // '2026-09-07'
142
+ * @example addDays('2026-08-08', -1) // '2026-08-07'
143
+ */
87
144
  export function addDays(date: DayInput, amount: number): string
145
+ /** @example subDays('2026-08-08', 1) // '2026-08-07' */
88
146
  export function subDays(date: DayInput, amount: number): string
147
+ /** @example addWeeks('2026-08-08', 2) // '2026-08-22' */
89
148
  export function addWeeks(date: DayInput, amount: number): string
149
+ /** Whole weeks. @example subWeeks('2026-08-22', 2) // '2026-08-08' */
90
150
  export function subWeeks(date: DayInput, amount: number): string
151
+ /**
152
+ * Overflow **clamps** to the last day of the target month; it never rolls into
153
+ * the next one. Same rule in both directions, and the same rule `setMonth`,
154
+ * `setDate` and `setYear` use.
155
+ * @example addMonths('2026-01-31', 1) // '2026-02-28' clamped, not '2026-03-03'
156
+ * @example addMonths('2026-03-31', -1) // '2026-02-28'
157
+ * @example addMonths('2024-01-31', 1) // '2024-02-29' leap year
158
+ */
91
159
  export function addMonths(date: DayInput, amount: number): string
160
+ /**
161
+ * Clamps, exactly as `addMonths` does.
162
+ * @example subMonths('2026-03-31', 1) // '2026-02-28' clamped
163
+ */
92
164
  export function subMonths(date: DayInput, amount: number): string
165
+ /**
166
+ * Clamps, so 29 February lands on the 28th of a common year.
167
+ * @example addYears('2024-02-29', 1) // '2025-02-28' clamped
168
+ */
93
169
  export function addYears(date: DayInput, amount: number): string
170
+ /**
171
+ * Clamps, exactly as `addYears` does.
172
+ * @example subYears('2024-02-29', 1) // '2023-02-28' clamped
173
+ */
94
174
  export function subYears(date: DayInput, amount: number): string
175
+ /**
176
+ * Three months at a time, and it clamps.
177
+ * @example addQuarters('2026-01-31', 1) // '2026-04-30' clamped
178
+ */
95
179
  export function addQuarters(date: DayInput, amount: number): string
180
+ /** Three months back, and it clamps. @example subQuarters('2026-04-30', 1) // '2026-01-30' */
96
181
  export function subQuarters(date: DayInput, amount: number): string
97
182
 
98
- /** Full year number. */
183
+ /**
184
+ * Full year number. An annotation is applied on top of the ISO date part, never instead of it.
185
+ * @example getYear('2026-08-08') // 2026
186
+ * @example getYear('2026-01-31[u-ca=buddhist]') // 2569 the annotation adds 543
187
+ */
99
188
  export function getYear(date: DayInput): number
100
- /** Month number, ISO 8601: 1 = January … 12 = December. Not date-fns's 0-based index. */
189
+ /**
190
+ * Month number, ISO 8601: 1 = January … 12 = December. Not date-fns's 0-based index.
191
+ * @example getMonth('2026-08-08') // 8 — date-fns would answer 7
192
+ * @example getMonth('2026-01-15') // 1
193
+ */
101
194
  export function getMonth(date: DayInput): number
102
- /** Day of month 1…31. */
195
+ /** Day of month 1…31. @example getDate('2026-08-08') // 8 */
103
196
  export function getDate(date: DayInput): number
104
- /** Weekday, ISO 8601: 1 = Monday … 7 = Sunday. Only Sunday differs from date-fns. */
197
+ /**
198
+ * Weekday, ISO 8601: 1 = Monday … 7 = Sunday. Only Sunday differs from date-fns.
199
+ * @example getDay('2026-08-10') // 1 a Monday
200
+ * @example getDay('2026-08-09') // 7 a Sunday — date-fns would answer 0
201
+ */
105
202
  export function getDay(date: DayInput): number
203
+ /** 1 on 1 January. @example getDayOfYear('2026-03-01') // 60 */
106
204
  export function getDayOfYear(date: DayInput): number
205
+ /** @example getDaysInMonth('2024-02-10') // 29 */
107
206
  export function getDaysInMonth(date: DayInput): number
108
- /** Quarter 1…4. */
207
+ /** Quarter 1…4. @example getQuarter('2026-08-08') // 3 */
109
208
  export function getQuarter(date: DayInput): number
209
+ /**
210
+ * Leap year of the day's own ISO year.
211
+ *
212
+ * **This is the one way to get a wrong answer with no error**, and it is worth reading before
213
+ * you pass a non-ISO calendar's year. A `[u-ca=…]` day is judged on its ISO year, which is the
214
+ * date part — so a Buddhist year written as a bare ISO year is a different year. Buddhist 2567
215
+ * is ISO 2024 and IS a leap year, but 2567 read as ISO is not, because 543 mod 4 is 3. The two
216
+ * rules disagree in 49 of the 101 Buddhist years from 2500 to 2600, and nothing throws.
217
+ * @example isLeapYear('2024-01-01') // true
218
+ * @example isLeapYear('2567-01-01') // false ISO 2567, NOT Buddhist 2567 (which is ISO 2024)
219
+ */
110
220
  export function isLeapYear(date: DayInput): boolean
111
221
 
222
+ /**
223
+ * Overflow clamps, so 29 February survives a move to a common year. **date-fns rolls here
224
+ * instead**, so this is the second deliberate disagreement after `setDate` — and the one a caller
225
+ * reaches by accident, through a stored 29 February.
226
+ * @example setYear('2024-02-29', 2026) // '2026-02-28' clamped; date-fns gives '2026-03-01'
227
+ */
112
228
  export function setYear(date: DayInput, year: number): string
113
- /** `month`: 1 = January … 12 = December (ISO 8601). */
229
+ /**
230
+ * `month`: 1 = January … 12 = December (ISO 8601). Overflow clamps.
231
+ * @example setMonth('2026-01-31', 2) // '2026-02-28' clamped
232
+ */
114
233
  export function setMonth(date: DayInput, month: number): string
234
+ /**
235
+ * Overflow clamps to the end of the month. **date-fns rolls here instead**, so
236
+ * this is one of the few places the two answer differently on purpose.
237
+ * @example setDate('2026-02-01', 31) // '2026-02-28' daymath clamps
238
+ */
115
239
  export function setDate(date: DayInput, dayOfMonth: number): string
116
240
 
241
+ /** @example startOfMonth('2026-08-08') // '2026-08-01' */
117
242
  export function startOfMonth(date: DayInput): string
243
+ /**
244
+ * @example endOfMonth('2024-02-01') // '2024-02-29'
245
+ */
118
246
  export function endOfMonth(date: DayInput): string
247
+ /** @example startOfYear('2026-08-08') // '2026-01-01' */
119
248
  export function startOfYear(date: DayInput): string
249
+ /** @example endOfYear('2026-08-08') // '2026-12-31' */
120
250
  export function endOfYear(date: DayInput): string
251
+ /** @example startOfQuarter('2026-08-08') // '2026-07-01' */
121
252
  export function startOfQuarter(date: DayInput): string
253
+ /** @example endOfQuarter('2026-08-08') // '2026-09-30' */
122
254
  export function endOfQuarter(date: DayInput): string
255
+ /**
256
+ * `weekStartsOn` defaults to **7 (Sunday)**, and `0` is accepted for Sunday too.
257
+ * @example startOfWeek('2026-08-12') // '2026-08-09' Sunday
258
+ * @example startOfWeek('2026-08-12', { weekStartsOn: 1 }) // '2026-08-10' Monday
259
+ */
123
260
  export function startOfWeek(date: DayInput, options?: WeekOptions): string
261
+ /**
262
+ * `weekStartsOn` defaults to 7 (Sunday), so the week ends on Saturday.
263
+ * @example endOfWeek('2026-08-12') // '2026-08-15' Saturday
264
+ * @example endOfWeek('2026-08-12', { weekStartsOn: 1 }) // '2026-08-16' Sunday
265
+ */
124
266
  export function endOfWeek(date: DayInput, options?: WeekOptions): string
125
267
 
126
- /** Full days: `dateLeft − dateRight` (date-fns argument order). */
268
+ /**
269
+ * Full days: `dateLeft − dateRight` (date-fns argument order).
270
+ * @example differenceInDays('2026-08-08', '2026-08-01') // 7
271
+ * @example differenceInDays('2026-08-01', '2026-08-08') // -7 order matters
272
+ */
127
273
  export function differenceInDays(dateLeft: DayInput, dateRight: DayInput): number
274
+ /** Whole weeks. @example differenceInWeeks('2026-08-15', '2026-08-08') // 1 */
128
275
  export function differenceInWeeks(dateLeft: DayInput, dateRight: DayInput): number
276
+ /**
277
+ * A month counts as full when `addMonths` would carry the earlier date to the
278
+ * later one, so `differenceInMonths(addMonths(d, n), d) === n` always holds. Use
279
+ * `differenceInCalendarMonths` to count boundaries crossed instead.
280
+ * @example differenceInMonths('2026-02-28', '2026-01-31') // 1 addMonths clamps to here
281
+ * @example differenceInCalendarMonths('2026-02-01', '2026-01-31') // 1 one boundary, one day apart
282
+ */
129
283
  export function differenceInMonths(dateLeft: DayInput, dateRight: DayInput): number
284
+ /**
285
+ * Month boundaries crossed, not whole months. `differenceInMonths` is the other question.
286
+ * @example differenceInCalendarMonths('2026-02-01', '2026-01-31') // 1 one day apart
287
+ */
130
288
  export function differenceInCalendarMonths(
131
289
  dateLeft: DayInput,
132
290
  dateRight: DayInput,
133
291
  ): number
292
+ /**
293
+ * Counts by `addYears`, the same rule `differenceInMonths` uses, so the round-trip law holds.
294
+ * @example differenceInYears('2026-02-28', '2024-02-29') // 2 addYears clamps to here
295
+ */
134
296
  export function differenceInYears(dateLeft: DayInput, dateRight: DayInput): number
297
+ /**
298
+ * Boundaries crossed, not whole years.
299
+ * @example differenceInCalendarYears('2026-01-01', '2025-12-31') // 1 one day apart
300
+ */
135
301
  export function differenceInCalendarYears(dateLeft: DayInput, dateRight: DayInput): number
302
+ /** Counts by `addQuarters`. @example differenceInQuarters('2026-07-01', '2026-01-01') // 2 */
136
303
  export function differenceInQuarters(dateLeft: DayInput, dateRight: DayInput): number
304
+ /**
305
+ * Quarter boundaries crossed, not whole quarters.
306
+ * @example differenceInCalendarQuarters('2026-07-01', '2026-06-30') // 1 one day apart
307
+ */
137
308
  export function differenceInCalendarQuarters(
138
309
  dateLeft: DayInput,
139
310
  dateRight: DayInput,
140
311
  ): number
141
312
 
313
+ /**
314
+ * Strictly before; an equal pair is `false`.
315
+ * @example isBefore('2026-08-01', '2026-08-08') // true
316
+ * @example isBefore('2026-08-08', '2026-08-08') // false not strictly before
317
+ */
142
318
  export function isBefore(date: DayInput, dateToCompare: DayInput): boolean
319
+ /** Strictly after. @example isAfter('2026-08-01', '2026-08-08') // false */
143
320
  export function isAfter(date: DayInput, dateToCompare: DayInput): boolean
144
- /** Same calendar day. */
321
+ /**
322
+ * Same calendar day, and the calendar label is ignored. Temporal's own `equals`
323
+ * compares the calendar too, so it answers `false` for one day written two ways;
324
+ * daymath compares the day alone and answers `true`.
325
+ * @example isEqual('2026-01-31[u-ca=buddhist]', '2026-01-31') // true same day
326
+ */
145
327
  export function isEqual(dateLeft: DayInput, dateRight: DayInput): boolean
146
- /** Alias of `isEqual` (date-fns name). */
328
+ /**
329
+ * Alias of `isEqual` (date-fns name), and it ignores the calendar label the same way.
330
+ * @example isSameDay('2026-01-31[u-ca=buddhist]', '2026-01-31') // true same day
331
+ */
147
332
  export const isSameDay: typeof isEqual
148
333
 
334
+ /**
335
+ * `weekStartsOn` defaults to 7 (Sunday), so a Saturday and the Sunday after it are DIFFERENT weeks.
336
+ * @example isSameWeek('2026-08-08', '2026-08-09') // false Sat then Sun
337
+ * @example isSameWeek('2026-08-08', '2026-08-09', { weekStartsOn: 1 }) // true Monday weeks
338
+ */
149
339
  export function isSameWeek(
150
340
  dateLeft: DayInput,
151
341
  dateRight: DayInput,
152
342
  options?: WeekOptions,
153
343
  ): boolean
344
+ /** Same month AND year. @example isSameMonth('2026-08-01', '2026-08-31') // true */
154
345
  export function isSameMonth(dateLeft: DayInput, dateRight: DayInput): boolean
346
+ /** @example isSameYear('2026-01-01', '2026-12-31') // true */
155
347
  export function isSameYear(dateLeft: DayInput, dateRight: DayInput): boolean
348
+ /** @example isSameQuarter('2026-07-01', '2026-09-30') // true */
156
349
  export function isSameQuarter(dateLeft: DayInput, dateRight: DayInput): boolean
157
350
 
351
+ /**
352
+ * Comparator for `Array.prototype.sort`, oldest first.
353
+ * @example compareAsc('2026-01-01', '2026-08-08') // -1
354
+ */
158
355
  export function compareAsc(dateLeft: DayInput, dateRight: DayInput): -1 | 0 | 1
356
+ /** Newest first. @example compareDesc('2026-01-01', '2026-08-08') // 1 */
159
357
  export function compareDesc(dateLeft: DayInput, dateRight: DayInput): -1 | 0 | 1
160
358
 
359
+ /**
360
+ * Takes an **array**, not a rest argument.
361
+ * @example min(['2026-08-08', '2026-01-01']) // '2026-01-01'
362
+ */
161
363
  export function min(dates: DayInput[]): string
364
+ /**
365
+ * Takes an **array**, not a rest argument, exactly as `min` does.
366
+ * @example max(['2026-01-01', '2026-08-08']) // '2026-08-08'
367
+ */
162
368
  export function max(dates: DayInput[]): string
163
369
 
370
+ /** ISO weekday 7. @example isSunday('2026-08-09') // true */
164
371
  export function isSunday(date: DayInput): boolean
372
+ /** ISO weekday 1. @example isMonday('2026-08-10') // true */
165
373
  export function isMonday(date: DayInput): boolean
374
+ /** ISO weekday 2. @example isTuesday('2026-08-11') // true */
166
375
  export function isTuesday(date: DayInput): boolean
376
+ /** ISO weekday 3. @example isWednesday('2026-08-12') // true */
167
377
  export function isWednesday(date: DayInput): boolean
378
+ /** ISO weekday 4. @example isThursday('2026-08-13') // true */
168
379
  export function isThursday(date: DayInput): boolean
380
+ /** ISO weekday 5. @example isFriday('2026-08-14') // true */
169
381
  export function isFriday(date: DayInput): boolean
382
+ /** ISO weekday 6. @example isSaturday('2026-08-08') // true */
170
383
  export function isSaturday(date: DayInput): boolean
384
+ /**
385
+ * ISO Saturday and Sunday (6 and 7). Not configurable, and no holiday calendar.
386
+ * @example isWeekend('2026-08-08') // true a Saturday
387
+ * @example isWeekend('2026-08-10') // false a Monday
388
+ */
171
389
  export function isWeekend(date: DayInput): boolean
390
+ /** @example isFirstDayOfMonth('2026-08-01') // true */
172
391
  export function isFirstDayOfMonth(date: DayInput): boolean
392
+ /** @example isLastDayOfMonth('2024-02-29') // true leap year */
173
393
  export function isLastDayOfMonth(date: DayInput): boolean
174
394
 
395
+ /**
396
+ * Both ends **inclusive**, so a one-day interval returns one day.
397
+ * @example eachDayOfInterval({ start: '2026-08-08', end: '2026-08-10' })
398
+ * // ['2026-08-08', '2026-08-09', '2026-08-10']
399
+ */
175
400
  export function eachDayOfInterval(interval: Interval): string[]
401
+ /**
402
+ * The FIRST of each month touched, both ends inclusive.
403
+ * @example eachMonthOfInterval({ start: '2026-01-15', end: '2026-03-02' })
404
+ * // ['2026-01-01', '2026-02-01', '2026-03-01']
405
+ */
176
406
  export function eachMonthOfInterval(interval: Interval): string[]
407
+ /**
408
+ * The FIRST of January of each year touched, both ends inclusive.
409
+ * @example eachYearOfInterval({ start: '2025-06-01', end: '2026-02-01' })
410
+ * // ['2025-01-01', '2026-01-01']
411
+ */
177
412
  export function eachYearOfInterval(interval: Interval): string[]
413
+ /**
414
+ * Both ends **inclusive**, so a date equal to an endpoint is within.
415
+ * @example isWithinInterval('2026-08-10', { start: '2026-08-08', end: '2026-08-10' }) // true
416
+ */
178
417
  export function isWithinInterval(date: DayInput, interval: Interval): boolean
418
+ /**
419
+ * Pull a day inside the interval, or return it unchanged if it is already inside.
420
+ * @example clamp('2026-12-25', { start: '2026-08-08', end: '2026-08-10' }) // '2026-08-10'
421
+ */
179
422
  export function clamp(date: DayInput, interval: Interval): string
180
423
  /**
181
424
  * date-fns default: `inclusive: false` (touching endpoints only is not overlap).
182
425
  * Pass `{ inclusive: true }` for closed intervals.
426
+ * @example areIntervalsOverlapping({ start: '2026-08-01', end: '2026-08-08' }, { start: '2026-08-08', end: '2026-08-10' }) // false
427
+ * @example areIntervalsOverlapping({ start: '2026-08-01', end: '2026-08-08' }, { start: '2026-08-08', end: '2026-08-10' }, { inclusive: true }) // true
183
428
  */
184
429
  export function areIntervalsOverlapping(
185
430
  intervalLeft: Interval,
package/index.js CHANGED
@@ -55,6 +55,45 @@ const RUNTIME_CALENDARS = new Set(
55
55
  */
56
56
  const ISO_DAY = /^(?:[+-]\d{6}|\d{4})-\d{2}-\d{2}$/u
57
57
 
58
+ /**
59
+ * A day with a wall clock and no zone: `YYYY-MM-DD HH:MM[:SS[.fff]]`, with `T`,
60
+ * `t` or a space between them. Group 1 is the day. The clock gets dropped.
61
+ *
62
+ * This is what SQLite hands you — `datetime('now')`, `CURRENT_TIMESTAMP` and
63
+ * `strftime('%Y-%m-%d %H:%M:%f')` all emit a space, seconds and optional
64
+ * fractions — so a column value needs no reshaping before daymath reads it.
65
+ *
66
+ * **All three separators, because a separator is punctuation and never
67
+ * information.** Temporal accepts a space and a lowercase `t` wherever it accepts
68
+ * `T`, and the ZONED forms of all three already answered, so the only hole was a
69
+ * clock naming NO zone.
70
+ *
71
+ * **The hour is the only bound, because the hour is the only field that can
72
+ * change the date. That is the whole rule: daymath drops a clock only when the
73
+ * clock cannot move the day.** A leap second and a fraction of any length stay
74
+ * inside their own day, so both are accepted, matching what `day('…23:59:60Z')`
75
+ * already answered.
76
+ *
77
+ * **`24:00` is refused, and the reason is that SQLite disagrees with itself
78
+ * about it.** ISO `24:00` is midnight starting the NEXT day, and SQLite's own
79
+ * `julianday('2026-08-08 24:00:00')` is 2461261.5 — byte-identical to
80
+ * `julianday('2026-08-09 00:00:00')` — while its `date()` and
81
+ * `strftime('%Y-%m-%d')` on the same string both answer `'2026-08-08'`. So no
82
+ * answer daymath could give agrees with the database: the 9th contradicts its
83
+ * `date()`, the 8th contradicts its `julianday()`. Refusing is the only option
84
+ * that never silently disagrees. Temporal refuses `24:00` on every path too, so
85
+ * accepting would also mean doing carry arithmetic the engine will not do.
86
+ *
87
+ * That also separates it from the two cases above. `t` and `:60` were holes
88
+ * because the ZONED twin already answered; every `24:00` spelling is refused
89
+ * today, zoned and zoneless, so refusing it is the consistent choice.
90
+ *
91
+ * `Z`, an offset and a `[Zone]` cannot reach this pattern — it is anchored — so
92
+ * a string that names a real instant still takes the instant path in `day()`.
93
+ */
94
+ const ISO_DAY_TIME =
95
+ /^((?:[+-]\d{6}|\d{4})-\d{2}-\d{2})[Tt ](?:[01]\d|2[0-3]):[0-5]\d(?::(?:[0-5]\d|60)(?:\.\d+)?)?$/u
96
+
58
97
  /**
59
98
  * Temporal's calendar annotation, `[u-ca=…]` or the critical `[!u-ca=…]`.
60
99
  * Returns what it is attached to, and the calendar it names, or `null`.
@@ -153,13 +192,19 @@ const ZONE_LIKE =
153
192
  * One predicate, because `toPlainDate` and `day()` both ask this question. They
154
193
  * asked it separately once, and day() alone then refused a string that every
155
194
  * other export accepted.
195
+ *
196
+ * `clock` reports whether a zoneless wall clock came off the string. Every export ignores it and
197
+ * answers the same day either way; only `day()` reads it, because `day()` is the only one that
198
+ * takes a zone and so the only one that can be asked to convert what it just discarded.
156
199
  * @param {string} text
157
- * @returns {string | null}
200
+ * @returns {{ day: string, clock: boolean } | null}
158
201
  */
159
202
  function bareDay(text) {
160
203
  const annotated = calendarAnnotation(text)
161
204
  const bare = annotated ? annotated.head : text
162
- return ISO_DAY.test(bare) ? bare : null
205
+ if (ISO_DAY.test(bare)) return { day: bare, clock: false }
206
+ const clocked = ISO_DAY_TIME.exec(bare)
207
+ return clocked === null ? null : { day: clocked[1], clock: true }
163
208
  }
164
209
 
165
210
  /**
@@ -341,14 +386,15 @@ function toPlainDate(value, label = 'date') {
341
386
  const bare = bareDay(text)
342
387
  if (bare === null) {
343
388
  throw new RangeError(
344
- `daymath: ${label} must be ISO 8601 day YYYY-MM-DD or ±YYYYYY-MM-DD (got ${JSON.stringify(text)})`,
389
+ `daymath: ${label} must be ISO 8601 day YYYY-MM-DD or ±YYYYYY-MM-DD, optionally with a zoneless clock HH:MM[:SS[.fff]] after a T, t or space, where only the hour is bounded (00-23) (got ${JSON.stringify(text)})`,
345
390
  )
346
391
  }
347
392
  try {
348
393
  // The bare day is parsed with the ISO resolver's own entry point. `getAny` is passed as the
349
394
  // resolver rather than called, because `fromString` calls it with whatever the string names,
350
- // and a bare day names nothing.
351
- const plain = PlainDateFns.fromString(bare, getAny)
395
+ // and a bare day names nothing. A wall clock is already off the string by this line, so no
396
+ // export ever parses one and every answer stays a day.
397
+ const plain = PlainDateFns.fromString(bare.day, getAny)
352
398
  // The annotation rides along, so getYear answers 2569 for a Buddhist day and every
353
399
  // returned string keeps the calendar the caller named.
354
400
  return calendar === undefined
@@ -502,6 +548,9 @@ function toInterval(interval) {
502
548
  * truncated the same way, so a fractional value is not an error
503
549
  * - an ISO 8601 day string, or a `Temporal.PlainDate` from any implementation,
504
550
  * both of which are already a day
551
+ * - a day carrying a zoneless wall clock, `'YYYY-MM-DD HH:MM[:SS[.fff]]'`, with
552
+ * `T`, `t` or a space between them: a day with noise on it, and the clock is
553
+ * dropped. Only the hour is bounded, at 00-23
505
554
  * - an ISO 8601 timestamp carrying `Z` or an offset, which names an exact
506
555
  * instant, so there is nothing left to guess
507
556
  * - a string carrying a `[Zone]` annotation, which names its own zone, so it
@@ -518,9 +567,15 @@ function toInterval(interval) {
518
567
  * ways in one function.
519
568
  *
520
569
  * `'11/12/2026'` is refused. Nobody can tell November from December in it.
521
- * `'2026-08-08T12:00'` is refused too: no offset and no zone, so daymath would
522
- * have to pick one, and it will not pick on the caller's behalf. Name the zone
523
- * `'2026-08-08T12:00[America/New_York]'` and it is accepted.
570
+ *
571
+ * A **zoneless wall clock is a day**, so `'2026-08-08 12:00:00'` and
572
+ * `'2026-08-08T12:00'` both answer `'2026-08-08'`. The clock is dropped, never
573
+ * read. That is what SQLite hands you from `datetime()` and `CURRENT_TIMESTAMP`,
574
+ * and it takes the day path here exactly as a bare day does.
575
+ *
576
+ * **Pass `tz` with one and it throws.** Naming a zone means convert, and a clock
577
+ * with no zone gives nothing to convert from. Name the zone in the string —
578
+ * `'2026-08-08T12:00[America/New_York]'`, or `Z`, or an offset — and it converts.
524
579
  *
525
580
  * A lone string takes one of four roles, decided in this order: a day, then a
526
581
  * zoned time, then an instant, then a zone. The zone test is by **shape**, and
@@ -543,20 +598,22 @@ function toInterval(interval) {
543
598
  * annotation is accepted on input and dropped from the result. Every other export carries it.
544
599
  * @throws {TypeError} If `moment` is not one of the accepted shapes
545
600
  * @throws {RangeError} On an Invalid Date, a non-finite number, or an unknown zone
546
- * @example day() // '2026-08-08' now, UTC
547
- * @example day('Asia/Tokyo') // '2026-08-09' today in Tokyo
548
- * @example day(row.createdAt) // '2026-08-07' a Date, UTC
549
- * @example day(row.createdAt, 'America/New_York') // '2026-08-06'
601
+ * @example day() // today, in UTC
602
+ * @example day('Asia/Tokyo') // today in Tokyo
603
+ * @example day(row.createdAt) // a Date, read in UTC
604
+ * @example day(row.createdAt, 'America/New_York') // the same instant, New York's day
550
605
  * @example day(1761616161771) // '2025-10-28' epoch ms
551
606
  * @example day('1999-01-01T00:00:00Z') // '1999-01-01' an ISO timestamp
552
607
  * @example day(zdt.toString()) // the zone in the string wins
553
- * @example addDays(day(), 2) // '2026-08-10'
608
+ * @example day(row.created_at) // a SQLite DATETIME
609
+ * @example day('2026-08-08 12:00:00', 'utc') // throws TypeError, the clock names no zone
610
+ * @example addDays(day(), 2) // two days from today
554
611
  */
555
612
  export function day(moment, tz) {
556
613
  // Already a day, in every accepted spelling. `bareDay` is the same predicate
557
614
  // toPlainDate uses, so day() cannot drift from the other 68 exports.
558
- const isDay =
559
- isPlainDate(moment) || (typeof moment === 'string' && bareDay(moment) !== null)
615
+ const bare = typeof moment === 'string' ? bareDay(moment) : null
616
+ const isDay = isPlainDate(moment) || bare !== null
560
617
  const isMoment = isDay || moment instanceof Date || typeof moment === 'number'
561
618
 
562
619
  let zone = tz
@@ -672,7 +729,25 @@ export function day(moment, tz) {
672
729
  // plain ISO day comes out. Carrying it here also could not be made
673
730
  // consistent, because an `Instant` has no fields for a calendar to renumber,
674
731
  // so that path has nothing to carry and would answer bare ISO regardless.
675
- if (isDay) return toDayString(isoOf(toPlainDate(moment)))
732
+ if (isDay) {
733
+ // The value is judged FIRST, so a day that does not exist reports itself rather than the zone.
734
+ // The guard below is about the argument PAIR, so it must never pre-empt a fault in either half.
735
+ const answer = toDayString(isoOf(toPlainDate(moment)))
736
+ // The one argument pair daymath refuses rather than answers. On 2026-08-24 SQLite's
737
+ // `datetime('now')` read 2026-08-24 01:57:31 and `datetime('now','localtime')` read
738
+ // 2026-08-23 21:57:31 — different days — because `datetime()` defaults to UTC. So the caller
739
+ // who stores the default and asks for a local day is the one a silent answer would mislead.
740
+ //
741
+ // Only this pair. A bare day plus a zone still answers, because no information was discarded
742
+ // there. Sibling of the `two time zones` refusals above, which a wall clock never reaches:
743
+ // both of those sit inside `if (!isMoment …)`.
744
+ if (bare?.clock && tz !== undefined && tz !== null) {
745
+ throw new TypeError(
746
+ `daymath: day() cannot apply the zone ${JSON.stringify(tz)} to ${JSON.stringify(moment)}, because that clock names no zone (add Z or an offset to it, or drop the zone)`,
747
+ )
748
+ }
749
+ return answer
750
+ }
676
751
 
677
752
  // The string named its own zone, so that zone decides the day, not the
678
753
  // default. This is the one path where `zone` is deliberately not consulted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "daymath",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Calendar date math (ISO 8601 day / PlainDate). date-fns-shaped. No Date. No time zones.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -29,11 +29,13 @@
29
29
  "test:runtimes": "node scripts/cross-runtime.mjs",
30
30
  "test:runtimes:write": "node scripts/cross-runtime.mjs --write",
31
31
  "test:intl-day": "node scripts/intl-day.mjs",
32
+ "test:examples": "node scripts/examples.mjs",
33
+ "test:examples:write": "node scripts/examples.mjs --write",
32
34
  "test:intl-day:sweep": "node scripts/intl-day.mjs --sweep",
33
35
  "size": "node scripts/bundle-size.mjs --run",
34
36
  "size:check": "node scripts/bundle-size.mjs --check",
35
37
  "size:write": "node scripts/bundle-size.mjs --write",
36
- "prepublishOnly": "npm run test:coverage",
38
+ "prepublishOnly": "npm run test:coverage && npm run test:examples",
37
39
  "publish:github": "node scripts/publish-github-packages.mjs"
38
40
  },
39
41
  "keywords": [
@@ -67,9 +69,9 @@
67
69
  "devDependencies": {
68
70
  "c8": "^12.0.0",
69
71
  "date-fns": "4.4.0",
70
- "esbuild": "0.28.1",
71
- "oxfmt": "0.62.0",
72
- "oxlint": "1.77.0",
72
+ "esbuild": "0.28.2",
73
+ "oxfmt": "0.64.0",
74
+ "oxlint": "1.78.0",
73
75
  "typescript": "7.0.2"
74
76
  }
75
77
  }