Dates and time zones
Calendar days, instants, presets, time zones, relative time and machine forms.
@workspace/active-format 1.0.0Developmentunreleased, in development in Foundationbaseline ce081dc3
Two kinds of dates#
The package keeps two things apart that look alike:
A calendar day (
YYYY-): a birthday, a due date, a holiday. It has no time and no zone, and it reads the same everywhere. Format it withMM- DD format.Date Only An instant: a moment in time, written with
Zor an offset (2026-), a03- 09 T23:30:00 Z Date, or epoch milliseconds. It reads differently per zone. Format it withformatorDate format, in the presentation's time zone.Date Time
A date-time without a zone (2026-) is neither: it gives '', never a guess.
| Zone | The instant | Its day there | The calendar day |
|---|---|---|---|
| UTC | 9 Mar 2026, 23:30 | 2026-03-09 | 9 March 2026 |
| Europe/Amsterdam | 10 Mar 2026, 00:30 | 2026-03-10 | 9 March 2026 |
| America/New_York | 9 Mar 2026, 19:30 | 2026-03-09 | 9 March 2026 |
| Asia/Tokyo | 10 Mar 2026, 08:30 | 2026-03-10 | 9 March 2026 |
// apps/docs/examples/format/calendar-day-vs-instant.tsx'use client';
import { calendarDayOf, composePresentationContext, formatDateOnly, formatDateTime, toIanaTimeZone,} from '@workspace/active-format';import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@workspace/active-ui/components/primitives/table';
// One moment, late in the evening in UTC.const INSTANT = '2026-03-09T23:30:00Z';// One calendar day: a birthday, a due date. It has no zone.const CALENDAR_DAY = '2026-03-09';const ZONES = ['UTC', 'Europe/Amsterdam', 'America/New_York', 'Asia/Tokyo'];
/** * An instant reads differently per zone; a calendar day never moves. */export default function CalendarDayVsInstant() { return ( <Table> <TableHeader> <TableRow> <TableHead>Zone</TableHead> <TableHead>The instant</TableHead> <TableHead>Its day there</TableHead> <TableHead>The calendar day</TableHead> </TableRow> </TableHeader> <TableBody> {ZONES.map((zone) => { const timeZone = toIanaTimeZone(zone); const presentation = composePresentationContext('en', null, timeZone); return ( <TableRow key={zone}> <TableCell className="font-mono text-xs">{zone}</TableCell> <TableCell suppressHydrationWarning> {formatDateTime(INSTANT, 'datetime', presentation)} </TableCell> <TableCell className="font-mono text-xs"> {calendarDayOf(INSTANT, timeZone)} </TableCell> <TableCell suppressHydrationWarning> {formatDateOnly(CALENDAR_DAY, 'long', presentation)} </TableCell> </TableRow> ); })} </TableBody> </Table> );}calendar gives the calendar day of an instant in a zone. An instant
without a zone argument throws, so the zone is never the runtime's by accident.
Time zones#
Time zones are Iana values: a validated IANA identity such as 'Europe/, never
an offset. Make one with to (throws for an unknown zone) or test a string with
is. UTC_ is 'UTC'.
The zone belongs in the presentation: compose.
Without a zone, instants are formatted in the runtime zone.
Presets#
A preset names a form; the language and profile decide its exact pattern.
Calendar-day presets (
Date):Only Preset numeric,short,long,full,day-,month day-,month- numeric day-,month- year- short month-,year quarter-,year month,month-,long weekday-,long year.Time and date-time presets (
Dateadds):Preset time,time-,seconds time-,millis numeric-,datetime numeric-,datetime- seconds numeric-,datetime- millis datetime,datetime-,long time-.day
Without a preset, the form follows the presentation's date shape (normal,
normal). Language-derived notations are day-month-year with a 24-hour clock; a
month-first or 12-hour form only comes from a profile (MDY_, H12).
format is the escape hatch for a date-fns
pattern. The reason argument is required, so every use explains itself.
Today#
today gives today's calendar day in an explicit zone, never the runtime's day.
It is one of only two functions that read the clock.
Relative time#
resolve is the only route to
relative time. Within the threshold (seven days by default, in both directions) it is relative
("2 minutes ago", "in 3 days"), beyond it absolute. Pass now and it reads no clock.
| Value | mode | text |
|---|---|---|
| 2026-03-09T11:58:00Z | relative | 2 minutes ago |
| 2026-03-08T09:00:00Z | relative | 1 day ago |
| 2026-03-12T12:00:00Z | relative | in 3 days |
| 2026-02-01T08:00:00Z | absolute | 1 Feb 2026, 08:00 |
| 2026-03-09 | unavailable | (none) |
// apps/docs/examples/format/recency.tsx'use client';
import {resolveRecency} from '@workspace/active-format';import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@workspace/active-ui/components/primitives/table';
// A fixed "now": the example reads no clock, so every reader sees the same.const NOW = new Date('2026-03-09T12:00:00Z');const VALUES = [ '2026-03-09T11:58:00Z', '2026-03-08T09:00:00Z', '2026-03-12T12:00:00Z', '2026-02-01T08:00:00Z', '2026-03-09',];
/** * Relative within the threshold (seven days by default), absolute beyond it, * unavailable for a value that is not an instant. */export default function Recency() { return ( <Table> <TableHeader> <TableRow> <TableHead>Value</TableHead> <TableHead>mode</TableHead> <TableHead>text</TableHead> </TableRow> </TableHeader> <TableBody> {VALUES.map((value) => { const result = resolveRecency(value, 'en', {now: NOW}); return ( <TableRow key={value}> <TableCell className="font-mono text-xs">{value}</TableCell> <TableCell className="font-mono text-xs">{result.mode}</TableCell> <TableCell suppressHydrationWarning> {result.text || '(none)'} </TableCell> </TableRow> ); })} </TableBody> </Table> );}Machine forms#
For files, APIs and URLs, never for text a person reads:
format:Machine Date(value) yyyy-.MM- dd format:Machine Date Time(value) YYYY-, for an instant only.MM- DDTHH:mm:ss. sss Z format: a plain dot decimal, never an exponent.Machine Number(value, decimals?)
Absence gives ''; an invalid value throws, unlike the human formatters.