temporal-humanize
v0.3.2
Published
Human-readable relative time formatting built on the Temporal API
Maintainers
Readme
temporal-humanize
Human-readable relative time formatting built on the Temporal API.
Install
npm install temporal-humanizeTemporal is available natively in newer JS engines. If you're targeting a
runtime without it yet, also install the polyfill as a peer dependency:
npm install temporal-polyfilltemporal-polyfill is an optional peer dependency — install it only if
globalThis.Temporal isn't already available.
Quickstart
import { Temporal } from 'temporal-polyfill';
import { calendar, fromNow, humanizeDuration, toNow } from 'temporal-humanize';
const twoHoursAgo = Temporal.Now.instant().subtract({ hours: 2 });
fromNow(twoHoursAgo); // "2 hours ago"
const inThreeDays = Temporal.Now.instant().add({ hours: 72 });
toNow(inThreeDays); // "in 3 days"
const duration = Temporal.Duration.from({ days: 1, hours: 26, minutes: 5 });
humanizeDuration(duration); // "2 days, 2 hours, 5 minutes"
const yesterday = Temporal.Now.zonedDateTimeISO().subtract({ days: 1 });
calendar(yesterday); // "Yesterday at 3:00 PM"API
fromNow(value, options?) / toNow(value, options?)
Both format a Temporal value relative to now (or an explicit reference point) and auto-detect direction from the sign of the difference. In this version they're behaviorally identical — kept as separate exports so call sites can express intent, even though the output doesn't currently diverge.
function fromNow(value: TemporalInput, options?: RelativeTimeOptions): string;
function toNow(value: TemporalInput, options?: RelativeTimeOptions): string;value accepts Temporal.Instant, Temporal.ZonedDateTime, or
Temporal.PlainDateTime.
RelativeTimeOptions
interface RelativeTimeOptions {
locale?: string | readonly string[];
relativeTo?: Temporal.Instant | Temporal.ZonedDateTime | Temporal.PlainDateTime;
style?: 'long' | 'short' | 'narrow';
numeric?: 'always' | 'auto';
}locale— passed through toIntl.RelativeTimeFormat. Falls back to an English-only formatter ifIntl.RelativeTimeFormatisn't available in the runtime.relativeTo— the reference point to compare against, instead of the current moment. Must match the timezone-awareness ofvalue: pairing aPlainDateTimevalue with a tz-awarerelativeTo(or vice versa) throws aRangeError.style/numeric— forwarded toIntl.RelativeTimeFormat.
humanizeDuration(duration, options?)
Formats a Temporal.Duration as a human-readable string. The magnitude is
balanced up into a small ladder of units (years down to milliseconds) and
capped to a handful of units by default, rounding the last unit shown.
function humanizeDuration(duration: Temporal.Duration, options?: HumanizeDurationOptions): string;const duration = Temporal.Duration.from({ hours: 3, minutes: 15 });
humanizeDuration(duration); // "3 hours, 15 minutes"
humanizeDuration(duration, { style: 'short' }); // "3h 15m"HumanizeDurationOptions
interface HumanizeDurationOptions {
largestUnit?: 'years' | 'months' | 'weeks' | 'days' | 'hours' | 'minutes' | 'seconds';
style?: 'long' | 'short';
locale?: string | readonly string[];
maxUnits?: number;
}largestUnit— the unit to balance up to. If omitted, balancing defaults to'hours'unless the duration's own fields already carry a non-zero days/weeks/months/years value, in which case the largest such populated field is used. Balancing into weeks/months/years uses a fixed-ratio approximation (a week is 7 days, a month 30.44 days, a year 365.25 days) — the same conventionfromNow/toNowuse for their unit thresholds — sinceTemporal.Durationneeds arelativeTodate to balance calendar units exactly.style—'long'usesIntl.DurationFormatwhen available (English-only fallback otherwise);'short'always uses a compact renderer (1d 2h 15m) regardless ofIntl.DurationFormatsupport.locale— passed through toIntl.DurationFormat(long style) andIntl.NumberFormat(short style, for digit shaping).maxUnits— the maximum number of units shown, defaulting to 3. PassInfinityto disable capping and render full precision down to milliseconds.
A negative duration is rendered with a single leading - on the whole
string, e.g. "-3 hours, 15 minutes", rather than negating each unit.
calendar(value, options?)
Formats a Temporal value as a calendar-relative string — day.js's
calendar() plugin, essentially: "Today at 3:00 PM", "Yesterday at 9:15
AM", "Last Monday at 3:00 PM", "Wednesday at 3:00 PM" — falling back to a
locale date string (e.g. "Jun 3, 2024") once the value is more than
thresholdDays (6 by default) away from now.
function calendar(value: CalendarInput, options?: CalendarOptions): string;value accepts Temporal.ZonedDateTime or Temporal.PlainDateTime
(Temporal.Instant isn't accepted directly — convert it to a
ZonedDateTime with a timezone first, since a calendar day only makes
sense relative to a specific zone).
// output below varies with the current date/time and weekday
const now = Temporal.Now.zonedDateTimeISO();
calendar(now); // "Today at 3:00 PM"
calendar(now.subtract({ days: 1 })); // "Yesterday at 3:00 PM"
calendar(now.subtract({ days: 3 })); // "Last Sunday at 3:00 PM"
calendar(now.subtract({ days: 30 })); // "Jun 17, 2026" (dateStyle 'medium')CalendarOptions
interface CalendarOptions {
locale?: string | readonly string[];
relativeTo?: Temporal.ZonedDateTime | Temporal.PlainDateTime;
timeFormat?: 'short' | 'medium' | 'long' | 'full';
thresholdDays?: number;
}locale— passed through toIntl.RelativeTimeFormat(day word) andIntl.DateTimeFormat(weekday, time, fallback date). Falls back to English-only rendering for whichever formatter isn't available in the runtime.relativeTo— the reference point to compare against, instead of the current moment. Must match the timezone-awareness ofvalue: pairing aPlainDateTimevalue with aZonedDateTimerelativeTo(or vice versa) throws aRangeError.timeFormat— maps toIntl.DateTimeFormat'stimeStyle. Defaults to'short'(e.g. "3:00 PM").thresholdDays— how many days away from now a value can be and still get relative (day/weekday) phrasing instead of falling back to a plain date string. Defaults to 6, applied symmetrically in both directions.
For Temporal.ZonedDateTime input, "today" means today in the value's
own timezone — now (or relativeTo) is converted into that zone before
the day difference is computed, the same way fromNow/toNow treat
ZonedDateTime as timezone-aware. For Temporal.PlainDateTime input,
there's no timezone to convert into: "today" is whatever day now happens
to be on as wall-clock time. The day boundary itself is always computed as
plain calendar-date arithmetic, so DST transitions never shift which
bucket a ZonedDateTime value lands in.
The structural words "at" and "Last" render in English by default — only the day/weekday word and the time portion are localized through Intl. This package ships no built-in non-English glue words for these, but they can be overridden per locale — see Adding a translation below.
PlainDateTime vs ZonedDateTime / Instant
Temporal.PlainDateTimeis wall-clock time with no timezone attached. Differences are computed as plain calendar/clock math, with no notion of DST — a PlainDateTime 24 hours later is always "in 1 day" even across a DST transition in some timezone, because there is no timezone in the calculation at all.Temporal.InstantandTemporal.ZonedDateTimeare timezone-aware. Differences are computed from absolute instants, so results stay correct across DST transitions — the same wall-clock offset can resolve to a different elapsed duration depending on the zone's DST rules.
Adding a translation
Most of what this package renders is already localized through Intl —
day words, weekday names, times, numbers, and long-style duration wording
all follow options.locale with no extra setup. A small number of
structural "glue" strings aren't produced by any Intl formatter, though,
and this package doesn't ship non-English wording for them: shipping and
maintaining accurate translations for arbitrary locales is out of scope
for a library this size, and getting it subtly wrong is worse than not
having it. Instead, registerLocale lets any consumer — an app, or a
separate npm package — supply exactly these strings for whichever locales
it cares about.
What's registerable (all fields optional, via LocaleTranslations):
| Field | Default | Used by |
| --------------------- | ----------------------------------- | ------------------------------------------------ |
| now | 'now' | fromNow/toNow, zero-delta case |
| calendarDay | (w, t) => `${w} at ${t}` | calendar(), today/yesterday/tomorrow bucket |
| calendarLastWeekday | (w, t) => `Last ${w} at ${t}` | calendar(), last-week bucket |
| calendarWeekday | (w, t) => `${w} at ${t}` | calendar(), next-week bucket |
| durationShortUnits | { y, mo, w, d, h, m, s, ms } | humanizeDuration(), short style, per unit |
| durationShortJoin | (parts) => parts.join(' ') | humanizeDuration(), short style, joining parts |
Everything not in this table — the day/weekday word itself, the time
portion, number formatting, and all of long-style duration wording — comes
from Intl and is not overridable through this API; pass a locale
to the relevant function as usual for those.
function registerLocale(locale: string, translations: Partial<LocaleTranslations>): void;
function unregisterLocale(locale: string): void;
function clearLocales(): void;
function getRegisteredLocales(): string[];registerLocale(locale, translations)— registers translations for a locale key. Calling it more than once for the samelocalemerges with whatever was registered previously rather than replacing it, so fields can be supplied incrementally across multiple calls without clobbering earlier ones.unregisterLocale(locale)— removes everything registered for a single locale key. A no-op if nothing was registered for it.clearLocales()— removes every registered locale, restoring English defaults everywhere. Mainly useful for test teardown.getRegisteredLocales()— returns the currently registered locale keys.
Locale keys resolve the same way Intl locale matching intuitively
suggests: a request for 'es-MX' checks 'es-MX' first, then falls back
to the base language subtag 'es' if 'es-MX' itself isn't registered.
The same applies to each entry when locale is an array.
The example below uses only placeholder text — swap '<...>' for whatever
your own locale's wording should be; this package intentionally doesn't
suggest real translations:
import { registerLocale } from 'temporal-humanize';
registerLocale('xx', {
now: '<your now word>',
calendarDay: (dayWord, time) => `${dayWord} <at> ${time}`,
calendarLastWeekday: (weekday, time) => `<last> ${weekday} <at> ${time}`,
calendarWeekday: (weekday, time) => `${weekday} <at> ${time}`,
durationShortUnits: { hours: '<h>' },
durationShortJoin: (parts) => parts.join(' '),
});registerLocale/unregisterLocale/clearLocales/getRegisteredLocales
are named exports the rest of the package never calls internally on your
behalf — if an app never imports them, a bundler can tree-shake them away
entirely (this package sets "sideEffects": false and ships only named
exports).
Roadmap
Nothing currently planned beyond what's documented above.
