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

@northguild/gmt

v1.17.0

Published

Temporal-based date and time utilities with timezone support and polyfill integration

Readme

@northguild/gmt

Give Me Temporal.

@northguild/gmt is a Temporal-first date and time library with a simple rule set:

  • ISO 8601 strings in
  • ISO 8601 strings, numbers, booleans, or arrays out
  • no Date
  • plain and zoned operations kept separate

It wraps @js-temporal/polyfill behind a smaller, more opinionated API aimed at the cases application code actually hits: arithmetic, comparison, parsing, formatting, unix conversions, timezone conversion, and validation.

Read the docs, or ask us on Discord.

Why GMT:

  • 100% Temporal, Temporal-first. GMT is built directly on the TC39 Temporal standard (via @js-temporal/polyfill) — not a custom, homegrown date/time type system like @internationalized/date's own CalendarDate/ZonedDateTime classes. No Date object anywhere, enforced by 3 dedicated lint packages.
  • A full replacement for any and all of them. Luxon, date-fns, Moment.js, and react-aria's @internationalized/date don't have parity with each other — GMT covers the combined capabilities of all four in one library, plus what none of them do alone.
  • ~55× more CI test executions than all four competitors combined: 1,109,250 from 36,975 tests run in all 10 timezones × 3 Node versions, vs. their combined 20,190.
  • ~96× more test cases than @internationalized/date: 36,975 vs. 386 — Adobe's own library, run at its own commit.
  • The only one of the five that tests systematically across locales in CI at all. Zero of the four comparison libraries run a locale-test matrix; GMT mandates all 17 locales on every locale-aware function.
  • The only one that runs its entire suite under a real TZ env var across real-world zones. Luxon and @internationalized/date have no CI timezone matrix; date-fns's zone scope is unclear; Moment.js covers 6 zones but not its full suite.
  • Explicit DST disambiguation control on both construction and arithmetic — a control none of the others expose.
  • The only actively-maintained one that's Temporal-native. Moment.js is officially in maintenance mode; Luxon, date-fns, and @internationalized/date are still active but all still depend on Date internally.

Install

npm install @northguild/gmt
pnpm add @northguild/gmt

Design Philosophy

GMT enforces a strict input/output contract to keep behavior predictable and auditable:

  • Explicit inputs only: Public APIs accept clearly defined shapes — ISO 8601 date/time strings, IANA timezone identifiers, or Unix epochs as safe integers or digit strings, in seconds or milliseconds ({ epochUnit }). We do not attempt to parse arbitrary or ambiguous date formats.
  • Predictable outputs: Helpers return normalized values (ISO strings, numbers, booleans, or arrays). Invalid input yields typed fallbacks ("", null, false or []) instead of throwing.
  • No fuzzy parsing: Avoid "throw everything at the wall" patterns found in permissive libraries. If you need permissive parsing, perform it outside of @northguild/gmt and then canonicalize to the strict shapes before calling into gmt.
  • Developer comfort with standards: The library's goal is to make developers comfortable and deliberate with ISO 8601, IANA timezones, UTC instants, and Unix epochs by keeping APIs small and explicit.

Core Rules

| Rule | Current behavior | | ----------------------- | ---------------------------------------------------------------------------------- | | String-first API | Public helpers consume ISO strings and return normalized strings where appropriate | | Temporal-only internals | Temporal does the parsing and timezone math | | Plain/zoned separation | plain/* is timezone-free, zoned/* is timezone-aware | | No-throw public helpers | Invalid input returns a typed fallback instead of throwing |

Invalid input fallbacks are consistent across the library:

  • string-returning helpers return ""
  • number-returning helpers return null
  • boolean-returning helpers return false
  • array-returning helpers return []

Testing

Every function is exercised across 17 locales and a full IANA timezone matrix. The CI pipeline runs the complete suite in 30 environments — 3 Node versions (22, 24, 26) × 10 timezones spanning every UTC offset band from Pacific/Niue (−11:00) to Pacific/Apia (+14:00):

| Timezone | UTC Offset | | ------------------- | --------------- | | Pacific/Niue | −11:00 | | America/New_York | −05:00 / −04:00 | | UTC | ±00:00 | | Europe/London | ±00:00 / +01:00 | | Asia/Kolkata | +05:30 | | Asia/Kathmandu | +05:45 | | Asia/Shanghai | +08:00 | | Australia/Lord_Howe | +10:00 / +11:00 | | Pacific/Chatham | +12:45 / +13:45 | | Pacific/Apia | +13:00 / +14:00 |

This guarantees that DST transitions, leap seconds, half-hour offsets, and locale-specific weekend boundaries are all covered — not just the happy path.

Testing strategy

GMT's test suite balances thoroughness against maintenance burden by testing behavior, not permutations.

What we test exhaustively:

  • 17-locale matrix — every locale-aware function is exercised across all 17 MustTestLocales (en-US, en-GB, de-DE, fr-FR, es-ES, it-IT, pt-PT, sv-SE, zh-CN, zh-TW, ja-JP, ko-KR, ar-SA, he-IL, ru-RU, tr-TR, is-IS). This covers script direction, first-day-of-week differences, and calendar metadata.
  • Timezone battle matrix — every zoned function is exercised across 10 IANA timezones spanning every UTC offset band from Pacific/Niue (−11:00) to Pacific/Apia (+14:00), including DST-transition and half-hour-offset zones.
  • Zero-length and identity cases — every interval and arithmetic function is tested with zero-length inputs, identity operations, and boundary-adjacent values.
  • Invalid-input sentinels — every public function is tested for the documented fallback behavior ("", null, false, []) on malformed strings, wrong types, leap seconds, and inverted intervals.

What we collapse:

  • Non-string input tables — functions that guard with typeof x !== "string" return the same sentinel for null, undefined, 123, true, [], and {}. We test one representative non-string per argument position rather than all six types × N positions. The collapse is safe because all non-string types hit the identical early-return code path.
  • Redundant permutations — adjacent/disjoint/reversed interval cases that produce identical results are not duplicated across every function variant. The plain/, zoned/, utc/, and unix/ families share the same mathematical behavior; each family gets the minimum set of cases needed to prove correctness.

Result: 36,975 tests across 674 files that exercise real behavior differences without redundant permutations. They run in CI as 1,109,250 executions — every one of them × 3 Node versions × 10 timezones.

How GMT is tested, vs. the libraries it targets

GMT is measured directly against react-aria's @internationalized/date, Luxon, date-fns, and Moment.js — the same four libraries compared below. All numbers were verified 2026-08-22 against the exact package versions/commits below — nothing is estimated. Re-verify before citing these numbers elsewhere; library surfaces and CI configs move.

| Library | Version tested | | ------------------------- | --------------------------------------- | | GMT (@northguild/gmt) | 1.14.2 | | @internationalized/date | 3.12.3 (adobe/react-spectrum@5d191ab) | | Luxon | 3.7.2 (moment/luxon@f427515) | | date-fns | 4.4.0 (date-fns/date-fns@a0a3922) | | Moment.js | 2.30.1 (moment/moment@cf524af) |

| Metric | GMT | @internationalized/date | Luxon | date-fns | Moment.js | | ------------------------------- | -------------------------------------------------- | ------------------------------ | ------------------------------------ | ----------------------------------------- | -------------------------------- | | Test files | 674 | 6 | 58 / 60(2 didn't runlocally) | 256 | 191(52 core +139 locale) | | Individual test cases | 36,975 | 386 | 1,222 | 3,213 | 3,901 | | Effective CI testexecutions | 1,109,250(36,975 × 3 Node× 10 timezones) | 386(×1 Node) | 4,888(1,222 × 4 Node) | 3,213(×1 Node) | 11,703(3,901 × 3 Node) | | CI Node.js matrix | 22, 24, 26 | n/a — testsReact 16–canary | 20, 22, 24, 25 | not explicit(node = "latest") | LTS, LTS-1,latest | | CI timezone matrix | 10 zones × 3Node, full suite | none found | none found | dedicated workflow,zone scope unclear | 6 zones,partial suite only | | Locale test matrix | 17 locales,every locale fn | none found | none found | none found | none found | | Real-browser CI | not yet | yes (Playwright) | not found | yes (Playwright) | not found | | Maintenance | active | active | active | active | maintenancemode |

Methodology: "Test files" and the CI/maintenance rows come from each project's public CI configuration and repository file listing. "Individual test cases" for GMT, Luxon, date-fns, and Moment.js were obtained by actually cloning the repo at the commit above, installing dependencies, running the project's own test command (vitest run / jest / node scripts/test.js), and reading that runner's own final summary — not grepped from source. @internationalized/date was run by cloning adobe/react-spectrum at 5d191ab, installing dependencies, and executing npx jest packages/@internationalized/date/tests/, yielding 386 passing tests. Luxon (39 failures) and date-fns (46 failures) had environment-dependent local failures that don't affect the total count: Luxon's suite assumes its CI container's local time zone is America/New_York; date-fns's experimental native-Temporal code path needs a global Temporal Node doesn't yet provide natively. Moment.js passed cleanly (0 failed) on Node 24. Sources: GMT · @internationalized/date · Luxon · date-fns · Moment.js.

Feature parity

GMT has full functional parity with all four comparison libraries, capability for capability — with several areas where GMT goes further than any of them.

| Capability | Status | Also has it | | -------------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------ | | Duration type(ISO 8601 parse/format/arithmetic) | ✅ Done | Luxon Duration | | Interval/range math(contains, overlap, union,intersection, split, set ops) | ✅ Done | Luxon Interval,date-fns areIntervalsOverlapping | | DST disambiguation controlon construction and arithmetic | ✅ Done — differentiator | None of the others exposethis on arithmetic | | Locale-aware calendar helpers(weekend, week start/end, day-of-week) | ✅ Done | @internationalized/date | | Business-day arithmetic withholiday calendars and roll conventions,clamp/closest, time rounding | ✅ Done | temporal-kit (arithmetic only) | | Interval rounding-out(boundary count, from-duration) | ✅ Done | Luxon | | Locale calendar metadata(names, hasDST) | ✅ Done | Luxon Info | | Overlap-day count, relativerounding, DST transitions, hours-in-day | ✅ Done | date-fns, @internationalized/date | | Field setters, token-patternparsing, named machine formats,calendar-style formatting | ✅ Done | Luxon .set(),toRFC2822/toHTTP/toSQL,Moment .calendar() | | Non-Gregorian calendar systems(conversion + calendar-awareinterval/duration math) | ✅ Done | @internationalized/date'stoCalendar |

Where GMT stands alone

Specific, sourced claims — not a repeat of the metrics above.

| Claim | The others | | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Only GMT runs its entire suite in CIunder a real TZ env var across 10real-world zones × 3 Node versions(30 full-suite runs) | Luxon/@internationalized/date: noCI timezone matrix. date-fns: zonescope unclear. Moment.js: 6 zones,partial suite only | | Only GMT enforces a mandatory17-locale test matrix on everylocale-aware function | No CI-level or systematiclocale-matrix testing foundin any of the four | | Only GMT exposes explicit DSTdisambiguation control on bothconstruction and arithmetic | Luxon's docs call this explicitlyundefined; @internationalized/dateonly covers construction, not arithmetic | | Only GMT is Temporal-native withzero Date usage, enforced by3 dedicated lint packages | Luxon, date-fns, and Moment.js allstill wrap or depend on Date internally | | GMT's effective CI testexecutions exceed all fourcompetitors combinedby ~55× | 1,109,250 vs. 386 + 4,888 + 3,213+ 11,703 = 20,190 |

Package Layout

Every public function, type and regex is a flat named export of the package root, beside Temporal, Intl and toTemporalInstant re-exported from @js-temporal/polyfill. There are no namespace objects: a namespace is a subpath.

import { addDate, getNow, formatRelativeZoned, Temporal } from "@northguild/gmt";

The fourteen namespace subpaths:

  • @northguild/gmt/calendar: ISO week and ordinal dates, quarter and fiscal periods, zone-aware bucketing, and business calendars with holiday sets and roll conventions
  • @northguild/gmt/duration: ISO 8601 duration string parsing, validation, and arithmetic
  • @northguild/gmt/instant: the instant-plus-offset pair, and explicit resolution of zoneless local wall times
  • @northguild/gmt/interval: half-open [start, end) interval algebra over instants — overlap, intersect, clamp, merge, subtract, split, sum
  • @northguild/gmt/plain: timezone-free helpers
  • @northguild/gmt/precision: nanosecond (bigint) instants, their JSON bridge, storage truncation, and foreign epoch bridges
  • @northguild/gmt/span: elapsed and wall-clock durations between two timestamps, as raw numbers
  • @northguild/gmt/transport: transit legs as exact elapsed time, arrivals rendered where they land, and dwell measured in local calendar days
  • @northguild/gmt/intermodal: free time, demurrage and detention counted in the terminal's local days, with the charged dates behind every count, and the invoice, dispute and resolution deadline chain with every window a caller parameter
  • @northguild/gmt/zoned: timezone-aware helpers
  • @northguild/gmt/unix: Unix epoch (seconds or milliseconds) helpers
  • @northguild/gmt/utc: UTC instant helpers
  • @northguild/gmt/regex: low-level regex building blocks
  • @northguild/gmt/types: the shared option and unit types

Every namespace subpath except types also re-exports Temporal, Intl and toTemporalInstant, so import { Temporal, addZoned } from "@northguild/gmt/zoned" needs no second import.

Every namespace except regex and types also exposes its modules as subpaths, @northguild/gmt/<namespace>/<module>:

import { getZonedNow } from "@northguild/gmt/zoned";
import { addDateTime, diffDate } from "@northguild/gmt/plain/calculate";
import type { BusinessCalendar } from "@northguild/gmt/types";

Quick Start

Plain arithmetic and comparisons

import {
  addDate,
  addBusinessDays,
  subtractBusinessDays,
  areDatesEqual,
  diffDateTime,
  isBeforeDateTime,
} from "@northguild/gmt";

addDate("2026-01-01", { days: 90 });
// "2026-04-01"

addBusinessDays("2024-03-15", 1);
// "2024-03-18" (skips weekend)

subtractBusinessDays("2024-03-18", 1);
// "2024-03-15" (skips weekend)

diffDateTime("2024-03-17T12:00:00", "2024-03-17T12:30:00", "minutes");
// 30

areDatesEqual("2026-03-17", "2026-03-17T09:00:00");
// true

isBeforeDateTime("2026-03-17T09:00:00", "2026-03-17T10:00:00");
// true

add*/subtract* accept an optional overflow ("constrain" (default) | "reject") to control out-of-range results (e.g. adding a month to Jan 31) — except addTime/subtractTime, which take no options argument because a clock time wraps (addTime("23:00:00", { hours: 2 }) is "01:00:00"), as Temporal's PlainTime#add does — and diff* accept optional smallestUnit/roundingIncrement/roundingMode to round the computed difference:

import { addDate, diffDate } from "@northguild/gmt";

addDate("2024-01-31", { months: 1 }, { overflow: "reject" });
// "" — Feb 31 doesn't exist and overflow: "reject" refuses to clamp it

addDate("2024-02-29", { years: 1 });
// "2025-02-28" — "constrain" (the default) clamps to the last valid day, as Temporal does

diffDate("2023-01-01", "2023-01-10", "weeks", {
  smallestUnit: "week",
  roundingMode: "halfExpand",
});
// 1

setDate/setDateTime/setTime/setZoned/setUnix/setUtc set one or more fields on a value in a single atomic .with()-based call — the safe alternative to composing add* calls field-by-field, which resolves each field's overflow independently and can silently diverge on multi-field updates:

import { setDate, setZoned } from "@northguild/gmt";

setDate("2024-01-31", { month: 2 });
// "2024-02-29" (constrain, the default, clamps to the last valid day)

setZoned(
  "2024-11-03T01:45:00-05:00[America/New_York]",
  { minute: 0 },
  { disambiguation: "reject" },
);
// "2024-11-03T01:00:00-05:00[America/New_York]" — offset defaults to "prefer", as Temporal's
// ZonedDateTime#with does: the source's -05:00 is still valid, so it is kept

setZoned(
  "2024-11-03T01:45:00-05:00[America/New_York]",
  { minute: 0 },
  { disambiguation: "reject", offset: "ignore" },
);
// "" — offset "ignore" re-resolves the repeated 01:00, and "reject" fires on the fall-back overlap

setZoned/setUnix also accept disambiguation and offset for DST gap/overlap control (setUtc takes only overflow: a UTC wall clock is never ambiguous) — see DST Disambiguation.

cycleDate/cycleDateTime/cycleTime/cycleZoned adjust a single field and wrap at that field's own min/max instead of carrying into the next larger field — the datepicker-segment-editing primitive add* can't express, since overflowing into the next field is exactly what add* is for:

import { addDate, cycleDate, cycleZoned } from "@northguild/gmt";

cycleDate("2024-12-15", "month", 1);
// "2024-01-15" — stays in the same year

addDate("2024-12-15", { months: 1 });
// "2025-01-15" — addDate correctly overflows into the next year instead

cycleZoned("2024-03-10T01:30:00-06:00[America/Chicago]", "hour", 1);
// "2024-03-10T03:30:00-05:00[America/Chicago]" — the cycled hour lands in a spring-forward
// gap; disambiguation ("compatible" by default) resolves it the same way setZoned does

cycleZoned also accepts disambiguation and offset (default offset: "prefer") for the same DST gap/overlap control as setZoned — see DST Disambiguation. options.round on any of the four steps to the next multiple of amount rather than rounding to the nearest one, matching @internationalized/date's CycleOptions.round.

isWeekend/isZonedWeekend check locale-specific weekend days (via Intl.Locale#getWeekInfo(), falling back to weekInfo) rather than assuming Saturday/Sunday:

import { isWeekend, isZonedWeekend } from "@northguild/gmt";

isWeekend("2024-02-03", "en-US");
// true (Saturday, en-US weekend is Sat/Sun)

isWeekend("2024-02-03", "he-IL");
// true (Saturday is also part of he-IL's Fri/Sat weekend)

isZonedWeekend("2024-02-04T10:00:00+02:00[Asia/Jerusalem]", "he-IL");
// false (Sunday isn't part of he-IL's weekend)

isBusinessDay is the complement to locale-aware isWeekend, and shares its weekend rule with addBusinessDays/subtractBusinessDays. Called with one argument it uses fixed ISO Monday–Friday business days (Mon=1 … Fri=5), locale-agnostic and with no holidays; pass a BusinessCalendar to state the weekend and holidays yourself:

import { isBusinessDay } from "@northguild/gmt";

isBusinessDay("2024-02-05");
// true (Monday)

isBusinessDay("2024-02-10");
// false (Saturday)

isBusinessDay("2024-07-04", {
  weekend: [6, 7],
  holidays: ["2024-07-04"],
  timeZone: "America/New_York",
});
// false (a holiday, though it's a Thursday)

Business calendars and roll conventions

A BusinessCalendar is { weekend: number[], holidays: string[], timeZone: string }. The weekend is explicit ISO weekday numbers because Saturday–Sunday is not universal — much of the Middle East is Friday–Saturday, and some markets keep a one-day weekend. Holidays are yours to supply: GMT bundles no holiday table on the default import path, because holiday data is jurisdictional and changes annually, sometimes with days of notice. timeZone records which locality the calendar describes; the business-day functions take and return local dates and never read it, so a caller holding an instant reduces it with floorToZone first.

isBusinessDay, addBusinessDays and subtractBusinessDays all take a calendar as an optional trailing argument. The rest of the family requires one — there is no default weekend to fall back on:

import {
  businessDaysBetween,
  nextBusinessDay,
  previousBusinessDay,
  rollDate,
  mergeCalendars,
  isValidBusinessCalendar,
} from "@northguild/gmt";

const nyse = {
  weekend: [6, 7],
  holidays: ["2024-05-31", "2024-07-04"],
  timeZone: "America/New_York",
};

businessDaysBetween("2024-07-01", "2024-07-05", nyse);
// 3 — start exclusive, end inclusive, and 4 July is a holiday

nextBusinessDay("2024-07-03", nyse);
// "2024-07-05" — strictly after, skipping the holiday

previousBusinessDay("2024-07-05", nyse);
// "2024-07-03"

rollDate moves a date onto a working day by an explicit convention — following, modifiedFollowing, preceding, modifiedPreceding, endOfMonth or none. following and preceding are on-or-after and on-or-before, so they leave a working day alone; nextBusinessDay/previousBusinessDay are the strict neighbours:

rollDate("2024-05-31", "following", nyse);
// "2024-06-03" — forward past the weekend

rollDate("2024-05-31", "modifiedFollowing", nyse);
// "2024-05-30" — backward instead, because forward leaves May

rollDate("2024-03-15", "endOfMonth", nyse);
// "2024-03-29" — March's last working day; March ends on a Sunday

rollDate("2024-06-01", "none", nyse);
// "2024-06-01" — unadjusted, Saturday or not

mergeCalendars composes jurisdictions: weekend rules and holidays union, so a date survives only if it is a working day in every input. That is the two-currency intersection FX settlement needs, and the two-port one an intermodal move needs:

const london = { weekend: [6, 7], holidays: ["2024-05-06"], timeZone: "Europe/London" };

const both = mergeCalendars([nyse, london]);
// { weekend: [6, 7], holidays: ["2024-05-06", "2024-05-31", "2024-07-04"], timeZone: "America/New_York" }
// timeZone is the first calendar's; the merged set spans localities that may disagree

isBusinessDay("2024-05-06", both);
// false — a UK holiday closes the merged calendar too

mergeCalendars([]);
// null

isValidBusinessCalendar and isValidRollConvention narrow a candidate, so a misconfigured calendar or contract term can be told apart from bad date input when a function returns its sentinel.

isRelativeDay/isThisUnit/isPast/isFuture are now-relative predicates — isRelativeDay subsumes isToday/isYesterday/isTomorrow, isThisUnit subsumes isThisWeek/isThisMonth/isThisYear. They compare against getToday(), so they depend on the system clock and system timeZone; the zoned variants (isZonedRelativeDay, isZonedThisUnit, isZonedPast, isZonedFuture) resolve "today"/"now" in the value's own timeZone instead, for deterministic results regardless of the host machine's timeZone:

import { isRelativeDay, isThisUnit, isPast, isFuture } from "@northguild/gmt";

isRelativeDay("2024-03-15", 0);
// true, if today is 2024-03-15 ("isToday")

isThisUnit("2024-02-26", "week", "fr-FR");
// locale-aware week boundary — fr-FR weeks start Monday

isPast("2024-03-14");
// true, if today is 2024-03-15 (strictly before, not on-or-before)

isFuture("2024-03-16");
// true, if today is 2024-03-15 (strictly after, not on-or-before)

nextWeekday/previousWeekday find the next/previous occurrence of a given ISO day of week (1 = Monday … 7 = Sunday, matching getDayOfWeek) on or after/before a date, replacing date-fns's sixteen next*/previous* functions with two parameterized calls. options.inclusive (default false) controls what happens when the input already falls on the target day — false advances/retreats a full week, matching date-fns:

import { nextWeekday, previousWeekday } from "@northguild/gmt";

nextWeekday("2024-03-13", 5);
// "2024-03-15" (Wednesday -> next Friday)

nextWeekday("2024-03-15", 5);
// "2024-03-22" (already a Friday -> advances a full week by default)

nextWeekday("2024-03-15", 5, { inclusive: true });
// "2024-03-15" (already a Friday -> returned as-is)

previousWeekday("2024-03-13", 5);
// "2024-03-08" (Wednesday -> previous Friday)
import { isZonedRelativeDay, isZonedPast } from "@northguild/gmt";

isZonedRelativeDay("2024-03-15T10:00:00-04:00[America/New_York]", 0);
// "today" resolved in America/New_York, not the host's system timeZone

isZonedPast("2020-01-01T00:00:00Z[UTC]");
// true — compares the exact instant, not just the calendar day

clampDate restricts a date to a range, and closestDateTo finds the nearest candidate by calendar distance:

import { clampDate, closestDateTo } from "@northguild/gmt";

clampDate("2024-02-01", "2024-03-01", "2024-03-31");
// "2024-03-01"

closestDateTo("2024-03-15", ["2024-03-01", "2024-03-20", "2024-03-18"]);
// "2024-03-18"

getLocaleStartOfWeek/getLocaleEndOfWeek (and their zoned equivalents) compute week boundaries from the locale's first day of week, instead of an ISO-Monday default:

import { getLocaleStartOfWeek, getLocaleEndOfWeek } from "@northguild/gmt";

getLocaleStartOfWeek("2024-02-29", "en-US");
// "2024-02-25" (Sunday, en-US weeks start Sunday)

getLocaleStartOfWeek("2024-02-29", "fr-FR");
// "2024-02-26" (Monday, fr-FR weeks start Monday)

getLocaleEndOfWeek("2024-02-29", "en-US");
// "2024-03-02" (Saturday)

getLocaleDayOfWeek/getLocaleZonedDayOfWeek return a locale-relative day-of-week index (0 = first day of the locale's week):

import { getLocaleDayOfWeek, getLocaleZonedDayOfWeek } from "@northguild/gmt";

getLocaleDayOfWeek("2024-02-25", "en-US");
// 0 (Sunday = first day of en-US week)

getLocaleDayOfWeek("2024-02-26", "fr-FR");
// 0 (Monday = first day of fr-FR week)

getLocaleDayOfWeek("2024-02-24", "ar-EG");
// 0 (Saturday = first day of ar-EG week)

getLocaleZonedDayOfWeek("2024-02-25T12:00:00+00:00[UTC]", "en-US");
// 0

getLocaleEraNames/getLocaleMonthNames/getLocaleWeekdayNames/getLocaleMeridiems return standalone, locale-aware calendar names with no date value required — the GMT equivalents of Luxon's Info.eras/Info.months/Info.weekdays/Info.meridiems:

import {
  getLocaleEraNames,
  getLocaleMonthNames,
  getLocaleWeekdayNames,
  getLocaleMeridiems,
} from "@northguild/gmt";

getLocaleEraNames("en-US");
// ["Before Christ", "Anno Domini"]

getLocaleEraNames("ja-JP", "short");
// ["紀元前", "西暦"]

getLocaleMonthNames("en-US");
// ["January", "February", ... "December"]

getLocaleMonthNames("de-DE", "short");
// ["Jan", "Feb", "Mär", ... "Dez"]

getLocaleWeekdayNames("en-US");
// ["Sunday", "Monday", ... "Saturday"] (locale-first-day order)

getLocaleWeekdayNames("fr-FR");
// ["lundi", "mardi", ... "dimanche"]

getLocaleMeridiems("en-US");
// ["AM", "PM"]

getLocaleMeridiems("zh-CN");
// ["上午", "下午"]

getLocaleWeekdayNames returns names in the locale's first-day order, consistent with getLocaleDayOfWeek (index 0 is the locale's first day of the week). All four delegate to the host runtime's Intl data, so their output depends on the runtime's ICU build.

Parsing

parseDateWithPattern/parseDateTimeWithPattern/parseTimeWithPattern decode a known, fixed producer format — a CSV column, a legacy API field, or a partially-typed form value — against a caller-supplied token pattern. This is for decoding, not display: the pattern hard-codes field order, so formatDate/formatDateToParts remain the correct choice for locale-correct output.

import {
  parseDateWithPattern,
  parseDateTimeWithPattern,
  parseTimeWithPattern,
} from "@northguild/gmt";

parseDateWithPattern("03/15/2024", "MM/dd/yyyy");
// "2024-03-15"

parseDateTimeWithPattern("15-Mar-2024 14:30", "dd-MMM-yyyy HH:mm");
// "2024-03-15T14:30:00"

parseTimeWithPattern("02:30:45 PM", "hh:mm:ss a");
// "14:30:45"

parseDateWithPattern("02/31/2024", "MM/dd/yyyy");
// "" — shape-valid but not a real date; Temporal validates after the regex match

Supported tokens include yyyy/MM/dd/HH/mm/ss/SSS for fixed-width fields, M/d/H/h/m/s for variable-width, MMMM/MMM/EEEE/EEE/a/GGGG/GG for locale-aware names, and 'single quotes' for literal text. A locale parameter (default "en-US") controls name-token matching. Invalid input, no-match, and malformed patterns all return "".

Calendar systems

convertDateToCalendar expresses a date in another calendar system. GMT writes the standard form, exactly what Temporal.PlainDate.prototype.toString() writes: the ISO 8601 date, then an RFC 9557 [u-ca=<id>] annotation naming the calendar. The digits stay ISO. The annotation says which calendar the date is presented and computed in (RFC 9557 §3.3), so the string means the same date to GMT, to Temporal and to any other RFC 9557 parser.

import { convertDateToCalendar } from "@northguild/gmt";

convertDateToCalendar("2024-10-03", "hebrew");
// "2024-10-03[u-ca=hebrew]" — Rosh Hashanah 5785, written as its ISO date

convertDateToCalendar("2024-10-03[u-ca=hebrew]", "iso8601");
// "2024-10-03" — iso8601 writes no annotation

convertDateToCalendar("2024-10-03", "gregory");
// "2024-10-03[u-ca=gregory]" — gregory is its own calendar, so it is annotated

convertDateToCalendar("invalid", "hebrew");
// ""

CalendarSystem is "iso8601" | "gregory" | "hebrew" | "islamic-civil" | "islamic-tbla" | "islamic-umalqura" | "japanese" | "buddhist" | "roc" | "persian" | "indian" | "ethiopic" | "ethioaa" | "coptic": the canonical BCP 47 / CLDR calendar.xml ids Temporal uses, in the annotation and as function arguments. An alias or another letter case is canonicalized the way Temporal's withCalendar does it ("ethiopic-amete-alem" writes [u-ca=ethioaa], [u-ca=HEBREW] reads as hebrew), and the canonical id is always written. gregorian, taiwan and islamic-tabular are not calendar ids, so they return the sentinel. chinese, dangi, islamic and islamic-rgsa are not supported.

Every calendar-accepting function reads annotations the way Temporal.PlainDate.from / Temporal.ZonedDateTime.from read them. The critical flag is accepted and not written back ("2024-10-03[!u-ca=hebrew]" converts to "2024-10-03[u-ca=hebrew]"), and an unknown critical annotation is rejected. ;era= is not RFC 9557 syntax and is rejected. A year is four digits, or a sign and six ("+275760-09-13[u-ca=hebrew]"), across Temporal's whole range, -271821-04-19 to +275760-09-13.

Calendar fields are read values, never string content. The Hebrew year 5785, the Japanese era reiwa and year 6, and the Umm al-Qura day number are all properties of the date, so GMT never puts them in a string. No standard defines a machine-readable date string with calendar-native digits, and such a string would be ambiguous: 5785-01-01[u-ca=hebrew] is also a valid RFC 9557 string for ISO year 5785. When you need a field for display, format the date with Intl.DateTimeFormat and its calendar option, or read it from Temporal, which every GMT entry point re-exports:

import { Temporal } from "@northguild/gmt";

Temporal.PlainDate.from("2024-10-03[u-ca=hebrew]").year; // 5785
Temporal.PlainDate.from("2024-10-03[u-ca=japanese]").eraYear; // 6

The three Islamic variants are different calendars, not spellings of one. On ISO 2020-02-24, islamic-civil (Friday epoch) reads day 29 of month 6 of 1441, islamic-tbla (the same arithmetic cycle with a Thursday epoch) reads day 1 of month 7, and islamic-umalqura (the Saudi civil calendar, from Umm al-Qura University's published tables) reads day 30 of month 6. Month arithmetic follows each calendar's own months, so the same +1 month lands on different ISO dates:

import { addDate } from "@northguild/gmt";

addDate("2020-02-24[u-ca=islamic-umalqura]", { months: 1 });
// "2020-03-24[u-ca=islamic-umalqura]"

addDate("2020-02-24[u-ca=islamic-tbla]", { months: 1 });
// "2020-03-25[u-ca=islamic-tbla]"

ethiopic and coptic compute in ethioaa. The three share months, days and arithmetic and differ only by a constant year offset, and @js-temporal/polyfill 0.5.1 throws reading ethiopic and coptic fields under ICU 78 or later (Temporal.PlainDate.from("2024-10-03[u-ca=ethiopic]").year throws there). GMT's results are the same either way, and only the written id differs. Where the polyfill or the runtime's ICU computes a calendar wrongly (Buddhist before 1582, Hebrew years ≤ 0, Indian dates before ISO year 1, dates near either range limit), GMT's arithmetic computes the specified answer instead. Each correction probes the runtime once and stays inactive where the runtime is already right.

Calendar-aware interval and duration arithmetic

A calendar-annotated date feeds addDate/subtractDate/diffDate/diffDateAsDuration and every Date-suffixed plain/interval/* function (intervalContainsDate, intervalCountDate, splitIntervalByUnitDate, and the rest). Calendar units ("add 1 month") resolve in the value's own calendar:

import { addDate, diffDate, diffDateAsDuration, durationAs, intervalCountDate } from "@northguild/gmt";

addDate("2024-02-24[u-ca=hebrew]", { months: 1 });
// "2024-03-25[u-ca=hebrew]" — 15 Adar I 5784 to 15 Adar II; addDate("2024-02-24", { months: 1 }) is "2024-03-24"

intervalCountDate("2023-09-16[u-ca=hebrew]", "2024-10-03[u-ca=hebrew]", "month");
// 13 — Hebrew leap year 5784 has 13 months; the same ISO span touches 14 ISO months

diffDate("2024-03-11[u-ca=hebrew]", "2024-04-10[u-ca=hebrew]", "months");
// 1 — 1 Adar II to 2 Nisan; the bare ISO dates are 0 whole months apart

diffDateAsDuration("2024-08-31[u-ca=buddhist]", "2024-09-30[u-ca=buddhist]", "months");
// "P30D" — not "P1M": a month counts only once the end reaches the same day of the next month

durationAs("P1Y", "days", { relativeTo: "2023-09-16[u-ca=hebrew]" });
// 383 — Hebrew leap year 5784; relativeTo "2023-09-16" gives 366

Month and year differences follow Temporal's NonISODateSurpasses in every calendar, so a span from a month's last day into a shorter month is days, not a month. This matches the ISO calendar (diffDateAsDuration("2024-08-31", "2024-09-30", "months") is "P30D"), and applies to diffDate*, intervalLength*, intervalCount* and diffZoned* alike.

utc/ reads a UTC string as Temporal.Instant.from does, so a [u-ca=...] annotation is ignored there: isValidUtc("2024-01-01T00:00:00Z[u-ca=hebrew]") is true. duration/'s relativeTo reads a calendar-annotated string as Temporal's ParseTemporalRelativeToString does: zoned when it carries a time zone annotation, otherwise a date.

Two values that name different calendars follow Temporal's CalendarEquals. A bare ISO string names iso8601.

  • Differences return the sentinel on a mismatch, as Temporal's until throws: diffDate, diffDateAsDuration, intervalCountDate, intervalLengthDate, splitIntervalByUnitDate and intervalOverlappingDaysDate.
  • Ordering accepts mixed calendars, as Temporal.PlainDate.compare has no calendar check: intervalContainsDate, intervalsOverlapDate, intervalAbutsDate, intervalEngulfsDate and isValidDateInterval.
  • Functions that return a date value (intervalUnionDate, intervalIntersectionDate, intervalDifferenceDate, intervalXorDate, intervalXorAllDate, mergeIntervalsDate, intervalDivideEquallyDate, intervalSplitAtDate) require one shared calendar and return null/[] on a mismatch, because no calendar can be chosen for the output.
intervalCountDate("2023-09-16[u-ca=hebrew]", "2024-10-03", "month");
// null — hebrew and iso8601

intervalContainsDate("2023-09-16[u-ca=hebrew]", "2024-10-03", "2024-01-01[u-ca=roc]");
// true — ordering compares the ISO dates

Calendar-aware zoned datetimes

A calendar-annotated ZonedDateTime string is the RFC 9557 form Temporal.ZonedDateTime.prototype.toString() writes: the time zone annotation first, then the calendar (RFC 9557 §4.1):

<date>T<time><offset>[<timeZone>][u-ca=<id>]

2024-02-24T14:30:00-05:00[America/New_York][u-ca=hebrew]
2019-04-30T12:00:00+09:00[Asia/Tokyo][u-ca=japanese]

convertZonedToCalendar produces it, and isValidCalendarZonedDateTime validates it. A calendar annotation before the zone is not RFC 9557 and returns the sentinel:

import { addZoned, convertZonedToCalendar } from "@northguild/gmt";

convertZonedToCalendar("2024-10-03T14:30:45-04:00[America/New_York]", "hebrew");
// "2024-10-03T14:30:45-04:00[America/New_York][u-ca=hebrew]"

convertZonedToCalendar("2024-10-03T14:30:45-04:00[u-ca=hebrew][America/New_York]", "iso8601");
// "" — the calendar annotation must follow the zone

addZoned("2024-02-24T14:30:00-05:00[America/New_York][u-ca=hebrew]", { months: 1 });
// "2024-03-25T14:30:00-04:00[America/New_York][u-ca=hebrew]"
// Adar I -> Adar II AND EST -> EDT, resolved in one call. No ordering of a plain/ calendar
// operation and a zoned/ conversion produces this: do the calendar step first and DST is
// applied to an already-resolved wall time; do the zoned step first and there is no calendar
// left to step in.

Scope: addZoned, subtractZoned, diffZoned, diffZonedAsDuration, convertZonedToCalendar, and the zoned/interval/* family. Everything else in zoned/, and isValidZonedDateTime, accepts [u-ca=iso8601] and rejects any other calendar, so a function that has not opted in fails closed rather than silently answering in the wrong calendar. addZonedBusinessDays/subtractZonedBusinessDays stay out by design: day-of-week is ISO-fixed in every supported calendar, so a tag would change nothing while implying it might.

Mixed-calendar endpoints follow the same rules as plain/. Ordering functions (intervalContainsZoned, intervalsOverlapZoned, intervalAbutsZoned, intervalEngulfsZoned, isValidCalendarZonedInterval) accept them. The value-returning set operations require one shared calendar. Differences (diffZoned, diffZonedAsDuration, intervalCountZoned, intervalLengthZoned, splitIntervalByUnitZoned, intervalOverlappingDaysZoned) return their sentinel on a mismatch for every unit, including hours, as Temporal.ZonedDateTime.prototype.until throws across calendars.

Migrating strings written before 1.16.0. Earlier releases wrote the calendar's own year, month and day ("5785-01-01[u-ca=hebrew]" for ISO 2024-10-03), a ;era= suffix, the zoned calendar annotation before the zone, and the ids gregorian, taiwan, islamic-tabular and ethiopic-amete-alem (now iso8601, roc, islamic-tbla and ethioaa). No converter exists: most old strings are also valid RFC 9557 strings for a different ISO date, and the two readings cannot be told apart. convertDateToCalendar("5785-01-01[u-ca=hebrew]", "iso8601") returns "5785-01-01". Regenerate each stored value from its ISO date with convertDateToCalendar or convertZonedToCalendar.

Durations

import {
  absDuration,
  addDuration,
  compareDurations,
  diffDateAsDuration,
  durationAs,
  formatDuration,
  getDurationSign,
  getDurationUnit,
  isValidDuration,
  negateDuration,
  normalizeDuration,
  parseDuration,
  subtractDuration,
} from "@northguild/gmt";

isValidDuration("P1DT2H30M");
// true

parseDuration("P1DT2H30M");
// "P1DT2H30M"

parseDuration("PT1.5S", { smallestUnit: "second", roundingMode: "trunc" });
// "PT1S"

parseDuration("not a duration");
// ""

addDuration("P1D", "PT2H");
// "P1DT2H"

subtractDuration("P1D", "PT2H");
// "PT22H"

normalizeDuration("PT90M", { largestUnit: "hour" });
// "PT1H30M"

normalizeDuration("P45D", { largestUnit: "month", relativeTo: "2024-01-01" });
// "P1M14D"

getDurationUnit("P1DT2H30M", "hours");
// 2 — the hours component as stored

durationAs("P1DT2H30M", "hours");
// 26.5 — the whole duration totalled into hours

durationAs("P1M", "days");
// null — a calendar unit needs a relativeTo anchor

durationAs("P1M", "days", { relativeTo: "2024-02-01" });
// 29

negateDuration("P1DT2H");
// "-P1DT2H"

absDuration("-P1DT2H");
// "P1DT2H"

getDurationSign("-P1DT2H");
// -1

compareDurations("PT60M", "PT1H");
// 0 — equal by length, not by spelling

compareDurations("P1M", "P30D", { relativeTo: "2024-01-01" });
// 1 — January is 31 days; relativeTo "2024-02-01" gives -1

formatDuration("P1DT2H30M", "en-US");
// "1 day, 2 hours, and 30 minutes"

formatDuration("P1DT2H30M", "en-US", { style: "short" });
// "1 day, 2 hr, & 30 min"

formatDuration("P1DT0H30M", "en-US");
// "1 day and 30 minutes"

diffDateAsDuration("2024-03-10", "2024-04-05", "days");
// "P26D" — bridges diffDate to an ISO duration string instead of a single-unit number

getDurationUnit reads a component as stored, while durationAs converts the whole duration — getDurationUnit("PT90M", "hours") is 0 but durationAs("PT90M", "hours") is 1.5. durationAs and compareDurations return null when a calendar unit (year/month/week) is involved without a relativeTo anchor, the same documented constraint normalizeDuration carries; negateDuration, absDuration, getDurationSign, and getDurationUnit are sign/field reads and never need one.

diffDateAsDuration/diffDateTimeAsDuration/diffZonedAsDuration/diffUnixAsDuration/diffUtcAsDuration are sibling functions to diffDate/diffDateTime/diffZoned/diffUnix/diffUtc, returning an ISO 8601 duration string (sentinel "") instead of a single-unit number (sentinel null). They take a single unit (not an array) to set the duration's largestUnit.

Intervals

One boundary model. Every interval function in this section (intervalsOverlapDate, intervalDifferenceUtc, …) reads an interval as half-open, [start, end): start is inside and end is not, as in the interval/ namespace described under Interval algebra (SQL:2011 closed-open PERIOD, RFC 5545's non-inclusive DTEND, EWD831). Touching intervals share no instant, so they abut rather than overlap, and no function steps an endpoint by one unit. For a Date interval that means end is the first day after the period: pass "2024-07-01", not "2024-06-30", for the first half of 2024 (addDate(lastDay, { days: 1 })). Before 1.16.0 most positional families read intervals as closed [start, end].

Interval and range validators are available in two API shapes — range validators (matching isValidDateRange's { value1, value2, options? } object-param shape) and interval validators ((start, end) positional args, start <= end always):

import {
  isValidDateInterval,
  isValidTimeInterval,
  isValidDateTimeInterval,
  isValidDateRange,
  isValidTimeRange,
  isValidDateTimeRange,
  isValidUtcRange,
  isValidUnixRange,
  isValidZonedRange,
  isValidUtcInterval,
  isValidUnixInterval,
  isValidZonedInterval,
} from "@northguild/gmt";

// Interval validators — positional args, start <= end
isValidDateInterval("2024-01-01", "2024-12-31");
// true

isValidTimeInterval("09:00:00", "17:00:00");
// true

isValidDateTimeInterval("2024-01-01T10:00:00", "2024-12-31T23:59:59");
// true

isValidZonedInterval(
  "2024-01-01T10:00:00+00:00[UTC]",
  "2024-12-31T23:59:59+00:00[UTC]",
);
// true

// Range validators — object params, with optional allowEqual
isValidDateRange({ value1: "2024-01-01", value2: "2024-12-31" });
// true

isValidTimeRange({ value1: "09:00:00", value2: "17:00:00" });
// true

isValidZonedRange({
  value1: "2024-01-01T10:00:00+00:00[UTC]",
  value2: "2024-12-31T23:59:59+00:00[UTC]",
});
// true

Interval containment checks (intervalContains*) test whether a point or inner interval falls within an outer interval. Each supports two modes via an optional fourth argument:

  • 3-arg: intervalContains(start, end, point) — true when start <= point < end
  • 4-arg: intervalContains(start, end, innerStart, innerEnd) — true when the inner interval lies within the outer one and overlaps it, so an inner interval may share the outer end, but an empty inner interval at end is not contained
import {
  intervalContainsDate,
  intervalContainsTime,
  intervalContainsDateTime,
  intervalContainsUtc,
  intervalContainsUnix,
  intervalContainsZoned,
} from "@northguild/gmt";

// Point-in-interval (3-arg)
intervalContainsDate("2024-01-01", "2024-12-31", "2024-06-15");
// true

intervalContainsDate("2024-01-01", "2024-12-31", "2024-12-31");
// false (the end is excluded)

intervalContainsTime("09:00:00", "17:00:00", "12:00:00");
// true

intervalContainsUtc(
  "2024-01-01T00:00:00Z",
  "2024-12-31T23:59:59Z",
  "2024-06-15T12:00:00Z",
);
// true

intervalContainsUnix(0, 1700000000, 170000000);
// true

intervalContainsZoned(
  "2024-01-01T00:00:00+00:00[UTC]",
  "2024-12-31T23:59:59+00:00[UTC]",
  "2024-06-15T12:00:00+00:00[UTC]",
);
// true

// Interval-in-interval (4-arg)
intervalContainsDate("2024-01-01", "2024-12-31", "2024-03-01", "2024-09-01");
// true

intervalContainsTime("09:00:00", "17:00:00", "10:00:00", "16:00:00");
// true

All interval containment checks return false on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the unix/ grammar: not a safe integer or a digit string).

intervalsOverlap* checks whether two intervals share any instant. Returns false when they are disjoint. Intervals that touch (one's end equals the other's start) share no instant, so they do not overlap, exactly as intervalsOverlap in interval/:

import {
  intervalsOverlapDate,
  intervalsOverlapTime,
  intervalsOverlapDateTime,
  intervalsOverlapUtc,
  intervalsOverlapUnix,
  intervalsOverlapZoned,
} from "@northguild/gmt";

intervalsOverlapDate("2024-01-01", "2024-06-30", "2024-04-01", "2024-12-31");
// true

intervalsOverlapDate("2024-01-01", "2024-06-30", "2024-06-30", "2024-12-31");
// false (touching — 2024-06-30 is outside the first interval)

intervalsOverlapUnix(0, 1700000000, 1000000, 2000000);
// true

intervalsOverlapUtc(
  "2024-01-01T09:00:00Z",
  "2024-01-01T17:00:00Z",
  "2024-01-01T17:00:00Z",
  "2024-01-01T18:00:00Z",
);
// false (touching at 17:00)

All overlap checks return false on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the unix/ grammar: not a safe integer or a digit string).

intervalIntersection* returns the overlapping span of two intervals, or null when they do not overlap. Touching intervals share no instant, so their intersection is null:

import {
  intervalIntersectionDate,
  intervalIntersectionTime,
  intervalIntersectionDateTime,
  intervalIntersectionUtc,
  intervalIntersectionUnix,
  intervalIntersectionZoned,
} from "@northguild/gmt";

intervalIntersectionDate(
  "2024-01-01",
  "2024-07-01",
  "2024-04-01",
  "2025-01-01",
);
// { start: "2024-04-01", end: "2024-07-01" }

intervalIntersectionDate(
  "2024-01-01",
  "2024-07-01",
  "2024-07-01",
  "2025-01-01",
);
// null (touching, no shared day)

intervalIntersectionUnix(0, 1700000000, 1000000, 2000000);
// { start: 1000000, end: 2000000 } (B lies inside A)

intervalIntersectionUtc(
  "2024-01-01T00:00:00Z",
  "2024-07-01T00:00:00Z",
  "2024-04-01T00:00:00Z",
  "2025-01-01T00:00:00Z",
);
// { start: "2024-04-01T00:00:00Z", end: "2024-07-01T00:00:00Z" }

All intersection functions return null on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the unix/ grammar: not a safe integer or a digit string).

intervalOverlappingDays* returns how many distinct calendar dates two intervals share — the numeric counterpart to intervalIntersection*'s span. It counts the calendar dates that hold at least one instant of the half-open intersection [max(aStart, bStart), min(aEnd, bEnd)), so an empty intersection counts 0: intervalOverlappingDaysDate("2024-01-01", "2024-01-02", "2024-01-01", "2024-01-02") is 1, and touching intervals are 0. There is no Time variant — PlainTime has no calendar, so a day count is undefined for it:

import {
  intervalOverlappingDaysDate,
  intervalOverlappingDaysDateTime,
  intervalOverlappingDaysUtc,
  intervalOverlappingDaysUnix,
  intervalOverlappingDaysZoned,
} from "@northguild/gmt";

intervalOverlappingDaysDate(
  "2024-01-01",
  "2024-07-01",
  "2024-04-01",
  "2025-01-01",
);
// 91 (2024-04-01 through 2024-06-30)

intervalOverlappingDaysDate(
  "2024-01-01",
  "2024-07-01",
  "2024-07-01",
  "2025-01-01",
);
// 0 (touching, no shared date)

intervalOverlappingDaysUtc(
  "2024-01-17T12:00:00Z",
  "2024-01-19T00:00:00Z",
  "2024-01-10T00:00:00Z",
  "2024-01-18T06:00:00Z",
);
// 2 (18 hours of overlap touch 17 and 18 January)

intervalOverlappingDaysUnix(0, 172800000, 86400000, 259200000);
// 1 (the intersection [86400000, 172800000) is 1970-01-02 in UTC)

Returns 0 when the intervals do not overlap (a well-defined answer, not invalid input) and null on invalid input, including an inverted interval (start > end). intervalOverlappingDaysZoned and intervalOverlappingDaysUnix count days in aStart's time zone (intervalOverlappingDaysUnix defaults to UTC; pass { timeZone }, or "local" for the system zone) — the same rule intervalCountZoned/intervalCountUnix use — so intervalOverlappingDaysZoned is not commutative when the two intervals carry different zones: swapping the arguments can change the answer. Both count the distinct local dates the overlap touches, so a date the zone skipped (Pacific/Apia, 2011-12-30) is not counted, and a fall-back that sends the clock back into the previous date counts that date too.

It counts calendar dates, not elapsed days: the 18-hour overlap above touches two dates. For the elapsed length of the overlap, compose intervalIntersection* with intervalLength*:

const span = intervalIntersectionUtc(aStart, aEnd, bStart, bEnd);
span ? intervalLengthUtc(span.start, span.end, "day") : 0; // 0.75 for the overlap above

intervalUnion* returns the combined span of two overlapping or touching intervals, or null when a gap separates them. Touching intervals (one's end equals the other's start) join:

import {
  intervalUnionDate,
  intervalUnionTime,
  intervalUnionDateTime,
  intervalUnionUtc,
  intervalUnionUnix,
  intervalUnionZoned,
} from "@northguild/gmt";

intervalUnionDate("2024-01-01", "2024-07-01", "2024-04-01", "2025-01-01");
// { start: "2024-01-01", end: "2025-01-01" }

intervalUnionDate("2024-01-01", "2024-07-01", "2024-07-01", "2025-01-01");
// { start: "2024-01-01", end: "2025-01-01" } (touching, joined)

intervalUnionDate("2024-01-01", "2024-06-30", "2024-07-01", "2024-12-31");
// null (2024-06-30 lies in neither interval)

intervalUnionUnix(0, 1700000000, 1000000, 2000000);
// { start: 0, end: 1700000000 }

intervalUnionUtc(
  "2024-01-01T00:00:00Z",
  "2024-07-01T00:00:00Z",
  "2024-04-01T00:00:00Z",
  "2025-01-01T00:00:00Z",
);
// { start: "2024-01-01T00:00:00Z", end: "2025-01-01T00:00:00Z" }

All union functions return null on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the unix/ grammar: not a safe integer or a digit string).

intervalDifference* returns the portion(s) of interval A not covered by interval B, as an array of { start, end } records:

import {
  intervalDifferenceDate,
  intervalDifferenceTime,
  intervalDifferenceDateTime,
  intervalDifferenceUtc,
  intervalDifferenceUnix,
  intervalDifferenceZoned,
} from "@northguild/gmt";

intervalDifferenceDate("2024-01-01", "2024-12-31", "2024-06-01", "2024-07-01");
// [{ start: "2024-01-01", end: "2024-06-01" }, { start: "2024-07-01", end: "2024-12-31" }]

intervalDifferenceDate("2024-01-01", "2024-12-31", "2024-01-01", "2024-12-31");
// [] (B fully covers A)

intervalDifferenceUnix(0, 1700000000, 1000000, 2000000);
// [{ start: 0, end: 1000000 }, { start: 2000000, end: 1700000000 }]

intervalDifferenceUtc(
  "2024-01-01T09:00:00Z",
  "2024-01-01T17:00:00Z",
  "2024-01-01T12:00:00Z",
  "2024-01-01T13:00:00Z",
);
// [{ start: "2024-01-01T09:00:00Z", end: "2024-01-01T12:00:00Z" }, { start: "2024-01-01T13:00:00Z", end: "2024-01-01T17:00:00Z" }]

Each piece ends exactly where B starts and resumes exactly where B ends, with no one-unit step, so the pieces and B together cover A once. intervalDifferenceUtc returns the same pieces as subtractIntervals.

All difference functions return [] on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the unix/ grammar: not a safe integer or a digit string).

intervalXor* returns the symmetric difference of two intervals — the portions covered by exactly one of them, not both — as an array of { start, end } records:

import {
  intervalXorDate,
  intervalXorTime,
  intervalXorDateTime,
  intervalXorUtc,
  intervalXorUnix,
  intervalXorZoned,
} from "@northguild/gmt";

intervalXorDate("2024-01-01", "2024-07-01", "2024-04-01", "2025-01-01");
// [{ start: "2024-01-01", end: "2024-04-01" }, { start: "2024-07-01", end: "2025-01-01" }]

intervalXorDate("2024-01-01", "2024-07-01", "2024-07-01", "2025-01-01");
// [{ start: "2024-01-01", end: "2025-01-01" }] (touching intervals share no date, so the runs join)

intervalXorUnix(0, 1700000000, 1000000, 2000000);
// [{ start: 0, end: 1000000 }, { start: 2000000, end: 1700000000 }]

intervalXorUtc(
  "2024-01-01T09:00:00Z",
  "2024-01-01T13:00:00Z",
  "2024-01-01T12:00:00Z",
  "2024-01-01T17:00:00Z",
);
// [{ start: "2024-01-01T09:00:00Z", end: "2024-01-01T12:00:00Z" }, { start: "2024-01-01T13:00:00Z", end: "2024-01-01T17:00:00Z" }]

All xor functions return [] on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the unix/ grammar: not a safe integer or a digit string).

intervalAbuts* checks whether two intervals are exactly adjacent, in either order: one interval's end equals the other's start (Allen's "meets"). The two then share no instant and leave no gap. Intervals separated by any gap, even one nanosecond or one epoch unit, do not abut, and an empty interval abuts nothing:

import {
  intervalAbutsDate,
  intervalAbutsTime,
  intervalAbutsDateTime,
  intervalAbutsUtc,
  intervalAbutsUnix,
  intervalAbutsZoned,
} from "@northguild/gmt";

intervalAbutsDate("2024-01-01", "2024-07-01", "2024-07-01", "2025-01-01");
// true (the first interval ends where the second starts)

intervalAbutsDate("2024-01-01", "2024-06-30", "2024-07-01", "2024-12-31");
// false (2024-06-30 lies in neither interval)

intervalAbutsDate("2024-01-01", "2024-07-01", "2024-04-01", "2025-01-01");
// false (overlap)

intervalAbutsUnix(0, 1000000, 1000000, 2000000);
// true

intervalAbutsUtc(
  "2024-01-01T09:00:00Z",
  "2024-01-01T12:00:00Z",
  "2024-01-01T12:00:00.000000001Z",
  "2024-01-01T17:00:00Z",
);
// false (1 ns apart)

All abuts checks return false on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the unix/ grammar: not a safe integer or a digit string).

intervalEngulfs* checks whether interval B lies within interval A and overlaps it — B may share A's start or end, but an empty B at A's end is not engulfed. Equivalent to the 4-argument intervalContains* mode:

import {
  intervalEngulfsDate,
  intervalEngulfsTime,
  intervalEngulfsDateTime,
  intervalEngulfsUtc,
  intervalEngulfsUnix,
  intervalEngulfsZoned,
} from "@northguild/gmt";

intervalEngulfsDate("2024-01-01", "2024-12-31", "2024-06-01", "2024-07-01");
// true

intervalEngulfsDate("2024-01-01", "2024-12-31", "2024-01-01", "2024-12-31");
// true (equal intervals)

intervalEngulfsDate("2024-06-01", "2024-07-01", "2024-01-01", "2024-12-31");
// false

intervalEngulfsUnix(0, 1700000000, 1000000, 2000000);
// true

intervalEngulfsUtc(
  "2024-01-01T00:00:00Z",
  "2024-12-31T23:59:59Z",
  "2024-06-01T00:00:00Z",
  "2024-07-01T00:00:00Z",
);
// true

All engulfs checks return false on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, or an epoch outside the unix/ grammar: not a safe integer or a digit string).

splitIntervalByUnit* splits an interval into sub-intervals of amount × unit, returning an array of { start, end } records. The final sub-interval is trimmed so its end never exceeds the original end:

import {
  splitIntervalByUnitDate,
  splitIntervalByUnitTime,
  splitIntervalByUnitDateTime,
  splitIntervalByUnitUtc,
  splitIntervalByUnitUnix,
  splitIntervalByUnitZoned,
} from "@northguild/gmt";

splitIntervalByUnitDate("2024-01-01", "2024-01-10", "day", 2);
// [{ start: "2024-01-01", end: "2024-01-03" }, { start: "2024-01-03", end: "2024-01-05" }, { start: "2024-01-05", end: "2024-01-07" }, { start: "2024-01-07", end: "2024-01-09" }, { start: "2024-01-09", end: "2024-01-10" }]

splitIntervalByUnitUtc(
  "2024-01-01T00:00:00Z",
  "2024-01-02T00:00:00Z",
  "hour",
  6,
);
// [{ start: "2024-01-01T00:00:00Z", end: "2024-01-01T06:00:00Z" }, { start: "2024-01-01T06:00:00Z", end: "2024-01-01T12:00:00Z" }, { start: "2024-01-01T12:00:00Z", end: "2024-01-01T18:00:00Z" }, { start: "2024-01-01T18:00:00Z", end: "2024-01-02T00:00:00Z" }]

splitIntervalByUnitUnix(0, 86400000, "hour", 6);
// [{ start: 0, end: 21600000 }, { start: 21600000, end: 43200000 }, { start: 43200000, end: 64800000 }, { start: 64800000, end: 86400000 }]

All split functions return [] on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, non-positive amount, unsupported unit, or a unit that has no effect on the target type). A zero-length interval returns itself as the one piece ([{ start, end }]) for a supported unit, and [] for an unsupported one: splitIntervalByUnitDate("2024-01-01", "2024-01-01", "hour", 1) is [].

splitIntervalByUnit*, intervalDivideEqually*, mapDatesInRange and mapZonedDatesInRange build one array element per piece, so each takes an optional { maxPieces } (a positive safe integer, default 1_000_000) and returns [] when the result would be longer. A split stops as soon as the piece past the limit is due, so a huge range answers in bounded time instead of exhausting the heap. Pass a larger maxPieces when a long range legitimately needs it:

import { mapDatesInRange, splitIntervalByUnitDate } from "@northguild/gmt";

splitIntervalByUnitDate("2024-01-01", "2024-01-10", "day", 2, { maxPieces: 4 });
// [] — 5 slices exceed the limit

mapDatesInRange("0001-01-01", "9999-12-31", 1);
// [] — 3,652,059 dates exceed the default

Each boundary is computed from start (start + k × amount, as Temporal and Luxon's Interval.splitBy do), never by stepping from the previous boundary, so month-end starts don't drift. A yearly split from February 29 likewise returns to February 29 in leap years:

splitIntervalByUnitDate("2024-01-31", "2024-05-01", "month", 1);
// [{ start: "2024-01-31", end: "2024-02-29" }, { start: "2024-02-29", end: "2024-03-31" }, { start: "2024-03-31", end: "2024-04-30" }, { start: "2024-04-30", end: "2024-05-01" }]

intervalCount* returns how many calendar-unit boundaries an interval crosses — the number of units the half-open interval [start, end) touches. This is distinct from diff*, which measures exact elapsed duration: an interval from 23:59 to 00:01 is two minutes long but touches two days:

import {
  intervalCountDate,
  intervalCountTime,
  intervalCountDateTime,
  intervalCountUtc,
  intervalCountUnix,
  intervalCountZoned,
} from "@northguild/gmt";

intervalCountDateTime("2024-01-01T23:59:00", "2024-01-02T00:01:00", "day");
// 2 (two minutes long, but two days touched)

intervalCountDate("2024-01-01", "2024-01-03", "day");
// 2 (the end boundary is excluded)

intervalCountDate("2024-01-15", "2024-03-10", "month");
// 3

intervalCountTime("12:30:00", "13:00:00", "hour");
// 1

intervalCountZoned(
  "2024-03-10T00:00:00-05:00[America/New_York]",
  "2024-03-11T00:00:00-04:00[America/New_York]",
  "hour",
);
// 23 (the spring-forward local day is 23 hours long)

intervalCountUnix(0, 86400000, "hour");
// 24

A zero-length interval counts 0 in every unit — the empty [start, start) holds no instant, so `intervalCountDate(