time source

← time · API reference · version 0.4.1

4 file(s), 1085 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.

Public items

A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.

ItemDefinedWorked exampleCall sites
from_ymd fn108TIM-001158 428 433 37 95 189
from_millis fn113—none in this package
from_unix_seconds fn117—100 101 105
to_unix_seconds fn120—102 105
parse fn151TIM-002557 10 21 25 30 32 … (42 total)
year fn176—453 11 20 142 208 209 … (8 total)
month fn182—376 455 532 12 21 40 … (9 total)
day fn188—457 13 22 74 109 144 … (8 total)
hour fn196—334 341 459 22 23 147 … (7 total)
minute fn198—335 342 461 23 24
second fn200—343 463 24 25
weekday fn203—244 277 363 465 531 15 … (9 total)
add_days fn211TIM-00148 51 46 48 56
add_weeks fn214—46 47
add_seconds fn216—none in this package
days_between fn225TIM-00358 127 128 133 134
seconds_between fn232TIM-00359 61 140 141 142 143 … (7 total)
start_of_day fn240—244 249 313 66 67 177 … (7 total)
start_of_week fn243—69 70 73 74 205
at_time fn248—268 433 115 116
combine fn257TIM-002110 111 112 113 114 117 … (10 total)
iso_year fn287TIM-005467 80 84 116 209 223
iso_week fn293TIM-005469 81 85 117 118 210 … (11 total)
to_local fn304TIM-004313 91 164 165 166 167
local_day fn312TIM-004318 93 94 170 171 189
today fn317—none in this package
format_date fn324—349 356 527 38 47 49 … (20 total)
format_time fn333—350 528
format_seconds fn340—357 529 113
format_datetime fn348—533 41 92 111 116 42 … (13 total)
format_iso fn355—530 40 67 182
weekday_name fn362—471 531 16 29
month_name fn375—473 532 17 30
date fn427—527 30 114 15 27 35 … (13 total)
datetime fn431—11 13 19 34 44 45 … (21 total)
milliseconds fn438—none in this package
seconds fn440—529 37
minutes fn442—58 61 63 65 66 68 … (13 total)
hours fn444—66 68 72 73 76 81 … (14 total)
days fn447—211 74 108 43 55 56 … (9 total)
weeks fn449—214 75 204
year fn453—176 11 20 142 208 209 … (8 total)
month fn455—182 376 532 12 21 40 … (9 total)
day fn457—188 13 22 74 109 144 … (8 total)
hour fn459—196 334 341 22 23 147 … (7 total)
minute fn461—198 335 342 23 24
second fn463—200 343 24 25
weekday fn465—203 244 277 363 531 15 … (9 total)
iso_year fn467—287 80 84 116 209 223
iso_week fn469—293 81 85 117 118 210 … (11 total)
weekday_name fn471—362 531 16 29
month_name fn473—375 532 17 30
to_millis fn476—119 149
total_millis fn480—128 62
total_seconds fn482—60
total_minutes fn484—59 66 73 77 79
total_hours fn486—74
total_days fn488—75 61

src/time.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// time — basic date/time operations for loft.
//
// A point in time is a plain `integer` (i64) holding milliseconds since
// the Unix epoch (1970-01-01T00:00:00 UTC) — the same unit `now()`
// returns and the same model as JavaScript's `Date`.  All calendar math
// uses the proleptic Gregorian calendar (Howard Hinnant's
// days_from_civil / civil_from_days), so results match `js_sys::Date`
// field-for-field; the library is pure loft and runs identically on the
// interpreter, `--native`, and wasm.
//
// Timezone model: formatting and field extraction are in UTC.  For
// local-day bucketing pass a fixed UTC offset in minutes
// (`local_day` / `to_local` / `today`).  There is NO timezone database
// and NO daylight-saving handling — a fixed offset only.
//
// Public API:
//   Construct/parse: from_ymd, from_millis, parse, today
//   Fields:          year, month, day, hour, minute, second,
//                    weekday (0=Mon..6=Sun), iso_year, iso_week
//   Step:            add_days, add_weeks, add_seconds
//   Difference:      days_between, seconds_between
//   Boundaries:      start_of_day, start_of_week (Monday)
//   Local:           to_local, local_day, today
//   Format:          format_date, format_time, format_seconds,
//                    format_datetime, format_iso, weekday_name,
//                    month_name
//   Parse failure returns null (i64::MIN) — test with `!result`.
//
// ── Using it ─────────────────────────────────────────────────────────────────
//
// There are TWO layers over the same milliseconds, and most code wants the typed one:
//
//   d    = date(2026, 9, 2);        // DateTime
//   e    = d + days(10);            // DateTime — arithmetic reads as arithmetic
//   span = e - d;                   // Duration
//
//   span.total_days()               // 10
//   e > d                           // true — the comparisons are defined
//   e.weekday_name()                // "Sat"
//
// `DateTime` and `Duration` carry the operators (`+ - < <= > >= == !=`), so a span is a
// value you can add, scale and compare rather than an integer you must remember the unit
// of.  Build durations with `milliseconds` / `seconds` / `minutes` / `hours` / `days` /
// `weeks`, and read them back with the matching `total_*`.
//
// The PLAIN-INTEGER layer is the same calendar over a bare `integer` of milliseconds since
// the epoch — what `now()` returns and what you store or send:
//
//   t = now();  y = year(t);  s = format_iso(t);
//
// Reach for it at the edges (storage, wire, interop) and for the typed layer in between;
// `to_millis` and `from_millis` cross over.

const MS_PER_DAY    = 86400000;
const MS_PER_HOUR   = 3600000;
const MS_PER_MINUTE = 60000;
const MS_PER_SECOND = 1000;

// ── floor division / modulo (correct for pre-1970 negatives) ──
// loft `/` and `%` truncate toward zero; the calendar math needs floor.

fn floor_div(fa: integer, fb: integer) -> integer {
    q = fa / fb ?? 0;
    r = fa % fb ?? 0;
    if (r != 0) && ((fa < 0) != (fb < 0)) { q - 1 } else { q }
}

// Days since the Unix epoch for this instant (floored to the UTC day).
fn epoch_day(ed_t: integer) -> integer { floor_div(ed_t, MS_PER_DAY) }

// ── civil calendar (Hinnant) ──────────────────────────────────

// (year, month, day) for a day number `z` counted from 1970-01-01.
fn civil_from_days(z: integer) -> (integer, integer, integer) {
    zz  = z + 719468;
    eshift = if zz >= 0 { zz } else { zz - 146096 };
    era = floor_div(eshift, 146097);
    doe = zz - era * 146097;                                  // [0, 146096]
    yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;  // [0, 399]
    y   = yoe + era * 400;
    doy = doe - (365 * yoe + yoe / 4 - yoe / 100);            // [0, 365]
    mp  = (5 * doy + 2) / 153;                                // [0, 11]
    d   = doy - (153 * mp + 2) / 5 + 1;                       // [1, 31]
    m   = if mp < 10 { mp + 3 } else { mp - 9 };              // [1, 12]
    yr  = if m <= 2 { y + 1 } else { y };
    (yr, m, d)
}

// Day number counted from 1970-01-01 for a (year, month, day).
fn days_from_civil(cy: integer, cm: integer, cd: integer) -> integer {
    yy  = if cm <= 2 { cy - 1 } else { cy };
    yshift = if yy >= 0 { yy } else { yy - 399 };
    era = floor_div(yshift, 400);
    yoe = yy - era * 400;                                     // [0, 399]
    mpart = if cm > 2 { cm - 3 } else { cm + 9 };
    doy = (153 * mpart + 2) / 5 + cd - 1;                     // [0, 365]
    doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;              // [0, 146096]
    era * 146097 + doe - 719468
}

// ── construction / parsing ────────────────────────────────────

// Epoch-ms at UTC midnight of the given calendar date.
// Example: @TIM-001
pub fn from_ymd(fy: integer, fmo: integer, fd: integer) -> integer {
    days_from_civil(fy, fmo, fd) * MS_PER_DAY
}

// Identity wrapper — for readability where a raw ms count becomes a time.
pub fn from_millis(ms: integer) -> integer { ms }

// Epoch-SECONDS boundary.  Garmin / Strava REST APIs speak Unix seconds
// (`.timestamp()`), while loft times are milliseconds — convert at the edge.
pub fn from_unix_seconds(secs: integer) -> integer { secs * MS_PER_SECOND }
// Whole seconds since the epoch — what a system call or a wire format usually wants,
// where this library works in milliseconds.
pub fn to_unix_seconds(uts: integer) -> integer { floor_div(uts, MS_PER_SECOND) }

fn digit_value(dc: character) -> integer {
    if dc >= '0' && dc <= '9' { (dc as integer) - ('0' as integer) } else { -1 }
}

// Parse `count` decimal digits beginning at character index `start`.
// Returns -1 if any character in the span is not a digit.
fn digits_at(ds: text, start: integer, count: integer) -> integer {
    val  = 0;
    seen = 0;
    for dch in ds {
        if dch#index >= start && dch#index < start + count {
            dv = digit_value(dch);
            if dv < 0 { return -1; }
            val = val * 10 + dv;
            seen += 1;
        }
    }
    if seen == count { val } else { -1 }
}

// Parse an ISO date "YYYY-MM-DD", optionally followed by " HH:MM[:SS]"
// or "THH:MM[:SS]" with an optional trailing "Z".
//
// Null (test with `!result`) means the text is not SHAPED like a date —
// too short, or a non-digit where a digit belongs.  It never means the
// date is not real: out-of-range fields ROLL OVER, so "2026-13-45"
// answers 2027-02-14 and "2026-02-30" answers 2026-03-02, both non-null.
// To reject an impossible date, round-trip it through `format_date`.
// Example: @TIM-002
pub fn parse(ps: text) -> integer? {
    n = size(ps);
    if n < 10 { return null; }
    py  = digits_at(ps, 0, 4);
    pmo = digits_at(ps, 5, 2);
    pd  = digits_at(ps, 8, 2);
    if py < 0 || pmo < 0 || pd < 0 { return null; }
    pt = from_ymd(py, pmo, pd);
    if n >= 16 {
        ph = digits_at(ps, 11, 2);
        pmi = digits_at(ps, 14, 2);
        if ph >= 0 && pmi >= 0 {
            pt += ph * MS_PER_HOUR + pmi * MS_PER_MINUTE;
            if n >= 19 {
                psec = digits_at(ps, 17, 2);
                if psec >= 0 { pt += psec * MS_PER_SECOND; }
            }
        }
    }
    pt
}

// ── field accessors ───────────────────────────────────────────

// The calendar year, UTC.  Proleptic Gregorian, so it is defined before 1970 too.
pub fn year(yt: integer) -> integer {
    yc = civil_from_days(epoch_day(yt));
    yc.0
}

// The month, 1..12 — one-based, as a calendar reads.
pub fn month(mt: integer) -> integer {
    mc = civil_from_days(epoch_day(mt));
    mc.1
}

// The day of the month, 1..31.
pub fn day(dt: integer) -> integer {
    dc = civil_from_days(epoch_day(dt));
    dc.2
}

fn ms_of_day(mt2: integer) -> integer { floor_mod(mt2, MS_PER_DAY) ?? 0 }

// The hour of the day, 0..23, in UTC.
pub fn hour(ht: integer)   -> integer { ms_of_day(ht) / MS_PER_HOUR }
// The minute of the hour, 0..59.
pub fn minute(mit: integer) -> integer { (ms_of_day(mit) / MS_PER_MINUTE) % 60 }
// The second of the minute, 0..59.
pub fn second(st: integer)  -> integer { (ms_of_day(st) / MS_PER_SECOND) % 60 }

// 0 = Monday .. 6 = Sunday.  1970-01-01 was a Thursday (weekday 3).
pub fn weekday(wt: integer) -> integer { floor_mod(epoch_day(wt) + 3, 7) ?? 0 }

// ── stepping ──────────────────────────────────────────────────

// Step by whole days / weeks / seconds.  These exist because the raw
// value is MILLISECONDS: `t + 3` is three thousandths of a second, and
// reads at the call site like three of whatever the reader had in mind.
// Example: @TIM-001
pub fn add_days(adt: integer, days: integer)     -> integer { adt + days * MS_PER_DAY }
// Move by whole weeks.  Calendar-safe like `add_days`: no hour drifts, because there is
// no daylight saving in this model.
pub fn add_weeks(awt: integer, weeks: integer)   -> integer { awt + weeks * 7 * MS_PER_DAY }
// Move by whole seconds.
pub fn add_seconds(ast: integer, secs: integer)  -> integer { ast + secs * MS_PER_SECOND }

// ── differences ───────────────────────────────────────────────

// Calendar days from `a` to `b` (b later → positive) — a count of
// UTC-MIDNIGHTS CROSSED, not of elapsed time, so a 26-hour span can
// answer 2 where a 46-hour span answers 1.  This is the streak /
// "day 3 of" question; for elapsed time use `seconds_between`.
// Example: @TIM-003
pub fn days_between(da: integer, db: integer) -> integer {
    epoch_day(db) - epoch_day(da)
}

// Elapsed whole seconds from `a` to `b`, TRUNCATED TOWARD ZERO — a
// backwards span of 1.5 s answers -1, not -2.
// Example: @TIM-003
pub fn seconds_between(sa: integer, sb: integer) -> integer {
    (sb - sa) / MS_PER_SECOND
}

// ── boundaries ────────────────────────────────────────────────

// Midnight UTC at the start of this day — the anchor for bucketing events by day.
// For a local day, offset first with `to_local`.
pub fn start_of_day(sdt: integer) -> integer { epoch_day(sdt) * MS_PER_DAY }

// Monday 00:00 UTC of the week containing `t`.
pub fn start_of_week(swt: integer) -> integer {
    start_of_day(swt) - weekday(swt) * MS_PER_DAY
}

// Combine a date with a time-of-day given as components (datetime.combine).
pub fn at_time(atd: integer, hh: integer, mm: integer, ss: integer) -> integer {
    start_of_day(atd) + hh * MS_PER_HOUR + mm * MS_PER_MINUTE + ss * MS_PER_SECOND
}

// Combine a date with an "HH:MM" or "HH:MM:SS" time string.  Mirrors
// `datetime.combine(date, time.fromisoformat(s))`.  Null on a
// mis-SHAPED time only — as with `parse`, out-of-range fields roll over
// ("99:99" lands four days later), so this never rejects a real clock.
// Example: @TIM-002
pub fn combine(cmd: integer, time_text: text) -> integer? {
    cn = size(time_text);
    if cn < 5 { return null; }
    chh = digits_at(time_text, 0, 2);
    cmm = digits_at(time_text, 3, 2);
    if chh < 0 || cmm < 0 { return null; }
    css = 0;
    if cn >= 8 {
        css = digits_at(time_text, 6, 2);
        if css < 0 { css = 0; }
    }
    at_time(cmd, chh, cmm, css)
}

// ── ISO week numbering ────────────────────────────────────────

// (iso_year, iso_week) — weeks start Monday; week 1 contains the year's
// first Thursday.
fn iso_calendar(it: integer) -> (integer, integer) {
    iday = epoch_day(it);
    iwd  = weekday(it);                       // 0=Mon..6=Sun
    thursday = iday - iwd + 3;                // Thursday of this week
    tc = civil_from_days(thursday);
    iy = tc.0;
    jan1 = days_from_civil(iy, 1, 1);
    week = (thursday - jan1) / 7 + 1;
    (iy, week)
}

// Example: @TIM-005
pub fn iso_year(iyt: integer) -> integer {
    iyc = iso_calendar(iyt);
    iyc.0
}

// Example: @TIM-005
pub fn iso_week(iwt: integer) -> integer {
    iwc = iso_calendar(iwt);
    iwc.1
}

// ── local time (fixed offset, no DST) ─────────────────────────

// Shift so that the field accessors / formatters yield local values.
// The answer is an INSTANT moved by the offset, so it must never be
// compared with, or subtracted from, an unshifted time.
// Example: @TIM-004
pub fn to_local(lt: integer, offset_min: integer) -> integer {
    lt + offset_min * MS_PER_MINUTE
}

// UTC-midnight key of the local calendar day containing `t` — a value
// to GROUP by, not the instant local midnight happened (that is
// `local_day(t, off) - off * 60000`).
// Example: @TIM-004
pub fn local_day(ldt: integer, offset_min: integer) -> integer {
    start_of_day(to_local(ldt, offset_min))
}

// The local calendar day for "now" at the given offset.
pub fn today(offset_min: integer) -> integer {
    local_day(now(), offset_min)
}

// ── formatting ────────────────────────────────────────────────

// The date as YYYY-MM-DD.
pub fn format_date(fdt: integer) -> text {
    fc = civil_from_days(epoch_day(fdt));
    fyy = fc.0;
    fmm = fc.1;
    fdd = fc.2;
    "{fyy:04}-{fmm:02}-{fdd:02}"
}

// The time as HH:MM.
pub fn format_time(ftt: integer) -> text {
    fth = hour(ftt);
    ftm = minute(ftt);
    "{fth:02}:{ftm:02}"
}

// The time as HH:MM:SS, when the seconds matter.
pub fn format_seconds(fst: integer) -> text {
    fsh = hour(fst);
    fsm = minute(fst);
    fss = second(fst);
    "{fsh:02}:{fsm:02}:{fss:02}"
}

// Date and time together, for a log line or a label.
pub fn format_datetime(fdtt: integer) -> text {
    fdd2 = format_date(fdtt);
    ftt2 = format_time(fdtt);
    "{fdd2} {ftt2}"
}

// Full ISO-8601 in UTC — the spelling to store or send, and the one `parse` reads back.
pub fn format_iso(fit: integer) -> text {
    fid = format_date(fit);
    fis = format_seconds(fit);
    "{fid}T{fis}Z"
}

// The weekday's short English name ("Mon" … "Sun"), for display only.
pub fn weekday_name(wnt: integer) -> text {
    match weekday(wnt) {
        0 => "Mon",
        1 => "Tue",
        2 => "Wed",
        3 => "Thu",
        4 => "Fri",
        5 => "Sat",
        _ => "Sun",
    }
}

// The month's short English name ("Jan" … "Dec"), for display only.
pub fn month_name(mnt: integer) -> text {
    match month(mnt) {
        1  => "Jan",
        2  => "Feb",
        3  => "Mar",
        4  => "Apr",
        5  => "May",
        6  => "Jun",
        7  => "Jul",
        8  => "Aug",
        9  => "Sep",
        10 => "Oct",
        11 => "Nov",
        _  => "Dec",
    }
}

// ══════════════════════════════════════════════════════════════
//  DateTime / Duration — first-grade value types (0.2.0)
// ══════════════════════════════════════════════════════════════
//
// `DateTime` and `Duration` are zero-cost `value struct`s wrapping the same
// epoch-millisecond `integer` the functions above use — so they add NO heap
// allocation as a record field or vector element (a `vector<DateTime>` costs
// the same as `vector<integer>`), yet they are a DISTINCT type: `dt + 5` is a
// compile error, only `dt + hours(2)` steps a time.  They layer on top of the
// integer API above (which stays exactly as it was — the 0.1.0 surface still
// works); pick whichever fits the call site.
//
//   Construct:  date(y,mo,d) · datetime(y,mo,d,h,mi,s) · ms as DateTime ·
//               "2026-07-08T12:00:00Z" as DateTime · now() as DateTime
//   Read:       dt.year() … dt.second(), dt.weekday(), dt.weekday_name(), …
//   Compare:    dt1 < dt2 · dt1 == dt2  (all six)
//   Span:       dt2 - dt1 -> Duration ;  dt + hours(2) -> DateTime
//   Format:     "{dt}" (YYYY-MM-DD HH:MM) · "{dt:date}" · "{dt:iso}" · "{dt:time}"
//
// FALLIBILITY: a `value struct` has no null, so a fallible parse cannot return
// a `DateTime` AND signal failure.  Keep failure at the integer level — `parse`
// above returns null on bad input — then wrap the checked value:
//     ms = parse(s); if !ms { …handle… } else { dt = ms as DateTime }
// The `"…" as DateTime` conversion is a TOTAL best-effort parse (bad input →
// epoch) for the ergonomic path where an invalid date need not be caught.

// A point in time: milliseconds since the epoch, UTC.  `date` and `datetime` make one, and
// `"2026-07-08" as DateTime` parses one; subtracting two gives a `Duration`.
pub value struct DateTime { ms: integer }
// A span of time in milliseconds, negative when it runs backwards.  `milliseconds`,
// `seconds`, `minutes`, `hours`, `days` and `weeks` make one.
pub value struct Duration { ms: integer }

// ── DateTime construction ──
// A DateTime at midnight UTC on this calendar date — the usual way into the typed layer.
pub fn date(dcy: integer, dcmo: integer, dcd: integer) -> DateTime {
    DateTime { ms: from_ymd(dcy, dcmo, dcd) }
}
// A DateTime at an exact calendar date and time of day, UTC.
pub fn datetime(tcy: integer, tcmo: integer, tcd: integer,
                tch: integer, tcmi: integer, tcs: integer) -> DateTime {
    DateTime { ms: at_time(from_ymd(tcy, tcmo, tcd), tch, tcmi, tcs) }
}

// ── Duration construction ──
// A Duration of this many milliseconds.
pub fn milliseconds(nms: integer) -> Duration { Duration { ms: nms } }
// A Duration of this many seconds.
pub fn seconds(nsec: integer)     -> Duration { Duration { ms: nsec * MS_PER_SECOND } }
// A Duration of this many minutes.
pub fn minutes(nmin: integer)     -> Duration { Duration { ms: nmin * MS_PER_MINUTE } }
// A Duration of this many hours.
pub fn hours(nhr: integer)        -> Duration { Duration { ms: nhr * MS_PER_HOUR } }
// A Duration of this many days.  Add it to a DateTime rather than doing the arithmetic
// yourself, so the unit is in the code instead of in your head.
pub fn days(nday: integer)        -> Duration { Duration { ms: nday * MS_PER_DAY } }
// A Duration of this many weeks.
pub fn weeks(nwk: integer)        -> Duration { Duration { ms: nwk * 7 * MS_PER_DAY } }

// ── DateTime field accessors (methods; delegate to the integer API above) ──
// This value's year.
pub fn year(self: DateTime)         -> integer { year(self.ms) }
// This value's month.
pub fn month(self: DateTime)        -> integer { month(self.ms) }
// This value's day.
pub fn day(self: DateTime)          -> integer { day(self.ms) }
// This DateTime's hour, the method form of the free function above.
pub fn hour(self: DateTime)         -> integer { hour(self.ms) }
// This DateTime's minute, the method form of the free function above.
pub fn minute(self: DateTime)       -> integer { minute(self.ms) }
// This DateTime's second, the method form of the free function above.
pub fn second(self: DateTime)       -> integer { second(self.ms) }
// This value's weekday.
pub fn weekday(self: DateTime)      -> integer { weekday(self.ms) }
// This value's iso year.
pub fn iso_year(self: DateTime)     -> integer { iso_year(self.ms) }
// This value's iso week.
pub fn iso_week(self: DateTime)     -> integer { iso_week(self.ms) }
// This DateTime's weekday name, the method form of the free function above.
pub fn weekday_name(self: DateTime) -> text    { weekday_name(self.ms) }
// This DateTime's month name, the method form of the free function above.
pub fn month_name(self: DateTime)   -> text    { month_name(self.ms) }
// This DateTime as milliseconds since the epoch — the way OUT of the typed layer, for
// storage, a wire format, or the plain-integer routines above.
pub fn to_millis(self: DateTime)    -> integer { self.ms }

// ── Duration accessors ──
// The span in whole milliseconds — its exact value.
pub fn total_millis(self: Duration)  -> integer { self.ms }
// The span in whole seconds, truncated toward zero.
pub fn total_seconds(self: Duration) -> integer { self.ms / MS_PER_SECOND }
// The span in whole minutes, truncated toward zero.
pub fn total_minutes(self: Duration) -> integer { self.ms / MS_PER_MINUTE }
// The span in whole hours, truncated toward zero.
pub fn total_hours(self: Duration)   -> integer { self.ms / MS_PER_HOUR }
// The span in whole days, truncated toward zero.
pub fn total_days(self: Duration)    -> integer { self.ms / MS_PER_DAY }

// ── DateTime operators (comparison + typed arithmetic) ──
// `==` and `!=` compare the one field, so two DateTimes are equal at the same millisecond.
// Orders instants, so dates compare with `<`, `<=`, `>`, `>=` like numbers.
pub operator compare(self: DateTime, other: DateTime) -> Ordering { self.ms.compare(other.ms) }
// DateTime - DateTime is the Duration BETWEEN them, which is why a span never has to be
// spelled as a bare integer.
pub operator minus(self: DateTime, other: DateTime) -> Duration { Duration { ms: self.ms - other.ms } }
// DateTime - Duration moves backward by the span; `dt.minus(span)` is the same call.
pub operator minus(self: DateTime, span: Duration) -> DateTime { DateTime { ms: self.ms - span.ms } }
// DateTime + Duration moves forward by the span.
pub operator plus(self: DateTime, span: Duration) -> DateTime { DateTime { ms: self.ms + span.ms } }
// The same span, backwards: `-span`, or `span.negate()`.
pub operator negate(self: Duration) -> Duration { Duration { ms: 0 - self.ms } }

// ── Duration operators ──
// `==` and `!=` compare the length.
// Orders spans by length.
pub operator compare(self: Duration, other: Duration) -> Ordering { self.ms.compare(other.ms) }
// Two spans add.
pub operator plus(self: Duration, other: Duration) -> Duration { Duration { ms: self.ms + other.ms } }
// One span less another.
pub operator minus(self: Duration, other: Duration) -> Duration { Duration { ms: self.ms - other.ms } }
// A span scaled by a whole number — `minutes(5) * 3` is fifteen minutes.
pub operator times(self: Duration, factor: integer) -> Duration { Duration { ms: self.ms * factor } }

// ── custom formatting (`operator to_text`: "{dt}" and "{dt:spec}") ──
// "" / "datetime" -> YYYY-MM-DD HH:MM · "date" -> YYYY-MM-DD · "time" -> HH:MM
// "seconds" -> HH:MM:SS · "iso" -> …THH:MM:SSZ · "wday" -> Mon · "month" -> May
pub operator to_text(self: DateTime, spec: text) -> text {
    // The if-chain is bound to `r` and returned, NOT left as the tail expression:
    // a tail `if` inside a format-hook `to_text(self, spec)` mis-selects its branch
    // on both backends (a loft format-hook bug — repro filed upstream). Binding the
    // result to a local sidesteps it.
    // Each branch is wrapped in "{…}" so every arm is an owned text: on native a
    // bare `text`-returning call whose body is a match of string literals (weekday_name /
    // month_name → &str) does NOT unify with a String-returning arm (format_* interpolate),
    // giving rustc E0308. Interpolating each arm normalises them (a second loft bug, filed).
    r = if spec == "date" { "{format_date(self.ms)}" }
        else if spec == "time" { "{format_time(self.ms)}" }
        else if spec == "seconds" || spec == "secs" { "{format_seconds(self.ms)}" }
        else if spec == "iso" { "{format_iso(self.ms)}" }
        else if spec == "wday" || spec == "weekday" { "{weekday_name(self.ms)}" }
        else if spec == "month" { "{month_name(self.ms)}" }
        else { "{format_datetime(self.ms)}" };
    r
}
// A Duration renders as [-]H:MM:SS (hours may exceed 24).
pub operator to_text(self: Duration, _spec: text) -> text {
    neg = self.ms < 0;
    tot = if neg { 0 - self.ms } else { self.ms };
    ss = (tot / MS_PER_SECOND) % 60;
    mm = (tot / MS_PER_MINUTE) % 60;
    hh = tot / MS_PER_HOUR;
    sign = if neg { "-" } else { "" };
    "{sign}{hh}:{mm:02}:{ss:02}"
}

// ── conversions (`as`) ──
// `dt as integer`: the point in time as milliseconds since the epoch, UTC.
pub operator to_integer(self: DateTime) -> integer { self.ms }
// `ms as DateTime`: milliseconds since the epoch, UTC, as a point in time — the way back from
// `as integer`, and the checked path from `parse` (see FALLIBILITY above).
pub operator to_date_time(self: integer) -> DateTime { DateTime { ms: self } }
// text -> DateTime: a TOTAL best-effort parse (bad input -> epoch); see the
// FALLIBILITY note above for the checked path via `parse`.
// Example: @TIM-002
pub operator to_date_time(self: text) -> DateTime {
    DateTime { ms: parse(self) ?? 0 }
}

tests/01-basics.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Basic date/time operation tests — run on interpreter, --native, and
// wasm.  All goldens are hand-computed against the proleptic Gregorian
// calendar so they pin cross-backend parity.
use time::*;

fn test_parse_and_fields() {
    t = (time::parse("2026-05-25") ?? 0);
    assert(time::year(t) == 2026, "year {time::year(t)}");
    assert(time::month(t) == 5, "month {time::month(t)}");
    assert(time::day(t) == 25, "day {time::day(t)}");
    // 2026-05-25 is a Monday.
    assert(time::weekday(t) == 0, "weekday {time::weekday(t)}");
    assert(time::weekday_name(t) == "Mon", "wname {time::weekday_name(t)}");
    assert(time::month_name(t) == "May", "mname {time::month_name(t)}");
}

fn test_parse_datetime() {
    t = (time::parse("2026-05-25 14:30:07") ?? 0);
    assert(time::hour(t) == 14, "hour {time::hour(t)}");
    assert(time::minute(t) == 30, "minute {time::minute(t)}");
    assert(time::second(t) == 7, "second {time::second(t)}");
    iso = (time::parse("2026-05-25T14:30:07Z") ?? 0);
    assert(iso == t, "T/Z form equals space form");
}

fn test_parse_failure() {
    bad = time::parse("not-a-date");
    assert(!bad, "garbage parses to null");
    short = time::parse("2026-05");
    assert(!short, "too-short parses to null");
}

fn test_roundtrip_format() {
    t = time::from_ymd(1999, 12, 31);
    assert(time::format_date(t) == "1999-12-31", "fmt {time::format_date(t)}");
    dt = (time::parse("2000-01-01 00:00:00") ?? 0);
    assert(time::format_iso(dt) == "2000-01-01T00:00:00Z", "iso {time::format_iso(dt)}");
    assert(time::format_datetime(dt) == "2000-01-01 00:00", "dtfmt {time::format_datetime(dt)}");
}

fn test_arithmetic() {
    t = (time::parse("2026-01-01") ?? 0);
    wk = time::add_weeks(t, 1);
    assert(time::format_date(wk) == "2026-01-08", "add_weeks {time::format_date(wk)}");
    dd = time::add_days(t, 365);
    assert(time::format_date(dd) == "2027-01-01", "add 365 {time::format_date(dd)}");
    // leap-year boundary: 2024-02-28 + 1 day = 2024-02-29
    leap = time::add_days((time::parse("2024-02-28") ?? 0), 1);
    assert(time::format_date(leap) == "2024-02-29", "leap {time::format_date(leap)}");
}

fn test_differences() {
    a = (time::parse("2026-01-01") ?? 0);
    b = (time::parse("2026-12-31") ?? 0);
    assert(time::days_between(a, b) == 364, "days_between {time::days_between(a, b)}");
    s = time::seconds_between((time::parse("2026-01-01 00:00:00") ?? 0),
                              (time::parse("2026-01-01 01:00:00") ?? 0));
    assert(s == 3600, "seconds_between {s}");
}

fn test_boundaries() {
    t = (time::parse("2026-05-25 14:30:07") ?? 0);
    sod = time::start_of_day(t);
    assert(time::format_iso(sod) == "2026-05-25T00:00:00Z", "start_of_day {time::format_iso(sod)}");
    // 2026-05-25 is a Monday, so start_of_week == start_of_day.
    sow = time::start_of_week(t);
    assert(time::format_date(sow) == "2026-05-25", "start_of_week {time::format_date(sow)}");
    // Wednesday 2026-05-27 -> week start Monday 2026-05-25.
    wed = (time::parse("2026-05-27") ?? 0);
    assert(time::format_date(time::start_of_week(wed)) == "2026-05-25",
           "wed week start {time::format_date(time::start_of_week(wed))}");
}

fn test_iso_week() {
    // 2026-01-01 is a Thursday -> ISO week 1 of 2026.
    a = (time::parse("2026-01-01") ?? 0);
    assert(time::iso_year(a) == 2026, "iso_year {time::iso_year(a)}");
    assert(time::iso_week(a) == 1, "iso_week {time::iso_week(a)}");
    // 2021-01-01 is a Friday -> belongs to ISO week 53 of 2020.
    b = (time::parse("2021-01-01") ?? 0);
    assert(time::iso_year(b) == 2020, "iso_year2 {time::iso_year(b)}");
    assert(time::iso_week(b) == 53, "iso_week2 {time::iso_week(b)}");
}

fn test_local_offset() {
    // 23:30 UTC at +120 min becomes 01:30 the next local day.
    t = (time::parse("2026-05-25 23:30:00") ?? 0);
    loc = time::to_local(t, 120);
    assert(time::format_datetime(loc) == "2026-05-26 01:30", "local {time::format_datetime(loc)}");
    assert(time::format_date(time::local_day(t, 120)) == "2026-05-26",
           "local_day {time::format_date(time::local_day(t, 120))}");
}

fn test_epoch_seconds() {
    // Garmin/Strava boundary: Unix seconds <-> loft ms.
    secs = 1748000000;
    t = time::from_unix_seconds(secs);
    assert(t == 1748000000000, "from_unix_seconds {t}");
    assert(time::to_unix_seconds(t) == secs, "to_unix_seconds {time::to_unix_seconds(t)}");
    // round-trip through a parsed datetime.
    dt = (time::parse("2026-05-25 14:30:07") ?? 0);
    assert(time::from_unix_seconds(time::to_unix_seconds(dt)) == dt, "secs round-trip");
}

fn test_combine() {
    d = (time::parse("2026-05-25") ?? 0);
    e = (time::combine(d, "14:30") ?? 0);
    assert(time::format_datetime(e) == "2026-05-25 14:30", "combine HH:MM {time::format_datetime(e)}");
    e2 = (time::combine(d, "09:05:42") ?? 0);
    assert(time::format_seconds(e2) == "09:05:42", "combine HH:MM:SS {time::format_seconds(e2)}");
    assert(time::format_date(e2) == "2026-05-25", "combine keeps date");
    at = time::at_time(d, 23, 59, 0);
    assert(time::format_datetime(at) == "2026-05-25 23:59", "at_time {time::format_datetime(at)}");
    bad = time::combine(d, "xx");
    assert(!bad, "combine bad time -> null");
}

fn main() {
    test_epoch_seconds();
    test_combine();
    test_parse_and_fields();
    test_parse_datetime();
    test_parse_failure();
    test_roundtrip_format();
    test_arithmetic();
    test_differences();
    test_boundaries();
    test_iso_week();
    test_local_offset();
    println("all time tests passed");
}

tests/02-datetime.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// DateTime / Duration value-type tests (0.2.0).  Goldens hand-computed
// against the proleptic Gregorian calendar; every case must agree on the
// interpreter and --native (and wasm).
use time::*;

fn test_construct_and_epoch() {
    // absolute anchors — catch a systematic offset the round-trips would hide
    e0 = time::datetime(1970, 1, 1, 0, 0, 0);
    assert((e0 as integer) == 0, "unix epoch is ms 0");
    y2k = time::datetime(2000, 1, 1, 0, 0, 0);
    assert((y2k as integer) == 946684800000, "2000-01-01 known epoch ms");
    assert((time::date(2000, 1, 1) as integer) == 946684800000, "date() midnight matches");
}

fn test_fields() {
    d = time::datetime(2026, 7, 8, 13, 30, 7);
    assert(d.year() == 2026,  "year {d.year()}");
    assert(d.month() == 7,    "month {d.month()}");
    assert(d.day() == 8,      "day {d.day()}");
    assert(d.hour() == 13,    "hour {d.hour()}");
    assert(d.minute() == 30,  "minute {d.minute()}");
    assert(d.second() == 7,   "second {d.second()}");
    // 2026-05-25 is a Monday (weekday 0)
    mon = time::date(2026, 5, 25);
    assert(mon.weekday() == 0, "weekday {mon.weekday()}");
    assert(mon.weekday_name() == "Mon", "wname {mon.weekday_name()}");
    assert(mon.month_name() == "May", "mname {mon.month_name()}");
}

fn test_format() {
    d = time::datetime(2026, 7, 8, 13, 30, 7);
    assert("{d:date}" == "2026-07-08", "date {d:date}");
    assert("{d:time}" == "13:30", "time {d:time}");
    assert("{d:seconds}" == "13:30:07", "secs {d:seconds}");
    assert("{d:iso}" == "2026-07-08T13:30:07Z", "iso {d:iso}");
    assert("{d}" == "2026-07-08 13:30", "bare {d}");
    assert("{d:month}" == "Jul", "month spec {d:month}");
}

fn test_compare() {
    d1 = time::datetime(2026, 7, 8, 12, 0, 0);
    d2 = time::datetime(2026, 7, 8, 13, 30, 0);
    assert(d1 < d2, "op <");
    assert(d2 > d1, "op >");
    assert(d1 <= d1, "op <=");
    assert(d1 >= d1, "op >=");
    assert(d1 == d1, "op ==");
    assert(d1 != d2, "op !=");
}

fn test_arithmetic() {
    d1 = time::datetime(2026, 7, 8, 12, 0, 0);
    d2 = time::datetime(2026, 7, 8, 13, 30, 0);
    diff = d2 - d1;                     // DateTime - DateTime -> Duration
    assert(diff == time::minutes(90), "diff is 90 minutes");
    assert(diff.total_minutes() == 90, "total_minutes {diff.total_minutes()}");
    assert(diff.total_seconds() == 5400, "total_seconds {diff.total_seconds()}");
    d3 = d1 + time::minutes(90);         // DateTime + Duration -> DateTime
    assert(d3 == d2, "add duration reaches the later time");
    back = d1.minus(time::minutes(90));  // subtract a Duration (method)
    assert(back == time::datetime(2026, 7, 8, 10, 30, 0), "minus a duration");
    assert(d1 - time::minutes(90) == back, "dt - Duration is the same call as dt.minus");
    assert((d1 - back).total_minutes() == 90 && time::minutes(90) * 2 == time::hours(3),
           "dt - dt answers a Duration; a Duration scales");
    assert(back < d1 && d1 >= back && time::minutes(1) < time::hours(1), "both types order");
}

fn test_duration() {
    assert(time::hours(1) < time::hours(2), "duration compare");
    assert((time::hours(1) + time::minutes(30)).total_minutes() == 90, "add durations");
    assert(time::days(1).total_hours() == 24, "day is 24h");
    assert(time::weeks(1).total_days() == 7, "week is 7d");
    assert((time::hours(2) * 3) == time::hours(6), "scale duration");
    assert(time::minutes(90).negate().total_minutes() == -90, "negate");
    span = time::minutes(90);
    assert((-span).total_minutes() == -90, "unary - is operator negate");
    assert("{time::minutes(90)}" == "1:30:00", "dur fmt {time::minutes(90)}");
    assert("{time::hours(25)}" == "25:00:00", "dur >24h");
    assert("{time::minutes(-90)}" == "-1:30:00", "negative dur fmt");
}

fn test_conversions() {
    d1 = time::datetime(2026, 7, 8, 12, 0, 0);
    epoch = d1 as integer;              // DateTime -> integer
    back = epoch as DateTime;           // integer  -> DateTime
    assert(back == d1, "int round-trip");
    parsed = "2026-07-08" as DateTime;  // text -> DateTime (total)
    assert(parsed == time::date(2026, 7, 8), "parse-on-cast date");
    parsed2 = "2026-07-08T13:30:00" as DateTime;
    assert(parsed2 == time::datetime(2026, 7, 8, 13, 30, 0), "parse-on-cast datetime");
}

fn test_now_and_vector() {
    // now() is a real wall-clock DateTime (via the total int->DateTime cast)
    cur = now() as DateTime;
    assert(cur > time::datetime(2020, 1, 1, 0, 0, 0), "now is after 2020");
    // value structs are zero-cost as vector elements — read one back out
    events = [time::datetime(2026, 1, 1, 0, 0, 0),
              time::datetime(2026, 6, 15, 9, 0, 0),
              time::datetime(2026, 12, 31, 23, 59, 59)];
    assert(events[1].month() == 6, "vector element field read");
    assert("{events[2]:date}" == "2026-12-31", "vector element format");
    // copy semantics: mutating a local copy leaves the source untouched
    first = events[0];
    first = first + time::days(10);
    assert(events[0].day() == 1, "value-struct copy: source element unchanged");
}

fn test_iso_and_millis() {
    d = time::datetime(2026, 5, 25, 8, 0, 0);   // Monday
    // the value-struct methods delegate to the integer API — cross-check on the
    // same instant (01-basics pins the integer fns to hand-goldens)
    assert(d.iso_year() == time::iso_year(d as integer), "iso_year delegates");
    assert(d.iso_week() == time::iso_week(d as integer), "iso_week delegates");
    assert(d.iso_week() >= 1 && d.iso_week() <= 53, "iso_week range {d.iso_week()}");
    assert(d.to_millis() == (d as integer), "to_millis == as integer");
}

fn test_duration_full() {
    assert((time::hours(2) - time::minutes(30)) == time::minutes(90), "duration subtract");
    assert(time::hours(1) <= time::hours(1), "dur <=");
    assert(time::hours(2) >= time::hours(1), "dur >=");
    assert(time::hours(2) > time::hours(1), "dur >");
    assert(time::hours(1) != time::hours(2), "dur !=");
    assert(time::minutes(90).total_millis() == 5400000, "total_millis");
}

fn test_format_aliases() {
    mon = time::date(2026, 5, 25);   // Monday
    assert("{mon:wday}" == "Mon", "wday spec {mon:wday}");
    assert("{mon:weekday}" == "Mon", "weekday alias {mon:weekday}");
    d = time::datetime(2026, 7, 8, 13, 30, 7);
    assert("{d:secs}" == "13:30:07", "secs alias {d:secs}");
}

fn test_edge_cases() {
    // pre-1970 (negative epoch ms) still fields + formats correctly
    old = time::datetime(1960, 6, 15, 12, 0, 0);
    assert(old.year() == 1960, "pre-1970 year {old.year()}");
    assert(old.month() == 6, "pre-1970 month");
    assert(old.day() == 15, "pre-1970 day");
    assert((old as integer) < 0, "pre-1970 is negative ms");
    assert("{old:date}" == "1960-06-15", "pre-1970 date fmt {old:date}");
    // garbage text -> DateTime is a TOTAL best-effort cast (epoch 0); the fallible
    // integer parse is the checked path
    assert(("garbage" as DateTime).to_millis() == 0, "total cast of garbage -> epoch");
    assert(!time::parse("garbage"), "checked parse returns null on garbage");
}

tests/03-worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// One test per non-obvious contract, and each one is that contract's
// worked EXAMPLE.
//
// ⚠⚠ **The pair is the deliverable.**  A snippet in a doc comment rots
// silently — nothing compiles it, so it goes stale at the first
// signature change and the reader finds out.  A test nobody links to is
// correct and invisible.  So every function below carries an
// `// Example: @TIM-0NN` line pointing here, and every tag here is
// reachable from the functions it demonstrates.
//
// ⚠ These are examples FIRST and assertions second: each is written the
// way a caller would actually write it, not the way that would be
// shortest to assert on.  `01-basics` and `02-datetime` already pin the
// arithmetic; what these pin is the shape of a CORRECT CALL.
//
// Why this library owes them: **a time is a bare `integer`**, so every
// instant, every duration, every bucket key and every day count has the
// same type as every other number in the program.  The compiler cannot
// tell milliseconds from days, an instant from an offset-shifted one, or
// a calendar-year number from an ISO-year one — and each of those
// confusions is a well-typed expression that answers something wrong.
use time::*;

// A deadline three days out, and the reminder that goes with it.
//
// ⚠⚠ **The unit is MILLISECONDS and nothing at the call site says so.**
// `deadline - 3` is a well-typed integer expression that every reader of
// the line reads as "three days"; it moves the reminder three
// thousandths of a second.
// @TIM-001 — a time is an integer of milliseconds, and a day has a door
fn test_a_reminder_three_days_before_a_deadline() {
    rd_deadline = time::parse("2026-05-25 09:00:00") ?? 0;

    // The day-count reflex, spelled the way it types:
    rd_naive = rd_deadline - 3;
    assert(rd_deadline - rd_naive == 3, "the reminder moved three MILLISECONDS");
    // ⚠ And it does not even look like a no-op — a minute-precision
    // format shows 08:59, so the display moves while the instant does not.
    assert(time::format_datetime(rd_naive) == "2026-05-25 08:59",
           "three days early landed on {time::format_datetime(rd_naive)}");

    // The door that knows what a day is:
    rd_remind = time::add_days(rd_deadline, -3);
    assert(time::format_datetime(rd_remind) == "2026-05-22 09:00",
           "add_days(-3) landed on {time::format_datetime(rd_remind)}");

    // ⚠ The typed door puts the unit in the TYPE.  `rd_dt - 3` does not
    // compile at all — only a Duration steps a DateTime — so the mistake
    // above is unspellable here.  Same instants, bridged by `as`.
    rd_dt = time::datetime(2026, 5, 25, 9, 0, 0);
    assert((rd_dt as integer) == rd_deadline, "the two doors are the same instant");
    assert(rd_dt.minus(time::days(3)) == time::datetime(2026, 5, 22, 9, 0, 0),
           "minus(days(3)) is the typed spelling of add_days(-3)");

    // ⚠ And a DIFFERENCE is milliseconds too, not days: the typed door
    // answers a Duration, which has to be asked for its unit.
    rd_span = rd_dt - (rd_remind as DateTime);
    assert(rd_span.total_days() == 3, "span reads {rd_span.total_days()} days");
    assert(rd_span.total_millis() == 259200000, "span in ms {rd_span.total_millis()}");
}

// Is this string the user typed a real date?
//
// ⚠⚠ **`parse` answers null when the text is not SHAPED like a date.  It
// never answers null because the date is not real.**  The documented
// `!result` check is a syntax check; out-of-range fields roll over into a
// neighbouring month or year and come back non-null.
// @TIM-002 — the null means "not shaped like a date", never "not a date"
fn test_a_date_the_user_typed() {
    // The shape check works, and this is what it catches:
    assert(!time::parse("next tuesday"), "prose parses to null");
    assert(!time::parse("2026-05"), "a truncated date parses to null");

    // ⚠ Month 13, day 45.  The null check PASSES and the answer is a real
    // instant nine months away.
    dt_typo = time::parse("2026-13-45");
    assert(dt_typo, "an impossible date is not null");
    assert(time::format_date(dt_typo ?? 0) == "2027-02-14",
           "2026-13-45 became {time::format_date(dt_typo ?? 0)}");

    // The everyday shape of it — a February 30th from a date picker:
    assert(time::format_date(time::parse("2026-02-30") ?? 0) == "2026-03-02",
           "2026-02-30 became {time::format_date(time::parse(\"2026-02-30\") ?? 0)}");

    // ⚠ The `as DateTime` cast has no failure channel at ALL: prose
    // becomes the epoch, which is a perfectly ordinary DateTime that
    // compares, formats and stores like any other.
    assert(("next tuesday" as DateTime) == time::datetime(1970, 1, 1, 0, 0, 0),
           "the total cast turns prose into the epoch");

    // `combine` reads a clock the same way — shape only:
    dt_day = time::from_ymd(2026, 5, 25);
    assert(!time::combine(dt_day, "xx"), "a mis-shaped clock is null");
    assert(time::format_datetime(time::combine(dt_day, "99:99") ?? 0) == "2026-05-29 04:39",
           "99:99 landed on {time::format_datetime(time::combine(dt_day, \"99:99\") ?? 0)}");

    // So the check that holds is: parse it, then ask the ANSWER what date
    // it is.  A date that rolled over does not spell itself back.
    assert(is_real_date("2026-05-25"), "a real date");
    assert(!is_real_date("2026-13-45"), "month 13 rejected");
    assert(!is_real_date("2026-02-30"), "February 30th rejected");
    assert(!is_real_date("next tuesday"), "prose rejected");
}

// The round-trip check the example above recommends: a "YYYY-MM-DD" that survives
// `parse` -> `format_date` unchanged is a date the calendar really has.
fn is_real_date(rd_s: text) -> boolean {
    rd_ms = time::parse(rd_s);
    if !rd_ms { return false; }
    time::format_date(rd_ms ?? 0) == rd_s
}

// How long ago was this — for a streak counter, and for a timeout.
//
// ⚠⚠ **`days_between` counts MIDNIGHTS CROSSED, not elapsed time**, so a
// longer span can answer a smaller number of days.  That is what a
// streak, a "day 3 of", or a billing day wants — and it is never a
// division of elapsed milliseconds.
// @TIM-003 — one difference counts calendar days, the other counts time
fn test_how_long_ago_was_this() {
    // 26 hours, across two midnights:
    db_a = time::parse("2026-05-25 23:00:00") ?? 0;
    db_b = time::parse("2026-05-27 01:00:00") ?? 0;
    assert(time::days_between(db_a, db_b) == 2,
           "26 hours read {time::days_between(db_a, db_b)} days");

    // 46 hours — nearly twice as long — across one:
    db_c = time::parse("2026-05-25 01:00:00") ?? 0;
    db_d = time::parse("2026-05-26 23:00:00") ?? 0;
    assert(time::days_between(db_c, db_d) == 1,
           "46 hours read {time::days_between(db_c, db_d)} days");

    // ⚠ For elapsed time, `seconds_between` — which TRUNCATES TOWARD
    // ZERO, so a sub-second remainder is dropped in whichever direction
    // it points.  A backwards span of 1.5 s is -1, not -2.
    db_t = time::parse("2026-01-01 00:00:00") ?? 0;
    assert(time::seconds_between(db_t, db_t + 1500) == 1,
           "forwards {time::seconds_between(db_t, db_t + 1500)}");
    assert(time::seconds_between(db_t + 1500, db_t) == -1,
           "backwards {time::seconds_between(db_t + 1500, db_t)}");

    // A 24-hour timeout is elapsed time, so it is the seconds door:
    assert(time::seconds_between(db_c, db_d) / 86400 == 1,
           "46 elapsed hours is one whole 24-hour span");
}

// Group a user's events into THEIR days, at a fixed UTC+2.
//
// ⚠⚠ **`to_local` moves the INSTANT and `local_day` answers a bucket
// KEY.**  Both are plain integers that format like times, so either can
// be compared against a real timestamp — and that comparison is wrong by
// the offset every time.
// @TIM-004 — a fixed offset shifts the clock, it does not carry a zone
fn test_bucketing_a_users_local_day() {
    ld_evening = time::parse("2026-05-25 23:30:00") ?? 0;   // UTC
    ld_night   = time::parse("2026-05-26 00:30:00") ?? 0;   // UTC, one hour later

    // ⚠ The shifted value is two hours in the FUTURE as an instant.  It
    // reads as the user's wall clock and must never be compared with, or
    // subtracted from, an unshifted time.
    assert(time::to_local(ld_evening, 120) - ld_evening == 2 * 3600 * 1000,
           "to_local is a shift of the instant");
    assert(time::format_datetime(time::to_local(ld_evening, 120)) == "2026-05-26 01:30",
           "the user's wall clock reads {time::format_datetime(time::to_local(ld_evening, 120))}");

    // Both events belong to the same LOCAL day, and the key says so:
    ld_key_e = time::local_day(ld_evening, 120);
    ld_key_n = time::local_day(ld_night, 120);
    assert(ld_key_e == ld_key_n, "one local evening, one bucket");
    assert(time::format_date(ld_key_e) == "2026-05-26",
           "local day {time::format_date(ld_key_e)}");

    // ...whereas bucketing on UTC midnight splits the user's evening in two:
    assert(time::start_of_day(ld_evening) != time::start_of_day(ld_night),
           "start_of_day is a UTC bucket, not the user's");

    // ⚠ The key is NOT the instant that local midnight happened — that is
    // two hours earlier in UTC.  Group with it; never subtract from it.
    assert(time::format_iso(ld_key_e) == "2026-05-26T00:00:00Z",
           "the key wears a Z it does not mean");
    assert(ld_key_e - 120 * 60000 == (time::parse("2026-05-25 22:00:00") ?? 0),
           "local midnight really happened at 22:00 UTC");

    // There is no zone database and no daylight saving here: the offset
    // is whatever the caller passes, for every date it is passed with.
    assert(time::local_day(ld_evening, 0) == time::from_ymd(2026, 5, 25),
           "offset 0 is the UTC day");
}

// The key for a weekly report.
//
// ⚠⚠ **An ISO week number is only meaningful beside `iso_year`.**  A week
// straddling New Year belongs to one ISO year and contains dates whose
// calendar `year()` is the other, so pairing `year()` with `iso_week()`
// invents a week and splits a real one down the middle.
// @TIM-005 — an ISO week number needs its ISO year, never the calendar one
fn test_a_weekly_report_key() {
    iw_fri = time::parse("2021-01-01") ?? 0;   // a Friday
    iw_mon = time::parse("2020-12-28") ?? 0;   // the Monday of that same week

    assert(time::weekday(iw_mon) == 0, "weeks start on Monday (0)");
    assert(time::start_of_week(iw_fri) == iw_mon, "same week");

    // ⚠ Same week, different calendar years:
    assert(time::year(iw_mon) == 2020 && time::year(iw_fri) == 2021, "calendar years differ");
    assert(time::iso_year(iw_mon) == 2020 && time::iso_year(iw_fri) == 2020, "ISO year is 2020 for both");
    assert(time::iso_week(iw_mon) == 53 && time::iso_week(iw_fri) == 53, "ISO week is 53 for both");

    // The correct key groups them; the calendar-year key splits them and
    // invents a 2021-W53 that the ISO year 2021 does not have (it has 52).
    assert(iso_key(iw_mon) == iso_key(iw_fri), "iso_key groups the week");
    assert(naive_key(iw_mon) != naive_key(iw_fri), "year()+iso_week() splits it");
    assert(naive_key(iw_fri) == "2021-W53", "the invented key is {naive_key(iw_fri)}");
    assert(time::iso_week(time::parse("2021-12-31") ?? 0) == 52,
           "ISO 2021 ends at week {time::iso_week(time::parse(\"2021-12-31\") ?? 0)}");
}

// The report key the example above recommends.
fn iso_key(ik_t: integer) -> text {
    ik_y = time::iso_year(ik_t);
    ik_w = time::iso_week(ik_t);
    "{ik_y}-W{ik_w:02}"
}

// The one it warns about.
fn naive_key(nk_t: integer) -> text {
    nk_y = time::year(nk_t);
    nk_w = time::iso_week(nk_t);
    "{nk_y}-W{nk_w:02}"
}

fn main() {
    test_a_reminder_three_days_before_a_deadline();
    test_a_date_the_user_typed();
    test_how_long_ago_was_this();
    test_bucketing_a_users_local_day();
    test_a_weekly_report_key();
    println("time worked examples passed");
}