Ultimate

Timezones and dates

Store UTC. Format at the edge with Intl.DateTimeFormat and an explicit IANA timeZone.

A Intl.DateTimeFormat or toLocaleString call with no timeZone option is a lint failure and fails x verify (check 2). It silently uses the process TZ, which in a container is UTC and in a laptop is not — the same code renders two different days depending on where it runs.

Storage

RuleDetail
Column typetimestamptz, always. timestamp (without zone) is a build error in a generated migration
Stored valuethe UTC instant. Postgres normalizes on write; the session TimeZone is UTC on every role
Never storeda formatted string, a local wall-clock time, or an offset in a separate column
Date-only valuesdate column for a true civil date (birthday, invoice date). A date has no instant and is never converted
Wall-clock intentwhen a user means "9am in their city", store timestamptz plus the IANA zone in its own text column. An offset (+02:00) is not a zone — it does not survive DST
Zone stringsIANA identifiers only: Europe/Berlin, America/Sao_Paulo. Never CET, never GMT+2, never PST

The actor's zone

import { currentTimeZone } from '@ultimat3/time';

resolveTimeZone reads, first valid IANA name wins, canonicalized; an invalid one is skipped, never thrown. As of 2026-11:

SourcePrecedence
the member's saved zone (actor.tz)1 — what a person chose
x_timezone cookie, written by a zone picker2 — chosen
?tz=Europe/Berlin3 — share links, email previews
x-timezone header, sent by a client from Intl4 — what the browser is installed as
x_timezone_detected cookie, written by the zone script under time.detect5 — a guess, never written over a pick
configureTime({ defaultZone })6 — UTC unless the app says otherwise
Process TZnever

Detecting a new visitor's zone

A document navigation sends no x-timezone, so a new visitor's server-rendered app/ pages render in the default zone. time: { detect: true } in app.config.ts (off by default: it writes a cookie) puts a 153 B inline script on every app/ document — never on site/ — that writes x_timezone_detected from Intl.DateTimeFormat().resolvedOptions().timeZone. The first paint stays the default; every page after it is in the visitor's zone. The script is admitted by its own CSP hash, and it never touches the chosen x_timezone cookie.

import { defineConfig } from '@ultimat3/core';

export const config = defineConfig({ name: 'my-app', time: { detect: true } });

The zone rides the ALS request context alongside the locale, so format* helpers take neither argument. Jobs and scheduled tasks carry the enqueueing actor's zone in the payload — a worker has no request and must not invent one.

Formatting

HelperOutputNotes
formatDate(instant)2026-07-26 in the actor's zone + localeIntl.DateTimeFormat, dateStyle: 'medium' by default
formatTime(instant)14:3012/24h from the locale, never hardcoded
formatDateTime(instant)date + timeone formatter, cached per (locale, zone, style)
formatRelative(instant)3 days agoIntl.RelativeTimeFormat; computed against the frozen clock in tests. From a day apart the number is calendar days in the zone — 47 hours ahead across two midnights is in 2 days, never tomorrow — so it takes zone like every other formatter
formatRange(a, b)Jul 24 – 26, 2026formatRange on the native formatter, so the collapse rules are CLDR's
formatZone(zone)Central European Summer TimetimeZoneName: 'long'
zonedParts(instant, zone){ year, month, day, hour, … }for arithmetic that must respect civil calendars

All of them read locale and zone from the context and pass timeZone explicitly to Intl. There is no variant that omits it.

Pickers

As of 2026-11. A calendar date the user picks is a PlainDate — DatePicker, DateRangePicker and Calendar take and post YYYY-MM-DD and know no zone. Month and weekday names are Intl.DateTimeFormat with timeZone: 'UTC' on the plain date's UTC midnight, which names the date and never shifts it; the week starts on the locale's day (Intl.Locale#getWeekInfo). A window is instants: timeRangeUrl(url, { presets, default, timeZone, now }) reads a custom from/to as wall-clock time in timeZone (a spring-forward gap resolves forward) and TimeRangePicker requires the same timeZone as a prop — omitting it is a type error. "Today" is the caller's: pass month (or now) computed with plainDateIn(now(), actor.tz); with none, Calendar opens on today in useUi().timeZone, the request's zone, never the device's.

Common mistakes

MistakeWhat breaksFix
new Date().toLocaleDateString()uses process TZ; wrong day for half your usersformatDate(instant)
Intl.DateTimeFormat(locale) with no timeZonesame, and lint-failingpass timeZone or use a helper
timestamp columnthe offset is lost on write; two writers disagreetimestamptz
Storing +02:00 as "the zone"breaks at the next DST transitionstore the IANA zone
Date.now() in a test assertionflakeclock.advance('3d') and assert on the frozen instant
setHours(0, 0, 0, 0) for "start of day"midnight in the server's zone, not the user'sstartOfDay(instant, zone) via zonedParts
Adding 24 * 60 * 60 * 1000 for "tomorrow"wrong on the 23h and 25h DST daysadd one calendar day in the zone
date column read as an instantshifts by a day west of UTCdate columns are civil dates; never convert
Formatting in a service or repountestable, unlocalizableformat in the route/component only
date.setMonth(date.getMonth() + 1)Jan 31 + 1 rolls into MarchaddMonthsInZone(at, 1, zone) or addPlainMonths(date, 1) — Month arithmetic

Month arithmetic

A month has no fixed length, so both helpers clamp the day to the target month's last day.

FunctionAnswers
addPlainMonths(date, months)a PlainDate: 2026-01-31 + 1 → 2026-02-28; 2024-01-31 + 1 → 2024-02-29; negative counts go back. A non-integer count or a year outside 0000–9999 is X_SCHEDULE_INVALID
addMonthsInZone(at, months, timeZone)an Instant at the same local wall-clock time months calendar months away; timeZone is required. A wall time that does not exist moves forward by the gap; one that exists twice takes the earlier instant
plainDateRange(start, end, { exclusive?, stepDays? })a frozen array of PlainDates, [start, end] by default; a span over MAX_PLAIN_DATE_RANGE_DAYS (3660) is X_SCHEDULE_INVALID

Compute a recurring date from its anchor, never by chaining. A clamp does not compose: 2026-01-31 + 1 + 1 is 2026-03-28, 2026-01-31 + 2 is 2026-03-31.

import { addMonthsInZone, fromIso } from '@ultimat3/time';

const anchor = fromIso('2026-01-31T00:00:00-05:00');
const renewal = (n: number) => addMonthsInZone(anchor, n, 'America/Bogota');
// renewal(1): Feb 28 00:00, renewal(2): Mar 31 00:00 — never stuck on the 28th

Full rules: packages/time/README.md.

Duration strings

One notation for every "how long" field.

StringMeansUsed by
'30s'30 secondscache TTL, retry backoff floor
'15m'15 minutesstep.sleep, live-query reconnect window
'6h'6 hoursISR revalidate TTL
'3d'3 daysstep.sleep, semantic cache ttl
'7d'7 daysLLM semantic cache default

Parsed once into milliseconds. A bare number is a type error — the unit must be in the string. See Caching and invalidation and Jobs and workflows.

Scheduled tasks

tz is required on every task and must be an explicit IANA zone. There is no default.

// task (cron)
export const nightlyDigest = task({
  cron: '0 3 * * *',
  tz: 'UTC',
  enqueue: () => [[sendDigest, {}]],
});
ConcernRule
Missing tztype error, then X_CONFIG_INVALID at boot if bypassed
'UTC'correct for machine work: digests, compaction, exports
A civil zonecorrect for user-facing schedules: "9am local" is tz: 'Europe/Berlin', not '0 7 * * *' in UTC
Leader electionone scheduler role, holding an expiring lease row in x_scheduler_leader — never an advisory lock. A task fires once per cluster, not once per pod
Catch-upa missed window is logged and skipped, never replayed silently

See Scheduled tasks.

DST-crossing recurrence

CaseBehavior
Spring forward, 0 2 * * * in a zone that skips 02:00fires at the zone's next valid instant that day (03:00) — shifted, never skipped
Fall back, 0 2 * * * in a zone that repeats 02:00a fixed time fires once, on the first pass
Fall back, */5 * * * * or 0 * * * * — the minute or hour field is * or */nan interval runs through both passes of the repeated hour: it is real elapsed time, and no hour of the night is dark
step.sleep('1d') across a transitionsleeps 24h of elapsed time, not one calendar day. For calendar semantics, compute the next instant in the zone and step.sleepUntil
Monthly on the 31stmonths without a 31st are skipped, not clamped to the 30th. Use '0 3 28 * *' if you need every month

Cron is a civil-calendar schedule; sleeps are elapsed time. Mixing them is where "the job ran twice in November" comes from.

Tests

ControlBehavior
Frozen clocktime starts at a fixed instant, does not advance on its own
clock.advance('3d')moves it, and also drives step.sleep and cron dispatch
Fixed zoneUTC unless a test declares otherwise
Fixed localeen-US unless a test declares otherwise
tz-dependent bugsfail deterministically — declare a zone in the test and the bug reproduces every run
Wall clocknever asserted on. Date.now() in an expectation is a rejected PR

Declare a zone when the behavior is the point:

test('digest window respects the org zone', async ({ seed, clock }) => {
  const { org } = await seed('org-in-berlin');
  clock.advance('1d');
  const window = digestWindow(org, 'Europe/Berlin');
  expect(window.label).toBe('Jul 27, 2026');
});

See Testing.

Rules

  • timestamptz for instants, date for civil dates. Nothing else.
  • Never format without an explicit IANA timeZone.
  • Never read the process TZ. Roles run with TZ=UTC and it is not an input.
  • Zone comes from the actor; a worker gets it from the job payload.
  • Format at the edge only — routes and components, never services or repos.
  • Durations are strings with units.
  • tz on a task is explicit and required.
  • Advance the frozen clock; never sleep in a test.

Edit this page on GitHubThis page as markdown