@burglekitt/gmt
Version:
Temporal-based date and time utilities with timezone support and polyfill integration
1,631 lines • 82.3 kB
Markdown
# @burglekitt/gmt
> ## ⚠️ Deprecation Notice
>
> This package has moved. **`gmt` is now published as
> [`@northguild/gmt`](https://www.npmjs.com/package/@northguild/gmt)** under the
> [northguild](https://github.com/northguild) GitHub organization.
>
> - **New repository:** <https://github.com/northguild/gmt>
> - **New package:** `@northguild/gmt`
>
> `@burglekitt/gmt` is deprecated and will receive no further updates. Please
> migrate to `@northguild/gmt`.
Give Me Temporal.
`@burglekitt/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.
**Status:** pre-alpha. Expect API movement while the surface is still being filled out.
## Install
```bash
npm install @burglekitt/gmt
```
```bash
pnpm add @burglekitt/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 numeric Unix epoch values (explicitly seconds or milliseconds). 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`, or `false`) 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 `@burglekitt/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 **20 environments** — 2 Node versions (22, 24) × 10 timezones spanning every UTC offset band from Pacific/Niue (−11:00) to Pacific/Apia (+13: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:** 15,611 tests across 527 files that exercise real behavior differences without redundant permutations. The suite runs in CI as 312,220 executions (15,611 × 2 Node versions × 10 timezones).
## How GMT is tested, vs. the libraries it targets
GMT's roadmap (see [context/roadmap](https://github.com/burglekitt/gmt/tree/main/context/roadmap)) is explicitly scoped 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 (`@burglekitt/gmt`) | 1.12.0 |
| `@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 | 527 | 6 | 58 / 60<br>(2 didn't run<br>locally) | 256 | 191<br>(52 core +<br>139 locale) |
| Individual test cases | **15,611** | 386 | 1,222 | 3,213 | 3,901 |
| Effective CI test<br>executions | **312,220**<br>(15,611 × 2 Node<br>× 10 timezones) | 386<br>(×1 Node) | 4,888<br>(1,222 × 4 Node) | 3,213<br>(×1 Node) | 11,703<br>(3,901 × 3 Node) |
| CI Node.js matrix | 22, 24 | n/a — tests<br>React 16–canary | 20, 22, 24, 25 | not explicit<br>(`node = "latest"`) | LTS, LTS-1,<br>latest |
| CI timezone matrix | **10 zones × 2**<br>**Node, full suite** | none found | none found | dedicated workflow,<br>zone scope unclear | 6 zones,<br>partial suite only |
| Locale test matrix | **17 locales**,<br>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 | **maintenance<br>mode** |
<sub>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](https://github.com/burglekitt/gmt/blob/main/.github/workflows/ci.yml) · [`@internationalized/date`](https://github.com/adobe/react-spectrum/blob/main/.circleci/config.yml) · [Luxon](https://github.com/moment/luxon/blob/master/.github/workflows/test.yml) · [date-fns](https://github.com/date-fns/date-fns/tree/main/.github/workflows) · [Moment.js](https://github.com/moment/moment/tree/develop/.github/workflows).</sub>
### Functionality parity progress
GMT's roadmap tracks parity against the same four libraries story-by-story, with each gap sourced against the specific competitor function it closes — see [context/roadmap](https://github.com/burglekitt/gmt/tree/main/context/roadmap) for the full, source-verified audit trail. This is a live snapshot, not a finished-parity claim: ✅ shipped, 🟡 in progress, ⏳ backlog and not yet scheduled.
| Capability | Status | Also has it |
| -------------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------ |
| Duration type<br>(ISO 8601 parse/format/arithmetic) | ✅ Done | Luxon `Duration` |
| Interval/range math<br>(contains, overlap, union,<br>intersection, split, set ops) | ✅ Done | Luxon `Interval`,<br>date-fns `areIntervalsOverlapping` |
| DST disambiguation control<br>on construction _and_ arithmetic | ✅ Done — **differentiator** | None of the others expose<br>this on arithmetic |
| Locale-aware calendar helpers<br>(weekend, week start/end, day-of-week) | ✅ Done | `@internationalized/date` |
| Business-day arithmetic,<br>clamp/closest, time rounding | ✅ Done | `temporal-kit` |
| Interval rounding-out<br>(boundary count, from-duration) | ✅ Done | Luxon |
| Locale calendar metadata<br>(names, `hasDST`) | ✅ Done | Luxon `Info` |
| Overlap-day count, relative<br>rounding, DST transitions, hours-in-day | ✅ Done | date-fns, `@internationalized/date` |
| Field setters, token-pattern<br>parsing, named machine formats,<br>calendar-style formatting | ✅ Done | Luxon `.set()`,<br>`toRFC2822`/`toHTTP`/`toSQL`,<br>Moment `.calendar()` |
| Non-Gregorian calendar systems<br>(conversion + calendar-aware<br>interval/duration math) | ✅ Done | `@internationalized/date`'s<br>`toCalendar` |
<sub>Status reflects [context/roadmap/tracker.md](https://github.com/burglekitt/gmt/tree/main/context/roadmap/tracker.md) as of this writing.</sub>
### Where GMT stands alone
Specific, sourced claims — not a repeat of the metrics above.
| Claim | The others |
| --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| Only GMT runs its **entire** suite in CI<br>under a real `TZ` env var across 10<br>real-world zones × 2 Node versions<br>(20 full-suite runs) | Luxon/`@internationalized/date`: no<br>CI timezone matrix. date-fns: zone<br>scope unclear. Moment.js: 6 zones,<br>partial suite only |
| Only GMT enforces a mandatory<br>17-locale test matrix on every<br>locale-aware function | No CI-level or systematic<br>locale-matrix testing found<br>in any of the four |
| Only GMT exposes explicit DST<br>disambiguation control on both<br>construction _and_ arithmetic | Luxon's docs call this explicitly<br>undefined; `@internationalized/date`<br>only covers construction, not arithmetic |
| Only GMT is Temporal-native with<br>zero `Date` usage, enforced by<br>3 dedicated lint packages | Luxon, date-fns, and Moment.js all<br>still wrap or depend on `Date` internally |
| GMT's effective CI test<br>executions exceed all four<br>competitors **combined**<br>by ~15× | 312,220 vs. 386 + 4,888 + 3,213<br>+ 11,703 = 20,190 |
## Package Layout
The package exports seven top-level namespaces:
```typescript
import {
Temporal,
duration,
plain,
zoned,
unix,
utc,
regex,
} from "@burglekitt/gmt";
```
- `Temporal`: re-exported from `@js-temporal/polyfill`
- `duration`: ISO 8601 duration string parsing, validation, and arithmetic
- `plain`: timezone-free helpers
- `zoned`: timezone-aware helpers
- `unix`: Unix epoch (seconds or milliseconds) helpers
- `utc`: UTC instant helpers
- `regex`: low-level regex building blocks
You can also import subpaths directly:
```typescript
import { addDate, getNow, formatRelativeZoned } from "@burglekitt/gmt";
```
## Quick Start
### Plain arithmetic and comparisons
```typescript
import {
addDate,
addBusinessDays,
subtractBusinessDays,
areDatesEqual,
diffDateTime,
isBeforeDateTime,
} from "@burglekitt/gmt";
addDate("2026-01-01", 90, "day");
// "2026-03-32" is impossible, so Temporal normalizes correctly -> "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", "minute");
// 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), and `diff*` accept optional `smallestUnit`/`roundingIncrement`/`roundingMode` to round the computed difference:
```typescript
import { addDate, diffDate } from "@burglekitt/gmt";
addDate("2024-01-31", { months: 1 }, { overflow: "reject" });
// "" — Feb 31 doesn't exist and overflow: "reject" refuses to clamp it
diffDate("2023-01-01", "2023-01-10", "week", {
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:
```typescript
import { setDate, setZoned } from "@burglekitt/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",
},
);
// "" — offset defaults to "ignore" so disambiguation actually fires on this fall-back overlap
```
`setZoned`/`setUnix`/`setUtc` also accept `disambiguation` and `offset` for DST gap/overlap control — see [DST Disambiguation](../../docs/dst-disambiguation.md).
`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:
```typescript
import { addDate, cycleDate, cycleZoned } from "@burglekitt/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: "ignore"`) for the same DST gap/overlap control as `setZoned` — see [DST Disambiguation](../../docs/dst-disambiguation.md). `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`'s `weekInfo`) rather than assuming Saturday/Sunday:
```typescript
import { isWeekend, isZonedWeekend } from "@burglekitt/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` returns true for fixed ISO Monday–Friday business days (Mon=1 … Fri=5), locale-agnostic and with no holiday calendar. It's the complement to locale-aware `isWeekend` and matches the boundary that `addBusinessDays`/`subtractBusinessDays` use:
```typescript
import { isBusinessDay } from "@burglekitt/gmt";
isBusinessDay("2024-02-05");
// true (Monday)
isBusinessDay("2024-02-10");
// false (Saturday)
```
`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:
```typescript
import { isRelativeDay, isThisUnit, isPast, isFuture } from "@burglekitt/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:
```typescript
import { nextWeekday, previousWeekday } from "@burglekitt/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)
```
```typescript
import { isZonedRelativeDay, isZonedPast } from "@burglekitt/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:
```typescript
import { clampDate, closestDateTo } from "@burglekitt/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:
```typescript
import { getLocaleStartOfWeek, getLocaleEndOfWeek } from "@burglekitt/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):
```typescript
import { getLocaleDayOfWeek, getLocaleZonedDayOfWeek } from "@burglekitt/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", "he-IL");
// 0 (Saturday = first day of he-IL 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`:
```typescript
import {
getLocaleEraNames,
getLocaleMonthNames,
getLocaleWeekdayNames,
getLocaleMeridiems,
} from "@burglekitt/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.
```typescript
import {
parseDateWithPattern,
parseDateTimeWithPattern,
parseTimeWithPattern,
} from "@burglekitt/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
GMT's `CalendarSystem` type (`"gregorian" | "hebrew" | "islamic-civil" | "islamic-tabular" | "islamic-umalqura" | "japanese" | "buddhist" | "taiwan" | "persian" | "indian" | "ethiopic" | "ethiopic-amete-alem" | "coptic"`, extended by later stories) and `convertDateToCalendar` express a date in a non-Gregorian calendar system, built almost entirely on Temporal's native calendar support — no bundled leap-year tables or ported arithmetic, with one deliberate exception (the Ethiopic family, covered below).
```typescript
import { convertDateToCalendar } from "@burglekitt/gmt";
convertDateToCalendar("2024-10-03", "hebrew");
// "5785-01-01[u-ca=hebrew]" — Rosh Hashanah 5785
convertDateToCalendar("5785-01-01[u-ca=hebrew]", "gregorian");
// "2024-10-03" — round-trips back
convertDateToCalendar("2024-10-03", "islamic-umalqura");
// "1446-03-30[u-ca=islamic-umalqura]" — Saudi Umm al-Qura calendar
convertDateToCalendar("invalid", "hebrew");
// ""
```
The output string shape is the key design decision here, and it deliberately **diverges from Temporal's own** `[u-ca=...]` convention. Temporal's `Temporal.PlainDate.prototype.toString()` always keeps the ISO/proleptic-Gregorian year-month-day digits and only tags the calendar (`"2024-10-03[u-ca=hebrew]"` — still literally October 3rd's Gregorian digits). That hides the calendar's own fields behind calendar-aware accessors, which GMT's string-only contract has no place for. GMT's annotated string instead carries the **calendar-native** year/month/day — Hebrew year 5785, not 2024 — so the calendar-system concept is visible directly in the string, not just in an object property. A plain, unannotated ISO string is always treated as (and always produced for) the `"gregorian"` calendar, so every existing GMT function keeps working unchanged.
Hebrew years can run 12 or 13 months (7 leap years per 19-year Metonic cycle insert a 13th month, Adar I, before the regular Adar); `convertDateToCalendar` resolves this the same way Temporal does internally, via ordinal month numbers (`1`-`13`) rather than fixed month names, so no month-counting logic lives in GMT itself.
Three Islamic (Hijri) calendar variants are supported, and they are **not interchangeable** — each resolves the same Gregorian date to different calendar-native digits:
- `"islamic-civil"` — a fixed 30-year leap-year cycle, Friday epoch (`1 AH = 622-07-19`).
- `"islamic-tabular"` — the same style of fixed arithmetic cycle, but a Thursday epoch one day earlier (`1 AH = 622-07-18`); maps to Temporal's `"islamic-tbla"` calendar id internally, though GMT's own string annotation always reads `[u-ca=islamic-tabular]`.
- `"islamic-umalqura"` — the Saudi civil calendar, based on Umm al-Qura University's own published tables rather than a fixed arithmetic rule. This is **not** approximated by the tabular variant's math — `convertDateToCalendar("2020-02-24", "islamic-umalqura")` returns `"1441-06-30[u-ca=islamic-umalqura]"` while the same input under `"islamic-tabular"` returns `"1441-07-01[u-ca=islamic-tabular]"`, a genuine one-day divergence, not a rounding difference.
Five era-based solar calendars round out the set. Unlike Hebrew and the Islamic variants, none of these needed new leap-year logic — each is either Gregorian-shaped with a different year numbering layered on top, or a distinct-but-simple solar calendar, so they were materially less work than Hebrew or Islamic:
- `"buddhist"` — Gregorian day/month structure, a fixed `+543` year offset (`convertDateToCalendar("2024-10-03", "buddhist")` → `"2567-10-03[u-ca=buddhist]"`). One continuous era, no reset.
- `"taiwan"` — Gregorian day/month structure, year numbering reset at 1912 (the Republic of China's founding): `2024` → `"0113-10-03[u-ca=taiwan]"` (`2024 - 1911`). Dates before 1912 count backward through Temporal's inverse era instead of going negative in the way a plain offset would — `convertDateToCalendar("1911-12-31", "taiwan")` is `"0000-12-31[u-ca=taiwan]"`, not `"-0001-12-31"`.
- `"persian"` — a genuinely distinct solar calendar (not Gregorian-derived): own month lengths (6 months of 31 days, 5 of 30, a 29/30-day 12th month) and its own 33-year leap-year cycle (a year is leap when `(25 × year + 11) mod 33 < 8`), verified against `@internationalized/date`'s `PersianCalendar.ts` rather than assumed from the offset-only calendars above.
- `"indian"` — the Indian National Calendar (Saka era, epoch 78 CE). Also not offset-only: its leap-year alignment follows the Gregorian rule rather than an independent cycle — the calendar's first month is 31 days in a Gregorian leap year, 30 otherwise, which is why its Saka-year boundary (`convertDateToCalendar("2024-03-20", "indian")` → `"1945-12-30[u-ca=indian]"`, `convertDateToCalendar("2024-03-21", "indian")` → `"1946-01-01[u-ca=indian]"`) doesn't land on a fixed day-of-year every year.
- `"japanese"` — era-based: the year resets to `1` at each imperial era change (Meiji, Taishō, Shōwa, Heisei, Reiwa). This is the one calendar where GMT's annotated string carries an era-relative year instead of the calendar's plain native year, and needs its own explanation below.
**Why `"japanese"` gets a different string shape.** Every other supported calendar's plain `year` field is exactly what belongs in GMT's annotated string — Hebrew year 5785, Taiwan year 113, and so on are each already the single number that identifies the year. Japanese is the exception: Temporal's `.year` for the `"japanese"` calendar stays **proleptic** across era changes (it doesn't reset — `1912-07-30`, the first day of Taishō, still reports `.year === 1912`, not `1`), because Temporal's `.year` is designed to be a stable sort key, not a display value. Using it directly would silently contradict the calendar's entire reason for existing. So `convertDateToCalendar` uses Temporal's `.eraYear` (the field that _does_ reset) paired with the era name, tagged onto the annotation as `;era=<name>`:
```typescript
convertDateToCalendar("2024-10-03", "japanese");
// "0006-10-03[u-ca=japanese;era=reiwa]" — year 6 of the Reiwa era, not the proleptic 2024
convertDateToCalendar("1912-07-30", "japanese");
// "0001-07-30[u-ca=japanese;era=taisho]" — the first day of Taishō reads year 1
convertDateToCalendar("0001-07-30[u-ca=japanese;era=taisho]", "gregorian");
// "1912-07-30" — round-trips back through the era + eraYear pair
```
`@internationalized/date` documents pre-Meiji (before 1868-10-23) dates as unsupported for its Japanese calendar. GMT does not replicate that restriction: since `convertDateToCalendar` is built entirely on Temporal's own calendar support rather than a ported implementation, and Temporal resolves those dates correctly under a synthetic `"japanese"` era (with an ISO-aligned `eraYear`), rejecting them would mean writing new validation solely to reproduce another library's gap rather than an actual GMT limitation. `convertDateToCalendar("1800-01-01", "japanese")` returns `"1800-01-01[u-ca=japanese;era=japanese]"` rather than `""`.
Three Ethiopic-family calendars round out the set, all sharing one 13-month structure (12 months of 30 days, plus a short Pagume/Nasie 13th month of 5 days, or 6 in a leap year) but differing in epoch:
- `"ethiopic"` — the modern Ethiopian calendar, era-based like `"japanese"`: it resets to the Amete Mihret ("Year of Mercy"/Incarnation) era at its own epoch (~AD 8), so `convertDateToCalendar("2024-10-03", "ethiopic")` returns `"2017-01-23[u-ca=ethiopic;era=ethiopic]"` — year 2017, not a 5-digit proleptic number. Dates before that epoch resolve under the Amete Alem ("Year of the World") era instead: `convertDateToCalendar("0001-01-01", "ethiopic")` → `"5493-05-08[u-ca=ethiopic;era=ethioaa]"`.
- `"ethiopic-amete-alem"` — the same calendar, but always counted continuously from the Amete Alem epoch (~5493 BCE), never resetting: `convertDateToCalendar("2024-10-03", "ethiopic-amete-alem")` → `"7517-01-23[u-ca=ethiopic-amete-alem]"`.
- `"coptic"` — the Coptic Orthodox calendar: same 13-month structure, its own epoch (the Diocletian/Martyrs era, AD 284): `convertDateToCalendar("2024-10-03", "coptic")` → `"1741-01-23[u-ca=coptic]"`.
**Why this family isn't built on Temporal's native `"ethiopic"`/`"coptic"` calendar ids, unlike every other calendar above.** `@js-temporal/polyfill`'s implementation of these two calendars resolves year/era by formatting the date through `Intl.DateTimeFormat` and matching the result against a hardcoded era-name table — unlike calendars with a fixed year-offset from ISO (Buddhist, Taiwan, and Ethiopic Amete Alem, which the polyfill computes with pure arithmetic and never touches `Intl` for). CLDR's era-name output for these two calendars changed between ICU versions: under ICU ≥ 78 (the version Node 22 and 24 both bundle — this is an ICU-version boundary, not a Node-major one), every read _or_ write of Temporal's `"ethiopic"`/`"coptic"` calendar ids throws a `RangeError` (`Era am (ISO year …) was not matched by any era`) — confirmed directly, not a hypothetical. `"ethiopic-amete-alem"` (Temporal's `"ethioaa"` id) is unaffected, since it has no era at all and is resolved with pure arithmetic. GMT routes around the bug rather than inheriting it: month/day are identical across all three calendars (they share one annual cycle), so `convertDateToCalendar` reads/writes them via the safe `"ethioaa"` calendar and computes each calendar's own displayed year (+ era, for `"ethiopic"`) with GMT-owned arithmetic — ported from the same `EthiopicHelper`/`CopticHelper` epoch constants `@js-temporal/polyfill` itself uses internally, just evaluated in GMT's code instead of through the ICU-dependent path. See `internal/ethiopicFamilyCalendar.ts` for the implementation.
#### Calendar-aware interval and duration arithmetic
`convertDateToCalendar`'s output feeds directly back into `addDate`/`subtractDate`/`diffDate`/`diffDateAsDuration` and every `Date`-suffixed `plain/interval/*` function (`intervalContainsDate`, `intervalCountDate`, `splitIntervalByUnitDate`, and the rest) — calendar-unit arithmetic ("add 1 month") resolves in the value's own calendar rather than being rejected or silently treated as Gregorian:
```typescript
addDate("5784-06-15[u-ca=hebrew]", { months: 1 });
// "5784-07-15[u-ca=hebrew]" — Adar I (a leap-only 30-day month) -> Adar
intervalCountDate("5784-01-01[u-ca=hebrew]", "5785-01-01[u-ca=hebrew]", "month");
// 13 — a Hebrew leap year crosses 13 month boundaries, not 12 (ISO's answer for the same span)
diffDate("5784-06-15[u-ca=hebrew]", "5784-07-15[u-ca=hebrew]", "months");
// 1 — measured in the shared calendar when both endpoints carry the same tag
```
`utc/` and `unix/` reject a `[u-ca=...]` calendar annotation outright, and `duration/`'s `relativeTo` option accepts GMT's calendar-annotated string (not Temporal's own differently-shaped `[u-ca=...]` convention) when a calendar-aware anchor is needed:
```typescript
durationAs("P1Y", "days", { relativeTo: "5784-06-15[u-ca=hebrew]" });
// 385 — a Hebrew leap year, not the 366 a Gregorian P1Y would total
```
`zoned/` has its own calendar-annotated grammar as of E7 (issue #152) — see "Calendar-aware zoned datetimes" below.
Interval functions that only compare or diff absolute instants (`intervalContainsDate`, `intervalsOverlapDate`, `intervalAbutsDate`, `intervalEngulfsDate`, `isValidDateInterval`, `intervalOverlappingDaysDate`) accept endpoints tagged with *different* calendars, since ordering and day-counting don't depend on which calendar a date is expressed in. Functions that return a date *value* (`intervalUnionDate`, `intervalIntersectionDate`, `intervalDifferenceDate`, `intervalXorDate`, `intervalXorAllDate`, `mergeIntervalsDate`, `intervalDivideEquallyDate`, `intervalSplitAtDate`) require every argument to share one calendar and return their sentinel (`null`/`[]`) on a mismatch, since there's no principled way to pick which calendar the output should be expressed in. See `context/roadmap/issues/E.md`'s "E5 outcome" section for the full per-function audit, including the negatives ("no change needed, verified why") this scope boundary implies — `*DateTime`/`*Time` variants, `unix/`, and `utc/` were all confirmed unaffected rather than assumed to be.
#### Calendar-aware zoned datetimes
A calendar-annotated `ZonedDateTime` string adds a time, a UTC offset and an IANA zone to the
plain grammar above:
```
<calendar-native-date>T<time><offset>[u-ca=<id>[;era=<era>]][<timeZone>]
5784-06-15T14:30:00-05:00[u-ca=hebrew][America/New_York]
0031-04-30T12:00:00+09:00[u-ca=japanese;era=heisei][Asia/Tokyo]
7517-12-30T00:30:00-04:00[u-ca=ethiopic-amete-alem][America/Santiago]
```
`convertZonedToCalendar` produces it, and `isValidCalendarZonedDateTime` validates it:
```typescript
convertZonedToCalendar("2024-10-03T14:30:45-04:00[America/New_York]", "hebrew");
// "5785-01-01T14:30:45-04:00[u-ca=hebrew][America/New_York]"
addZoned("5784-06-15T14:30:00-05:00[u-ca=hebrew][America/New_York]", { months: 1 });
// "5784-07-15T14:30:00-04:00[u-ca=hebrew][America/New_York]"
// Adar I -> Adar 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.
addZoned("0031-04-30T12:00:00+09:00[u-ca=japanese;era=heisei][Asia/Tokyo]", { days: 1 });
// "0001-05-01T12:00:00+09:00[u-ca=japanese;era=reiwa][Asia/Tokyo]" — era re-derived, never copied
```
> **The `[u-ca=...]` segment comes BEFORE `[timeZone]` — the reverse of RFC 9557.** This is
> deliberate. GMT's digits are calendar-native (Hebrew year 5784, not ISO year 5784), so the
> string is never valid RFC 9557 to begin with, and the `;era=` suffix is not valid RFC 9557 at
> any ordering. Writing it in RFC order is actively dangerous:
> `Temporal.ZonedDateTime.from("5784-01-01T14:30:00-05:00[America/New_York][u-ca=hebrew]")`
> *succeeds*, silently reading 5784 as an ISO year — a ~3760-year misparse with no error. GMT's
> ordering makes that shape uniformly rejected instead.
Scope: `addZoned`, `subtractZoned`, `diffZoned`, `diffZonedAsDuration`, `convertZonedToCalendar`,
and the `zoned/interval/*` family. Everything else in `zoned/` still rejects the annotation —
`isValidZonedDateTime` is unchanged, 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 split as `plain/`: ordering functions
(`intervalContainsZoned`, `intervalsOverlapZoned`, `intervalAbutsZoned`, `intervalEngulfsZoned`,
`intervalOverlappingDaysZoned`, `isValidCalendarZonedInterval`) accept them; the eight
value-returning set operations require one shared calendar and return their sentinel on a
mismatch. Measurement functions (`diffZoned`, `diffZonedAsDuration`, `intervalCountZoned`,
`intervalLengthZoned`, `splitIntervalByUnitZoned`) measure in the endpoints' shared calendar when
both tags match and fall back to Gregorian otherwise — mandatory here rather than merely
convenient, because `Temporal.ZonedDateTime.prototype.until` throws across mismatched calendars
for *every* unit, including pure time units like hours.
### Durations
```typescript
import {
absDuration,
addDuration,
compareDurations,
diffDateAsDuration,
durationAs,
formatDuration,
getDurationSign,
getDurationUnit,
isValidDuration,
negateDuration,
normalizeDuration,
parseDuration,
subtractDuration,
} from "@burglekitt/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
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):
```typescript
import {
isValidDateInterval,
isValidTimeInterval,
isValidDateTimeInterval,
isValidDateRange,
isValidTimeRange,
isValidDateTimeRange,
isValidUtcRange,
isValidUnixRange,
isValidZonedRange,
isValidUtcInterval,
isValidUnixInterval,
isValidZonedInterval,
} from "@burglekitt/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 is fully contained
```typescript
import {
intervalContainsDate,
intervalContainsTime,
intervalContainsDateTime,
intervalContainsUtc,
intervalContainsUnix,
intervalContainsZoned,
} from "@burglekitt/gmt";
// Point-in-interval (3-arg)
intervalContainsDate("2024-01-01", "2024-12-31", "2024-06-15");
// true
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, non-finite values for Unix).
`intervalsOverlap*` checks whether two intervals share any time. Returns `true` when intervals overlap, `false` when they are disjoint or only adjacent (touching at a single instant with no shared time):
```typescript
import {
intervalsOverlapDate,
intervalsOverlapTime,
intervalsOverlapDateTime,
intervalsOverlapUtc,
intervalsOverlapUnix,
intervalsOverlapZoned,
} from "@burglekitt/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 (adjacent, no shared time)
intervalsOverlapUnix(0, 1700000000, 1000000, 2000000);
// true
intervalsOverlapUtc(
"2024-01-01T00:00:00Z",
"2024-06-30T23:59:59Z",
"2024-04-01T00:00:00Z",
"2024-12-31T23:59:59Z",
);
// true
```
All overlap checks return `false` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, non-finite values for Unix).
`intervalIntersection*` returns the overlapping span of two intervals, or `null` when they do not overlap. Adjacent intervals (sharing one instant) count as overlapping and return a single-point span:
```typescript
import {
intervalIntersectionDate,
intervalIntersectionTime,
intervalIntersectionDateTime,
intervalIntersectionUtc,
intervalIntersectionUnix,
intervalIntersectionZoned,
} from "@burglekitt/gmt";
intervalIntersectionDate(
"2024-01-01",
"2024-06-30",
"2024-04-01",
"2024-12-31",
);
// { start: "2024-04-01", end: "2024-06-30" }
intervalIntersectionDate(
"2024-01-01",
"2024-06-30",
"2024-06-30",
"2024-12-31",
);
// { start: "2024-06-30", end: "2024-06-30" } (adjacent, shares one instant)
intervalIntersectionDate(
"2024-01-01",
"2024-06-30",
"2024-07-01",
"2024-12-31",
);
// null (disjoint)
intervalIntersectionUnix(0, 1700000000, 1000000, 2000000);
// { start: 1000000, end: 1700000000 }
intervalIntersectionUtc(
"2024-01-01T00:00:00Z",
"2024-06-30T23:59:59Z",
"2024-04-01T00:00:00Z",
"2024-12-31T23:59:59Z",
);
// { start: "2024-04-01T00:00:00Z", end: "2024-06-30T23:59:59Z" }
```
All intersection functions return `null` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, non-finite values for Unix).
`intervalOverlappingDays*` returns how many distinct calendar dates two intervals share — the numeric counterpart to `intervalIntersection*`'s span. Counting is inclusive of both endpoints of the closed intersection `[max(aStart, bStart), min(aEnd, bEnd)]`, so `intervalOverlappingDaysDate("2024-01-01", "2024-01-01", "2024-01-01", "2024-01-01")` is `1`, not `0`. There is no `Time` variant — `PlainTime` has no calendar, so a day count is undefined for it:
```typescript
import {
intervalOverlappingDaysDate,
intervalOverlappingDaysDateTime,
intervalOverlappingDaysUtc,
intervalOverlappingDaysUnix,
intervalOverlappingDaysZoned,
} from "@burglekitt/gmt";
intervalOverlappingDaysDate(
"2024-01-01",
"2024-06-30",
"2024-04-01",
"2024-12-31",
);
// 91
intervalOverlappingDaysDate(
"2024-01-01",
"2024-06-30",
"2024-06-30",
"2024-12-31",
);
// 1 (adjacent, shares one date)
intervalOverlappingDaysDate(
"2024-01-01",
"2024-06-30",
"2024-07-01",
"2024-12-31",
);
// 0 (disjoint)
intervalOverlappingDaysUnix(0, 172800000, 86400000, 259200000, {
timeZone: "UTC",
});
// 2
```
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 the system time zone, overridable via `{ timeZone }`) — 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.
This deliberately diverges from date-fns's `getOverlappingDaysInIntervals`, which rounds up elapsed 24-hour periods instead of counting calendar dates — its own doc example (`Jan 10–20` vs `Jan 17–21`) returns `3` there and `4` here. To reproduce date-fns's number, compose `intervalIntersection*` with `intervalCount*`:
```typescript
const span = intervalIntersectionDate(aStart, aEnd, bStart, bEnd);
span ? intervalCountDate(span.start, span.end, "day") : 0; // date-fns semantics
```
`intervalUnion*` returns the combined span of two overlapping or adjacent intervals, or `null` when they are disjoint with a gap. Adjacent intervals (sharing one instant) count as mergeable:
```typescript
import {
intervalUnionDate,
intervalUnionTime,
intervalUnionDateTime,
intervalUnionUtc,
intervalUnionUnix,
intervalUnionZoned,
} from "@burglekitt/gmt";
intervalUnionDate("2024-01-01", "2024-06-30", "2024-04-01", "2024-12-31");
// { start: "2024-01-01", end: "2024-12-31" }
intervalUnionDate("2024-01-01", "2024-06-30", "2024-06-30", "2024-12-31");
// { start: "2024-01-01", end: "2024-12-31" } (adjacent, merged)
intervalUnionDate("2024-01-01", "2024-06-30", "2024-07-01", "2024-12-31");
// null (disjoint with a gap)
intervalUnionUnix(0, 1700000000, 1000000, 2000000);
// { start: 0, end: 1700000000 }
intervalUnionUtc(
"2024-01-01T00:00:00Z",
"2024-06-30T23:59:59Z",
"2024-04-01T00:00:00Z",
"2024-12-31T23:59:59Z",
);
// { start: "2024-01-01T00:00:00Z", end: "2024-12-31T23:59:59Z" }
```
All union functions return `null` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, non-finite values for Unix).
`intervalDifference*` returns the portion(s) of interval A not covered by interval B, as an array of `{ start, end }` records:
```typescript
import {
intervalDifferenceDate,
intervalDifferenceTime,
intervalDifferenceDateTime,
intervalDifferenceUtc,
intervalDifferenceUnix,
intervalDifferenceZoned,
} from "@burglekitt/gmt";
intervalDifferenceDate("2024-01-01", "2024-12-31", "2024-06-01", "2024-07-01");
// [{ start: "2024-01-01", end: "2024-05-31" }, { start: "2024-07-02", 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: 999999 }, { start: 2000001, end: 1700000000 }]
intervalDifferenceUtc(
"2024-01-01T00:00:00Z",
"2024-12-31T23:59:59Z",
"2024-06-01T00:00:00Z",
"2024-07-01T00:00:00Z",
);
// [{ start: "2024-01-01T00:00:00Z", end: "2024-05-31T23:59:59Z" }, { start: "2024-07-02T00:00:00Z", end: "2024-12-31T23:59:59Z" }]
```
All difference functions return `[]` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, non-finite values for Unix).
`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:
```typescript
import {
intervalXorDate,
intervalXorTime,
intervalXorDateTime,
intervalXorUtc,
intervalXorUnix,
intervalXorZoned,
} from "@burglekitt/gmt";
intervalXorDate("2024-01-01", "2024-06-30", "2024-04-01", "2024-12-31");
// [{ start: "2024-01-01", end: "2024-03-31" }, { start: "2024-07-01", end: "2024-12-31" }]
intervalXorDate("2024-01-01", "2024-06-30", "2024-06-30", "2024-12-31");
// [{ start: "2024-01-01", end: "2024-06-29" }, { start: "2024-07-01", end: "2024-12-31" }] (adjacent)
intervalXorUnix(0, 1700000000, 1000000, 2000000);
// [{ start: 0, end: 999999 }, { start: 2000001, end: 1700000000 }]
intervalXorUtc(
"2024-01-01T00:00:00Z",
"2024-06-30T23:59:59Z",
"2024-04-01T00:00:00Z",
"2024-12-31T23:59:59Z",
);
// [{ start: "2024-01-01T00:00:00Z", end: "2024-03-31T23:59:59Z" }, { start: "2024-07-01T00:00:00Z", end: "2024-12-31T23:59:59Z" }]
```
All xor functions return `[]` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, non-finite values for Unix).
`intervalAbuts*` checks whether two intervals are exactly adjacent — one's end equals the other's start with zero gap and zero overlap:
```typescript
import {
intervalAbutsDate,
intervalAbutsTime,
intervalAbutsDateTime,
intervalAbutsUtc,
intervalAbutsUnix,
intervalAbutsZoned,
} from "@burglekitt/gmt";
intervalAbutsDate("2024-01-01", "2024-06-30", "2024-06-30", "2024-12-31");
// true
intervalAbutsDate("2024-01-01", "2024-06-29", "2024-06-30", "2024-12-31");
// false (one-day gap)
intervalAbutsDate("2024-01-01", "2024-06-30", "2024-04-01", "2024-12-31");
// false (overlap)
intervalAbutsUnix(0, 1000000, 1000000, 2000000);
// true
intervalAbutsUtc(
"2024-01-01T00:00:00Z",
"2024-06-30T23:59:59Z",
"2024-06-30T23:59:59Z",
"2024-12-31T23:59:59Z",
);
// true
```
All abuts checks return `false` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, non-finite values for Unix).
`intervalEngulfs*` checks whether interval B is fully contained within interval A — every instant of B falls within A. Equivalent to the 4-argument `intervalContains*` mode:
```typescript
import {
intervalEngulfsDate,
intervalEngulfsTime,
intervalEngulfsDateTime,
intervalEngulfsUtc,
intervalEngulfsUnix,
intervalEngulfsZoned,
} from "@burglekitt/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, non-finite values for Unix).
`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`:
```typescript
import {
splitIntervalByUnitDate,
splitIntervalByUnitTime,
splitIntervalByUnitDateTime,
splitIntervalByUnitUtc,
splitIntervalByUnitUnix,
splitIntervalByUnitZoned,
} from "@burglekitt/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).
`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:
```typescript
import {
intervalCountDate,
intervalCountTime,
intervalCountDateTime,
intervalCountUtc,
intervalCountUnix,
intervalCountZoned,
} from "@burglekitt/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
```
Zero-length intervals count `1` when they sit mid-unit and `0` when they sit exactly on a unit boundary — `intervalCountDate("2024-01-15", "2024-01-15", "month")` is `1`, while `intervalCountDate("2024-01-01", "2024-01-01", "month")` is `0`. Weeks start on Monday (ISO 8601), singular and plural units are interchangeable (`"day"` and `"days"`), and `intervalCountUnix` uses the system timeZone for calendar boundaries (consistent with `addUnix`). All count functions return `null` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, unsupported unit, or a unit that has no effect on the target type).
`intervalLength*` is `intervalCount*`'s exact-duration counterpart — it answers "how long is this interval" as a real, possibly fractional number, rather than "how many boundaries does it touch":
```typescript
import {
intervalLengthDate,
intervalLengthTime,
intervalLengthDateTime,
intervalLengthUtc,
intervalLengthUnix,
intervalLengthZoned,
} from "@burglekitt/gmt";
intervalLengthDateTime("2024-01-01T23:59:00", "2024-01-02T00:01:00", "day");
// 0.001388888888888889 (the same interval intervalCount* reports as 2 day boundaries)
intervalLengthDate("2024-01-01", "2024-01-16", "month");
// 0.4838709677419355 (15 of January's 31 days)
intervalLengthZoned(
"2024-03-10T00:00:00-05:00[America/New_York]",
"2024-03-11T00:00:00-04:00[America/New_York]",
"hour",
);
// 23 (spring-forward local day is 23 real hours)
```
`intervalLength*` uses `Temporal.Duration.prototype.total`, so calendar units (month, year) resolve against the interval's own `start` rather than truncating, and zoned/unix/utc variants are DST-aware the same way `intervalCount*` is. Returns `0` for a zero-length interval, and `null` on invalid input (wrong type, malformed strings, leap seconds, inverted intervals, unsupported unit).
`intervalDivideEqually*` splits an interval into `n` equal-length sub-intervals, and `intervalSplitAt*` splits an interval at arbitrary points instead of by count:
```typescript
import {
intervalDivideEquallyDate,
intervalSplitAtDate,
} from "@burglekitt/gmt";
intervalDivideEquallyDate("2024-01-01", "2024-01-05", 4);
// [{ start: "2024-01-01", end: "2024-01-02" }, { start: "2024-01-02", end: "2024-01-03" }, { start: "2024-01-03", end: "2024-01-04" }, { start: "2024-01-04", end: "2024-01-05" }]
intervalSplitAtDate("2024-01-01", "2024-01-10", ["2024-01-07", "2024-01-03"]);
// [{ start: "2024-01-01", end: "2024-01-03" }, { start: "2024-01-03", end: "2024-01-07" }, { start: "2024-01-07", end: "2024-01-10" }]
```
`n` must be a positive integer (`[]` otherwise); `n === 1` returns the original interval unchanged, and a zero-length interval returns `n` identical zero-length sub-intervals. Non-fractional types (`PlainDate`) round internal boundaries to the nearest whole unit; every other variant is exact, computed from total elapsed nanoseconds (`intervalDivideEquallyZoned` splits DST-crossing intervals by real elapsed time, not local clock time). `intervalSplitAt*` sorts its `points` internally — they need not be pre-sorted — and drops points outside `[start, end]` or exactly on a boundary, since those cannot introduce a new sub-interval; an empty or all-dropped `points` array returns `[{ start, end }]` unsplit.
`mergeIntervals*` and `intervalXorAll*` are the list-form generalizations of `intervalUnion*` and `intervalXor*`, which are pairwise only — each takes a single array of `{ start, end }` records instead of two flat intervals:
```typescript
import { mergeIntervalsDate, intervalXorAllDate } from "@burglekitt/gmt";
mergeIntervalsDate([
{ start: "2024-01-01", end: "2024-01-10" },
{ start: "2024-01-05", end: "2024-01-15" },
]);
// [{ start: "2024-01-01", end: "2024-01-15" }]
intervalXorAllDate([
{ start: "2024-01-01", end: "2024-01-10" },
{ start: "2024-01-05", end: "2024-01-15" },
{ start: "2024-01-08", end: "2024-01-20" },
]);
// [{ start: "2024-01-01", end: "2024-01-04" }, { start: "2024-01-08", end: "2024-01-10" }, { start: "2024-01-16", end: "2024-01-20" }]
```
`mergeIntervals*` collapses overlapping or adjacent intervals (shared endpoint) into the minimum non-overlapping set. `intervalXorAll*` returns the set covered by an odd number of the input intervals — for exactly two intervals the result is identical to the pairwise `intervalXor*`, and two identical intervals cancel out to `[]`. All four return `[]` for an empty list or on invalid input.
`intervalFromDuration*` constructs an interval from a single point plus an ISO 8601 duration, anchored at either end — Luxon's `Interval.after`/`Interval.before` as one function with an `anchor` param instead of two:
```typescript
import {
intervalFromDurationDate,
intervalFromDurationTime,
intervalFromDurationDateTime,
intervalFromDurationUtc,
intervalFromDurationUnix,
intervalFromDurationZoned,
} from "@burglekitt/gmt";
intervalFromDurationDate("2024-01-01", "P1M", "start");
// { start: "2024-01-01", end: "2024-02-01" }
intervalFromDurationDate("2024-02-01", "P1M", "end");
// { start: "2024-01-01", end: "2024-02-01" }
intervalFromDurationZoned(
"2024-03-09T02:30:00-05:00[America/New_York]",
"P1D",
"start",
);
// { start: "2024-03-09T02:30:00-05:00[America/New_York]", end: "2024-03-10T03:30:00-04:00[America/New_York]" } (spring-forward day is 23 hours long)
intervalFromDurationTime("12:00:00", "P1D", "start");
// null (PlainTime has no calendar — a date-unit duration needs a relativeTo it can't supply)
```
Calendar units (years/months/weeks) resolve against `value` itself, so no separate `relativeTo` is needed — except for `intervalFromDurationTime`, which returns `null` for a `duration` with a nonzero years/months/weeks/days component, since `PlainTime` has no calendar to resolve it against. `intervalFromDurationZoned` accepts the same `disambiguation`/`offset`/`overflow` options as `addZoned`; `intervalFromDurationUnix` accepts `addUnix`'s `epochUnit`/`timeZone`/`overflow` options. A negative `duration` that inverts the computed span, or an `overflow: "reject"` result, returns `null` — same sentinel as any other invalid input.
All validators return `false` on invalid input (wrong type, malformed strings, leap seconds, mixed kinds for plain interval validators, non-finite values for Unix).
### Zoned operations
```typescript
import { addZoned, formatZonedDateTime } from "@burglekitt/gmt";
addZoned("2026-03-07T23:00:00-05:00[America/New_York]", 2, "hour");
// "2026-03-08T01:00:00-05:00[America/New_York]"
formatZonedDateTime("2024-03-17T14:30:45+00:00[UTC]", "en-US", {
dateStyle: "full",
timeStyle: "short",
});
// locale-dependent non-empty formatted string
```
Twice a year, DST creates local times that don't exist (spring-forward gap) or happen twice (fall-back overlap). Functions that attach a timezone to a plain/local value accept an optional `disambiguation` (`"compatible"` (default) | `"earlier"` | `"later"` | `"reject"`) to control how that's resolved instead of silently guessing:
```typescript
import { convertPlainDateTimeToZoned } from "@burglekitt/gmt";
// 2024-03-10T02:30:00 doesn't exist in America/New_York (clocks jump 2am -> 3am).
convertPlainDateTimeToZoned("2024-03-10T02:30:00", "America/New_York", {
disambiguation: "reject",
});
// "" — no such local time exists
```
The `startOfZoned`/`endOfZoned`/`startOfQuarterForZoned`/`endOfQuarterForZoned`/`mapZonedHoursInDay` family (and their `unix/` counterparts) also accept `disambiguation`, plus an `offset` option (`"prefer"` | `"use"` | `"ignore"` (default) | `"reject"`) that controls whether the source's existing UTC offset is kept when computing the new boundary. **`offset` must stay at its default (`"ignore"`) for `disambiguation` to take effect** — Temporal's own default (`"prefer"`) keeps the source offset whenever still valid, which silently makes `disambiguation` a no-op:
```typescript
import { startOfZoned } from "@burglekitt/gmt";
// 2024-11-03T01:45:00-05:00 is the SECOND, repeated 1am of the fall-back overlap in America/New_York.
const source = "2024-11-03T01:45:00-05:00[America/New_York]";
startOfZoned(source, "hour", { disambiguation: "reject" });
// "" — offset defaults to "ignore", so disambiguation actually fires and "reject" throws
startOfZoned(source, "hour", { disambiguation: "reject", offset: "prefer" });
// "2024-11-03T01:00:00-05:00[America/New_York]" — offset:"prefer" keeps the source's
// still-valid -05:00 offset, so disambiguation is never consulted and "reject" never fires
```
`convertPlainDateTimeToZoned` and `addZoned`/`subtractZoned` also accept `offset` for API consistency, but it's permanently inert on both — their construction path never has a stored offset for it to act on.
`hasDaylightSaving` reports whether an IANA timezone observes daylight saving time at all:
```typescript
import { hasDaylightSaving } from "@burglekitt/gmt";
hasDaylightSaving("America/New_York");
// true
hasDaylightSaving("Asia/Tokyo");
// false
hasDaylightSaving("Invalid/Zone");
// false
```
`getDstTransitions` enumerates the exact transition instants for a timezone in a given year:
```typescript
import { getDstTransitions } from "@burglekitt/gmt/zoned";
getDstTransitions("America/New_York", 2024);
// [
// { instant: "2024-03-10T07:00:00Z", offsetBefore: "-05:00", offsetAfter: "-04:00" },
// { instant: "2024-11-03T06:00:00Z", offsetBefore: "-04:00", offsetAfter: "-05:00" }
// ]
getDstTransitions("Asia/Tokyo", 2024);
// []
getDstTransitions("Invalid/Zone", 2024);
// []
```
Each object's `instant` is a UTC ISO 8601 string; `offsetBefore`/`offsetAfter` are `±HH:MM` offset strings. Returns `[]` for zones with no transitions in the requested year and on invalid input.
**Four DST-related questions, four different functions.** The names are close enough to be misread, so here's the map:
| Question | Function | Scope |
| ------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------------------------- |
| Does this zone observe DST at all? | `hasDaylightSaving(timeZone)` | Zone-level, no instant |
| Where do this zone's transitions fall? | `getDstTransitions(timeZone, year)` | Enumerates instants |
| Is _this particular instant_ currently in DST? | `isInDaylightSaving(value)` | A single zoned value |
| What should happen when construction lands on an ambiguous/nonexistent instant? | `disambiguation` / `offset` options (Group C) | Orthogonal — a construction-time choice, not a query |
`isInDaylightSaving` answers the third question:
```typescript
import { isInDaylightSaving } from "@burglekitt/gmt/zoned";
isInDaylightSaving("2024-07-15T12:00:00-04:00[America/New_York]");
// true
isInDaylightSaving("2024-01-15T12:00:00-05:00[America/New_York]");
// false
// Southern-hemisphere DST spans the new year.
isInDaylightSaving("2024-01-15T12:00:00+11:00[Australia/Sydney]");
// true
isInDaylightSaving("2024-07-15T12:00:00+09:00[Asia/Tokyo]");
// false — Asia/Tokyo has no DST, so this is always false
isInDaylightSaving("invalid");
// false
```
`getZonedOffset` and `getZonedOffsetAs` read a zoned value's UTC offset — the former as a `±HH:MM` string, the latter as a number in minutes or nanoseconds:
```typescript
import { getZonedOffset, getZonedOffsetAs } from "@burglekitt/gmt/zoned";
getZonedOffset("2024-07-15T12:00:00-04:00[America/New_York]");
// "-04:00"
getZonedOffsetAs("2024-07-15T12:00:00-04:00[America/New_York]", "minutes");
// -240
getZonedOffsetAs("2024-05-15T12:00:00+05:45[Asia/Kathmandu]", "minutes");
// 345 — GMT has half- and quarter-hour offsets too, not just whole hours
getZonedOffset("invalid");
// ""
```
`getTimeZoneOffset` looks up a timezone's offset at a given instant without needing an existing zoned value in hand:
```typescript
import { getTimeZoneOffset } from "@burglekitt/gmt/zoned";
getTimeZoneOffset("America/New_York", "2024-07-15T12:00:00Z");
// "-04:00"
getTimeZoneOffset("America/New_York", "2024-01-15T12:00:00Z");
// "-05:00"
getTimeZoneOffset("Invalid/Zone", "2024-07-15T12:00:00Z");
// ""
```
`formatTimeZoneName` returns a timezone's localized display name. `options.style` covers every `Intl.DateTimeFormatOptions` `timeZoneName` value:
```typescript
import { formatTimeZoneName } from "@burglekitt/gmt/zoned";
formatTimeZoneName("America/New_York", "en-US", { style: "shortGeneric" });
// "ET" — season-independent
formatTimeZoneName("America/New_York", "en-US", { style: "longGeneric" });
// "Eastern Time" — season-independent
formatTimeZoneName("America/New_York", "en-US", { style: "long" });
// "Eastern Standard Time" or "Eastern Daylight Time", depending on the current date
formatTimeZoneName("Invalid/Zone", "en-US");
// ""
```
The `"short"`/`"long"`/`"shortOffset"`/`"longOffset"` styles name the zone's _current_ offset — for a DST-observing zone the label flips between standard and daylight names depending on when this is called, since there's no instant parameter to pin it to (this matches how `Intl.DateTimeFormat.prototype.format()` itself defaults to "now" with no argument). The `"shortGeneric"`/`"longGeneric"` styles are season-independent and don't have this issue — prefer them for a name that won't change twice a year.
`clampZoned` restricts a zoned datetime to a range, and `closestZonedTo` finds the nearest candidate by temporal distance:
```typescript
import { clampZoned, closestZonedTo } from "@burglekitt/gmt";
clampZoned(
"2024-02-01T12:00:00[America/New_York]",
"2024-03-01T00:00:00[America/New_York]",
"2024-03-31T23:59:59[America/New_York]",
);
// "2024-03-01T00:00:00-05:00[America/New_York]"
closestZonedTo("2024-03-15T12:00:00[America/New_York]", [
"2024-03-01T00:00:00[America/New_York]",
"2024-03-20T00:00:00[America/New_York]",
"2024-03-18T00:00:00[America/New_York]",
]);
// "2024-03-18T00:00:00-04:00[America/New_York]"
```
See [`docs/dst-disambiguation.md`](../../docs/dst-disambiguation.md) for the full explanation, including why `overflow` was deliberately left off the public API.
### Formatting
```typescript
import {
formatDate,
formatRelativeDate,
formatTime,
formatRelativeTime,
formatDateTime,
formatRelativeDateTime,
formatCalendar,
formatDateRange,
formatDateTimeRange,
formatDateToParts,
formatDateTimeToParts,
formatZonedDateTime,
formatZonedRange,
formatZonedToParts,
formatRelativeZoned,
formatCalendarZoned,
formatUtc,
formatRelativeUtc,
formatCalendarUtc,
formatUnix,
formatRelativeUnix,
formatCalendarUnix,
} from "@burglekitt/gmt";
// Relative to "now" — auto-picks the best unit.
formatRelativeDate("2026-01-15");
// e.g. "3 months ago"
formatRelativeTime("14:30:00", "en-US", { style: "short" });
// e.g. "2 hours ago"
formatRelativeDateTime("2026-03-17T09:00:00", "en-GB", {
style: "long",
numeric: "always",
});
// e.g. "17 March, 2026 at 09:00"
// Zoned relative formatting — reference can be a ZonedDateTime, UTC string, or unix epoch.
formatRelativeZoned("2026-03-08T01:00:00-05:00[America/New_York]", "en-US");
// e.g. "tomorrow"
formatRelativeUtc("2024-03-17T14:30:45+00:00[UTC]", "en-US");
// e.g. "2 years ago"
// Unix epoch relative formatting.
formatRelativeUnix(1710685845000, "en-US", { epochUnit: "milliseconds" });
// e.g. "3 years ago"
// roundingMethod ("floor" | "ceil" | "round", default "round") controls how the
// computed distance rounds to the display unit — every formatRelative* function accepts it.
formatRelativeUtc(value, "en-US", { roundingMethod: "floor" });
// formatCalendar* — a relative day label + time-of-day, joined with the
// locale's own connector (never a hardcoded "at"). Distinct from
// formatRelative*'s elapsed-time phrasing ("in 1 day"): this is Moment's
// `.calendar()` — for user-facing schedules, not elapsed-time displays.
// Within ±6 days of "now" (or an explicit `reference`) it stays relative;
// beyond that it falls back to an absolute date + time, no relative wording.
formatCalendar("2026-03-16T14:30:00", "en-US", {
reference: "2026-03-15T09:00:00",
});
// "tomorrow at 2:30 PM"
formatCalendar("2026-03-08T14:30:00", "en-US", {
reference: "2026-03-15T09:00:00",
});
// "March 8, 2026 at 2:30 PM" — 7 days out, beyond the threshold
formatCalendarZoned("2026-03-16T14:30:00-04:00[America/New_York]", "de-DE", {
reference: "2026-03-15T09:00:00-04:00[America/New_York]",
});
// "morgen um 14:30" — locale's own connector, not "at"
// formatDateRange / formatDateTimeRange — plain counterparts of
// formatZonedRange (same parameter order and option shape), for a
// locale-elided range between two timezone-free values.
formatDateRange("2024-02-03", "2024-02-05", "en-US", { dateStyle: "long" });
// "February 3 – 5, 2024"
formatDateTimeRange("2024-02-03T09:00:00", "2024-02-03T17:00:00", "en-US", {
dateStyle: "long",
timeStyle: "short",
});
// "February 3, 2024, 9:00 AM – 5:00 PM"
// formatDateToParts / formatDateTimeToParts / formatZonedToParts return the
// locale-ordered Array<{ type, value }> parts behind the strings above,
// instead of a finished string — GMT's substitute for a token formatter.
// Iterate the array as returned; reassembling parts in a fixed order
// reintroduces the locale-ordering bug formatToParts exists to avoid.
formatDateToParts("2024-03-15", "en-US");
// [{ type: "month", value: "3" }, { type: "literal", value: "/" },
// { type: "day", value: "15" }, { type: "literal", value: "/" },
// { type: "year", value: "2024" }]
formatDateToParts("2024-03-15", "fr-FR");
// day comes before month, same locale-order guarantee as formatDate:
// [{ type: "day", value: "15" }, { type: "literal", value: "/" },
// { type: "month", value: "3" }, { type: "literal", value: "/" },
// { type: "year", value: "2024" }]
formatZonedToParts("2024-03-15T14:30:00-04:00[America/New_York]", "en-US", {
timeZoneName: "longOffset",
});
// includes { type: "timeZoneName", value: "GMT-4" }
```
### Named machine formats
Fixed, non-locale-adaptive grammars for interchange with email, HTTP, SQL, and
RFC 3339 consumers — none of these take a `locale` argument, since the
grammar itself is constant (English weekday/month names where the format
mandates them).
```typescript
import {
formatRfc2822,
parseRfc2822,
formatHttp,
parseHttp,
formatSql,
parseSql,
formatRfc3339,
parseRfc3339,
} from "@burglekitt/gmt";
// Email `Date:` headers (RFC 5322 / RFC 2822).
formatRfc2822("2024-03-15T14:30:00-04:00[America/New_York]");
// "Fri, 15 Mar 2024 14:30:00 -0400"
parseRfc2822("Fri, 15 Mar 2024 14:30:00 -0400");
// "2024-03-15T14:30:00-04:00[-04:00]"
// HTTP headers (RFC 7231 IMF-fixdate) — Last-Modified, Date, Expires.
formatHttp("2024-03-15T14:30:00Z");
// "Fri, 15 Mar 2024 14:30:00 GMT"
parseHttp("Fri, 15 Mar 2024 14:30:00 GMT");
// "2024-03-15T14:30:00Z"
// ANSI SQL / ODBC datetime literals (DATETIME/TIMESTAMP columns, no tz).
formatSql("2024-03-15T14:30:00");
// "2024-03-15 14:30:00"
parseSql("2024-03-15 14:30:00");
// "2024-03-15T14:30:00"
// Strict RFC 3339 — strips the bracketed IANA zone GMT's own zoned strings
// carry, which RFC 3339 does not permit.
formatRfc3339("2024-03-15T14:30:00-04:00[America/New_York]");
// "2024-03-15T14:30:00-04:00"
parseRfc3339("2024-03-15T14:30:00-04:00");
// "2024-03-15T14:30:00-04:00[-04:00]"
```
### Unix and UTC helpers
```typescript
import { getUnixNow, getUtcNow, convertUnixToPlainDate } from "@burglekitt/gmt";
getUnixNow("milliseconds");
// 1710685845000
getUtcNow();
// "2026-03-18T11:42:33.123Z"
convertUnixToPlainDate(1710685845);
// "2024-03-17"
```
## API Surface
For the complete API listing, see the namespace documentation on GitHub:
- [Duration API](https://github.com/burglekitt/gmt/tree/main/packages/gmt/src/duration) — ISO 8601 duration parsing, validation, arithmetic, and formatting
- [Plain API](https://github.com/burglekitt/gmt/tree/main/packages/gmt/src/plain) — timezone-free operations
- [Zoned API](https://github.com/burglekitt/gmt/tree/main/packages/gmt/src/zoned) — IANA timezone-aware operations
- [Unix API](https://github.com/burglekitt/gmt/tree/main/packages/gmt/src/unix) — Unix epoch utilities
- [UTC API](https://github.com/burglekitt/gmt/tree/main/packages/gmt/src/utc) — UTC instant utilities
- [Regex API](https://github.com/burglekitt/gmt/tree/main/packages/gmt/src/regex) — composable regex patterns
## Agent Prompt
When working with `@burglekitt/gmt`, follow these rules:
1. **No `Date` object.** Use `Temporal` exclusively.
2. **String-in, string-out.** Public APIs accept ISO 8601 strings; return strings, numbers, booleans, or arrays.
3. **Invalid input returns a sentinel, never throws.** `""` for strings, `null` for numbers, `false` for booleans, `[]` for arrays.
4. **Wrap all Temporal calls in `try-catch`.** `.from()`, `.add()`, `.since()`, etc. throw `RangeError` on bad input.
5. **Keep `plain/` and `zoned/` strictly separate.** Never mix `PlainDateTime` and `ZonedDateTime`.
6. **Full locale matrix for any locale-aware function.** 17 locales, explicit rows, `hasFullIcu` ternaries where output differs.
7. **Use pre-built mocks for error-path tests.** See `packages/gmt/src/test/mocks`.
8. **JSDoc with `@example` on every public function.** Cover valid, invalid, and edge-case inputs.
## License
MIT — See [LICENSE](../../LICENSE) for details.