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.
Files changed (4) hide show
  1. package/README.md +24 -4
  2. package/index.d.ts +17 -2
  3. package/index.js +86 -11
  4. 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
- 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,
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
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
@@ -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 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.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.1",
71
- "oxfmt": "0.62.0",
72
- "oxlint": "1.77.0",
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
  }