Skip to content

Releases: leemr/daymath

v0.7.4

Choose a tag to compare

@leemr leemr released this 12 Sep 19:03

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.md and docs/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:tz runs 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 ci no longer warns about the esbuild install 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

Choose a tag to compare

@leemr leemr released this 06 Sep 17:10

Added

  • npm run test:split builds a broken Temporal on disk and checks that daymath still tells the truth. 0.7.2 fixed one catch-all and shipped test:demo to 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 copies temporal-polyfill out of node_modules once per subpath, rewrites the bare specifiers in index.js to point each import at its own copy, and imports the result. That is exactly what jsDelivr's +esm produces, 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.js or throws a TypeError. A RangeError is 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 reverting index.js to 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 in ci, because the shim lane and the native lane word their faults differently, and prepublishOnly now 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.fromString and ZonedFns.fromFields take 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 of index.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") reporting neither a moment nor a time zone and day(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 in scripts/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 lesson examples.baseline.json already carries. declined is a real state in that roster rather than a failure: index.js calls getAny at 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:write re-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 weekStartsOn it refuses. isSameWeek and areIntervalsOverlapping are the only exports that take three, so their whole options path was unprobed, and scripts/battery.mjs offered only 0, 1 and 7 — all accepted. That is why npm run test:runtimes passed straight through the isSameWeek message fix below. An export could change WHICH error it reports for a bad option and no hash would move. The battery now carries a WEEK_STARTS list holding both halves, passes it in the third slot as well as the second, and exercises inclusive on areIntervalsOverlapping. Proved by recording the baseline against 0.7.2 and running it against this branch: one export differs, and it is isSameWeek. 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 as JUNK in that file, which exists because no probe ever passed a bad string.

Fixed

  • A broken Temporal was still being relabelled everywhere toPlainDate is 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. isValid was the worst of them: it answered false for a perfectly valid day and raised nothing at all, so a reader learned nothing and had nowhere to look. calendarRule reported unknown for a calendar the runtime names happily. guardRange relabelled every throw under it, across every add, sub, setYear, startOf and interval export. The zoned branch of day() 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 a RangeError — find them with grep -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 a RangeError on the shim lane and on the native lane, checked through parse, withFields, addDays, addYears, fromFields and withCalendar. isValid needed its own shape check first, because toPlainDate reports a non-string with a TypeError and isValid still owes false for one.
  • isSameWeek blamed the dates for a bad weekStartsOn. It read the option inside its own range guard, so isSameWeek(a, b, { weekStartsOn: 8 }) answered daymath: isSameWeek could not produce a valid date and never named the option. startOfWeek and endOfWeek already read it before the guard and say weekStartsOn must be an integer 0…7. isSameWeek now 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

Choose a tag to compare

@leemr leemr released this 06 Sep 02:03

Added

  • npm run test:demo loads 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 of docs/index.html, fetches whatever that URL resolves to, and calls every op the page offers on every day the page ships. The oracle is index.js itself, 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 of npm test or the ci workflow, 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 with git tag --contains 4a6e564 | head -1. The page reported daymath: invalid date "2026-08-06" on load, for a day that is plainly valid, and the real error underneath it was TypeError: Invalid calling context out of temporal-polyfill. Nothing in daymath's own code is wrong, which is why every gate stayed green. The page loaded daymath from jsDelivr's +esm endpoint, and jsDelivr builds each subpath of a dependency as its own bundle, carrying its own private copy of that package's internals. daymath imports temporal-polyfill/fns/PlainDate and temporal-polyfill/fns/Calendar, so fromString came out of one copy while the getAny resolver 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/fns is 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 only fns API on offer. The page now loads from https://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 +esm hits the same wall, and that is a packaging question for temporal-polyfill rather than something daymath can answer from inside its own tarball.
  • A broken Temporal now says so, instead of being relabelled as a bad date. toPlainDate wrapped the parse in a catch-all and rewrote every throw as daymath: 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 a RangeError is relabelled now, and anything else surfaces unchanged with its own message. Temporal reports every genuine input fault as a RangeError, 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. The cause was 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

Choose a tag to compare

@leemr leemr released this 25 Aug 15:55

Added

  • index.d.ts now carries @example blocks, and it had none at all. Measured before starting: zero examples across 69 declarations, while index.js had ten and every one was inside the day() block. package.json declares exactly one types path — "types": "./index.d.ts", repeated in the exports map — so a TypeScript editor resolves documentation from the .d.ts and never parses index.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 @example does — 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, because day is overloaded and an editor shows the doc of the overload it RESOLVES: the whole contract block sat on day(tz?), the zone-only form, while day(moment, tz?) — the one that takes a Date, epoch milliseconds, a day string or a SQLite DATETIME — 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: min documented and max bare, startOfWeek documented and endOfWeek bare, addMonths documented and subMonths bare, differenceInMonths documented and differenceInYears bare on the same counting rule, eachDayOfInterval documented and the other two bare on the same inclusivity. Documenting one of a pair implies the other differs. isLeapYear was bare while carrying the library's single most dangerous trap — isLeapYear('2567-01-01') is false while 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: getMonth is 1-12, getDay is 1=Monday…7=Sunday, addMonths/setMonth/setDate/setYear clamp instead of rolling, startOfWeek defaults to Sunday, differenceInMonths counts by addMonths, min takes an array, and the interval helpers are inclusive at both ends. format and isValid show what they refuse.
  • npm run test:examples executes every @example and asserts its stated answer. It reads README.md too, which is the file that most needed it: it ships in the tarball, it is what a developer opens first, and three of its day() 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: tsc type-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 @example occurrences 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 in scripts/examples.baseline.json, beside cross-runtime.baseline.json and bundle-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, and npm run test:examples:write re-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, isSameDay and areIntervalsOverlapping, three of them the sibling asymmetry this entry already names as a defect. getYear was the worst of them, because getYear('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 @example fails 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 — collect joins a bare // … continuation onto the expression above it and collectFenced did not, so a README.md claim 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.md is read by the gate, and a released entry was already lying. Two blind spots, one cause. The gate read README.md only, and its fence regex was anchored at column zero — so every INDENTED ```js fence was invisible, which is how CHANGELOG.md and `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 publish cannot skip the gate. prepublishOnly ran test:coverage alone, 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' and addDays(day(), 2) was '2026-08-10' — all true the day they were written and none true afterwards, because day() 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 in README.md, and the seventh in CHANGELOG.md itself, inside the released 0.4.0 entry. Each set was found by a different means, and the order is the whole lesson. The index.js three came from the gate. The README.md three came from sweeping the CLASS rather than the diff, and they matter more, because README.md ships 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.

  • setYear now says that date-fns disagrees with it, which setDate already said. Measured on date-fns 4.4.0: setYear('2024-02-29', 2026) rolls to 2026-03-01 where daymath clamps to 2026-02-28. scripts/differential.mjs already recorded it. setDate named the divergence and setYear did not, so the silence implied agreement — and this is the one a caller reaches by accident, through a stored 29 February.

v0.7.0

Choose a tag to compare

@leemr leemr released this 24 Aug 03:45

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.

v0.6.0

Choose a tag to compare

@leemr leemr released this 10 Aug 03:11

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 size in 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 plus day() 25,269 to 19,349, −23%.
  • daymath no longer reads globalThis.Temporal at all. The selection block 0.5.0 added is deleted. fns picks 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 what temporal-polyfill/full failed 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 answer 2569 on Node 26 and 2026 on 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

  • differenceInDays no longer uses the fns diffDays convenience. PlainDate's minimum is -271821-04-19, one day below PlainDateTime's, and diffDays converts to a PlainDateTime and inherits the narrower limit, so it refused that one operand. diff with largestUnit: '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.js is unedited, which is the point: had a test needed changing, the behaviour would have changed.

Housekeeping

  • SECURITY.md now 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

Choose a tag to compare

@leemr leemr released this 09 Aug 20:11

Four calendars that only relabel the year are now accepted, and the annotation rides along. Nothing breaks.

Added

  • buddhist, roc, japanese and gregory are accepted, where every non-ISO calendar was refused before. getYear('2026-01-31[u-ca=buddhist]') answers 2569, addDays returns '2026-02-01[u-ca=buddhist]', and setYear(…, 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]') answers 29. 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/full replaces 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/full never 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') is false, 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

Choose a tag to compare

@leemr leemr released this 09 Aug 00:41

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. engines is now >=20.19.0 <21 || >=22.12.0, which states the real constraint: require('daymath') needs Node's require(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 now addDays could not produce a valid date. The original error is on cause. Match on the class, not the text.

Added

  • day(moment?, tz?) returns the calendar day of a moment, in a zone. It takes a Date, epoch milliseconds, an ISO day, a Temporal.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 a Date enters 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 --checkJs over the JSDoc.

Fixed

  • A Temporal.PlainDate from another implementation is accepted. isValid used to answer false for a valid date from native Temporal, or from a second copy of temporal-polyfill in 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

Choose a tag to compare

@leemr leemr released this 07 Aug 15:01

ISO 8601 month and weekday numbers, and full months/years counted by add.

Breaking

  • getMonth / setMonth are ISO 1-12 (1 = January), replacing date-fns's 0-11. The number now matches the MM field of the day string.
  • getDay is ISO 1-7 (1 = Monday, 7 = Sunday). Only Sunday changes number.
  • differenceInMonths and differenceInYears count a unit as full when addMonths / addYears would 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

  • weekStartsOn accepts 1-7 and defaults to 7. 0 still means Sunday, since 0 is congruent to 7 mod 7.
  • differenceInWeeks and differenceInQuarters no longer return -0.

Also carries the range-error fixes unreleased since 0.2.3.

Full detail in CHANGELOG.md and #4.

v0.2.3

Choose a tag to compare

@leemr leemr released this 06 Aug 06:10

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)