Dates & timezones

@voltro/datetime — UTC-instant storage, timezone-aware arithmetic/formatting on TC39 Temporal, and the request-scoped timezone-context seam.

@voltro/datetime is the framework's opinion on time: store UTC instants, render in the viewer's timezone, never do zone math by hand. It is built on the TC39 Temporal standard (via a polyfill, so the API is identical on the server and in the browser) and ships pure, browser-safe helpers plus a request-scoped timezone seam.

The . entry is browser-safe — no effect, no node:* — so a route or component can import it directly. The Effect seam lives in a separate subpath, @voltro/datetime/context, so the pure surface carries no effect dependency.

Phase 1. This is the storage + timezone + formatting layer. Schema-DSL temporal column types, interval(), and rrule() are later phases; use timestamp() columns and these helpers today.

The storage contract: a Date is a UTC instant

timestamp() stores TIMESTAMPTZ, and the driver hands your app code a plain JS Date. A Date is a bare epoch-millisecond count with no zone of its own, so the ONLY correct reading of it is "the UTC instant it points at". These helpers make that reading explicit and lossless in both directions:

import { toInstant, toUTCString, isValidTimeZone } from '@voltro/datetime'

// A `Date` from a query IS a UTC instant — read it as one, losslessly.
const when = toInstant(row.createdAt)   // Temporal.Instant
toUTCString(row.createdAt)              // "2026-08-06T12:00:00Z" — the wire/storage form

isValidTimeZone('Europe/Berlin')        // true
isValidTimeZone('Mars/Phobos')          // false

toInstant accepts a Temporal.Instant, a JS Date, or an ISO-8601 string — but a string MUST carry an explicit offset or Z. A naive 2026-01-01T12:00 denotes no instant and throws, by design: the whole point is that there is no silent zone-guessing. Bridge back with toDate(instant) for storage or interop.

Timezone resolution — the framework convention

Which zone should a given request render in? Resolve it in priority order, each candidate validated as an IANA name, falling back to UTC:

import { resolveTimezone } from '@voltro/datetime'

const tz = resolveTimezone({
  userTimeZone:    user.timezone,        // 1. the viewing user's profile
  tenantTimeZone:  tenant.timezone,      // 2. the tenant / org default
  browserTimeZone: 'Europe/Berlin',      // 3. the browser-reported zone
})                                        // → a validated IANA name, else 'UTC'

This mirrors @voltro/i18n's resolveLocale in spirit — pure, framework-agnostic, no Effect or RPC — so it runs in any request pipeline. An invalid or absent candidate falls through to the next signal; the result is GUARANTEED valid.

Carrying it through a request — the context seam

The resolved zone travels through a request via an Effect context tag in @voltro/datetime/context, so any route, mutation, workflow, or agent can read it:

import { currentTimezone, withTimezone } from '@voltro/datetime/context'
import { Effect } from 'effect'

const program = Effect.gen(function* () {
  const tz = yield* currentTimezone   // the request's zone, or 'UTC' when none set
  return tz
})

// Provide an explicit zone for a sub-computation:
program.pipe(withTimezone('Europe/Berlin'))

currentTimezone never fails — an absent context is the documented UTC default, so call sites don't handle a missing-service error.

Two zones, and only one of them is wired. Keep them apart.

The render zone — what a date LOOKS like in the UI — is wired end to end. Set timeZone in the web app.config.ts, and the framework resolves it per request, publishes it on the document, and every @voltro/i18n formatter on both sides of the hydration boundary uses it. Read it with useTimeZone() from @voltro/i18n. See Formatting → Timezones under SSR.

The server-side compute zone — what startOfDay or a workflow's "same time tomorrow" resolves against inside a handler — is still the SEAM only. @voltro/datetime/context exports currentTimezone, withTimezone and resolvedTimezoneLayer; the framework does not install the layer per request, so resolve the zone yourself (resolveTimezone) and provide resolvedTimezoneLayer around the work that needs it, or pass an explicit timeZone argument. Without a provided layer every call reads the UTC default.

Rendering a date correctly does NOT give a route handler the user's zone, and a handler that has it does NOT change what the browser renders. They are separate values today and a timeZone in app.config.ts configures the first one only.

Arithmetic — the DST split is in the names

Every operation that is ambiguous without a zone REQUIRES an IANA timeZone argument. There is no implicit "system zone", so a call site cannot silently do the wrong thing on a differently-configured box. The DST distinction is deliberate and encoded in the method names:

import { addDays, addHours, startOfDay, endOfDay } from '@voltro/datetime'

// WALL-CLOCK: same local time, one calendar day later in Berlin.
// Across a DST boundary the elapsed real time is 23h or 25h. Needs a zone.
addDays(when, 1, 'Europe/Berlin')

// EXACT elapsed time: 24 × 3600 seconds, DST-oblivious. No zone.
addHours(when, 24)

// Zone-relative day boundaries.
startOfDay(when, 'Europe/Berlin')
endOfDay(when, 'Europe/Berlin')

addDays / addMonths are wall-clock ("same local time, N days on"); addHours is exact elapsed time. Comparisons that measure absolute instants (isAfter, isBefore) take no zone; isSameDay does, because "same day" is a wall-clock question. For a calendar date with no time and no zone — a birthday, a holiday, a due date — use plainDate(year, month, day) / parseDate('2026-08-06').

Formatting — locale- and timezone-aware

Formatting is where the viewer's timezone and locale are APPLIED. Both are explicit arguments — this layer carries no ambient locale (the web layer resolves useLocale() and passes it in):

import { formatDate, formatDateTime, formatRelativeTime } from '@voltro/datetime'

formatDate(row.createdAt, 'Europe/Berlin', { locale: 'de' })        // "6. Aug. 2026"
formatDateTime(row.createdAt, 'America/New_York', { locale: 'en' })
formatRelativeTime(row.createdAt, { locale: 'en' })                 // "3 hours ago"

FormatOptions is { locale? } plus any Intl.DateTimeFormatOptions override, so formatDate(when, tz, { locale, dateStyle: 'full' }) works. formatRelativeTime picks the largest unit that fits the signed distance from now (default: the current instant) and is zone-independent — it measures elapsed real time, not wall-clock days.

These helpers are the STORAGE/TIMEZONE layer. @voltro/i18n's useFormatDate / useRelativeTime hooks are the React binding that read the active locale from the provider; reach for those in components, and for @voltro/datetime in server code, loaders, and tests.

Temporal directly

The exact standard Temporal types are re-exported, so you can drop to the full API when a helper doesn't cover your case:

import { Temporal } from '@voltro/datetime'

const noon = Temporal.PlainTime.from('12:00')

Import Temporal from @voltro/datetime, never from the polyfill directly — that keeps the eventual switch to the native global (once it is universal in browsers) a one-line change for the whole codebase.