← 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.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
from_ymd fn | 108 | TIM-001 | 158 428 433 37 95 189 |
from_millis fn | 113 | — | none in this package |
from_unix_seconds fn | 117 | — | 100 101 105 |
to_unix_seconds fn | 120 | — | 102 105 |
parse fn | 151 | TIM-002 | 557 10 21 25 30 32 … (42 total) |
year fn | 176 | — | 453 11 20 142 208 209 … (8 total) |
month fn | 182 | — | 376 455 532 12 21 40 … (9 total) |
day fn | 188 | — | 457 13 22 74 109 144 … (8 total) |
hour fn | 196 | — | 334 341 459 22 23 147 … (7 total) |
minute fn | 198 | — | 335 342 461 23 24 |
second fn | 200 | — | 343 463 24 25 |
weekday fn | 203 | — | 244 277 363 465 531 15 … (9 total) |
add_days fn | 211 | TIM-001 | 48 51 46 48 56 |
add_weeks fn | 214 | — | 46 47 |
add_seconds fn | 216 | — | none in this package |
days_between fn | 225 | TIM-003 | 58 127 128 133 134 |
seconds_between fn | 232 | TIM-003 | 59 61 140 141 142 143 … (7 total) |
start_of_day fn | 240 | — | 244 249 313 66 67 177 … (7 total) |
start_of_week fn | 243 | — | 69 70 73 74 205 |
at_time fn | 248 | — | 268 433 115 116 |
combine fn | 257 | TIM-002 | 110 111 112 113 114 117 … (10 total) |
iso_year fn | 287 | TIM-005 | 467 80 84 116 209 223 |
iso_week fn | 293 | TIM-005 | 469 81 85 117 118 210 … (11 total) |
to_local fn | 304 | TIM-004 | 313 91 164 165 166 167 |
local_day fn | 312 | TIM-004 | 318 93 94 170 171 189 |
today fn | 317 | — | none in this package |
format_date fn | 324 | — | 349 356 527 38 47 49 … (20 total) |
format_time fn | 333 | — | 350 528 |
format_seconds fn | 340 | — | 357 529 113 |
format_datetime fn | 348 | — | 533 41 92 111 116 42 … (13 total) |
format_iso fn | 355 | — | 530 40 67 182 |
weekday_name fn | 362 | — | 471 531 16 29 |
month_name fn | 375 | — | 473 532 17 30 |
date fn | 427 | — | 527 30 114 15 27 35 … (13 total) |
datetime fn | 431 | — | 11 13 19 34 44 45 … (21 total) |
milliseconds fn | 438 | — | none in this package |
seconds fn | 440 | — | 529 37 |
minutes fn | 442 | — | 58 61 63 65 66 68 … (13 total) |
hours fn | 444 | — | 66 68 72 73 76 81 … (14 total) |
days fn | 447 | — | 211 74 108 43 55 56 … (9 total) |
weeks fn | 449 | — | 214 75 204 |
year fn | 453 | — | 176 11 20 142 208 209 … (8 total) |
month fn | 455 | — | 182 376 532 12 21 40 … (9 total) |
day fn | 457 | — | 188 13 22 74 109 144 … (8 total) |
hour fn | 459 | — | 196 334 341 22 23 147 … (7 total) |
minute fn | 461 | — | 198 335 342 23 24 |
second fn | 463 | — | 200 343 24 25 |
weekday fn | 465 | — | 203 244 277 363 531 15 … (9 total) |
iso_year fn | 467 | — | 287 80 84 116 209 223 |
iso_week fn | 469 | — | 293 81 85 117 118 210 … (11 total) |
weekday_name fn | 471 | — | 362 531 16 29 |
month_name fn | 473 | — | 375 532 17 30 |
to_millis fn | 476 | — | 119 149 |
total_millis fn | 480 | — | 128 62 |
total_seconds fn | 482 | — | 60 |
total_minutes fn | 484 | — | 59 66 73 77 79 |
total_hours fn | 486 | — | 74 |
total_days fn | 488 | — | 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");
}