lib/time
civil dates, ISO 8601, and the Urbit @da.
Import
A module arrives by name and nothing in it is ambient. Every arm below is reached as time.<name>.
import time;
The name is the file name minus .jock: time is crates/jockc/hoon/lib/time.jock, and it is found because the directory holding it was passed as a --data-dir. There is no search path and no implicit import; tools/libs.py holds the whole discovery rule, and the compiler itself is a pure function of source and libraries.
About
Jock has Date and Span as TYPES — the %date and %span auras — and no literal for either (language.md L-189). Nothing in the ABI produces one, so until now a Date was a name with no way to make a value that meant anything. This module is the way: a broken-down Stamp, the conversions between it and an absolute instant, and ISO 8601 in both directions.
THE ARITHMETIC IS NOT INVENTED HERE. Every calendar arm is a transliteration of the vendored Hoon's own date core — +yawn, +yall, +yelp and the +yo constants in vendor/hoon-138.hoon, which vendor/SNAPSHOT.txt pins. That is deliberate: a calendar written twice is a calendar that disagrees with itself at some leap year nobody tests, and @da round-tripping through Urbit is the whole reason to have Date at all. Where this file differs from the Hoon it says so.
THE EPOCH. An Urbit @da counts seconds from the start of the Urbit epoch in its high 64 bits and a binary fraction of a second in its low 64. The epoch begins 292_277_024_400 years before 1 AD, so an ordinary year y is the internal year y + EPOCH. A Stamp carries the ORDINARY year; the conversion happens at the boundary.
AD ONLY. +year takes a BC flag and this does not: a Stamp's year is AD, which is what ISO 8601's four-digit year means anyway. fromDate on an instant before 1 AD crashes rather than answering a wrong year, which is the honest failure for a thing this module does not model.
import time;
let t = time.parse("2026-10-30T12:00:00Z"); match t { ~ => "unparseable", (~, s) => time.show(s) } An instant, broken down. Fields are plain atoms and nothing validates them on construction — show of a nonsense Stamp is nonsense, and valid is how you ask.
Types
10 types. A type’s members ARE its interface, so each is shown whole rather than by name.
Stamp
struct Stamp { year: @, month: @, day: @, hour: @, minute: @, second: @ }
No comment in the source. The signature above is the whole of what Stamp states about itself.
asserted by examples/time-arms.jock
assert not time.valid(time.Stamp(year: 2026, month: 2, day: 30,
Shift
trait Shift { plus(self: Self, p: Span) -> Self (+); minus(self: Self, p: Span) -> Self (-); until(self: Self, p: Self) -> Span; }
AND AS OPERATORS. t + anHour is a Stamp and b - a is a Span, which is what anyone reaching for a time library wants to write.
This needed a language change, landed 2026-10-30. An operator hook used to be spelled name(op); and ONLY that, with the parser synthesizing (self: Self, p: Self) -> Self for it --- so an operator could never take a right operand of another type, and Stamp + Span was unspellable by any route, including the type tricks that look like they should work: one type variable cannot be Stamp, Span and Stamp at once. The hook may now carry a WRITTEN signature, name(args) -> ret (op);, which is the form below.
Binding + here costs no one anything, which was the second half of the same change. The operator registry used to hold one trait per operator for the whole program, so a library claiming a glyph took it from every other library (%op-conflict, and %decl-op-rebind on the declaration). It now holds a LIST and resolves by the left operand's head, so Add keeps + for every numeric head and Shift has it for Stamp alone. Two traits reaching ONE head is the real conflict and refuses %mint-op-amb at the use site.
The free functions above remain: an operator is unsearchable, and time.plus is what a reader greps for.
The bodies inline rather than calling the free functions above: a method named plus whose body called plus would be ambiguous between the free function and itself, which is the same trap iso exists to avoid on the Show side.
Shift for Stamp
impl Shift for Stamp { plus(self: Stamp, p: Span) -> Stamp { fromSecs(toSecs(self) + spanSecs(p)) }; minus(self: Stamp, p: Span) -> Stamp { fromSecs(toSecs(self) - spanSecs(p)) }; until(self: Stamp, p: Stamp) -> Span { span(toSecs(p) - toSecs(self)) }; }
No comment in the source. The signature above is the whole of what Shift for Stamp states about itself.
Duration
trait Duration { secs(self: Self) -> @; plus(self: Self, p: Self) -> Self; minus(self: Self, p: Self) -> Self; shorter(self: Self, p: Self) -> Bool; show(self: Self) -> String; }
A Span is the language's own %span aura, so the prelude's traits cannot be given to it from here --- impl Add for Span refuses %mint-impl-orphan 'Add' '@span'. A trait declared HERE is a different matter: the orphan rule is satisfied by owning EITHER side, so this one may be implemented for a prelude type.
AND IT COULD HAVE BEEN AN OPERATOR. ⊕ and ⊛ are free --- runtime.md R-53 calls ⊛ trait-only and gives trait Seq { then(⊛); } as the shape --- and a local trait may bind one: trait Plus { plus(⊕); } compiles, and a ⊕ b works across an import. It is deliberately not done, because an operator binding is GLOBAL and first-come: two modules that each bind ⊕ cannot be imported by one program, which refuses %op-conflict. A stdlib module taking a glyph as generic as ⊕ --- union, direct sum, xor --- would spend everyone's namespace on durations, which rarely add. Methods cost a dot and take nothing from anyone.
The signature a slot demands comes from the operator's STATION, not from the trait: an additive or multiplicative one is (Self, Self) -> Self and a comparison is (Self, Self) -> Bool, both enforced as %mint-impl-sig. That is why Stamp + Span is not spellable by any route, operator or glyph.
Duration for Span
impl Duration for Span { secs(self: Span) -> @ { spanSecs(self) }; plus(self: Span, p: Span) -> Span { span(spanSecs(self) + spanSecs(p)) }; minus(self: Span, p: Span) -> Span { span(spanSecs(self) - spanSecs(p)) }; shorter(self: Span, p: Span) -> Bool { spanSecs(self) < spanSecs(p) }; show(self: Span) -> String { isoDur(spanSecs(self)) }; }
No comment in the source. The signature above is the whole of what Duration for Span states about itself.
Show for Stamp
impl Show for Stamp { show(self: Stamp) -> String { iso(self) }; }
Stamp is ours, so the prelude's own traits may be given to it directly --- which is the ordinary case, and the one Span above could not take.
Eq is absent because it would be redundant: a Stamp is a product of atoms, so two equal Stamps are the same noun and == is Nock 5 on them already. An impl would be a second answer to a question the substrate answers.
Ordering goes through toSecs and NOT through the field order. A Stamp is a product and Nock has no lexicographic compare, so structural comparison would order by whatever the erasure happens to be rather than by time.
Lt for Stamp
impl Lt for Stamp { lt(self: Stamp, p: Stamp) -> Bool { toSecs(self) < toSecs(p) }; }
No comment in the source. The signature above is the whole of what Lt for Stamp states about itself.
Lte for Stamp
impl Lte for Stamp { lte(self: Stamp, p: Stamp) -> Bool { toSecs(self) <= toSecs(p) }; }
No comment in the source. The signature above is the whole of what Lte for Stamp states about itself.
Gt for Stamp
impl Gt for Stamp { gt(self: Stamp, p: Stamp) -> Bool { toSecs(self) > toSecs(p) }; }
No comment in the source. The signature above is the whole of what Gt for Stamp states about itself.
Gte for Stamp
impl Gte for Stamp { gte(self: Stamp, p: Stamp) -> Bool { toSecs(self) >= toSecs(p) }; }
No comment in the source. The signature above is the whole of what Gte for Stamp states about itself.
Arms
22 arms, in source order. The signature is reproduced exactly; the prose is the comment above the arm in crates/jockc/hoon/lib/time.jock.
| arm | signature | pinned by |
|---|---|---|
epoch | func epoch() -> @ | time-arms (assert) |
daySecs | func daySecs() -> @ | time-arms (assert) |
hourSecs | func hourSecs() -> @ | time-arms (assert) |
minuteSecs | func minuteSecs() -> @ | time-arms (assert) |
leap | func leap(y: @) -> Bool | time-arms (assert) |
monthDays | func monthDays(y: @, m: @) -> @ | time-arms (assert) |
dayOf | func dayOf(y: @, m: @, d: @) -> @ | time-arms (assert) |
toSecs | func toSecs(t: Stamp) -> @ | time-arms (assert) |
fromSecs | func fromSecs(secs: @) -> Stamp | time-arms (use) |
toDate | func toDate(t: Stamp) -> Date | time-arms (use) |
fromDate | func fromDate(d: Date) -> Stamp | time-arms (assert) |
span | func span(secs: @) -> Span | time-arms (assert) |
spanSecs | func spanSecs(s: Span) -> @ | time-arms (assert) |
between | func between(a: Stamp, b: Stamp) -> Span | time-arms (assert) |
unixEpoch | func unixEpoch() -> @ | time-arms (assert) |
toUnix | func toUnix(t: Stamp) -> Int | time-arms (assert) |
fromUnix | func fromUnix(secs: Int) -> Stamp | time-arms (assert) |
valid | func valid(t: Stamp) -> Bool | time-arms (assert) |
parse | func parse(s: String) -> Stamp? | time-arms (assert) |
show | func show(t: Stamp) -> String | time-arms (assert) |
plus | func plus(t: Stamp, s: Span) -> Stamp | time-arms (assert) |
minus | func minus(t: Stamp, s: Span) -> Stamp | time-arms (assert) |
epoch
func epoch() -> @ { ... }
EPOCH: years from the start of the Urbit epoch to 1 AD. +year:hoon adds this to an AD year; so does this module.
asserted by examples/time-arms.jock
assert time.epoch() == 292277024400;
daySecs
func daySecs() -> @ { ... }
Seconds in a day, an hour, a minute — +yo:hoon.
asserted by examples/time-arms.jock
assert time.daySecs() == 86400;
hourSecs
func hourSecs() -> @ { ... }
No comment in the source. The signature above is the whole of what hourSecs states about itself.
asserted by examples/time-arms.jock
assert time.hourSecs() == 3600;
minuteSecs
func minuteSecs() -> @ { ... }
No comment in the source. The signature above is the whole of what minuteSecs states about itself.
asserted by examples/time-arms.jock
assert time.minuteSecs() == 60;
leap
func leap(y: @) -> Bool { ... }
+yelp:hoon. Note this is the FULL Gregorian test, and is right for the internal year as well as the ordinary one: both differ by a multiple of 400.
asserted by examples/time-arms.jock
assert time.leap(2024);
monthDays
func monthDays(y: @, m: @) -> @ { ... }
Length of month m (1-12) in year y. The +moh/+moy lists of +yo, as a test rather than a list, because Jock indexes a List in O(n) and this is a seven-way compare either way.
asserted by examples/time-arms.jock
assert time.monthDays(2024, 2) == 29;
dayOf
func dayOf(y: @, m: @, d: @) -> @ { ... }
+yawn:hoon: the day number of an ordinary (AD) y-m-d.
asserted by examples/time-arms.jock
assert time.dayOf(2026, 10, 30) - time.dayOf(1970, 1, 1) == 20756;
toSecs
func toSecs(t: Stamp) -> @ { ... }
Seconds from the start of the Urbit epoch. This is the high half of a @da, and the quantity all the arithmetic happens in.
asserted by examples/time-arms.jock
assert time.toSecs(noon) - time.toSecs(time.fromUnix(+0)) == 1793363696;
fromSecs
func fromSecs(secs: @) -> Stamp { ... }
The inverse. Crashes on an instant before 1 AD rather than answering a wrong year — see the AD ONLY note above.
used by examples/time-arms.jock
let back = time.fromSecs(time.toSecs(noon));
toDate
func toDate(t: Stamp) -> Date { ... }
A @da is the second count in the high 64 bits and a binary fraction in the low 64. These two arms are the whole bridge to Urbit, and they drop the fraction: a Stamp has no sub-second field, so fromDate of an instant with one is the instant truncated to its second.
used by examples/time-arms.jock
let d = time.toDate(noon);
fromDate
func fromDate(d: Date) -> Stamp { ... }
No comment in the source. The signature above is the whole of what fromDate states about itself.
asserted by examples/time-arms.jock
assert time.toUnix(time.fromDate(d)) == +1793363696;
span
func span(secs: @) -> Span { ... }
A Span is a DURATION in the same fixed-point shape: whole seconds in the high 64 bits. The difference of two instants.
asserted by examples/time-arms.jock
assert time.spanSecs(time.span(90)) == 90;
spanSecs
func spanSecs(s: Span) -> @ { ... }
No comment in the source. The signature above is the whole of what spanSecs states about itself.
asserted by examples/time-arms.jock
assert time.spanSecs(time.span(90)) == 90;
between
func between(a: Stamp, b: Stamp) -> Span { ... }
No comment in the source. The signature above is the whole of what between states about itself.
asserted by examples/time-arms.jock
assert time.spanSecs(time.between(newyear, newyear)) == 0;
unixEpoch
func unixEpoch() -> @ { ... }
Unix time, which is what everything outside Urbit means by a timestamp. The constant is COMPUTED, not written down.
SIGNED, and that is not fussiness: a Nat toUnix crashes on every instant before 1970, so time.toUnix of an 1900 date --- an ordinary thing to want --- would have been a crash rather than a negative number. The whole AD range answers.
asserted by examples/time-arms.jock
assert time.unixEpoch() == time.dayOf(1970, 1, 1) * 86400;
toUnix
func toUnix(t: Stamp) -> Int { ... }
No comment in the source. The signature above is the whole of what toUnix states about itself.
asserted by examples/time-arms.jock
assert time.toUnix(noon) == +1793363696;
fromUnix
func fromUnix(secs: Int) -> Stamp { ... }
No comment in the source. The signature above is the whole of what fromUnix states about itself.
asserted by examples/time-arms.jock
assert time.toSecs(noon) - time.toSecs(time.fromUnix(+0)) == 1793363696;
valid
func valid(t: Stamp) -> Bool { ... }
Is this Stamp a real instant? Nothing validates on construction, so this is how you ask. show of an invalid Stamp is nonsense rather than a refusal.
asserted by examples/time-arms.jock
assert time.valid(noon);
parse
func parse(s: String) -> Stamp? { ... }
Parse YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS with an optional trailing Z. Anything else is ~, including a well-formed date that is not a real one — parse("2026-02-30") is ~, because a Stamp that cannot happen is not a successful parse.
asserted by examples/time-arms.jock
assert match time.parse("2026-10-30") { ~ => 0, (~, s) => s.day } == 30;
show
func show(t: Stamp) -> String { ... }
No comment in the source. The signature above is the whole of what show states about itself.
asserted by examples/time-arms.jock
assert time.show(time.fromDate(d)) === "2026-10-30T12:34:56Z";
plus
func plus(t: Stamp, s: Span) -> Stamp { ... }
NOT operators, and not for want of trying. An arithmetic op-trait is (Self, Self) -> Self: Stamp + Span fails because the right operand is not Self, and Stamp - Stamp -> Span fails because the result is not. Both refuse %mint-impl-sig. So the two genuinely useful arithmetic operations on instants are named functions, and between above is the subtraction.
asserted by examples/time-arms.jock
assert time.show(time.plus(newyear, anHour)) === "2026-01-01T01:00:00Z";
minus
func minus(t: Stamp, s: Span) -> Stamp { ... }
No comment in the source. The signature above is the whole of what minus states about itself.
asserted by examples/time-arms.jock
assert time.toSecs(time.minus(time.plus(noon, anHour), anHour)) == time.toSecs(noon);
What no lane pins
9 of 32 public declarations are named by no example in examples/. They compile — the module is built by tools/modules.sh on every push — but no assertion mentions them by name, so treat the signature as the only claim about them that a lane checks.
7 of those are an impl, and the rule is what cannot reach them, not the example. A pin is a line naming time.<declaration>; an impl is named for its Trait for Type pair, and no program writes that — it writes a < b or t.show(), whose connection to the impl is a type fact this generator does not have. Guessing it from the text could claim coverage that is not there, so the count stays honest and says this instead.
That count is of DIRECT mentions. An arm an assertion reaches only through a caller is not counted here, so a helper below may well be exercised on every push without appearing to be — what is true is that no lane fixes its result independently of its caller’s.
Shift—trait Shift { plus(self: Self, p: Span) -> Self (+); minus(self: Self, p: Span) -> Self (-); until(self: Self, p: Self) -> Span; }Shift for Stamp—impl Shift for Stamp { plus(self: Stamp, p: Span) -> Stamp { fromSecs(toSecs(self) + spanSecs(p)) }; minus(self: Stamp, p: Span) -> Stamp { fromSecs(toSecs(self) - spanSecs(p)) }; until(self: Stamp, p: Stamp) -> Span { span(toSecs(p) - toSecs(self)) }; }Duration—trait Duration { secs(self: Self) -> @; plus(self: Self, p: Self) -> Self; minus(self: Self, p: Self) -> Self; shorter(self: Self, p: Self) -> Bool; show(self: Self) -> String; }Duration for Span—impl Duration for Span { secs(self: Span) -> @ { spanSecs(self) }; plus(self: Span, p: Span) -> Span { span(spanSecs(self) + spanSecs(p)) }; minus(self: Span, p: Span) -> Span { span(spanSecs(self) - spanSecs(p)) }; shorter(self: Span, p: Span) -> Bool { spanSecs(self) < spanSecs(p) }; show(self: Span) -> String { isoDur(spanSecs(self)) }; }Show for Stamp—impl Show for Stamp { show(self: Stamp) -> String { iso(self) }; }Lt for Stamp—impl Lt for Stamp { lt(self: Stamp, p: Stamp) -> Bool { toSecs(self) < toSecs(p) }; }Lte for Stamp—impl Lte for Stamp { lte(self: Stamp, p: Stamp) -> Bool { toSecs(self) <= toSecs(p) }; }Gt for Stamp—impl Gt for Stamp { gt(self: Stamp, p: Stamp) -> Bool { toSecs(self) > toSecs(p) }; }Gte for Stamp—impl Gte for Stamp { gte(self: Stamp, p: Stamp) -> Bool { toSecs(self) >= toSecs(p) }; }
19 further declarations are private and therefore not reachable by an importer at all: monthsBefore, yawnLoop, yallMonth, yallYear, yall, isDigit, digits, lit, some, none, parseTime, padLoop, pad, showFull, iso, num, isoSecs, isoMins, isoDur.
Where it is used
1 example imports time. Each is a complete program whose value tools/examples.sh pins on every push.
examples/time-arms.jock— the arms sheet: one assertion per arm
The worked examples page shows the shortest of these in full.