Utilities · Lesson 20.11

Time zone

Source
Build time zones from POSIX TZ rules with TimeZone::FromRule, read one moment on several clocks with StateAt and ToLocal, and turn a local time back into a moment with Resolve, which tells a time that happened once from one that happened twice or never.

An offset such as +03:00, from Date and time, describes a clock at one moment. It does not describe a place. Kyiv keeps +02:00 in winter and +03:00 in summer; New York keeps -05:00 and -04:00, and changes on different dates. To say "09:00 in Kyiv" on a date you have not looked up yet, you need the place's rules — a time zone.

A TimeZone turns a moment into the local time there, and a local time back into a moment. The second direction is the interesting one, because once a year a local time happens twice, and once a year it does not happen at all:

import TimeZone::{ LocalTimeKind, TimeZone, TimeZoneError };

Where a zone comes from

FunctionBuilds a zone from
TimeZone::Utcnothing — UTC never changes
TimeZone::FromRulea POSIX TZ rule, such as EET-2EEST,M3.5.0/3,M10.5.0/4
TimeZone::Loadthe tz database, by name, such as Europe/Kyiv
TimeZone::Localthe computer's own setting
TimeZone::FromTzifthe bytes of a tz database file you read yourself

Load is what most programs on Linux and macOS use, since the tz database holds every change a zone has ever made. But Windows keeps no tz database, so there Load finds nothing unless the program points the TZDIR environment variable at a copy. This lesson builds its zones from rules, which work the same everywhere, and leaves Local out because its answer depends on the machine.

Each of them allocates and can fail, so each returns TimeZone ! TimeZoneError, and each takes an allocator:

let utc = TimeZone::Utc(allocator) catch { else => return 1 };
let kyiv = TimeZone::FromRule(allocator, "Kyiv", "EET-2EEST,M3.5.0/3,M10.5.0/4") catch {
    else => return 1
};

A zone owns that memory, so it is move-only, and the functions in this program borrow it as &TimeZone.

Reading a POSIX rule

A rule packs a whole year into one line. Kyiv's reads:

PartMeans
EETthe standard time's abbreviation
-2its offset — minus means east of UTC, so this is +02:00
EESTthe daylight time's abbreviation, an hour ahead unless an offset follows it
M3.5.0/3daylight time starts in month 3, week 5 (the last), on day 0 (Sunday), at 3:00
M10.5.0/4and ends in month 10, on the last Sunday, at 4:00 daylight time

New York's EST5EDT,M3.2.0,M11.1.0 reads the same way: five hours west, with daylight time from the second Sunday in March to the first Sunday in November, at the default 2:00. A malformed rule, such as X5, whose name is too short, fails with TimeZoneError::InvalidRule.

From a moment to local time

StateAt says what the zone keeps at a moment — its offset, its abbreviation and whether it is daylight time — and ToLocal gives the local date and time as an OffsetDateTime:

func ShowMoment(zone: &TimeZone, moment: Timestamp) {
    let state = zone.StateAt(moment);
    PrintLine("  {:<8} {:<4} daylight {:<5}  {}", zone.Name(), state.abbreviation, state.daylight,
        zone.ToLocal(moment));
}

This direction always has exactly one answer. The program reads noon UTC on 15 January and on 1 July on three clocks: Kyiv moves from +02:00 to +03:00, New York from -05:00 to -04:00, and UTC stays where it is.

From local time to a moment

Going back is where clocks get awkward. On the last Sunday of March, Kyiv's clocks jump from 03:00 straight to 04:00; on the last Sunday of October they go back from 04:00 to 03:00:

flowchart LR
    q["a local time<br/>in Kyiv"] --> r{"`Resolve`"}
    r -- "an ordinary day" --> u["`Unique`:<br/>one moment"]
    r -- "29 March, 03:30" --> s["`Skipped`:<br/>no moment"]
    r -- "25 October, 03:30" --> a["`Ambiguous`:<br/>two moments"]

So Resolve does not return one Timestamp. It returns a LocalTime: a kind, which is a LocalTimeKind, and two candidate moments, earlier and later:

let found = zone.Resolve(local);
match found.kind {
    LocalTimeKind::Unique => PrintLine("  {} happens once:  {}", text,
        zone.ToLocal(found.earlier)),
    LocalTimeKind::Ambiguous => PrintLine("  {} happens twice: {} then {}", text,
        zone.ToLocal(found.earlier), zone.ToLocal(found.later)),
    LocalTimeKind::Skipped => PrintLine("  {} never happens: taken as {}", text,
        zone.ToLocal(found.later))
}
Kindearlierlater
Uniquethe momentthe same moment
Ambiguousthe first time, still in summer timethe second time, an hour later
Skipped03:30 at the new offset: 02:30 on the clock03:30 at the old offset: 04:30 on the clock

For a skipped time, later is what most programs want: an alarm set for 03:30 rings at 04:30, an hour after 02:30, just as it would have without the change. For a repeated time there is no right answer in general — a nightly job should usually run only on the first, a meeting may mean either — so the choice is left to you.

The program

The whole lesson is one package in the Examples repository. Its comments explain every step.

Src/Main.rux
// An offset such as +03:00 says how far a clock is from UTC at one moment. A time zone says which
// offset a place uses at every moment: Kyiv keeps +02:00 in winter and +03:00 in summer, and
// moves between them on dates its rules fix. So a time zone turns a moment into the local time
// there, and back.
//
// `TimeZone::FromRule` builds a zone from a POSIX `TZ` rule, a short text that names the
// standard time, its offset, the daylight time and when it starts and ends. Kyiv's is
// `EET-2EEST,M3.5.0/3,M10.5.0/4`: EET, two hours east of UTC (POSIX writes east as minus), and
// EEST from 03:00 on the last Sunday of March (M3.5.0/3) until 04:00 on the last Sunday of
// October (M10.5.0/4). `TimeZone::Load` reads a zone's whole history from the tz database by
// name instead, but Windows has no such database, so this program builds its zones from rules.
//
// `StateAt` gives the offset, the abbreviation and whether it is daylight time at a moment, and
// `ToLocal` the local date and time. `Resolve` goes the other way, from a local date and time to
// the moment, and says whether that local time happened once, twice as clocks fell back over it,
// or never as they sprang forward over it.
import Allocator::{ Allocator, SystemAllocator };
import Io::PrintLine;
import Time::{ ParseDateTime, ParseRfc3339, Timestamp };
import TimeZone::{ LocalTimeKind, TimeZone, TimeZoneError };

func Reason(error: TimeZoneError) -> char8[..] {
    return match error {
        TimeZoneError::NotFound => "no zone of that name",
        TimeZoneError::InvalidName => "not a zone name",
        TimeZoneError::InvalidData => "not a tz database file",
        TimeZoneError::InvalidRule => "not a POSIX TZ rule",
        TimeZoneError::Unreadable => "the zone file could not be read",
        TimeZoneError::OutOfMemory => "out of memory"
    };
}

// The local time in `zone` at `moment`, with what the zone calls it.
func ShowMoment(zone: &TimeZone, moment: Timestamp) {
    let state = zone.StateAt(moment);
    PrintLine("  {:<8} {:<4} daylight {:<5}  {}", zone.Name(), state.abbreviation, state.daylight,
        zone.ToLocal(moment));
}

// Which moments the local date and time `text` stands for in `zone`.
func ShowLocal(zone: &TimeZone, text: char8[..]) {
    let local = ParseDateTime(text) catch {
        else => {
            PrintLine("{} is not a date and time", text);
            return;
        }
    };
    let found = zone.Resolve(local);
    match found.kind {
        LocalTimeKind::Unique => PrintLine("  {} happens once:  {}", text,
            zone.ToLocal(found.earlier)),
        LocalTimeKind::Ambiguous => PrintLine("  {} happens twice: {} then {}", text,
            zone.ToLocal(found.earlier), zone.ToLocal(found.later)),
        LocalTimeKind::Skipped => PrintLine("  {} never happens: taken as {}", text,
            zone.ToLocal(found.later))
    }
}

func Main() -> int {
    var system = SystemAllocator();
    let allocator: Allocator = system;

    // Building a zone allocates, and a rule can be malformed, so each returns
    // `TimeZone ! TimeZoneError`. A zone owns that memory, so it is never copied by accident:
    //
    //     let copy = kyiv;
    //         error: move-only value 'kyiv' requires an explicit '<-' in initialization
    //
    // The functions below borrow it instead, as `&TimeZone`.
    let utc = TimeZone::Utc(allocator) catch { else => return 1 };
    let kyiv = TimeZone::FromRule(allocator, "Kyiv", "EET-2EEST,M3.5.0/3,M10.5.0/4") catch {
        else => return 1
    };
    let newYork = TimeZone::FromRule(allocator, "New York", "EST5EDT,M3.2.0,M11.1.0") catch {
        else => return 1
    };

    // One moment in winter and one in summer, each read on three clocks. The offsets change with
    // the season, and by a different amount on different dates in each place.
    let winter = ParseRfc3339("2026-01-15T12:00:00Z") catch { else => return 1 };
    let summer = ParseRfc3339("2026-07-01T12:00:00Z") catch { else => return 1 };
    PrintLine("{}", winter);
    ShowMoment(utc, winter.ToTimestamp());
    ShowMoment(kyiv, winter.ToTimestamp());
    ShowMoment(newYork, winter.ToTimestamp());
    PrintLine("{}", summer);
    ShowMoment(utc, summer.ToTimestamp());
    ShowMoment(kyiv, summer.ToTimestamp());
    ShowMoment(newYork, summer.ToTimestamp());

    // From local time back to a moment. On 29 March 2026 Kyiv's clocks jump from 03:00 to 04:00,
    // so 03:30 never happens; on 25 October they go back from 04:00 to 03:00, so 03:30 happens
    // twice, an hour apart. That is why `Resolve` returns a `LocalTime`, with the kind and both
    // candidate moments, and not one `Timestamp`:
    //
    //     let moment: Timestamp = kyiv.Resolve(local);
    //         error: cannot assign 'LocalTime' to 'Timestamp'
    PrintLine("Kyiv local times:");
    ShowLocal(kyiv, "2026-07-01T15:00:00");
    ShowLocal(kyiv, "2026-03-29T03:30:00");
    ShowLocal(kyiv, "2026-10-25T03:30:00");

    // A rule must name its standard time with at least three letters, then give its offset.
    match TimeZone::FromRule(allocator, "Nowhere", "X5") {
        .Success(_) => PrintLine("rule X5 accepted"),
        .Failure(error) => PrintLine("rule X5 refused: {}", Reason(error))
    }
    return 0;
}

Besides Io, its Rux.toml lists Allocator, Time and TimeZone under [Dependencies].

Run it

cd Examples/Utilities/TimeZone
rux run
2026-01-15T12:00:00Z
  UTC      UTC  daylight false  2026-01-15T12:00:00Z
  Kyiv     EET  daylight false  2026-01-15T14:00:00+02:00
  New York EST  daylight false  2026-01-15T07:00:00-05:00
2026-07-01T12:00:00Z
  UTC      UTC  daylight false  2026-07-01T12:00:00Z
  Kyiv     EEST daylight true   2026-07-01T15:00:00+03:00
  New York EDT  daylight true   2026-07-01T08:00:00-04:00
Kyiv local times:
  2026-07-01T15:00:00 happens once:  2026-07-01T15:00:00+03:00
  2026-03-29T03:30:00 never happens: taken as 2026-03-29T04:30:00+03:00
  2026-10-25T03:30:00 happens twice: 2026-10-25T03:30:00+03:00 then 2026-10-25T03:30:00+02:00
rule X5 refused: not a POSIX TZ rule

Common mistakes

Try it yourself

  1. Resolve 2026-03-08T02:30:00 and 2026-11-01T01:30:00 in New York. Which kind is each?
  2. Add Sydney, AEST-10AEDT,M10.1.0,M4.1.0/3, and read both moments on its clock. Which of them is in daylight time, and why?
  3. For the skipped time, also print found.earlier on Kyiv's clock. What does it read?
  4. Print how many seconds apart the two moments of the repeated 03:30 are, with UnixSeconds().

Learn more

  • TimeZone API — every function, including Load, Local and FromTzif
  • Date and time — DateTime, UtcOffset, OffsetDateTime and RFC 3339
  • Enum — matching the cases of LocalTimeKind
  • Move — why a zone is passed by reference
  • Age — a checkpoint project that works out an age from two calendar dates
  • Enums and Copy and move in the Rux Reference