Skip to content

v0.7.0

Choose a tag to compare

@leemr leemr released this 24 Aug 03:45
· 20 commits to master since this release

A zoneless wall clock on a day is now a day, and the clock is dropped. So a SQLite DATETIME column value goes straight into daymath with no reshaping step.

parse('2026-08-08 12:00:00')             // '2026-08-08'
addDays(row.created_at, 30)              // '2026-09-07'
startOfMonth('2026-08-08 01:57:31.913')  // '2026-08-01'
getYear('2026-08-08t12:00')              // 2026

Added

  • A day may carry a zoneless wall clock, YYYY-MM-DD HH:MM[:SS[.fff]], with T, t or a space between them. Measured on SQLite 3.54.0, that is every shape it emits: datetime() and CURRENT_TIMESTAMP give seconds, strftime('%Y-%m-%d %H:%M:%f') and datetime('now','subsec') add three fractional digits.
  • It lands in bareDay, the funnel all 69 exports share, so this is not a day() feature. addDays is still not routed through day(), and nothing added reads a clock, so no export became impure and day() is still the only door an instant enters by.
  • The separator was never the gap, and that is why all three spellings are taken. Temporal accepts a space and a lowercase t wherever it accepts T, so day('2026-08-08 12:00:00Z'), day('2026-08-08t12:00:00Z'), the offset form and the [Zone] form all answered before this release — verified against 0.6.0, not assumed. Exactly one hole existed: a clock naming no zone, and every separator fell in it. Accepting two of three would make the rule about punctuation instead of about information.

Changed

  • One rule bounds the clock: daymath drops it only when the clock cannot change the date. So the hour is the only bounded field. A leap second 23:59:60 and a fraction of any length stay inside their own day and are accepted, matching what day('…23:59:60Z') already answered on the instant path.
  • 24:00 is refused, because SQLite disagrees with itself about it. julianday('2026-08-08 24:00:00') is 2461261.5 — identical to julianday('2026-08-09 00:00:00') — while date() and strftime('%Y-%m-%d') on the same string both answer '2026-08-08'. No answer daymath could give agrees with the database: the 9th contradicts its date(), the 8th contradicts its julianday(). Refusing is the only option that never silently disagrees. Temporal refuses 24:00 on all five of its paths too.
  • day() throws a TypeError when a zone meets a zoneless clock, and it throws after both arguments are judged separately, so a bad day or a bad zone still reports itself. Measured at 2026-08-23 21:57 in America/New_York: datetime('now') read 2026-08-24 01:57:31 and datetime('now','localtime') read 2026-08-23 21:57:31 — different days at one instant, because datetime() defaults to UTC. The caller who stores the default and asks for a local day is exactly the one a silent answer would mislead. A bare day plus a zone still answers, unchanged.
  • A clock is dropped while a [u-ca=…] annotation still rides along. The two are consistent: an annotation renumbers the fields daymath answers with, and a clock names a field daymath does not have.

Verified

  • Nothing a 0.6.0 caller relied on changed, and that is measured rather than argued. The battery ran 0.6.0's index.js and this one over 0.6.0's own unchanged 62,928-probe set in one process, and every differing row was classified: 0 rows where 0.6.0 answered and 0.7.0 throws. 4 answered-to-different, all isValid going false to true on the newly valid strings, which is the feature. 148 threw-to-answers. The remainder are message and error-class changes on inputs that already threw.
  • 97 tests, coverage 100% on lines, functions and branches, the differential harness green against date-fns 4.4.0, the size gate inside tolerance on all three shapes. Cross-runtime baseline re-recorded on purpose and passing on Node 26.7.0, Deno 2.9.5 (native Temporal) and Bun 1.3.14 (bundled). CodeQL green, which matters because this adds a regex — it was timed at 4.74 ms on an 8 MB adversarial input, and doubling the input doubles the time.
  • Bundle: the three-call program goes 16,295 B gzip to 16,432 B, the whole surface 21,550 to 21,762, the program plus day() 19,349 to 19,568.

Three review rounds found six defects that twelve green CI checks did not, which is the note worth keeping. The lowercase t refused while its zoned twin already answered. A seconds bound justified by a clamp the instant path already tolerated. An argument-pair guard that blamed the zone for an impossible day. A changelog heading that silently reclassified four older bullets. An incomplete sweep after the first fix. A backlog rule restatement that went stale inside one round. Not one was caught by a gate.