Skip to content

Formatting: humanize & ics

Two output-oriented subpaths: one talks to humans, one talks to calendars.

import {
humanizeDuration, parseDuration, formatRelative, fromNow,
} from "@johnhenry/temporals/humanize";
humanizeDuration(Temporal.Duration.from({ hours: 2, minutes: 3 }));
// "2 hours, 3 minutes"
humanizeDuration(dur, { short: true }); // "2h 3m"
humanizeDuration(dur, { max: 1 }); // "2 hours" — largest units only
humanizeDuration(dur, { locale: "fr" }); // localized (see caveat below)
parseDuration("1h30m"); // Temporal.Duration — shorthand in, real duration out
formatRelative(from, to); // "in 5 days" (Intl.RelativeTimeFormat)
fromNow(someDate); // "3 days ago"

humanizeDuration reads units as-is — it does not balance

Section titled “humanizeDuration reads units as-is — it does not balance”

A Duration of { minutes: 90 } renders as “90 minutes”, not “1 hour, 30 minutes”. Balancing is a rounding decision with a calendar-dependent answer (how long is a month?), so the library leaves it to you: .round() the duration first if you want it rebalanced.

Passing locale uses Intl.DurationFormat when the runtime has it (Node 24+, modern browsers) and silently falls back to the built-in English rendering otherwise. Same-version output can differ across runtimes — don’t snapshot-test localized strings across Node versions.

formatRelative/fromNow pick the largest sensible unit via Intl.RelativeTimeFormat; zero-difference inputs render as “now”-equivalents rather than “in 0 seconds”.

Minimal RFC 5545 import/export for recurring events — DTSTART, RRULE, EXDATE, RDATE — bridging recur rules to the format calendars actually speak.

import { toICS, fromICS, icsToSeq } from "@johnhenry/temporals/ics";
const ics = toICS([{ start, rrule: "FREQ=WEEKLY;COUNT=4", exdate: [skipDay] }]);
const [event] = fromICS(ics); // Temporal values + the RRULE string
icsToSeq(event).toArray(); // expand DTSTART + RRULE + EXDATE/RDATE → Seq

Four Temporal point types round-trip through DTSTART/DTEND/EXDATE/RDATE, each with its own wire form — most surprises live in this table:

Temporal value On the wire Meaning
PlainDate DTSTART;VALUE=DATE:20260101 All-day event
ZonedDateTime DTSTART;TZID=America/New_York:20260101T090000 Wall-clock time in a named zone
Instant DTSTART:20260101T140000Z Absolute UTC moment
PlainDateTime DTSTART:20260101T090000 “Floating” time — no zone at all

Parsing runs the same table in reverse: VALUE=DATEPlainDate, a TZID param → ZonedDateTime, a trailing ZInstant, and a bare datetime → PlainDateTime. Which type you feed toICS is therefore a semantic choice, not a formatting one — a floating PlainDateTime and an Instant render one character apart but mean different things to every calendar that imports them.

toICS(fromICS(x)) is exercised in the suite for all-day events and zoned events — a ZonedDateTime start emits DTSTART;TZID=… and comes back as a ZonedDateTime in the same zone. All-day PlainDate starts use VALUE=DATE.

icsToSeq runs the same recurrence engine as the core, so everything on the index page holds: EXDATE removes occurrences before COUNT is applied to what remains, RDATE merges sorted and deduplicated, and DST policy applies to zoned starts.

This is an event-recurrence codec, not a full iCalendar suite — no VTODO, VALARM, VTIMEZONE generation, or free/busy publishing. It covers the subset where Temporal values and RRULEs need to enter or leave .ics files; zone names ride on TZID and resolve through Temporal’s own IANA handling.

Worked examples: examples/humanize.mjs · examples/ics.mjs.