Zones and local times
On this page
A zone turns an instant into the local time people see on a clock. This is
never unclear: temporal.in_zone(clock.now, Zone.europe_london) is a
ZonedTime.
Going the other way, from a date and a clock time to an instant, can be unclear. At 01:30 on the day the clocks go back, London has two 01:30s, and on the day they go forward 01:30 doesn't exist.
Choosing the instant: dst:
By default an unclear local time fails. The option dst: says which instant
to take instead:
Dst.earlierorDst.latertakes that one of the two instants when the time happens twice. When the time doesn't exist, it moves the clock time back or forward by the length of the gap.Dst.compatibleis the earlier when the time happens twice and the later when it doesn't exist, as JavaScript's Temporal does.Dst.reject, the same as leavingdst:out, fails.
The failure is mapped in the call's attempt by the option's name, dst:,
the way a construction's broken rule is mapped
(Construction failures by rule).
The failure is invalid_value, and invalid: covers every condition that
has no arm of its own.
type ShopDay = CalendarDate {}type Opening = ZonedTime {}type OpeningUnclear = Failure { kind: FailureKind.invalid_value}/// When the night shift opens on `day`: 01:30, which on the day the clocks/// go back happens twice, and on the day they go forward not at all.function night_opening(day: ShopDay) fails OpeningUnclear -> Opening { var opening: ZonedTime = attempt temporal.resolve(day, at: "01:30", zone: Zone.europe_london) { dst: OpeningUnclear } return Opening(opening)}/// The same, taking the earlier 01:30 of the two and, when there is none,/// the time an hour on.function night_opening_anyway(day: ShopDay) -> Opening { return Opening( temporal.resolve(day, at: "01:30", zone: Zone.europe_london, dst: Dst.compatible) )}test "an unclear local time fails unless `dst:` decides" { assert night_opening("2026-10-25") fails OpeningUnclear assert night_opening("2026-03-29") fails OpeningUnclear assert temporal.instant(night_opening_anyway("2026-10-25")) == Instant("2026-10-25T00:30:00Z") assert temporal.instant(night_opening_anyway("2026-03-29")) == Instant("2026-03-29T01:30:00Z")}test "a day on keeps the clock time, 25 hours later across the change" { var saturday: ZonedTime = temporal.resolve( "2026-10-24", at: "09:00", zone: Zone.europe_london, dst: Dst.compatible ) var sunday: ZonedTime = temporal.add_days(saturday, 1, dst: Dst.compatible) assert temporal.instant(sunday) - temporal.instant(saturday) == 25h assert temporal.hour(sunday) == 9 assert temporal.weekday(sunday) == Weekday.sunday}test "a month on from the 31st needs a day the month has" { var end_of_january: CalendarDate = "2026-01-31" assert temporal.add_months( end_of_january, 1, invalid_day: InvalidDay.last_valid_day ) == CalendarDate("2026-02-28")}
at: is a clock time written out, "09:30" or "09:30:15", which the
compiler checks (type.temporal_clock_invalid), or a LocalTime
(Time and calendar additions).
In place of a date and at:, the call takes a LocalDateTime, which
carries its own clock time: temporal.resolve(shift.start, zone: shift_zone, dst: Dst.compatible).
Only a dst: written out stops the call failing. A Dst passed in as a
value might be reject, so the call can still fail.
dst: means something only for a ZonedTime. On a CalendarDate it is
type.temporal_option_unused.
A day the month doesn't have: invalid_day:
Moving a date by months or years can land on a day the month doesn't have,
such as 31 January plus a month. invalid_day: InvalidDay.last_valid_day
takes the month's last day. Left out, or InvalidDay.reject, the call fails
with invalid_day: to map.
A day in a zone
A query for "deliveries on 25 October in London" uses the day's bounds:
from its first instant up to the first instant of the next day. These are
right whether the day has 23, 24 or 25 hours. The query compares a field
with an InstantRange parameter's start and end, as it would with any
parameter's fields (Filters):
use shop/entities { Delivery }use shop/types { DeliveryView }/// The deliveries that arrived from `arrivals.start` up to `arrivals.end`.query deliveries_in(arrivals: InstantRange) -> List<DeliveryView> { from: Delivery where: Delivery.arrived_at >= arrivals.start where: Delivery.arrived_at < arrivals.end order: [Delivery.arrived_at asc] limit: 100}
use shop/types { DeliveryView, ShopDay }use shop/queries { deliveries_in }/// The deliveries on `day` in London, however many hours it has.action deliveries_on(day: ShopDay) -> List<DeliveryView> { return deliveries_in(temporal.day_bounds(day, Zone.europe_london))}
The test runs against PostgreSQL. London's clocks go back at 02:00 on 25 October 2026, so the day runs from 23:00 UTC on the 24th to midnight UTC on the 26th:
use shop/actions { record_delivery }test "a London day has 25 hours when the clocks go back" { clock: "2026-10-24T22:59:00Z" assert record_delivery("before midnight").note == "before midnight" clock.advance(2m) assert record_delivery("just after midnight").note == "just after midnight" clock.advance(24h) assert record_delivery("late on the 25th").note == "late on the 25th" clock.advance(1h) assert record_delivery("the 26th").note == "the 26th" assert list.map(deliveries_on("2026-10-25"), delivery -> delivery.note) == [ "just after midnight", "late on the 25th" ]}
The temporal operations
The temporal library is always called through its name, as
temporal.<operation>. In this table "value" is a value built on
CalendarDate or ZonedTime, and a result of "its type" keeps the
argument's named type:
| Operation | Gives | Fails on, unless an option settles it |
|---|---|---|
in_zone(instant, zone) |
ZonedTime |
|
resolve(date, at:, zone:, dst:), or a LocalDateTime with no at: |
ZonedTime |
dst |
between(start, end) |
Duration, end - start |
|
add_days(value, days, dst:) |
its type, a whole number of calendar days on, the clock time kept | dst, for a ZonedTime |
add_months(value, months, invalid_day:, dst:) |
its type, months on, the day and clock time kept | invalid_day, and dst for a ZonedTime |
add_years(value, years, invalid_day:, dst:) |
its type, twelve months a year | the same |
day_bounds(date, zone) |
InstantRange: start, and end not included |
|
week_bounds(date, zone, starts_on:) |
the week holding date, from starts_on (a Weekday) |
|
month_bounds(date, zone), year_bounds(date, zone) |
the month or year holding date |
|
year(value), month(value), day(value) |
Int, of a date or a local time's date; month and day from 1 |
|
weekday(value) |
Weekday |
|
date(zoned), hour(zoned), minute(zoned) |
a local time's CalendarDate, and Ints from 0 |
|
zone(zoned), instant(zoned) |
the Zone it is seen in, and the Instant it is |
|
format(value, zone:, locale:, format:), format_relative(value, now:, zone:, locale:) |
PresentationText, below |
The standard library adds three more temporal operations, occurrences,
open_at and period_between
(Recurrences and opening hours).
A day's bounds start at its first instant: midnight or, in a zone whose
clocks skip midnight, the first time they show that day. instant(...) is
the operation that orders local times: a ZonedTime has no order of its
own, and its instant does.
Zones come from the IANA time zone database built into Jadpo (release
2026e), never from the platform's own files. So the interpreter, a native
deployable and WebAssembly on Workers give the same answers, and every
Zone variant is in the database. A newer release of the database comes in
a change of its own.
Markdown for agents: /docs/zones-and-local-times.md.Built from Jadpo main at a707efb.