@burglekitt/gmt
Version:
Temporal-based date and time utilities with timezone support and polyfill integration
71 lines (70 loc) • 3.82 kB
JavaScript
import { Temporal } from "@js-temporal/polyfill";
import { joinDateTimeConnector, normalizeDateTime } from "../../internal/index.js";
import { isValidDateTime } from "../validate/index.js";
// Beyond a week either direction, Moment's `.calendar()` falls back to an
// absolute date rather than "6 days ago" / "in 6 days" wording. GMT follows
// the same threshold (documented, not a byte-for-byte Moment port — see J15).
const ABS_DAY_THRESHOLD = 6;
/**
* Format a plain date-time as a relative day label plus time-of-day, e.g.
* "Tomorrow at 2:30 PM" — Moment's `.calendar()`, which the existing
* `formatRelativeDateTime` family does not cover (that family renders
* "in 1 day", an elapsed-time phrase, not a day label + clock time).
*
* - Within `±6` days of `reference` (default: now), renders `<day label>`
* joined to the localized time using the locale's own connector — never a
* hardcoded "at". The day label comes from `Intl.RelativeTimeFormat`
* (`numeric: "auto"`), so it reads "Today"/"Tomorrow"/"Yesterday" near
* the boundary and "in N days"/"N days ago" further out, all locale-native.
* - Beyond `±6` days, falls back to an absolute `dateStyle: "long"` +
* `timeStyle` string with no relative wording, matching Moment's
* `sameElse` behavior.
* - The connector between the day label and the time is read from CLDR's
* own combined date+time pattern for the locale (see
* `internal/joinDateTimeConnector.ts`), not hardcoded — this is what lets
* `formatCalendar` avoid the i18n objection that excludes a token
* formatter (Decision 1 in `context/roadmap/issues/J.md`).
* - Use `formatCalendar` for user-facing schedules ("Tomorrow at 2:30 PM");
* use `formatRelativeDateTime` for elapsed-time displays ("in 1 day").
*
* @param value ISO PlainDateTime string to format
* @param locale optional: BCP 47 locale tag
* @param options optional: { reference, timeStyle }
* @returns the formatted calendar string, or "" on invalid input
*
* @example formatCalendar("2026-03-16T14:30:00", "en-US", { reference: "2026-03-15T09:00:00" }) // "tomorrow at 2:30 PM"
* @example 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, absolute fallback)
* @example formatCalendar("not-a-date") // ""
*/
export function formatCalendar(value, locale, options = {}) {
if (!isValidDateTime(value))
return "";
if (options.reference !== undefined && !isValidDateTime(options.reference))
return "";
try {
const target = Temporal.PlainDateTime.from(value);
const reference = options.reference
? Temporal.PlainDateTime.from(options.reference)
: Temporal.Now.plainDateTimeISO();
const diffDays = target.toPlainDate().since(reference.toPlainDate()).days;
const timeStyle = options.timeStyle ?? "short";
// Plain values carry no timezone. UTC is an arbitrary but stable anchor
// for reusing Intl's part-level formatting — any fixed zone reproduces
// the same wall-clock fields since there's no real zone to get wrong.
const epochMilliseconds = target.toZonedDateTime("UTC").epochMilliseconds;
if (Math.abs(diffDays) > ABS_DAY_THRESHOLD) {
return normalizeDateTime(new Intl.DateTimeFormat(locale, {
dateStyle: "long",
timeStyle,
timeZone: "UTC",
}).format(epochMilliseconds));
}
const dayLabel = new Intl.RelativeTimeFormat(locale, {
numeric: "auto",
}).format(diffDays, "day");
return normalizeDateTime(joinDateTimeConnector(epochMilliseconds, "UTC", locale, dayLabel, timeStyle));
}
catch {
return "";
}
}