npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

daymath

v0.7.1

Published

Calendar date math (ISO 8601 day / PlainDate). date-fns-shaped. No Date. No time zones.

Readme

daymath

npm ci codecov license node bundle

ISO 8601 calendar day math. date-fns-shaped names. Temporal.PlainDate under the hood.

No Date. No time zones. No silent “local now”. ISO 8601 ❤️

Play → · npm · Changelog · Contributing · FUTURE

npm install daymath

Same code also publishes to GitHub Packages as @leemr/daymath (scoped; see GitHub npm registry docs):

# one-time: map the scope (auth with a PAT that has read:packages, or GITHUB_TOKEN in Actions)
echo '@leemr:registry=https://npm.pkg.github.com' >> .npmrc
npm install @leemr/daymath

Most people should keep using daymath on npmjs.

import { day, addDays, addMonths, differenceInDays, isSameDay } from 'daymath'

addDays('2026-08-06', 1)      // '2026-08-07'
addMonths('2026-01-31', 1)    // '2026-02-28'
differenceInDays('2026-08-06', '2026-08-01') // 5
isSameDay('2026-08-06', '2026-08-06')        // true

day is the way in. It turns a moment into a day, and it is the only function that reads a clock.

Start here. No arguments means today, in UTC.

day()                                  // today, in UTC
addDays(day(), 2)                      // two days from today

Then hand it whatever you already have. It converts; it never stores what you gave it.

day(row.createdAt)                     // '2026-08-07'  a Date
day(1761616161771)                     // '2025-10-28'  epoch milliseconds
day('1999-01-01T00:00:00Z')            // '1999-01-01'  an ISO timestamp
day('2026-05-05')                      // '2026-05-05'  already a day
day('2026-08-08 12:00:00')             // '2026-08-08'  a SQLite DATETIME

A SQLite DATETIME goes straight in, and so does the whole surface — the clock is dropped by the same funnel every export shares, so this is not a day() feature.

addDays(row.created_at, 30)            // '2026-09-07'  from '2026-08-08 12:00:00'
startOfMonth('2026-08-08 01:57:31.913') // '2026-08-01'  strftime('%…%H:%M:%f')
getYear('2026-08-08t12:00')            // 2026          T, t or a space; same rule

datetime(), CURRENT_TIMESTAMP and strftime('%Y-%m-%d %H:%M:%f') all emit that shape, so a column value needs no reshaping first. Pass a zone with one and it throws. Naming a zone means convert, and a clock with no zone gives nothing to convert from — and datetime() defaults to UTC, so a silent answer would hand the UTC day to the one caller who asked for a local one. Put the zone in the string (Z, an offset, or [Zone]) and it converts normally.

Name a zone when the answer depends on one.

day('Asia/Tokyo')                      // today in Tokyo
day(row.createdAt, 'America/New_York') // '2026-08-06'  the evening before
day('2026-08-08T23:00:00Z', 'Asia/Tokyo') // '2026-08-09'  same instant, next day
day(zdt.toString())                    // the zone in the string wins

Both defaults are stated, not assumed. UTC is still not your day for part of every day — it runs ahead of America/New_York for 16.7% of the day, and behind Asia/Tokyo for 37.5% — so name your zone when that matters.

Six rules worth knowing:

  • A number is epoch milliseconds, exactly as new Date(n) reads it. A seconds timestamp read as milliseconds lands in 1970, with no error. daymath states the unit rather than sniffing it, because no rule can separate the two: 13 digits means milliseconds for 2001–2286 and seconds for the year 275760, and both are inside the supported range.
  • A day carries no time, so a zone does not apply to one. day('2026-05-05', 'Asia/Tokyo') is '2026-05-05'.
  • A timestamp carrying Z or an offset names an exact instant, so it is read as a moment.
  • A string carrying a [Zone] names its own zone, so it keeps its own day and the UTC default never applies. day(zdt.toString()) equals zdt.toPlainDate(). A browser sending '2026-08-08T20:00:00-04:00[America/New_York]' gets back the 8th, which is the date its user saw. Passing tz as well throws.
  • A zoneless wall clock is a day. '2026-08-08 12:00:00', '2026-08-08T12:00' and the lowercase t all answer '2026-08-08'; the clock is dropped, never read. The hour is the only bound, because the hour is the only field that can change the date: 24:00 is refused, a leap second and a fraction of any length are not. Pass tz with one and it throws. Name the zone in the string — '2026-08-08T12:00[America/New_York]' — and it converts.
  • '11/12/2026' is refused. Nobody can tell November from December in it.

A lone string takes one of four roles, decided in this order: a day, a zoned time, an instant, then a zone. The zone test is by shape — an IANA name, which carries no :, or a bare offset such as +05:30. Temporal's own zone grammar cannot decide the role: it accepts a whole timestamp and reads the zone out of it, so day('1999-01-01T00:00:00Z') would answer today. That grammar also differs between implementations — 'T12:00:00Z' is a zone to native Temporal and not to the polyfill — which would make the answer depend on the runtime.

node examples/basic.mjs   # from a clone

Why

Date is a timestamp. Hire dates, passport expiry, trip days are calendar values. daymath only does plain days as ISO strings.

| In | Out | |----|-----| | YYYY-MM-DD or expanded ±YYYYYY-MM-DD | same forms (Temporal toString) | | or Temporal.PlainDate, from any implementation | string |

A Date throws everywhere except day(), including in isValid. isValid('asdf')false.

day() is the single door a Date comes through, and it makes you name the zone or take the stated UTC default. Nothing carries a zone past that point, and nothing gives you a Date back.

Range: -271821-04-19+275760-09-13 — the Temporal.PlainDate limit, roughly ±10⁸ days from the epoch. A day outside it throws a RangeError.

date-fns parity (names, not Date)

| Topic | daymath | |-------|---------| | Values | ISO day strings, not Date | | isSameDay | Alias of isEqual | | isValid | Valid daymath day; Date throws | | getMonth / setMonth | 1–12 (1 = January) — ISO, not date-fns | | getDay | 1–7 (1 = Monday, 7 = Sunday) — ISO, not date-fns | | weekStartsOn | default 7 (Sunday); 0 also accepted | | Intervals | { start, end } |

API

Start hereday

Parseparse · format · isValid

Add/sub — Days · Weeks · Months · Years · Quarters

Get/setgetYear · getMonth · getDate · getDay · getDayOfYear · getDaysInMonth · getQuarter · isLeapYear · setYear · setMonth · setDate

BoundsstartOf/endOf Month · Year · Quarter · Week

Diffs — Days · Weeks · Months · CalendarMonths · Years · CalendarYears · Quarters · CalendarQuarters

CompareisBefore · isAfter · isEqual · isSameDay · isSameWeek · Month · Year · Quarter · compareAsc · compareDesc · min · max

WeekdayisSundayisSaturday · isWeekend · first/last day of month

IntervalseachDayOfInterval · eachMonthOfInterval · eachYearOfInterval · isWithinInterval · clamp · areIntervalsOverlapping

Amounts are finite integers.

Temporal

Built on temporal-polyfill/fns, the functional API, rather than the Temporal class. A class is one unit to a bundler, so the class build shipped whole for the twenty-odd operations daymath uses; free functions drop what you do not call. It runs on native Temporal where the runtime has it and on the bundled build elsewhere, and fns makes that choice itself. Measured on a three-call program: 24.7 kB gzip to 16.3 kB, −34%.

A Temporal.PlainDate from any implementation is accepted — native, the bundled polyfill, or a second copy of it in the same dependency tree. daymath reads its ISO day and builds its own instance, so it never depends on instanceof agreeing across copies. The common case is Temporal.Now.plainDateISO(): daymath has no today() on purpose, so that is where a caller gets one.

Calendars

Temporal can put a calendar on a PlainDate. The same day then carries different numbers:

| calendar | year | month | day | toString() | daymath | |---|---|---|---|---|---| | iso8601 | 2026 | 1 | 31 | 2026-01-31 | accepted | | buddhist | 2569 | 1 | 31 | 2026-01-31[u-ca=buddhist] | accepted | | roc | 115 | 1 | 31 | 2026-01-31[u-ca=roc] | accepted | | japanese | 2026 | 1 | 31 | 2026-01-31[u-ca=japanese] | accepted | | gregory | 2026 | 1 | 31 | 2026-01-31[u-ca=gregory] | accepted | | hebrew | 5786 | 5 | 13 | 2026-01-31[u-ca=hebrew] | refused | | chinese | 2025 | 13 | 13 | 2026-01-31[u-ca=chinese] | refused |

daymath accepts a calendar that only relabels the year, and refuses one that renumbers. The line is measured at runtime, not held as a list, so a calendar CLDR adds later needs no code change here. Two conditions, both required, on nine probe dates spanning 1900 to 2100:

  1. Month and day equal the ISO fields. A month count would be wrong: hebrew is lunisolar, so it has 12 months in 2025 and 13 in 2027.
  2. The year offset is constant. Two probe pairs straddle a Japanese era boundary, because an era change inside one ISO year is what separates a label from a renumbering.

For the accepting family only the year label moves, so every export still answers honestly, and the annotation rides along:

getYear('2026-01-31[u-ca=buddhist]')        // 2569  not 2026
getMonth('2026-01-31[u-ca=buddhist]')       // 1
addDays('2026-01-31[u-ca=buddhist]', 1)     // '2026-02-01[u-ca=buddhist]'
setYear('2026-01-31[u-ca=buddhist]', 2570)  // '2027-01-31[u-ca=buddhist]'
getYear('2026-01-31[u-ca=roc]')             // 115
getYear('2026-01-31[u-ca=japanese]')        // 2026

A renumbering calendar is refused, and the message names the calendar and the way out. A calendar this runtime cannot build at all gets its own message, so a typo does not read as a renumbering calendar:

getMonth('2026-01-31[u-ca=hebrew]')
// RangeError: daymath: date calendar "hebrew" renumbers months or days, so daymath
//             cannot answer a day in it (convert with withCalendar('iso8601'))

getYear('2026-01-31[u-ca=buddhst]')
// RangeError: daymath: date calendar "buddhst" is not a calendar this runtime knows
//             (convert with withCalendar('iso8601'))

format(hebrewDate.withCalendar('iso8601'))   // '2026-01-31'

A day is the same day whatever its year is labelled, so a mixed pair measures rather than throwing. Temporal's own since refuses this with Mismatched calendars, and its equals compares the calendar as well as the day. Neither matters to a day count, so daymath normalises both sides for measurement. Only the exports that read or write a field honour the label:

differenceInDays('2026-03-01', '2026-01-31[u-ca=buddhist]')   // 29
isEqual('2026-01-31[u-ca=buddhist]', '2026-01-31')            // true

The object form is a different day, and that is Temporal's rule, not daymath's. A string's date part is always ISO; the annotation changes how fields are read, never how the string parses:

Temporal.PlainDate.from('2026-01-31[u-ca=buddhist]')            // ISO 2026-01-31, .year 2569
Temporal.PlainDate.from({year: 2026, month: 1, day: 31,
                         calendar: 'buddhist'})                  // ISO 1483-01-31, .year 2026

543 years apart. daymath takes strings and PlainDate objects, never the fields form, so it inherits Temporal's rule and stays consistent with it.

[u-ca=iso8601] is accepted and dropped rather than carried. Temporal writes it itself for toString({ calendarName: 'always' }), and daymath already answers in it:

const written = plainDate.toString({ calendarName: 'always' })  // '2026-01-31[u-ca=iso8601]'
getYear(written)                                                // 2026

A calendar is judged where it is applied. On a day string it is, and with a [Zone] bracket it is. Without a bracket the string names an Instant, which has no year, month or day for a calendar to renumber, so the annotation is inert.

day() accepts every annotation the other exports accept, and drops it from the result. It is the normaliser: a moment converts to a plain ISO day, and so does a day. One rule, three input shapes:

day('2026-08-08T20:00:00Z[u-ca=buddhist]')                  // '2026-08-08'
day('2026-08-08T12:00[America/New_York][u-ca=buddhist]')    // '2026-08-08'
day('2026-08-08[u-ca=buddhist]')                            // '2026-08-08'
day('1999-06-06[Asia/Tokyo][u-ca=hebrew]')                  // throws

parse('2026-08-08[u-ca=buddhist]')     // '2026-08-08[u-ca=buddhist]'  parse keeps it

parse validates and preserves. day() normalises. Every other export carries the annotation, because the caller asked for that numbering.

Working in a non-ISO calendar

Do not pass the calendar's own year as a bare ISO year. This is the one way to get a wrong answer with no error, and it is why the annotation is not decoration.

Buddhist 2567 is ISO 2024, which is a leap year. The Buddhist year is ISO + 543, and 543 mod 4 is 3, so the leap years land in different places:

| call | bare 2567 | annotated, the real Buddhist 2567 | |---|---|---| | isLeapYear | false | true | | getDaysInMonth for February | 28 | 29 | | parse('…-02-29') | throws invalid date | 2024-02-29[u-ca=buddhist] | | addDays(Feb 28, 1) | 2567-03-01 | 2024-02-29[u-ca=buddhist] |

The two disagree in 49 of the 101 Buddhist years from 2500 to 2600. Nothing throws on the bare form, and addDays('2567-02-28', 1) answering 2567-03-01 looks reasonable, so a date lands one day early for the rest of that year. Every other month is identical, because February is the only month whose length varies.

A Date cannot help you here, because a Date has no calendar. It is one number of milliseconds. getUTCFullYear() is always Gregorian, so a Thai user's Date already holds 2026. The 2569 exists only at display time, when Intl formats it:

new Intl.DateTimeFormat('th-TH-u-ca-buddhist', {dateStyle: 'short'}).format(d)   // '8/8/69'

So the recipe is:

  1. From a Date or a timestamp, call day(…). You get a plain ISO day.
  2. To work in Buddhist years, annotate that day: '2026-08-08[u-ca=buddhist]'. Now getYear is 2569, isLeapYear is right, and every export carries the annotation through.
  3. From a Buddhist year number, use Temporal's fields form. This is the one place the fields/string asymmetry helps rather than traps:
Temporal.PlainDate.from({year: 2569, month: 8, day: 8, calendar: 'buddhist'}).toString()
// '2026-08-08[u-ca=buddhist]'      <- and daymath accepts the object directly too
  1. For display, use Intl. daymath does no localised formatting.

One more trap in the same family: '2569-08-08[u-ca=buddhist]' is a valid string, and its getYear is 3112. The date part is ISO 2569, and the annotation adds 543 on top.

Supporting these calendars costs 4.2 kB gzip, because daymath resolves them with getAny, the fns resolver that carries every calendar's data. The narrower resolvers cannot serve the rule: on a runtime without native Temporal they drop the annotation instead of refusing it, so the same program would answer 2569 on one lane and 2026 on another. getAny answers identically everywhere, which is the point.

Error messages quote no Temporal text, because implementations word the same failure differently. The original error is on cause.

Behaviour is checked against a recorded baseline on Node, Deno (which ships native Temporal) and Bun — every export, npm run test:runtimes. The exact call count lives in scripts/cross-runtime.baseline.json, which is the only place it cannot go stale.

Requirements

ESM only. Node 20.19+, or 22.12+.

That floor is require(), not import. Node's require(esm) landed in 20.19 and 22.12, so those are the versions where require('daymath') works. engines states the range exactly, including the gap at 22.0–22.11. temporal-polyfill is ESM-only too, so a CJS build of daymath would not escape this. Bundlers and browsers are unaffected. A Jest consumer needs a transformIgnorePatterns entry, because Jest does not use Node's resolution.

Node 18 was supported through 0.3.0 and is dropped here. It went end-of-life in April 2025.

Types & tests

Plain JS + index.d.ts (no compile step). CI runs on Node 20, 22, 24, and 26; the coverage gate runs on 26, which is also the version in .node-version and the one used to publish. The matrix still proves the floor.

npm test
npm run test:coverage   # c8: 100% lines/funcs/branches on index.js + lcov

CI uploads coverage to Codecov (see CONTRIBUTING.md for one-time app/token setup).

PRs welcome via fork — see CONTRIBUTING.md. Security reports: SECURITY.md.

License

MIT