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(), andrrule()are later phases; usetimestamp()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') // falsetoInstant 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
timeZonein the webapp.config.ts, and the framework resolves it per request, publishes it on the document, and every@voltro/i18nformatter on both sides of the hydration boundary uses it. Read it withuseTimeZone()from@voltro/i18n. See Formatting → Timezones under SSR.The server-side compute zone — what
startOfDayor a workflow's "same time tomorrow" resolves against inside a handler — is still the SEAM only.@voltro/datetime/contextexportscurrentTimezone,withTimezoneandresolvedTimezoneLayer; the framework does not install the layer per request, so resolve the zone yourself (resolveTimezone) and provideresolvedTimezoneLayeraround the work that needs it, or pass an explicittimeZoneargument. Without a provided layer every call reads theUTCdefault.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
timeZoneinapp.config.tsconfigures 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'suseFormatDate/useRelativeTimehooks are the React binding that read the active locale from the provider; reach for those in components, and for@voltro/datetimein 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.