Skip to content

Time and languages

Dates and times are a common source of bugs in business apps: a birthday that moves a day when a user travels, a shift that starts an hour late after a clock change. Zeyn gives each kind of value its own type, so the compiler keeps them apart.

Four types

TypeIsExample
DateA calendar day, no zoneA birthday, an invoice date
TimeA wall clock time, no zoneOpening hours
DateTimeAn exact moment, stored in UTCWhen a record was created
LocalDateTimeA wall clock date and time at a placeAn appointment at 14:00 in Istanbul

Never store a calendar day as a moment. A Date is the same day in every zone; a DateTime is the same instant everywhere and shows a different wall clock time in each zone.

let day = Date::new(2026, 3, 29).unwrap_or_default();
let at = Time::new(9, 30, 0).unwrap_or_default();
let slot = LocalDateTime::new(day, at);
let zone = TimeZone::named("Europe/Istanbul").unwrap_or(TimeZone::UTC);
let moment = DateTime::from_local(slot, zone)?;
let back = moment.local(zone);

DateTime::from_local fails for a wall clock time that does not exist in the zone, such as during a spring clock change, instead of guessing.

The clock

Use ctx.now() and ctx.today(), never the system clock. ctx.today() is today in the user's zone. Tests fix the clock for every request with App::fixed_now(moment).

Time zones

App::new(t!("app.name"))
    .time_policy(TimePolicy::User { default: TimeZone::named("Europe/Istanbul").unwrap_or(TimeZone::UTC) })
    .time_zone_picker()
PolicyMoments are shown in
TimePolicy::User { default }The zone the user's browser reports, or default until it does
TimePolicy::Organization(zone)One zone for everyone

.time_zone_picker() adds a picker to the menu so users can choose a zone by hand. A single sign on proxy can also send the zone in a header (ForwardAuth::time_zone). Grid filters on DateTime columns are read in the user's zone.

Pickers

Date, time and date time fields get keyboard friendly pickers. DateOptions limits what can be picked:

Form::of(visit)
    .row(|r| r.field(Visit::day).field(Visit::arrives))
    .range(Visit::stay_from, Visit::stay_to)
    .date_options(Visit::day, DateOptions::new().no_weekends().week_numbers())
    .date_options(Visit::arrives, DateOptions::new().step_minutes(15))

Format: 12/31/2024. You can also type: today, +3d.

Format: 2:30 PM. You can also type: now, 930.

Format: 12/31/2024 2:30 PM. You can also type: today, +3d.

Format: 12/31/2024. You can also type: today, +3d.

Choosing the language

The server picks the language from the zeyn_locale cookie, then the browser's Accept-Language, then the app default. <html lang> always matches. The language picker in the menu sets the cookie and swaps the page in place, without a reload.

Numbers, money and dates

Values are formatted for the user's language on the server: 1.234,56 in Turkish and 1,234.56 in English, dates as 29.03.2026 or 3/29/2026, and money with its currency symbol. Inputs read the same formats back.

Money keeps integer minor units and a currency, so sums never lose a cent:

let price = Money::tl(2_500_000);
let text = price.format("tr");