v0.7.0
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') // 2026Added
- A day may carry a zoneless wall clock,
YYYY-MM-DD HH:MM[:SS[.fff]], withT,tor a space between them. Measured on SQLite 3.54.0, that is every shape it emits:datetime()andCURRENT_TIMESTAMPgive seconds,strftime('%Y-%m-%d %H:%M:%f')anddatetime('now','subsec')add three fractional digits. - It lands in
bareDay, the funnel all 69 exports share, so this is not aday()feature.addDaysis still not routed throughday(), and nothing added reads a clock, so no export became impure andday()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
twherever it acceptsT, soday('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:60and a fraction of any length stay inside their own day and are accepted, matching whatday('…23:59:60Z')already answered on the instant path. 24:00is refused, because SQLite disagrees with itself about it.julianday('2026-08-08 24:00:00')is2461261.5— identical tojulianday('2026-08-09 00:00:00')— whiledate()andstrftime('%Y-%m-%d')on the same string both answer'2026-08-08'. No answer daymath could give agrees with the database: the 9th contradicts itsdate(), the 8th contradicts itsjulianday(). Refusing is the only option that never silently disagrees. Temporal refuses24:00on all five of its paths too.day()throws aTypeErrorwhen 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 inAmerica/New_York:datetime('now')read2026-08-24 01:57:31anddatetime('now','localtime')read2026-08-23 21:57:31— different days at one instant, becausedatetime()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.jsand 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, allisValidgoingfalsetotrueon 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.