@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
Temporalstandard (via@js-temporal/polyfill) — not a custom, homegrown date/time type system like@internationalized/date's ownCalendarDate/ZonedDateTimeclasses. NoDateobject 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/datedon'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
TZenv var across real-world zones. Luxon and@internationalized/datehave 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/dateare still active but all still depend onDateinternally.
Install
npm install @northguild/gmtpnpm add @northguild/gmtDesign 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,falseor[]) 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/gmtand 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 fornull,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/, andunix/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");
// trueadd*/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",
});
// 1setDate/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 overlapsetZoned/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 doescycleZoned 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 notmergeCalendars 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([]);
// nullisValidBusinessCalendar 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 dayclampDate 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");
// 0getLocaleEraNames/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 matchSupported 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; // 6The 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 366Month 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
untilthrows:diffDate,diffDateAsDuration,intervalCountDate,intervalLengthDate,splitIntervalByUnitDateandintervalOverlappingDaysDate. - Ordering accepts mixed calendars, as
Temporal.PlainDate.comparehas no calendar check:intervalContainsDate,intervalsOverlapDate,intervalAbutsDate,intervalEngulfsDateandisValidDateInterval. - Functions that return a date value (
intervalUnionDate,intervalIntersectionDate,intervalDifferenceDate,intervalXorDate,intervalXorAllDate,mergeIntervalsDate,intervalDivideEquallyDate,intervalSplitAtDate) require one shared calendar and returnnull/[]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 datesCalendar-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 numbergetDurationUnit 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):startis inside andendis not, as in theinterval/namespace described under Interval algebra (SQL:2011 closed-openPERIOD, RFC 5545's non-inclusiveDTEND, EWD831). Touching intervals share no instant, so they abut rather than overlap, and no function steps an endpoint by one unit. For aDateinterval that meansendis 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]",
});
// trueInterval 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 whenstart <= 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 outerend, but an empty inner interval atendis 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");
// trueAll 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 aboveintervalUnion* 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",
);
// trueAll 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 defaultEach 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");
// 24A zero-length interval counts 0 in every unit — the empty [start, start) holds no instant, so `intervalCountDate(
