moondate

    Dates, times, durations and cron expressions for MoonBit.

    date
    time
    datetime
    duration
    calendar
    utc-offset
    timezone
    time-zone
    tz
    tzdata
    tzif
    zoneinfo
    iana
    daylight-saving
    dst
    local-time
    fold
    posix-tz
    rfc9636
    cron
    crontab
    iso8601
    rfc3339
    http-date
    asn1
    strftime
    strptime
    ldml
    parsing
    formatting
    moonbit
    Download zip
    Version
    0.1.5
    License
    Apache-2.0
    Last updated
    13 hours ago
    Downloads
    7K

    #moondate

    Dates, times, instants, durations and cron schedules.

    Homepage · Guide · API · Playground · mooncakes


    A calendar with the arithmetic that goes with it, the five ways to write a moment that C, Python, Rust, Go and JavaScript each speak, a second package that answers when a schedule fires next, and two more that carry the IANA time zone database for the callers who need it.

    moon add moonbitstack/moondate

    fn main raise {
    let stamp = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
    println(stamp.strftime("%A %d %B %Y at %H:%M"))
    println(stamp.epoch())
    }

    Tuesday 22 September 2026 at 14:30 1790058605

    #The four shapes

    A moment is one of four things, and the difference is not decoration. Each constructor answers a different question, and each one refuses the questions it cannot.

    What it isWrittenepoch
    ZonedAn instant. Sits on the timeline.2026-09-22T14:30:00+08:00Some
    PlainA wall clock. An alarm, an opening time.2026-09-22T14:30:00None
    DayA date with no time.2026-09-22None
    ClockA time with no date.14:30:00None

    Turning a Plain into an instant needs a zone nobody supplied, so epoch and astimezone answer None for it rather than guessing. parse keeps whatever shape the text had; nothing is padded with a hidden midnight.

    Two spellings besides the one in the table are read. ISO 8601's basic form drops the separators — 20260922T143000+0800, 20260922 — and a bracketed offset stands in for the one beside it, so 2020-01-01[+09:00] is midnight nine hours ahead and …[UTC] needs nothing else. A bracketed name is a different thing: [America/New_York] says which zone but not which side of a transition the clock was on, so it is refused unless the offset is written beside it. A zone name also keeps the minute — [+01:35] is a name, [+01:35:00] is not.

    An offset written finer than a minute — +00:19:32.37, which is what a zone read before standard time needs — is kept as Zone::Precise, a count of nanoseconds, rather than rounded. Moment::instant is then the exact instant as two halves, because one count of nanoseconds runs out in 2262 and this library's years do not:

    let m = @moondate.parse("1970-01-01T00:00:00+00:19:32.37")
    m.isoformat() // 1970-01-01T00:00:00+00:19:32.37
    m.instant() // Some((-1173, 630000000)) — 1172.37 s before the epoch
    m.epoch() // Some(-1173), the same instant floored

    Annotations after the moment are Temporal's, and parse reads them: [u-ca=iso8601] is the calendar it already has, [foo=bar] is somebody else's business and is ignored, and [!foo=bar] says the reader must understand the key — which it does not, so it refuses. Two zones, two calendars, or a key with a capital in it are refused as well.

    #What it reads and writes

    FormWhere it is used
    RFC 3339 / ISO 8601TOML, JSON Schema's date-time, most of the wire
    IMF-fixdate, RFC 850, asctimeHTTP's Date header (RFC 9110 §5.6.7): all three read, the first written
    UTCTime, GeneralizedTimeASN.1, so X.509 validity (ITU-T X.680 §47)
    strftime / strptimeThe % directives of C23, POSIX, glibc, Python, chrono and C++20
    layout / parse_layoutGo's time reference layouts (RFC3339, RFC1123, …)
    pattern / parse_patternUnicode LDML patterns, what ICU, Intl and Java use
    isoformat, to_iso_string, to_utc_stringECMA-262's Date Time String Format and Temporal

    The two-digit-year windows differ between HTTP and X.509: 69/70 against 49/50. Each is applied where its own specification says, because a certificate read by HTTP's rule expires a century early.

    #Five ways to write a moment

    The same instant can be written five ways, and moondate speaks all of them in both directions: whatever it writes, strptime, parse_layout or parse_pattern reads back to the same moment.

    StyleWriteReadWhere it is from
    % directivesstrftimestrptimeC23, POSIX, glibc, Python, chrono, C++20
    Reference-time layoutlayoutparse_layoutGo's time package
    LDML patternpatternparse_patternUnicode UTS #35, ICU, Intl, Java
    ECMAScript ISOisoformat, to_iso_string, to_utc_stringparseECMA-262 and Temporal

    let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")

    m.strftime("%A %d %B %Y, %I:%M %p") // Tuesday 22 September 2026, 02:30 PM
    m.layout(@moondate.RFC1123) // Tue, 22 Sep 2026 14:30:05 +08:00
    m.pattern("EEEE, MMMM d, yyyy 'at' HH:mm") // Tuesday, September 22, 2026 at 14:30
    m.to_iso_string() // 2026-09-22T06:30:05.123Z
    m.isoformat(fraction=3, offset=false) // 2026-09-22T14:30:05.123

    The % set is the union of the six lineages, with the conflicts settled in the table: %f is Python's six digits (chrono's nine are %9f or %N), %Z writes UTC±HH:MM for a fixed offset, and %+ is chrono's form. Flags sit between the sign and the letter — - drops the padding, _ pads with spaces, 0 pads with zeros, a number sets the width, ^ and # change the case of a name — and the C99 selectors E and O are kept: %Ec is %c, %Ez is +08:00 and %Oz is +0800. A moment that carries only a fixed offset has no name to write, so the zone letters in a Go layout and an LDML pattern fall back to the offset (GMT±HH:MM in LDML), which is what ICU and babel do for a zone they only know by its offset. Give the moment a name — the next section is how — and %Z, LDML's z and V, and a Go layout's MST write the name the database gave it.

    m.strftime("%#B %10Y %-d") // SEPTEMBER 0000002026 22
    m.strftime("%Ez %Oz") // +08:00 +0800
    m.pattern("Y-'W'ww c B") // 2026-W39 2 in the afternoon
    m.isoformat() // 2026-09-22T14:30:05.123456+08:00

    LDML answers in every width UTS #35 names, including the narrow era, quarter and weekday, the day periods (b short, B long, with midnight and noon named), the standalone weekday c, and the week year Y with its week w. A year outside 0000 to 9999 is written in the expanded form Temporal gives it — +012026-01-02 — and parse reads that form back.

    The vectors come from each language's own implementation — CPython for the % directives, a Rust program for chrono's additions, Go for the layout, Babel for LDML, and Node for the ECMAScript strings — and are committed under src/; CI only replays them. The generators are in scripts/.

    One corpus is not a comparison but a specification: test262's Temporal suites, pinned by commit, played against a stand-in that records what each test asserts. A text there answers three questions — the instant, the day, the exact time — and some tests ask only one property of a reading, which is answered as the property it is. The cases this library answers for all of them are replayed from src/temporal_vectors_wbtest.mbt; those it does not are counted by reason, and the transcription is scripts/temporal_vectors/.

    #Time zones

    The calendar above has no database behind it, on purpose: Zone is a fixed offset, which is everything a package with no I/O can honestly carry. The database is three packages beside it, and they are separate because a caller that formats an HTTP date should not have to carry a hundred kilobytes of rules it never reads, and a caller that carries its own rules should not have to ask the filesystem for them.

    PackageWhat it is
    moonbitstack/moondate/zoneA TZif file read (RFC 9636), the POSIX TZ strings a file's footer carries, and the question every wall clock raises in a zone
    moonbitstack/moondate/tzdataThe IANA release itself, compiled from its own source the first time a zone is asked for
    moonbitstack/moondate/zoneinfoThe zones this machine keeps: a file by path, a name from the system's directory, and the zone TZ and /etc/localtime name

    let tz = @tzdata.load("America/New_York")
    let moment = tz.moment(1790058605) // 2026-09-22T02:30:05-04:00
    moment.strftime("%H:%M %Z %z") // 02:30 EDT -0400
    moment.pattern("VV") // America/New_York
    moment.layout(@moondate.RFC1123) // Tue, 22 Sep 2026 02:30:05 EDT
    moment.isoformat(zone_name=true) // 2026-09-22T02:30:05-04:00[America/New_York]

    A moment that carries a name carries the offset in force at that instant as well, so it stays a value: it can be compared, written and moved to another zone with no database in reach. Crossing a transition is the one thing a value cannot do, which is what Tz::moment is for.

    #The database the machine already has

    Carrying the release is one way to have a database; reading the one the system keeps is the other, and that is the only thing here that opens a file.

    let tz = @zoneinfo.system() // TZ, then /etc/localtime
    let shanghai = @zoneinfo.named("Asia/Shanghai") // from /usr/share/zoneinfo
    let same = @zoneinfo.open("/etc/localtime") // any TZif file, by path

    system reads TZ the way a C library does: empty is UTC, a name is a file in the directory, a path is that file, and anything else is a POSIX rule — which is how TZ=EST5EDT,M3.2.0,M11.1.0 states a zone without carrying one. A machine that names no zone is an error rather than UTC: guessing UTC is how a program silently writes timestamps eight hours out. directory is TZDIR when the environment has one and /usr/share/zoneinfo otherwise.

    This is the one package of the four that does I/O, and it is a package of its own so that the other three do not. A wasm module with a zone table compiled into it links @zone and nothing else, and does not carry a filesystem import its host would have to satisfy.

    #The two hours that have no single answer

    A place name is the one thing parse will not answer, because the answer depends on a database it does not carry. @tzdata.parse is that entry point: it reads the place from the release and takes fold for the hour that has none.

    let moment = @tzdata.parse("2026-11-01T01:30:00[America/New_York]")
    moment.strftime("%H:%M %Z") // 01:30 EDT

    Twice a year a wall clock is a bad question. In most of the world one hour of a spring morning never happens and one hour of an autumn night happens twice, and a timestamp written by a program in that zone has to be read as one of the two.

    foldA clock read twiceA clock never read
    Compatible (default)the earlier instantthe later instant
    Earlier / Laterby instantby instant
    Rejectrefusesrefuses

    let tz = @tzdata.load("America/New_York")
    tz.read(@moondate.Date::new(2026, 3, 8), @moondate.Time::new(2, 30))
    // never, between 1772951400 and 1772955000
    tz.read(@moondate.Date::new(2026, 11, 1), @moondate.Time::new(1, 30))
    // twice, at 1793511000 and 1793514600

    Compatible is what most callers mean and what the other implementations do by default; Reject is there for a program that would rather stop than pick.

    #Interface

    TypeBuilt withAsked
    Tz@zone.Tz::parse(bytes), @zone.Tz::of, @tzdata.load(name), @zoneinfo.open, @zoneinfo.named, @zoneinfo.systemname, at, offset, abbrev, is_dst, changes, read, epoch, moment, zoned, readings, rule
    Reading—the fields: when it began, the offset in seconds, whether daylight saving was on, the abbreviation
    LocalTz::readepoch(fold~), is_exact
    RuleRule::parsetext, at
    Tz from the database@tzdata.load, @tzdata.findthe same, plus @tzdata.zones, @tzdata.links, @tzdata.release
    Tz from this machine@zoneinfo.open, @zoneinfo.named, @zoneinfo.systemthe same, plus @zoneinfo.directory

    Free functions in zone: Tz::parse, Rule::parse. In tzdata: zones, links, load, find. In zoneinfo: open, named, system, directory. Constants: @tzdata.release names the IANA version carried and @tzdata.source_digest is the SHA-256 of the source text, so a release can be recognised without reading it.

    Errors are zone.Refused: Malformed carries what was expected and the byte it stopped at, Truncated the size it wanted and the size it got, Unsupported the TZif version it cannot read, Unknown a zone name no database has, and Missing a wall clock that never happens when the policy is to refuse rather than guess. zoneinfo has a second one for the file it could not get to: Missing a path that is not there, Unreadable the path and what the filesystem said, BadZone the bytes that are not a zone, and Unset a machine that names none.

    #Where the data comes from

    The package carries tzdata.zi, the release's own compendium of the Rule, Zone and Link lines its editors maintain: 105 kB, a tenth the size of the compiled tree, and readable in a diff when a release lands. scripts/gen_tzdata.py fetches a release and rewrites src/tzdata/data.mbt; a workflow checks twice a month for a new one and opens a pull request when there is.

    Nothing in the database is a stub or a subset: every one of the 598 names the release has is compiled from the same rules, and the transitions are the ones the release's own zic writes.

    #Interface

    Types are listed with the calls that matter most; the API reference has every method and every parameter.

    TypeBuilt withAsked
    DateDate::new, of_days, of_ordinal, of_isocalendarweekday, yearday, isocalendar, ordinal, days, diff, plus_days, plus_months, replace
    TimeTime::new, midnight, end_of_daynanos, replace
    Momentparse, of_isoformat, http, asn1, strftime, strptime, layout, parse_layout, pattern, parse_pattern, combine, of_epochepoch, instant, timestamp, date, time, zone, utcoffset, astimezone, isoformat, to_iso_string, to_utc_string, strftime, layout, pattern, http_text, asn1_text, ctime, timetuple, weekday, isocalendar, ordinal, diff, plus, replace
    SpanSpan::new, of, nanosecond … dayadd, sub, seconds, total_seconds, parts, neg, abs
    ZoneUtc, Offset(minutes), Named(name, offset, abbrev), Precise(nanos)nanos, seconds, minutes, name, abbrev
    Cron@cron.parsematches, next

    Free functions: parse, of_isoformat, strptime, parse_layout, parse_pattern, http, asn1, leap, length. Constants: min_date, max_date, min_year, max_year, resolution, midnight, end_of_day, the Go layout constants (RFC3339, RFC1123, ANSIC, …), and one span per unit from nanosecond to day.

    Errors are one type with three reasons: Range carries the field, the value it got and the range it wanted; Malformed carries what was expected; Truncated carries the name of the thing that ran out of text.

    #Examples

    Every file under src/examples/ is a complete program with the output it prints. Run one with moon run src/examples/hello.

    What it shows
    helloThe smallest complete program: read, look inside, write back.
    shapesZoned, Plain, Day and Clock side by side, and what each one can answer.
    calendarWeekdays, ISO week dates, ordinals, month lengths, adding days and months.
    zonesOne instant written three ways, astimezone, the epoch both directions, spans.
    wireHTTP dates in three formats, ASN.1 time in two, and the refusals.
    formatOne moment through all five styles: % directives, Go layout, LDML pattern, ECMAScript.
    scheduleCron expressions: what they mean, the next few firings, the or-rule, the refusals.
    worldclockFive cities at one instant, and the two hours of the year that need a decision.

    #cron

    let every_quarter = @cron.parse("*/15 9-17 * * MON-FRI")
    let next = every_quarter.next(now)

    Five fields as crontab(5) defines them, or six with seconds in front. *, n, a-b, */n, a-b/n, lists of those, month and day names, ? for a day field that is not in use, and the @ shorthands. Both 0 and 7 are Sunday.

    Day-of-month and day-of-week are or-ed when both are restricted, which is Vixie cron's rule: 0 0 13 * 5 fires on the thirteenth or on any Friday, not only on Friday the thirteenth. When one of them is * they are and-ed. This surprises everyone once, so it has a test that says so.

    An expression naming a day that does not exist — 0 0 30 2 * — answers None instead of searching forever.

    #Reading the code

    The calendar is date.mbt, the wire formats are text.mbt, the % directives are strftime.mbt, the Go layout is layout.mbt, the LDML pattern is pattern.mbt, the rest of the surface is python.mbt, the schedule is cron/, the TZif reader and the POSIX rules are zone/, and the database is tzdata/. Each public item has a doc comment saying what it does and, where there is a rule behind it, which specification the rule comes from.

    There is no now(). Reading the clock is I/O, this package has none, so a caller passes what its own clock said to Moment::of_epoch.

    This package never reads a file and never reads a clock. A zone database is a file, and it is in zone/ and tzdata/ for the callers who need one.

    #Licence

    Apache-2.0. See LICENSE.

    Refused

    pub(all) suberror Refused {
    Range(field~ : String, got~ : Int, low~ : Int, high~ : Int)
    Malformed(String)
    Truncated(String)
    } derive(Eq,
    Debug
    )

    Why a date, a time or a text form was refused.

    A calendar that accepted the thirty-first of February would be no use as a check, so every constructor here validates and says what was wrong.
    impl Show for Refused

    Refused::equal

    fn Refused::equal(Refused, Refused) -> Bool

    Refused::not_equal

    fn Refused::not_equal(x : Refused, y : Refused) -> Bool

    Refused::output

    fn Refused::output(self : Refused, logger : &Logger) -> Unit

    Refused::to_repr

    Refused::to_string

    fn Refused::to_string(self : Refused) -> String

    Date

    pub(all) struct Date {
    year : Int
    month : Int
    day : Int
    } derive(Compare, Eq,
    Debug
    )

    A day on the proleptic Gregorian calendar.

    Proleptic means the Gregorian rules are applied before 1582 as well, which is what every interchange format in reach assumes and what makes arithmetic over the whole range uniform.
    impl Show for Date

    Date::compare

    fn Date::compare(Date, Date) -> Int

    Date::days

    fn Date::days(self : Date) -> Int

    Days since 1970-01-01, negative before it.

    Howard Hinnant's days_from_civil: shift the year to start in March so the leap day falls at the end, then the month lengths follow a pattern with no table.

    Date::diff

    fn Date::diff(self : Date, other : Date) -> Span

    One date less another, as a span of whole days.

    Date::equal

    fn Date::equal(Date, Date) -> Bool

    Date::isocalendar

    fn Date::isocalendar(self : Date) -> (Int, Int, Int)

    The ISO 8601 week date: the year the week belongs to, the week number, and the day of the week with Monday as 1.

    The year is not always the calendar year. A week belongs to the year that holds its Thursday, so the first of January can fall in the last week of the year before — which is exactly the case a hand-rolled week number gets wrong.

    Date::isoweekday

    fn Date::isoweekday(self : Date) -> Int

    The day of the week with Monday as 1 and Sunday as 7.

    [Date::weekday] counts from Sunday as 0, which is what cron and C's tm_wday use; ISO counts from Monday as 1. Both are here because both are asked for, and picking one silently is how an off-by-one gets into a schedule.

    Date::new

    fn Date::new(year : Int, month : Int, day : Int) -> Date raise Refused

    A date, checked against the calendar.

    Date::not_equal

    fn Date::not_equal(x : Date, y : Date) -> Bool

    Date::of_days

    fn Date::of_days(n : Int) -> Date

    The date n days after 1970-01-01, the inverse of [Date::days].

    Date::of_isocalendar

    fn Date::of_isocalendar(year : Int, week : Int, weekday : Int) -> Date raise Refused

    The date named by an ISO week date.

    Date::of_ordinal

    fn Date::of_ordinal(n : Int) -> Date

    The date with that ordinal.

    Date::op_ge

    fn Date::op_ge(x : Date, y : Date) -> Bool

    Date::op_gt

    fn Date::op_gt(x : Date, y : Date) -> Bool

    Date::op_le

    fn Date::op_le(x : Date, y : Date) -> Bool

    Date::op_lt

    fn Date::op_lt(x : Date, y : Date) -> Bool

    Date::ordinal

    fn Date::ordinal(self : Date) -> Int

    Days since 0001-01-01, that day being 1.

    The proleptic Gregorian ordinal, which is what makes two dates subtractable without going through an epoch.

    Date::output

    fn Date::output(self : Date, logger : &Logger) -> Unit

    Date::plus_days

    fn Date::plus_days(self : Date, n : Int) -> Date

    The date n days later, or earlier when n is negative.

    Date::plus_months

    fn Date::plus_months(self : Date, n : Int) -> Date

    The date n months later, clamped to the end of the month it lands in.

    The thirty-first of January plus one month is the twenty-eighth or twenty-ninth of February, because there is no thirty-first. Every calendar library makes this choice and they all make the same one.

    Date::replace

    fn Date::replace(self : Date, year? : Int, month? : Int, day? : Int) -> Date raise Refused

    The same date with some parts replaced.

    Date::to_repr

    Date::to_string

    fn Date::to_string(self : Date) -> String

    Date::weekday

    fn Date::weekday(self : Date) -> Int

    The day of the week, Sunday being 0.

    1970-01-01 was a Thursday, which is where the 4 comes from.

    Date::yearday

    fn Date::yearday(self : Date) -> Int

    The day of the year, the first of January being 1.

    Moment

    pub(all) enum Moment {
    Zoned(date~ : Date, time~ : Time, zone~ : Zone)
    Plain(date~ : Date, time~ : Time)
    Day(date~ : Date)
    Clock(time~ : Time)
    } derive(Eq,
    Debug
    )

    A point in time, in one of the four shapes a format names.

    TOML 1.0.0 has exactly these four and gives them these meanings; RFC 3339 defines only Zoned. The distinction is not decoration: Plain is a wall clock, which is what an alarm or an opening time is, and turning it into an instant needs a zone nobody has supplied.
    impl Show for Moment

    Moment::asn1_text

    fn Moment::asn1_text(self : Moment) -> String raise Refused

    A moment as an ASN.1 GeneralizedTime, which is the form DER uses from 2050 on and the one that needs no windowing rule to read back.

    Moment::astimezone

    fn Moment::astimezone(self : Moment, zone : Zone) -> Moment?

    The same instant read in another zone.

    None for the three shapes that are not instants: moving a wall clock to another zone would be inventing the zone it was written in.

    Moment::combine

    fn Moment::combine(date : Date, time : Time, zone? : Zone) -> Moment

    A date and a time as one wall-clock moment.

    zone makes it an instant instead; leaving it off keeps it a wall clock, which is what an opening time or an alarm is.

    Moment::ctime

    fn Moment::ctime(self : Moment) -> String

    The moment in C's ctime form: Sun Nov 6 08:49:37 1994.

    The day is space-padded rather than zero-padded, which is the one thing that distinguishes this form from every other.

    Moment::date

    fn Moment::date(self : Moment) -> Date?

    The date out of a moment.

    None for a bare time, which has no date to give.

    Moment::diff

    fn Moment::diff(self : Moment, other : Moment) -> Span?

    One moment less another, for the two shapes that can be subtracted.

    None when they are not comparable: an instant and a wall clock differ by an amount that depends on a zone nobody supplied, and a bare time and a bare date are not the same kind of thing.

    Moment::epoch

    fn Moment::epoch(self : Moment) -> Int64?

    Seconds since 1970-01-01T00:00:00Z, for a moment that names one.

    None for the three shapes that do not: a wall clock, a bare date and a bare time are not instants until someone supplies the zone they are read in.

    Moment::equal

    fn Moment::equal(Moment, Moment) -> Bool

    Moment::http_text

    fn Moment::http_text(self : Moment) -> String raise Refused

    A moment as an HTTP date (RFC 9110 §5.6.7): Sun, 06 Nov 1994 08:49:37 GMT.

    This is the one form a Date header may take. The two obsolete forms are read by [http] and never written, which is what §5.6.7 asks for.

    Moment::instant

    fn Moment::instant(self : Moment) -> (Int64, Int)?

    The instant, as whole seconds past 1970 and the nanoseconds after them.

    The two are apart because a count of nanoseconds runs out in 2262, and because a zone whose offset is not a whole second puts the nanoseconds of the instant at odds with the nanoseconds on the clock: 15:23:30.123456789-00:00:00.000000100 is 100 nanoseconds earlier than its clock says.

    Moment::isocalendar

    fn Moment::isocalendar(self : Moment) -> (Int, Int, Int)?

    The ISO week date of the moment's date.

    Moment::isoformat

    fn Moment::isoformat(self : Moment, sep? : Char, fraction? : Int, offset? : Bool, zone_name? : Bool) -> String

    The moment as ISO 8601 text, which is what [Show] writes.

    sep is the character between the date and the time, T by default; any character works, and a space is what a log line wants.

    fraction is the number of fractional-second digits, 0 to 9, truncated. Left out, it writes as many as the value needs and none for a whole second, which is Temporal's auto.

    offset writes the zone the moment carries, Z for UTC and ±HH:MM for an offset; false leaves the zone off, which is Temporal's offset: "never".

    zone_name follows the offset with the name the zone is filed under, in brackets: 2026-11-01T01:30:00-04:00[America/New_York], which is Temporal's timeZoneName: "critical" and the only way the text keeps the name as well as the instant.

    Moment::layout

    fn Moment::layout(self : Moment, layout : StringView) -> String

    A moment written by a Go reference-time layout.

    Moment::layout(RFC3339) is 2006-01-02T15:04:05Z07:00 with the fields filled in, which is the same text Go's time.Format would write for the same instant. A moment with no zone writes its offset forms as if it sat at UTC, and a bare time or date is padded with 1 January 1900 as strftime does.

    Moment::not_equal

    fn Moment::not_equal(x : Moment, y : Moment) -> Bool

    Moment::of_epoch

    fn Moment::of_epoch(seconds : Int64, nano? : Int, zone? : Zone) -> Moment

    The moment seconds after 1970-01-01T00:00:00Z, the inverse of [Moment::timestamp].

    Reading the clock is not this package's job — it has no I/O — so a caller that wants "now" passes what its own clock said.

    Moment::ordinal

    fn Moment::ordinal(self : Moment) -> Int?

    Days since 0001-01-01 of the moment's date.

    Moment::output

    fn Moment::output(self : Moment, logger : &Logger) -> Unit

    Moment::pattern

    fn Moment::pattern(self : Moment, pattern : StringView) -> String

    A moment written by an LDML date pattern.

    pattern("yyyy-MM-dd") is the calendar date, pattern("EEEE, MMMM d, yyyy") is Tuesday, September 22, 2026, and text in single quotes is literal, so pattern("yyyy-MM-dd'T'HH:mm:ss") writes an ISO-style timestamp.

    Moment::plus

    fn Moment::plus(self : Moment, span : Span) -> Moment

    The moment span later.

    A bare date moves by whole days and a bare time wraps within the day, because that is all each of them can mean.

    Moment::replace

    fn Moment::replace(self : Moment, year? : Int, month? : Int, day? : Int, hour? : Int, minute? : Int, second? : Int, nano? : Int, zone? : Zone) -> Moment raise Refused

    The same moment with some parts replaced.

    Moment::strftime

    fn Moment::strftime(self : Moment, format : StringView) -> String

    A moment written by a format string of % directives.

    The directives are the ones every Unix knows plus the ones chrono and C++ add; the table below is the whole set. Literal text between directives is copied through untouched, and %% is a single per cent sign.

    %Y %C %yyear of four digits, century, two-digit year
    %G %gISO week year, two-digit
    %m %B %b %hmonth, long and short name
    %d %eday of month, zero- and space-padded
    %jday of year, 001 to 366
    %U %W %Vweek of year, Sunday-, Monday-, and ISO-based
    %u %wweekday, 1 to 7 and 0 to 6
    %a %Ashort and long weekday name
    %qquarter of the year, 1 to `4 (chrono)
    %H %k %I %lhour, 00 and 0, twelve-hour 01 and 1
    %M %Sminute, second
    %p %PAM/PM and am/pm
    %f %.f %.Nf %Nf %Nfractional second, dot or not, N digits or auto
    %sseconds since the epoch
    %z %:z %::z %:::z %#z %Ez %Ozoffset: +0800, +08:00, +08:00:00, +08 at the precision it needs, and the two ISO 8601 forms of C++20
    %ZUTC, UTC±HH:MM for a fixed offset, or nothing when there is no zone
    %c %x %X %D %F %T %R %r %v %+the composite forms
    %n %t %%a newline, a tab, a per cent sign

    A flag may sit between the sign and the letter: - drops the padding, _ pads with spaces, 0 pads with zeros, a number sets the width, ^ upper-cases, and # upper-cases a name but lower-cases %p. The E and O selectors are kept as well: %Ec is %c, and %Ez and %Oz are the two ISO 8601 spellings of an offset, +08:00 and +0800.

    A moment with no date writes its date directives as 1 January 1900, as Python's time.strftime and C's struct tm do, so a bare time still formats.

    Moment::time

    fn Moment::time(self : Moment) -> Time

    The time out of a moment.

    Midnight for a bare date, which is the time it names.

    Moment::timestamp

    fn Moment::timestamp(self : Moment) -> Double?

    Seconds since the epoch as a Double, fraction included.

    Moment::timetuple

    fn Moment::timetuple(self : Moment) -> (Int, Int, Int, Int, Int, Int, Int, Int, Int)

    The nine fields of a broken-down time, in the usual order: year, month, day, hour, minute, second, weekday counted from Monday as 0, day of the year, and whether daylight saving applies — always -1 here, because that needs a zone database this package does not carry.

    Moment::to_iso_string

    fn Moment::to_iso_string(self : Moment) -> String

    The Date.prototype.toISOString shape: always the UTC instant, always three fractional digits, always a trailing Z.

    A moment with no zone has no instant of its own, so its wall clock is taken as UTC — the same convention [Moment::strftime] uses for a bare time.

    Moment::to_repr

    Moment::to_string

    fn Moment::to_string(self : Moment) -> String

    Moment::to_utc_string

    fn Moment::to_utc_string(self : Moment) -> String raise Refused

    The Date.prototype.toUTCString shape: Tue, 22 Sep 2026 05:30:05 GMT.

    This is the HTTP date form [Moment::http_text] already writes, on the UTC instant, which is exactly what ECMAScript's toUTCString produces.

    Moment::utcoffset

    fn Moment::utcoffset(self : Moment) -> Span?

    How far the moment's clock is from UTC.

    Moment::weekday

    fn Moment::weekday(self : Moment) -> Int?

    The day of the week of the moment's date, Sunday being 0.

    Moment::zone

    fn Moment::zone(self : Moment) -> Zone?

    The zone a moment carries, or None when it carries none.

    Span

    pub(all) struct Span {
    nanos : Int64
    } derive(Compare, Eq,
    Debug
    )

    A length of time, to the nanosecond, with no calendar attached.

    A span is not a number of months: a month is not a fixed length, so adding one is a calendar operation (Date::plus_months) rather than arithmetic.

    Span::abs

    fn Span::abs(self : Span) -> Span

    The span with its sign dropped.

    Span::add

    fn Span::add(self : Span, other : Span) -> Span

    Two spans added.

    Span::compare

    fn Span::compare(Span, Span) -> Int

    Span::equal

    fn Span::equal(Span, Span) -> Bool

    Span::neg

    fn Span::neg(self : Span) -> Span

    The span negated.

    Span::new

    fn Span::new(days? : Int64, hours? : Int64, minutes? : Int64, seconds? : Int64, millis? : Int64, micros? : Int64, nanos? : Int64) -> Span

    A span made of the usual parts: days, hours, minutes, seconds and fractions.

    Span::not_equal

    fn Span::not_equal(x : Span, y : Span) -> Bool

    Span::of

    fn Span::of(unit : Span, n : Int64) -> Span

    A span of n of these.

    Span::op_ge

    fn Span::op_ge(x : Span, y : Span) -> Bool

    Span::op_gt

    fn Span::op_gt(x : Span, y : Span) -> Bool

    Span::op_le

    fn Span::op_le(x : Span, y : Span) -> Bool

    Span::op_lt

    fn Span::op_lt(x : Span, y : Span) -> Bool

    Span::parts

    fn Span::parts(self : Span) -> (Int, Int, Int)

    The whole days in a span, and the seconds and nanoseconds left over — the three fields a duration is usually stored in.

    Span::seconds

    fn Span::seconds(self : Span) -> Int64

    The span as whole seconds, rounded towards zero.

    Span::sub

    fn Span::sub(self : Span, other : Span) -> Span

    One span less another.

    Span::to_repr

    Span::total_seconds

    fn Span::total_seconds(self : Span) -> Double

    The span as seconds, fraction included.

    Time

    pub(all) struct Time {
    hour : Int
    minute : Int
    second : Int
    nano : Int
    } derive(Compare, Eq,
    Debug
    )

    A time on a twenty-four hour clock, to the nanosecond.

    A second of 60 is allowed: RFC 3339 §5.6 permits it for a leap second, and a timestamp that records one is a real timestamp that a reader should not throw away.
    impl Show for Time

    Time::compare

    fn Time::compare(Time, Time) -> Int

    Time::equal

    fn Time::equal(Time, Time) -> Bool

    Time::nanos

    fn Time::nanos(self : Time) -> Int64

    Nanoseconds since midnight.

    Time::new

    fn Time::new(hour : Int, minute : Int, second? : Int, nano? : Int) -> Time raise Refused

    A time, checked against the clock.

    Time::not_equal

    fn Time::not_equal(x : Time, y : Time) -> Bool

    Time::op_ge

    fn Time::op_ge(x : Time, y : Time) -> Bool

    Time::op_gt

    fn Time::op_gt(x : Time, y : Time) -> Bool

    Time::op_le

    fn Time::op_le(x : Time, y : Time) -> Bool

    Time::op_lt

    fn Time::op_lt(x : Time, y : Time) -> Bool

    Time::output

    fn Time::output(self : Time, logger : &Logger) -> Unit

    Time::replace

    fn Time::replace(self : Time, hour? : Int, minute? : Int, second? : Int, nano? : Int) -> Time raise Refused

    The same time with some parts replaced.

    Time::to_repr

    Time::to_string

    fn Time::to_string(self : Time) -> String

    Zone

    pub(all) enum Zone {
    Utc
    Offset(Int)
    Named(ZoneName)
    Precise(Int64)
    } derive(Eq,
    Debug
    )

    How far the clock is from UTC.

    Utc and Offset(0) are both midnight-at-Greenwich, but they are not the same thing to write down: RFC 3339 §4.3 gives -00:00 to a timestamp whose offset is unknown, and Z to one that is genuinely UTC.

    Offset counts minutes; Named counts seconds and carries the name the zone is filed under, so VV and a Go layout's MST have something to write.

    Zone::abbrev

    fn Zone::abbrev(self : Zone) -> String

    The abbreviation written for the zone: EST, GMT, +0530.

    A named zone read from text that carried no abbreviation falls back to the numeric spelling, which is what a fixed offset writes anyway.

    Zone::equal

    fn Zone::equal(Zone, Zone) -> Bool

    Zone::minutes

    fn Zone::minutes(self : Zone) -> Int

    How far east of UTC the zone is, in minutes, rounded towards zero.

    A named zone whose local mean time is not a whole number of minutes loses the remainder here, which is what the %z forms write and what every other implementation writes for those years.

    Zone::name

    fn Zone::name(self : Zone) -> String

    The name the zone is filed under: EST5EDT for a POSIX rule, the path in the database for a named zone.

    A fixed offset has no name of its own, so it is spelled the way a POSIX rule spells one — UTC, UTC-05:00 — which is what VV writes for it.

    Zone::nanos

    fn Zone::nanos(self : Zone) -> Int64

    How far east of UTC the zone is, in nanoseconds.

    The finest of the three readers, and the one the others are built on: an instant is a count of nanoseconds, so this is what epoch subtracts.

    Zone::not_equal

    fn Zone::not_equal(x : Zone, y : Zone) -> Bool

    Zone::seconds

    fn Zone::seconds(self : Zone) -> Int

    How far east of UTC the zone is, in seconds, rounded towards zero.

    Zone::to_repr

    ZoneName

    pub(all) struct ZoneName {
    name : String
    offset : Int
    abbrev : String
    } derive(Eq,
    Debug
    )

    A zone named by a database, read at one instant.

    The offset and the abbreviation are the ones in force at the instant the moment carries, which is what makes a Zoned a value: it can be compared, written and moved to another zone with no database in reach. Crossing a transition is a question for the rules, so a moment that has to survive one goes back through @zone.Tz::moment.

    The offset is in seconds east of UTC, because a zone's local mean time before standard time was adopted is not a whole number of minutes: New York ran 4:56:02 behind Greenwich. Offsets in whole minutes are what [Zone::Offset] carries, and [Zone::seconds] puts both on the same footing.

    ZoneName::equal

    fn ZoneName::equal(ZoneName, ZoneName) -> Bool

    ZoneName::not_equal

    fn ZoneName::not_equal(x : ZoneName, y : ZoneName) -> Bool

    ZoneName::to_repr

    ANSIC

    let ANSIC : String

    The predefined layouts Go's time package ships with, so a caller need not spell the reference time out by hand.

    DateOnly

    let DateOnly : String

    DateTime

    let DateTime : String

    Kitchen

    let Kitchen : String

    RFC1123

    let RFC1123 : String

    RFC1123Z

    let RFC1123Z : String

    RFC3339

    let RFC3339 : String

    RFC3339Nano

    let RFC3339Nano : String

    RFC822

    let RFC822 : String

    RFC822Z

    let RFC822Z : String

    RFC850

    let RFC850 : String

    RubyDate

    let RubyDate : String

    Stamp

    let Stamp : String

    StampMicro

    let StampMicro : String

    StampMilli

    let StampMilli : String

    StampNano

    let StampNano : String

    TimeOnly

    let TimeOnly : String

    UnixDate

    let UnixDate : String

    asn1

    fn asn1(src : StringView) -> Moment raise Refused

    An ASN.1 UTCTime or GeneralizedTime (ITU-T X.680 §47), as DER writes them.

    This is how a certificate spells its validity. DER requires the Z and whole seconds; UTCTime's two-digit year is read as RFC 5280 §4.1.2.5.1 says — 49 and under are 2000s, 50 and over are 1900s, which is not the same rule HTTP uses.

    day

    let day : Span

    end_of_day

    let end_of_day : Time

    hour

    let hour : Span

    http

    fn http(src : StringView) -> Moment raise Refused

    An HTTP date read (RFC 9110 §5.6.7).

    The preferred IMF-fixdate, and the two obsolete forms a recipient must still accept: RFC 850's Sunday, 06-Nov-94 08:49:37 GMT and asctime's Sun Nov 6 08:49:37 1994. A two-digit year is read as RFC 6265 §5.1.1 says: 69 and under are 2000s, 70 and over are 1900s.

    leap

    fn leap(year : Int) -> Bool

    Whether year has a twenty-ninth of February.

    Every fourth year, except every hundredth, except every four-hundredth — the rule that makes the calendar year 365.2425 days long.

    length

    fn length(year : Int, month : Int) -> Int

    How many days month has in year.

    max_date

    let max_date : Date

    max_year

    let max_year : Int

    midnight

    let midnight : Time

    Midnight, and the last representable moment of a day.

    min_date

    let min_date : Date

    The earliest and latest dates this calendar writes with a four-digit year.

    They are 1-01-01 and 9999-12-31, which is as far as a four-digit year writes; the arithmetic here works outside that range, so these are a convention for writing rather than a limit on computing.

    min_year

    let min_year : Int

    The years this calendar spans.

    minute

    let minute : Span

    nanosecond

    let nanosecond : Span

    Nanoseconds in a second, a minute, an hour and a day.

    of_isoformat

    fn of_isoformat(src : StringView) -> Moment raise Refused

    A moment read from ISO 8601 text, which is what [parse] does.

    parse

    fn parse(src : StringView) -> Moment raise Refused

    A moment read from RFC 3339 text, in whichever of the four shapes it is written.

    2026-09-22T14:30:00Z and …+08:00 are Zoned; without an offset it is Plain; a bare 2026-09-22 is Day and a bare 14:30:00 is Clock. TOML 1.0.0 names these four and allows a space in place of the T, which is what a person writes; RFC 3339 §5.6 allows it too, under NOTE.

    A year outside 0000 to 9999 keeps the expanded form Temporal writes for it — +012026-01-02T03:04:05Z, -000001-01-02 — so what [Moment::isoformat] writes reads back.

    A name in brackets is read when the name is an offset — [UTC], [+09:00], [-0430] — because then it says when the moment was by itself. A name that is a place, [America/New_York], is kept when the text carries the offset beside it, and refused when it does not: the place alone does not say which side of a transition the clock was on.

    parse_layout

    fn parse_layout(src : StringView, layout : StringView) -> Moment raise Refused

    A moment read by a Go reference-time layout.

    The layout is the pattern the text must follow: each field token pulls its value out, and literal characters — including a layout space, which swallows any run of whitespace — must match. A layout that names an offset yields a Zoned moment; one that does not yields Plain, Day or Clock, as [parse] does.

    parse_pattern

    fn parse_pattern(src : StringView, pattern : StringView) -> Moment raise Refused

    A moment read by an LDML date pattern.

    The inverse of [Moment::pattern] for the field letters that carry a value: years, months (numeric or named), days, hours, minutes, seconds, fractions and zone offsets. A pattern that names an offset yields Zoned; one that does not yields Plain, Day or Clock.

    resolution

    let resolution : Span

    The smallest difference two moments can have.

    A nanosecond rather than a microsecond: the clock here is finer, which is what a protocol timestamp and a benchmark both want.

    second

    let second : Span

    strptime

    fn strptime(src : StringView, format : StringView) -> Moment raise Refused

    A moment read by a format string of % directives.

    The directives are the ones [Moment::strftime] writes. Literal characters in the format must match the text exactly, except that whitespace in the format matches any run of whitespace, which is what C's strptime does and what makes a format survive a double space.

    The result is Zoned when the text carried an offset or a raw %s, Plain when it carried a date and a time, Day when only a date, and Clock when only a time — the same four shapes [parse] answers with.