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

@crewhaus/tool-datetime

v0.7.0

Published

Deterministic date and time tools: parsing, formatting, timezone conversion, business-day and duration arithmetic, recurrence expansion

Downloads

151

Readme

@crewhaus/tool-datetime

Deterministic date and time tools. No filesystem, no network, no randomness, and — the rule that shapes the whole package — no clock.

There is no Date.now() here and no implicit "today". Every tool that needs a reference time takes it as an input field. CronNext makes you pass the instant to search forward from; BusinessDays makes you pass the start date and the holiday list. That is a constraint with teeth, and it is the point: a tool that quietly consults the system clock cannot be cached, cannot be replayed, and turns every eval into a flake.

LocalTime is the one tool whose answer depends on the machine, and it is the zone it depends on, not the clock — its instant is an input like everywhere else. See The one impure file.

tools:
  - all-datetime        # every tool below
  - -recurrenceExpand   # ...except this one

| Tool | What it does | |---|---| | BusinessDays | Count business days between two dates, or move a number of them, against your weekend and holiday list | | CronDescribe | Render a 5-field cron expression as English, with the expanded value set per field | | CronNext | The next N firing times of a cron expression, from a reference time you supply | | DateAdd | Add or subtract years, months, weeks, days, hours, minutes and seconds, clamping at month ends | | DateConvertTimezone | Move an instant between IANA zones, with the offset and abbreviation that applied then | | DateDiff | Distance between two instants in a chosen unit, plus a years/months/days/hours breakdown | | DateFormat | Render an instant through a token pattern in a chosen zone | | DateParse | Parse a date string into a normalized UTC instant, reporting ambiguity instead of guessing | | DateRange | Expand a start and end into a capped list of instants at a fixed step | | DayOfYear | Convert between a date and its ordinal day, in either direction | | DurationFormat | Render a length of time as human text, ISO 8601, or a clock | | DurationParse | Read P3DT4H, 2h30m or 01:30:00 into milliseconds and components | | IsLeapYear | The Gregorian leap rule, with the year's length and the nearest leap years | | LocalTime | What an instant is where the operator is — their zone and its provenance, the wall clock, whether the clocks are forward, when they next change, and what that means for a working window or a cron | | QuarterOf | The quarter a date falls in, calendar or fiscal, with its bounds | | RecurrenceExpand | Expand a supported subset of an iCalendar RRULE from an explicit start | | TimestampConvert | Unix seconds, millis, micros and nanos to ISO 8601 and back | | WeekOfYear | ISO-8601 week number and week-year, with the week's bounds |

The rules these tools implement, stated once

Date libraries differ on a handful of questions and rarely say which answer they picked. These are ours.

Calendar units move the wall clock; time units move the instant. In DateAdd, days: 1 in America/New_York across a spring-forward keeps 09:00 at 09:00 and advances the instant by 23 hours. hours: 24 advances the instant by 24 hours and lands at 10:00. Both are correct; they answer different questions. Mixing units applies the calendar part first.

Month arithmetic clamps. 31 January plus one month is 28 February, not 3 March. It follows that the operation is not reversible — subtracting a month from 28 February gives 28 January — and not associative. DateDiff is defined as the inverse of the same clamp, so 31 January to 28 February counts as one whole month and to 27 February as none.

DateRange anchors on the start, computing start + n × step rather than stepping from the previous item. A monthly range from 31 January runs 31 Jan, 28 Feb, 31 Mar — iterating would clamp once and then stay on the 28th forever.

RecurrenceExpand skips instead of clamping, because RFC 5545 says so. A monthly rule starting on the 31st fires seven times a year. This is the opposite of DateAdd, deliberately, and each says so in its output.

DST is reported, never papered over. A wall clock in a spring-forward gap does not exist and one in a fall-back repeat happens twice. Every tool that resolves a wall clock says which case it hit: DateParse returns wallClockResolution, CronNext returns skippedForDst and marks a repeated firing, DateAdd puts it in notes.

Ambiguity is reported, not guessed. DateParse refuses 03/04/2026 and returns both readings until you pass dateOrder. There is no fallback to new Date(string), whose behaviour outside ISO 8601 is implementation-defined.

Supported subsets, precisely

Each parser here implements a closed grammar and rejects everything else by name. A parser that quietly ignores what it does not understand produces a calendar that is wrong in a way nobody notices.

DateParse accepts: ISO 8601 extended (2026-09-17T14:30:00+02:00, with Z, +HH:MM, +HHMM or +HH), ISO basic (20260917T143000Z), ordinal (2026-260), week dates (2026-W38-4), year-first slashes (2026/09/17), bare numeric dates with a dateOrder, month names in either order (17 Sep 2026, September 17, 2026), and RFC 2822 with its weekday prefix. 24:00 rolls to the next day; second 60 clamps to 59 and says so.

DateFormat tokens: YYYY YY GGGG MMMM MMM MM M DDD DD D dddd ddd dd HH H hh h mm m ss s SSS A a ZZZ ZZ Z zz WW W Q X x, with [literal text] passing through. Named presets cover ISO, RFC 2822, filenames and log prefixes. Month and weekday names for a non-en-US locale come from the runtime's CLDR data.

CronNext / CronDescribe implement the 5-field form: *, numbers, a-b, lists, */n, a-b/n, a/n, JAN–DEC and SUN–SAT names, ? as a synonym for * (as a whole field, never inside a list or a step), and the @yearly/@annually/@monthly/@weekly/@daily/@midnight/@hourly macros. Rejected by name, in every field: a seconds or year field, Quartz's L, W and #, Jenkins's H, and @reboot.

Two dialect choices, since cron implementations differ and rarely say which they picked. When day-of-month and day-of-week are both restricted, a day matches if either matches — Vixie cron's rule, and the one that surprises people, so CronDescribe warns about it explicitly. A reversed range (FRI-MON, NOV-FEB) wraps around the end of the field; Vixie cron and cronie instead match nothing at all, which is never what the author meant.

RecurrenceExpand implements FREQ (DAILY, WEEKLY, MONTHLY, YEARLY), INTERVAL, COUNT, UNTIL, and plain BYDAY. Rejected by name: positional BYDAY (2MO), BYMONTH, BYMONTHDAY, BYYEARDAY, BYWEEKNO, BYHOUR, BYSETPOS, WKST, sub-daily frequencies, and BYDAY with FREQ=YEARLY. The week starts Monday. UNTIL is either a plain date, which covers the whole day, or a date-time that must carry its Z — RFC 5545 §3.3.10 requires UTC there, and reading a floating time as UTC would move the end of the series by the caller's offset without saying so.

DurationParse accepts ISO 8601 designators, shorthand (2h30m, 1d 4h, 90 minutes), and clock form (01:30:00). Bare m means minutes; months must be written mo or longer. In the shorthand form, whitespace, commas, a leading + and the word and are filler; anything else left over is refused by name rather than dropped, so 5m!!! is an error and not a five-minute timeout. Years and months are parsed but kept out of totalMilliseconds and the result is marked exact: false, because they have no fixed length — anchor them with DateAdd.

LocalTime's working window is HH:MM-HH:MM on a 24-hour clock in the answering zone, defaulting to 09:00-17:00 and saying in the output which of those two it used. An overnight window (22:00-06:00) is refused by name rather than wrapped, because a shift spanning midnight belongs to two calendar days and "is this a working day" stops having one answer. Weekends and holidays are the same inputs BusinessDays takes, read by the same code and counted the same way, so a date means the same thing to both tools. The window it reports is resolved through the same DST machinery as everything else here: a window that opens inside a spring-forward gap — midnight in America/Havana on 8 March 2026 — says nonexistent instead of quietly sliding to 01:00.

The fall-back case needs one more rule. A wall clock in a repeated hour maps to two instants, and resolveWallClock returns the earlier one, which is the right reading for a timestamp and the wrong answer to "when does this open". Asked at 01:30 EST — the second pass of that clock — the 01:45 close is already forty- five minutes in the past. So nextOpen and closesAt take the first of those two instants that has not gone by, and say in resolutionNote which one they used. Neither ever reports a window behind the instant it was asked about.

The range a date can hold

Every instant here lives inside ±8.64×10¹⁵ milliseconds of the epoch — roughly year −271821 to 275760 — because that is where Date and Intl stop working. Past it Intl.DateTimeFormat throws a bare "date value is not finite", which is not an answer a caller can do anything with, so each tool checks first and returns a sentence saying so. The grammar accepts six-digit years and DateAdd accepts a million of anything, so this is reachable from valid input, not only from abuse. Below that boundary the day arithmetic is exact for every integer year, negative ones included.

The one runtime dependency

Timezone data comes from the runtime's Intl implementation, which is the platform's tzdb copy. Offsets for recent and near-future dates are stable across any current runtime; very old ones (before standard zones, where the offset had seconds) are rounded to the minute, and far-future ones can move with a tzdb update.

The only other thing here that comes from outside the package is the host's own timezone, read by LocalTime alone and reported with its source — see below.

The one impure file

src/host.ts is the only file here that looks at the machine, and LocalTime is the only tool that calls it. It answers one question — which timezone is this host set to — from one of three places, and every answer says which:

| Source | Where it came from | |---|---| | override | the caller passed timeZone; the host was not consulted at all | | env | the TZ environment variable, validated as an IANA identifier | | system | what the runtime's Intl resolves as this process's default zone |

The provenance is part of the answer because a wrong zone is invisible: it does not look like an error, it looks like a correct time. The case that bites is a TZ the runtime threw away. TZ="EST5EDT,M3.2.0,M11.1.0" is legal POSIX and is not an IANA identifier, so the runtime silently uses the system zone instead — recorded on bun 1.3.14 — and the operator who set it gets someone else's timezone with no warning anywhere. LocalTime reports that as the system zone with the discarded TZ named beside it.

"Could not tell" is a third answer, distinct from any zone: a runtime built without tzdata, or a TZ that is unusable with no readable system zone behind it, comes back as ok: false with the reason and the suggestion to pass timeZone. Nothing here falls back to UTC, which would move every timestamp it printed without saying so.

Under bun test the un-injected read refuses. NODE_ENV=test closes a gate in host.ts, so a test that forgets to install a zone gets a refusal on every box rather than the author's zone on a laptop and UTC on CI. Tests inject with _setHostZone; integration.test.ts is the one file that opens the gate, checks that the real machine really is read, and closes it again.

Whether the clocks are forward

Intl does not expose tzdb's own DST flag, and the usual substitutes are each wrong somewhere: comparing January against July is backwards south of the equator, and "compare against the zone's standard offset" only moves the question, since nothing here can name that offset either. So LocalTime probes the zone daily across the surrounding year and reports the offsets it saw — this instant is N minutes above the lowest offset of the year, or it is at it, or the zone never moved. That is a statement about observed data, and the output says so.

Being above the year's low is not on its own a clock change, so the reading also counts how often the zone moved inside the window and reports it as offsetChangesInWindow. A zone on DST goes up and comes back: two changes. A zone that redefined its offset moved once and stayed — Europe/Volgograd sat on UTC+4 through 2020 with no DST at all and dropped to UTC+3 that December — and calling that "the clocks are forward" would be wrong twice over. Those are the two places this parts company with tzdb's own flag, and both are named in the output's caveat; the other is Europe/Dublin, which records its winter as the DST period.

Near either end of what a date can hold the window is clamped, and the output says truncated rather than resting a full year's claim on half a year of probes.

The daily step is not caution for its own sake: Morocco's Ramadan pause is about a month long, and a weekly probe that straddled it would report a zone that never changes its clocks.

nextOffsetChange finds the next transition to the minute, with the local reading on each side — and reports the shift in minutes, because Australia/Lord_Howe moves by thirty.

What is deliberately not here

Natural-language dates ("next Tuesday", "in three weeks"), which need a reference time and an interpretation — the interpretation is judgement, and this package does not do judgement. Holiday calendars, because they are a jurisdiction's political decision, not a computation: BusinessDays takes the list as an input. Relative phrasing ("3 days ago") for the same reason Date.now() is absent.

Layout

src/lib/ holds the pure functions and is where the behaviour is tested; src/index.ts wraps them as tools; src/host.ts holds the single host read. A bug in daysFromCivil or the cron field expander reads better as a failing unit than as a failing tool call.

  • lib/civil.ts — day-count arithmetic, IANA offsets, ISO rendering
  • lib/parse.ts — the date-string grammar
  • lib/format.ts — the token formatter
  • lib/arithmetic.ts — add, diff, business days, ranges
  • lib/duration.ts — duration parsing and rendering
  • lib/cron.ts — the cron parser, walker and describer
  • lib/recurrence.ts — the RRULE subset
  • host.ts — the host's timezone, and the seam that lets a test replace it

Safety flags

All eighteen are readOnly, non-destructive, scope: "internal", and declare no io capability, because none of them crosses a process or network boundary. src/index.test.ts asserts that for every tool, and then greps the source of every module for the ways out: Date.now(), a bare new Date(), performance.now(), Math.random(), crypto, process, fetch, require, Bun.*, and any node:/fs/path/child_process import. A future addition that reads the clock or reaches outside has to edit that list on purpose.

src/host.ts is outside that list and is held to a narrower rule of its own: a companion test collects every process. read in it and asserts the set is exactly TZ and NODE_ENV, and that no file under lib/ imports it. The exception stays one file and two variable names, or the suite fails.