Releases: leemr/daymath
Release list
v0.7.4
Nothing a caller can observe changes. No export was added, removed or altered, and no call answers differently. Upgrading is safe and optional.
What moved is the documentation and one gate.
- The README leads with the defect daymath prevents, and the calendar rule and CDN forensics moved to
docs/calendars.mdanddocs/cdn.md. Both joined the examples gate in the same commit, so every claim they carry is still executed and asserted. npm run test:examples:tzruns every prose claim in three fixed-offset zones. The gate had only ever run in whatever zone the machine was set to, which let a zone-dependent claim pass on a laptop and fail in CI.npm cino longer warns about theesbuildinstall script under npm 11.19.- A code of conduct, issue templates and a pull request template.
The full mechanism, the defects found on the way, and the commands that prove each one are in CHANGELOG.md.
v0.7.3
Added
-
npm run test:splitbuilds a broken Temporal on disk and checks that daymath still tells the truth. 0.7.2 fixed one catch-all and shippedtest:demoto watch for the next one, but that gate needs the network, reads only the ops the demo page offers, and can only see a build that is already published. This one copiestemporal-polyfillout ofnode_modulesonce per subpath, rewrites the bare specifiers inindex.jsto point each import at its own copy, and imports the result. That is exactly what jsDelivr's+esmproduces, so the brand check between the copies fails the same way, and it runs offline against the working tree instead of against npmjs. The law it checks is one sentence, and it holds for any export: a broken implementation must never make daymath answer wrongly or blame the caller. Every call in the roster either agrees with../index.jsor throws aTypeError. ARangeErroris the failure it exists to catch, because that is daymath saying the input was bad when the input was fine, and a wrong answer is worse still. Proved to block by revertingindex.jsto 0.7.2 and running it: the run names each mislabel and each wrong answer, with the source answer beside it, and exits 1. It runs on every matrix version inci, because the shim lane and the native lane word their faults differently, andprepublishOnlynow runs it too.A second phase raises the same fault from one import at a time, and that is the half that reaches every catch. The CDN shape cannot reach them all, and it never will:
InstantFns.fromStringandZonedFns.fromFieldstake no calendar resolver, so no record crosses between copies and a split leaves both alone however the bundles fall. Their narrowing would then be code no gate runs, which is exactly how a catch-all comes back in a later edit. So the second phase stops modelling one CDN and models the rule — any import can fault, and daymath owes the same answer either way. Nothing is hand-listed: the import roster is read out ofindex.js, so an import added later joins the phase with no edit here, and a namespace member counts only where the source actually calls it. Each phase was proved to block on its own by planting the defect it exists for. Deleting the narrowing at those two calls turns the run red and names both,day("2026-01-01T00:00:00Z")reportingneither a moment nor a time zoneandday(0, "America/New_York")reporting an unknown time zone for one it knows. An import that refuses to load is a legal outcome, because it is loud and answers nothing, and the run reports how many loaded. Three things stop that legality becoming a quiet pass, and a review round added all three. The stub is loaded on its own first, so a probe the gate failed to BUILD is a gate defect rather than a silent skip — an aliased import wrote a stub that did not parse, and the run would have gone quiet on the one import it was watching. A{ getAny as resolve }import now yields the export name rather than the local one. And which imports were checked is recorded inscripts/split-copy.baseline.json, so coverage cannot fall quietly — a count can say that something stopped being checked and never say what, which is the lessonexamples.baseline.jsonalready carries.declinedis a real state in that roster rather than a failure:index.jscallsgetAnyat module scope, so a faulting stub stops the whole module loading and no export can be judged. Drift fails in both directions and names the import.npm run test:split:writere-records. Every one of these was proved to block by planting it. -
The cross-runtime battery never passed a third argument, and never passed a
weekStartsOnit refuses.isSameWeekandareIntervalsOverlappingare the only exports that take three, so their whole options path was unprobed, andscripts/battery.mjsoffered only 0, 1 and 7 — all accepted. That is whynpm run test:runtimespassed straight through theisSameWeekmessage fix below. An export could change WHICH error it reports for a bad option and no hash would move. The battery now carries aWEEK_STARTSlist holding both halves, passes it in the third slot as well as the second, and exercisesinclusiveonareIntervalsOverlapping. Proved by recording the baseline against 0.7.2 and running it against this branch: one export differs, and it isisSameWeek. That is also the independent evidence that this release is a patch. The baseline is re-recorded, and it agrees on Node, Deno and Bun, so the widened probes read the same on native Temporal and on the polyfill. The same gap asJUNKin that file, which exists because no probe ever passed a bad string.
Fixed
- A broken Temporal was still being relabelled everywhere
toPlainDateis not. 0.7.2 narrowed one catch and called the job done. It was one instance of a class, and a sweep of the perimeter found the rest, each proved reachable by loading a broken build and calling it.isValidwas the worst of them: it answeredfalsefor a perfectly valid day and raised nothing at all, so a reader learned nothing and had nowhere to look.calendarRulereportedunknownfor a calendar the runtime names happily.guardRangerelabelled every throw under it, across every add, sub,setYear,startOfand interval export. The zoned branch ofday()said it could not read a zone that it had read correctly. All of them now go through one function,assertCallerFault, which re-throws anything that is not aRangeError— find them withgrep -n 'assertCallerFault' index.js. The discriminant is a measurement, not a guess: a bad string, a bad field, an out-of-range result and an unknown calendar each report aRangeErroron the shim lane and on the native lane, checked through parse,withFields,addDays,addYears,fromFieldsandwithCalendar.isValidneeded its own shape check first, becausetoPlainDatereports a non-string with aTypeErrorandisValidstill owesfalsefor one. isSameWeekblamed the dates for a badweekStartsOn. It read the option inside its own range guard, soisSameWeek(a, b, { weekStartsOn: 8 })answereddaymath: isSameWeek could not produce a valid dateand never named the option.startOfWeekandendOfWeekalready read it before the guard and sayweekStartsOn must be an integer 0…7.isSameWeeknow does the same, which is the same defect as the entry above wearing different clothes: a guard that catches more than it was written for.
v0.7.2
Added
npm run test:demoloads the demo page's own module URL and checks it against the source. Every other gate in this repo reads this repo, and the defect below was not in this repo, so nothing could see it. This one reads the page, pulls the import URL, the<option>roster, and the preset buttons straight out ofdocs/index.html, fetches whatever that URL resolves to, and calls every op the page offers on every day the page ships. The oracle isindex.jsitself, so a published build that merely throws is caught and so is one that quietly answers something else. The module graph is mirrored to disk one URL at a time rather than concatenated, which matters more than it sounds: a CDN handing out two copies of one package produces two files here too, so the bug reproduces instead of being bundled away. Three guards, each proved to fire alone by planting one defect apiece: pointing it at the old jsDelivr URL turns it red with the real error on every call, adding an<option>the gate cannot call fails by name, and removing one the gate does call fails by name too. It is deliberately not part ofnpm testor theciworkflow, on two grounds: it needs the network and a live CDN, so it must never be able to block a merge, and it measures what is PUBLISHED, so a pull request that changes behaviour would fail it until release. It runs weekly, on demand, and whenever the page or the gate changes on master. Run it by hand after a release.
Fixed
- The GitHub Pages demo threw on every date, and had done since the release carrying
4a6e564. Name that release withgit tag --contains 4a6e564 | head -1. The page reporteddaymath: invalid date "2026-08-06"on load, for a day that is plainly valid, and the real error underneath it wasTypeError: Invalid calling contextout oftemporal-polyfill. Nothing in daymath's own code is wrong, which is why every gate stayed green. The page loaded daymath from jsDelivr's+esmendpoint, and jsDelivr builds each subpath of a dependency as its own bundle, carrying its own private copy of that package's internals. daymath importstemporal-polyfill/fns/PlainDateandtemporal-polyfill/fns/Calendar, sofromStringcame out of one copy while thegetAnyresolver passed to it came out of another, and the first copy does not recognise a calendar the second one built. No import spelling avoids that split:temporal-polyfill/fnsis in the export map but its file is an empty stub (wc -c node_modules/temporal-polyfill/fns/index.js), so the separate subpaths are the onlyfnsAPI on offer. The page now loads fromhttps://esm.sh/daymath?bundle, which inlines a single copy, verified against every function the page exposes and every preset it ships. Anyone loading daymath through jsDelivr+esmhits the same wall, and that is a packaging question fortemporal-polyfillrather than something daymath can answer from inside its own tarball. - A broken Temporal now says so, instead of being relabelled as a bad date.
toPlainDatewrapped the parse in a catch-all and rewrote every throw asdaymath: invalid <label> <input>, so an implementation fault arrived wearing the one message guaranteed to send the reader off after their own input. That is precisely what happened above, and it is why the demo sat broken across three releases with nobody suspecting the CDN. Only aRangeErroris relabelled now, and anything else surfaces unchanged with its own message. Temporal reports every genuine input fault as aRangeError, and an impossible day, a month out of range, a year past the Temporal bounds, and an unknown calendar were each checked, so nothing that used to be caught stops being caught. Thecausewas always attached and the demo page was throwing it away; the page prints the whole chain now, so the next fault of this shape is readable on screen instead of only in a debugger.
v0.7.1
Added
index.d.tsnow carries@exampleblocks, and it had none at all. Measured before starting: zero examples across 69 declarations, whileindex.jshad ten and every one was inside theday()block.package.jsondeclares exactly one types path —"types": "./index.d.ts", repeated in theexportsmap — so a TypeScript editor resolves documentation from the.d.tsand never parsesindex.js. Every example daymath had was invisible on hover, which is where a typed caller reads. Every declaration site now has a hover tooltip, and every one that can carry a runnable@exampledoes — and the gate now enforces exactly that — before this, 55 of 69 exports showed nothing but their signature. The type aliases are exempt by name, because a type has no call to show. The count is by declaration SITE rather than by export name, and that difference is what found the gap, becausedayis overloaded and an editor shows the doc of the overload it RESOLVES: the whole contract block sat onday(tz?), the zone-only form, whileday(moment, tz?)— the one that takes aDate, epoch milliseconds, a day string or a SQLiteDATETIME— had none. The block now sits on the moment overload, where nearly all of it applies, and the clock-reading overload gets its own. The first pass covered only the traps, which left sibling asymmetries that are their own defect:mindocumented andmaxbare,startOfWeekdocumented andendOfWeekbare,addMonthsdocumented andsubMonthsbare,differenceInMonthsdocumented anddifferenceInYearsbare on the same counting rule,eachDayOfIntervaldocumented and the other two bare on the same inclusivity. Documenting one of a pair implies the other differs.isLeapYearwas bare while carrying the library's single most dangerous trap —isLeapYear('2567-01-01')isfalsewhile Buddhist 2567 is ISO 2024 and IS a leap year, and the two rules disagree in 49 of the 101 Buddhist years from 2500 to 2600 with nothing thrown. Coverage is complete, but emphasis is not: where daymath surprises a date-fns user earns a worked example rather than a bare call, and that choice is made by surprise rather than by call frequency:getMonthis 1-12,getDayis 1=Monday…7=Sunday,addMonths/setMonth/setDate/setYearclamp instead of rolling,startOfWeekdefaults to Sunday,differenceInMonthscounts byaddMonths,mintakes an array, and the interval helpers are inclusive at both ends.formatandisValidshow what they refuse.npm run test:examplesexecutes every@exampleand asserts its stated answer. It readsREADME.mdtoo, which is the file that most needed it: it ships in the tarball, it is what a developer opens first, and three of itsday()lines had silently rotted. Fixing those three closed the instances; reading the file closes the class. Every claim in every file it reads is either asserted or listed with the reason it cannot be, and the accounting closes with none unattributed. It is a new gate for a real blind spot:tsctype-checks the declarations and ignores the comments, the differential harness compares daymath to date-fns and never opens a JSDoc block, and the cross-runtime battery enumerates exports rather than documentation. So an example could go stale on any behaviour change with every gate still green — and the example is the line a caller copies. Proved to block by planting a wrong answer: exit 1, naming the file, line, expected and actual.- The gate reconciles its own count against the source, because it needed to. A one-line
/** … @example … */block was invisible to the collector, so every example inside one went unchecked and the run stayed green — the exact failure the gate exists to prevent, inside the gate. Fixing the regex would have closed that instance and not the class, so the script now counts raw@exampleoccurrences per file and fails when fewer were read than written. Proved by reverting the regex: the run prints the total written, the smaller total read, and each file's own written count, then exits 1. A recorded roster of unassertable claims catches the other direction, a checked claim being downgraded to prose — which the accounting cannot see, because the written total does not move. The roster is a roster and not a count, because a count can say that a claim stopped being checked and can never say WHICH. It lives inscripts/examples.baseline.json, besidecross-runtime.baseline.jsonandbundle-size.baseline.json, and it is keyed by file, expression and reason with no line number, so an edit anywhere above a claim does not move it. Dropping the line number means two claims can share a key, because the same expression is sometimes documented on two declarations, so the diff counts occurrences rather than testing membership. It has to: with a membership test, a key already in the roster once would let a SECOND claim with that key be downgraded in silence, which is the one direction the roster exists to catch. Measured at two edits from a clean tree, exit 0, reporting "roster unchanged" while a claim had stopped being checked. Only the diff is ever printed, so a green run is one line — a wall of skips on every pass is a wall nobody reads, and a check nobody reads is not a check. Drift fails both ways: an added line is a claim that stopped being checked, a missing line was asserted or deleted. Both are deliberate, andnpm run test:examples:writere-records. Proved by planting each direction, and each names the exact claim. - The gate now proves every declaration carries an example, and stops dropping two-line claims in
README.md. Two holes, both found by reviewing this PR's own fixes rather than its code. The release notes claimed every capable declaration had an@example, and five did not —getYear,getDate,getQuarter,isSameDayandareIntervalsOverlapping, three of them the sibling asymmetry this entry already names as a defect.getYearwas the worst of them, becausegetYear('2026-01-31[u-ca=buddhist]')is 2569 and that is the library's headline trap. All five now carry a runnable example, and a declaration site with no@examplefails the run by name. Proved by planting: strip an example, or add a bare export, and the run names it and exits 1. The second hole was an asymmetry between the two collectors —collectjoins a bare// …continuation onto the expression above it andcollectFenceddid not, so aREADME.mdclaim written across two lines landed in NO bucket, invisible to the skip list AND to the accounting, because both of that check's totals come from the same function. A declaration line carrying an answer was dropped the same way. Four claims sat in those two gaps: three joined the roster and the fourth asserts. Those three are newly VISIBLE, not newly unchecked — nothing moved from checked to unchecked. CHANGELOG.mdis read by the gate, and a released entry was already lying. Two blind spots, one cause. The gate readREADME.mdonly, and its fence regex was anchored at column zero — so every INDENTED ```js fence was invisible, which is howCHANGELOG.mdand `FUTURE.md` write all of theirs. Un-anchoring the regex and adding the file found `day() // '2026-08-08' now, UTC` in the shipped 0.4.0 notes, rotted since the day it was written and seen by no gate. A released entry is a record, but a wrong code example in it is still copied. `CHANGELOG.md` stays on the list, so every future release note carries the same duty as the README.npm publishcannot skip the gate.prepublishOnlyrantest:coveragealone, so the examples gate existed and the release path never consulted it — the same shape of gap as a claim nothing checks. It now runs both.
Fixed
-
Seven clock-dependent examples had silently rotted, across three files. They claimed
day()was'2026-08-08',day('Asia/Tokyo')was'2026-08-09'andaddDays(day(), 2)was'2026-08-10'— all true the day they were written and none true afterwards, becauseday()reads a clock. A literal that depends on today's date can only rot, so every one now reads as prose. No behaviour changed; the documentation stopped lying.Three were in
index.js, three inREADME.md, and the seventh inCHANGELOG.mditself, inside the released 0.4.0 entry. Each set was found by a different means, and the order is the whole lesson. Theindex.jsthree came from the gate. TheREADME.mdthree came from sweeping the CLASS rather than the diff, and they matter more, becauseREADME.mdships in the npm tarball. The seventh was found by nothing at all until the gate was pointed at the file — it had survived three releases and every check. So the class was closed twice by hand before the tool could see the last instance, which is the argument for widening what the tool reads rather than fixing what it reports. -
setYearnow says that date-fns disagrees with it, whichsetDatealready said. Measured on date-fns 4.4.0:setYear('2024-02-29', 2026)rolls to2026-03-01where daymath clamps to2026-02-28.scripts/differential.mjsalready recorded it.setDatenamed the divergence andsetYeardid not, so the silence implied agreement — and this is the one a caller reaches by accident, through a stored 29 February.
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.
v0.6.0
daymath is built on temporal-polyfill/fns instead of the Temporal class. Nothing a caller can observe changes, and the bundle drops by a third.
Changed
- A class is one unit to a bundler — it cannot prove a method unreachable — so the class build shipped whole for the twenty-odd Temporal operations daymath uses. Free functions drop what is not called. Measured by
npm run sizein one run: the three-call program goes 24,735 B gzip to 16,295 B, −34%; the whole surface 27,304 to 21,550, −21%; the three-call program plusday()25,269 to 19,349, −23%. - daymath no longer reads
globalThis.Temporalat all. The selection block 0.5.0 added is deleted.fnspicks its own implementation, and it defers to native where the runtime has one — Node 26 and Deno run native, Node 20 through 24 and Bun run the bundled build. That is whattemporal-polyfill/fullfailed to do, and it is why the hand-rolled selection could go rather than be ported. - The calendar resolver is
getAny, which carries every calendar's data. A narrower resolver would have been a defect: it drops the[u-ca=…]annotation on runtimes without native Temporal, so the same program would answer2569on Node 26 and2026on Node 20. It is the same 4.2 kB the calendars cost in 0.5.0, paid at a different layer rather than saved.
Fixed
differenceInDaysno longer uses thefnsdiffDaysconvenience.PlainDate's minimum is-271821-04-19, one day belowPlainDateTime's, anddiffDaysconverts to aPlainDateTimeand inherits the narrower limit, so it refused that one operand.diffwithlargestUnit: 'day'does not convert and answers what the class API answered.
Verified
- The battery ran against both implementations in one process and the rows were compared one by one: 69 exports, 62,928 calls, 0 differences. The cross-runtime baseline is unchanged and passes on Node 26.7.0, Deno 2.9.5 and Bun 1.3.14 — re-recording it would have hidden exactly this.
test.jsis unedited, which is the point: had a test needed changing, the behaviour would have changed.
Housekeeping
SECURITY.mdnow says 0.6.x. It had been stale at 0.2.x through three releases.
Upgrading from 0.5.0 needs no code change.
Full detail in CHANGELOG.md and #9.
v0.5.0
Four calendars that only relabel the year are now accepted, and the annotation rides along. Nothing breaks.
Added
buddhist,roc,japaneseandgregoryare accepted, where every non-ISO calendar was refused before.getYear('2026-01-31[u-ca=buddhist]')answers2569,addDaysreturns'2026-02-01[u-ca=buddhist]', andsetYear(…, 2570)returns'2027-01-31[u-ca=buddhist]'.- The line is measured at run time, not held as a list. The month and day must equal the ISO fields, and the year offset must be constant. A calendar CLDR adds later is admitted or refused with no code change.
Changed
- A mixed calendar pair measures instead of throwing.
differenceInDays('2026-03-01', '2026-01-31[u-ca=buddhist]')answers29. Every export that takes two dates measures in one coordinate system; only a single-date field read or write honours the year label. temporal-polyfill/fullreplaces the base build, at +4.2 kB gzip. The base build cannot construct these calendars where the runtime has no native Temporal, so Node 20 and Bun would have thrown.day()is the normaliser and drops the annotation, so it always returns a plain ISO day.
Fixed
- Nine two-date exports measured a mixed pair in two coordinate systems and answered wrong numbers with no error. A 29-day gap read 6,513 months.
- daymath ran the bundled polyfill on every runtime, because
temporal-polyfill/fullnever defers to native Temporal. daymath now selects the implementation itself. - The calendar verdict cache grew without bound from caller input. 200,000 rejected ids retained 56.5 MB.
Watch out
- Never pass a calendar's own year as a bare ISO year.
isLeapYear('2567-01-01')isfalse, while the real Buddhist 2567 is ISO 2024 and a leap year. Nothing throws. The two rules disagree in 49 of the 101 Buddhist years from 2500 to 2600.
Full detail in CHANGELOG.md and #7.
v0.4.0
day() — the one way a moment enters daymath — plus a cross-runtime test lane that proves the rest of the API answers identically everywhere.
Breaking
- Node 18 is dropped.
enginesis now>=20.19.0 <21 || >=22.12.0, which states the real constraint:require('daymath')needs Node'srequire(esm), added in 20.19 and 22.12. Node 18, 21 and 22.0–22.11 will warn on install. - Error messages no longer quote Temporal's own wording.
addDays could not produce a valid date (Out-of-bounds date)is nowaddDays could not produce a valid date. The original error is oncause. Match on the class, not the text.
Added
day(moment?, tz?)returns the calendar day of a moment, in a zone. It takes aDate, epoch milliseconds, an ISO day, aTemporal.PlainDate, or an ISO timestamp. Both defaults are stated: the moment is now, the zone is UTC. It is the only export that reads a clock, and the only door aDateenters by.npm run test:runtimes, a recorded baseline over every export, run on Node, Deno (native Temporal) and Bun.- Lint, format and type gates: oxlint, oxfmt, and
tsc --checkJsover the JSDoc.
Fixed
- A
Temporal.PlainDatefrom another implementation is accepted.isValidused to answerfalsefor a valid date from native Temporal, or from a second copy oftemporal-polyfillin the same dependency tree. - A non-ISO calendar is refused rather than reinterpreted, and the error names it.
[u-ca=iso8601]is accepted and dropped, because Temporal writes it itself. - The six interval-reading exports name the interval, rather than blaming a property the caller never passed.
Full detail in CHANGELOG.md and #5.
v0.3.0
ISO 8601 month and weekday numbers, and full months/years counted by add.
Breaking
getMonth/setMonthare ISO 1-12 (1 = January), replacing date-fns's 0-11. The number now matches theMMfield of the day string.getDayis ISO 1-7 (1 = Monday, 7 = Sunday). Only Sunday changes number.differenceInMonthsanddifferenceInYearscount a unit as full whenaddMonths/addYearswould carry the earlier date to the later one. 31 Jan to 28 Feb is 1 month; 29 Feb to 28 Feb of a common year is 1 year.
Not breaking
weekStartsOnaccepts 1-7 and defaults to 7.0still means Sunday, since 0 is congruent to 7 mod 7.differenceInWeeksanddifferenceInQuartersno longer return-0.
Also carries the range-error fixes unreleased since 0.2.3.
Full detail in CHANGELOG.md and #4.
v0.2.3
Added
- Hard 100% coverage gate via c8 + Codecov upload/badge
- CI Node 18–26; Actions checkout/setup-node v7
- GitHub Packages:
@leemr/daymath - CONTRIBUTING, SECURITY, llms.txt, examples/basic.mjs
- Open Graph
docs/og.png; master branch protection
Changed
- package homepage → Pages; sideEffects: false
No public API changes from 0.2.2.
npm: npm install daymath@0.2.3
GitHub Packages: @leemr/daymath@0.2.3 (see README)