daymath 0.6.0 → 0.7.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.
- package/README.md +24 -4
- package/index.d.ts +17 -2
- package/index.js +86 -11
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -53,8 +53,25 @@ 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
|
|
@@ -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
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
the
|
|
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,
|
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.
|
|
54
|
-
*
|
|
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
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
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
|
|
@@ -550,13 +605,15 @@ function toInterval(interval) {
|
|
|
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
|
|
608
|
+
* @example day(row.created_at) // '2026-08-08' a SQLite DATETIME
|
|
609
|
+
* @example day('2026-08-08 12:00:00', 'utc') // throws: the clock names no zone
|
|
553
610
|
* @example addDays(day(), 2) // '2026-08-10'
|
|
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
|
|
559
|
-
|
|
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)
|
|
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.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|
|
@@ -67,9 +67,9 @@
|
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"c8": "^12.0.0",
|
|
69
69
|
"date-fns": "4.4.0",
|
|
70
|
-
"esbuild": "0.28.
|
|
71
|
-
"oxfmt": "0.
|
|
72
|
-
"oxlint": "1.
|
|
70
|
+
"esbuild": "0.28.2",
|
|
71
|
+
"oxfmt": "0.64.0",
|
|
72
|
+
"oxlint": "1.78.0",
|
|
73
73
|
"typescript": "7.0.2"
|
|
74
74
|
}
|
|
75
75
|
}
|