-
Notifications
You must be signed in to change notification settings - Fork 0
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.
| Rule | Detail |
|---|---|
| Column type |
timestamptz, always. timestamp (without zone) is a build error in a generated migration |
| Stored value | the UTC instant. Postgres normalizes on write; the session TimeZone is UTC on every role |
| Never stored | a formatted string, a local wall-clock time, or an offset in a separate column |
| Date-only values |
date column for a true civil date (birthday, invoice date). A date has no instant and is never converted |
| Wall-clock intent | when 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 strings | IANA identifiers only: Europe/Berlin, America/Sao_Paulo. Never CET, never GMT+2, never PST
|
import { currentTimeZone } from '@ultimat3/time';| Source | Precedence |
|---|---|
user.timeZone on the profile |
1 — explicit user setting |
Session-detected zone (Intl.DateTimeFormat().resolvedOptions().timeZone, sent at sign-in) |
2 |
| Org / tenant default zone | 3 |
app.config.ts time.defaultZone
|
4 |
Process TZ
|
never |
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.
| Helper | Output | Notes |
|---|---|---|
formatDate(instant) |
2026-07-26 in the actor's zone + locale |
Intl.DateTimeFormat, dateStyle: 'medium' by default |
formatTime(instant) |
14:30 |
12/24h from the locale, never hardcoded |
formatDateTime(instant) |
date + time | one formatter, cached per (locale, zone, style) |
formatRelative(instant) |
3 days ago |
Intl.RelativeTimeFormat; computed against the frozen clock in tests |
formatRange(a, b) |
Jul 24 – 26, 2026 |
formatRange on the native formatter, so the collapse rules are CLDR's |
formatZone(zone) |
Central European Summer Time |
timeZoneName: '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.
| Mistake | What breaks | Fix |
|---|---|---|
new Date().toLocaleDateString() |
uses process TZ; wrong day for half your users |
formatDate(instant) |
Intl.DateTimeFormat(locale) with no timeZone
|
same, and lint-failing | pass timeZone or use a helper |
timestamp column |
the offset is lost on write; two writers disagree | timestamptz |
Storing +02:00 as "the zone" |
breaks at the next DST transition | store the IANA zone |
Date.now() in a test assertion |
flake |
clock.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's |
startOfDay(instant, zone) via zonedParts
|
Adding 24 * 60 * 60 * 1000 for "tomorrow" |
wrong on the 23h and 25h DST days | add one calendar day in the zone |
date column read as an instant |
shifts by a day west of UTC |
date columns are civil dates; never convert |
| Formatting in a service or repo | untestable, unlocalizable | format in the route/component only |
One notation for every "how long" field.
| String | Means | Used by |
|---|---|---|
'30s' |
30 seconds | cache TTL, retry backoff floor |
'15m' |
15 minutes |
step.sleep, live-query reconnect window |
'6h' |
6 hours | ISR revalidate TTL |
'3d' |
3 days |
step.sleep, semantic cache ttl
|
'7d' |
7 days | LLM 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.
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, {}]],
});| Concern | Rule |
|---|---|
Missing tz
|
type error, then X_CONFIG_INVALID at boot if bypassed |
'UTC' |
correct for machine work: digests, compaction, exports |
| A civil zone | correct for user-facing schedules: "9am local" is tz: 'Europe/Berlin', not '0 7 * * *' in UTC |
| Leader election | one scheduler role, advisory lock. A task fires once per cluster, not once per pod |
| Catch-up | a missed window is logged and skipped, never replayed silently |
See Scheduled tasks.
| Case | Behavior |
|---|---|
Spring forward, 0 2 * * * in a zone that skips 02:00 |
the occurrence does not exist; it is skipped for that day and logged |
Fall back, 0 2 * * * in a zone that repeats 02:00 |
fires once, on the first pass. Deduped by the occurrence's civil timestamp |
step.sleep('1d') across a transition |
sleeps 24h of elapsed time, not one calendar day. For calendar semantics, compute the next instant in the zone and step.sleepUntil
|
| Monthly on the 31st | months 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.
| Control | Behavior |
|---|---|
| Frozen clock | time 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 zone |
UTC unless a test declares otherwise |
| Fixed locale |
en-US unless a test declares otherwise |
| tz-dependent bugs | fail deterministically — declare a zone in the test and the bug reproduces every run |
| Wall clock | never 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.
-
timestamptzfor instants,datefor civil dates. Nothing else. - Never format without an explicit IANA
timeZone. - Never read the process
TZ. Roles run withTZ=UTCand 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.
-
tzon ataskis explicit and required. - Advance the frozen clock; never sleep in a test.
Ultimate — v1.1.0 As of 2026-08. Stable API, semver from here. MIT licensed.
Repository · Issues · Changelog · llms.txt
Edits to these pages are synced from wiki/ in the repository — change the file there, not the wiki, or the next sync overwrites it.
Start
Tutorials
- 1 · First app
- 2 · First feature
- 3 · Auth and admin
- 4 · Jobs and realtime
- 5 · Deploy free
- 6 · Growing up
Primitives
- The eight primitives
- Actions
- Entities and migrations
- Policies and authz
- Queries and live queries
- Jobs and workflows
- Scheduled tasks
- Routes and render modes
Capabilities
Cross-cutting
Reference