# Zones and local times

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.earlier` or `Dst.later` takes 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.compatible` is 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 leaving `dst:` 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](creating-values.md#construction-failures-by-rule)).
The failure is `invalid_value`, and `invalid:` covers every condition that
has no arm of its own.

```jadpo
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](time-and-calendar-additions.md)).
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](filters.md)):

```jadpo
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
}
```

```jadpo
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:

```jadpo
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 `Int`s 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](recurrences-and-opening-hours.md)).

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.
