// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// The rest of the surface beyond the calendar itself: the ordinal, the ISO week
// date, combining and replacing parts, the epoch both ways, and the text forms
// RFC 3339, TOML, HTTP and ASN.1 name. The directive language lives in
// `strftime.mbt`.
///|
/// 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.
pub let min_date : Date = { year: 1, month: 1, day: 1, }
///|
pub let max_date : Date = { year: 9999, month: 12, day: 31, }
///|
/// Midnight, and the last representable moment of a day.
pub let midnight : Time = { hour: 0, minute: 0, second: 0, nano: 0, }
///|
pub let end_of_day : Time = {
hour: 23,
minute: 59,
second: 59,
nano: 999_999_999,
}
///|
/// 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.
pub fn Date::ordinal(self : Date) -> Int32 {
self.days() + 719163
}
///|
/// The date with that ordinal.
pub fn Date::of_ordinal(n : Int32) -> Date {
Date::of_days(n - 719163)
}
///|
/// 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.
pub fn Date::isocalendar(self : Date) -> (Int32, Int32, Int32) {
let weekday = self.isoweekday()
// The Thursday of this week decides which year the week counts against.
let thursday = self.plus_days(4 - weekday)
let year = thursday.year
let jan1 = { year, month: 1, day: 1, }
let week = (thursday.days() - jan1.days()) / 7 + 1
(year, week, weekday)
}
///|
/// 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.
pub fn Date::isoweekday(self : Date) -> Int32 {
let d = self.weekday()
if d == 0 {
7
} else {
d
}
}
///|
/// The date named by an ISO week date.
pub fn Date::of_isocalendar(
year : Int32,
week : Int32,
weekday : Int32,
) -> Date raise Refused {
if week < 1 || week > 53 {
raise Range(field="week", got=week, low=1, high=53)
}
if weekday < 1 || weekday > 7 {
raise Range(field="weekday", got=weekday, low=1, high=7)
}
let jan4 : Date = { year, month: 1, day: 4, }
// The fourth of January is always in week one, so the week's Monday follows.
let week1 = jan4.plus_days(1 - jan4.isoweekday())
week1.plus_days((week - 1) * 7 + weekday - 1)
}
///|
/// 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.
pub fn Moment::combine(date : Date, time : Time, zone? : Zone) -> Moment {
match zone {
Some(z) => Zoned(date~, time~, zone=z)
None => Plain(date~, time~)
}
}
///|
/// The same date with some parts replaced.
pub fn Date::replace(
self : Date,
year? : Int32,
month? : Int32,
day? : Int32,
) -> Date raise Refused {
Date::new(
match year {
Some(v) => v
None => self.year
},
match month {
Some(v) => v
None => self.month
},
match day {
Some(v) => v
None => self.day
},
)
}
///|
/// The same time with some parts replaced.
pub fn Time::replace(
self : Time,
hour? : Int32,
minute? : Int32,
second? : Int32,
nano? : Int32,
) -> Time raise Refused {
Time::new(
match hour {
Some(v) => v
None => self.hour
},
match minute {
Some(v) => v
None => self.minute
},
second=match second {
Some(v) => v
None => self.second
},
nano=match nano {
Some(v) => v
None => self.nano
},
)
}
///|
/// 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.
pub fn Moment::of_epoch(
seconds : Int64,
nano~ : Int32 = 0,
zone~ : Zone = Utc,
) -> Moment {
let offset = match zone {
Utc => 0L
Offset(m) => m.to_int64() * 60L
}
let wall = seconds + offset
let mut days = wall / 86400L
let mut rest = wall % 86400L
if rest < 0L {
rest = rest + 86400L
days = days - 1L
}
Zoned(
date=Date::of_days(days.to_int()),
time={
hour: (rest / 3600L).to_int(),
minute: (rest / 60L % 60L).to_int(),
second: (rest % 60L).to_int(),
nano,
},
zone~,
)
}
///|
/// One date less another, as a span of whole days.
pub fn Date::diff(self : Date, other : Date) -> Span {
Span::of(day, (self.days() - other.days()).to_int64())
}
///|
/// 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.
pub fn Moment::diff(self : Moment, other : Moment) -> Span? {
match (self.epoch(), other.epoch()) {
(Some(a), Some(b)) => Some(Span::of(second, a - b))
_ =>
match (self, other) {
(Plain(date~, time~), Plain(date=d2, time=t2)) =>
Some({
nanos: (date.days() - d2.days()).to_int64() * day.nanos +
time.nanos() -
t2.nanos(),
})
(Day(date~), Day(date=d2)) => Some(date.diff(d2))
(Clock(time~), Clock(time=t2)) =>
Some({ nanos: time.nanos() - t2.nanos(), })
_ => None
}
}
}
///|
/// The span as seconds, fraction included.
pub fn Span::total_seconds(self : Span) -> Double {
self.nanos.to_double() / 1.0e9
}
///|
/// The whole days in a span, and the seconds and nanoseconds left over — the three
/// fields a duration is usually stored in.
pub fn Span::parts(self : Span) -> (Int32, Int32, Int32) {
let mut days = self.nanos / day.nanos
let mut rest = self.nanos % day.nanos
if rest < 0L {
rest = rest + day.nanos
days = days - 1L
}
(
days.to_int(),
(rest / 1_000_000_000L).to_int(),
(rest % 1_000_000_000L).to_int(),
)
}
///|
/// The years this calendar spans.
pub let min_year : Int32 = 1
///|
pub let max_year : Int32 = 9999
///|
/// 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.
pub let resolution : Span = nanosecond
///|
/// The date out of a moment.
///
/// `None` for a bare time, which has no date to give.
pub fn Moment::date(self : Moment) -> Date? {
match self {
Zoned(date~, ..) | Plain(date~, ..) | Day(date~) => Some(date)
Clock(_) => None
}
}
///|
/// The time out of a moment.
///
/// Midnight for a bare date, which is the time it names.
pub fn Moment::time(self : Moment) -> Time {
match self {
Zoned(time~, ..) | Plain(time~, ..) | Clock(time~) => time
Day(_) => midnight
}
}
///|
/// The zone a moment carries, or `None` when it carries none.
pub fn Moment::zone(self : Moment) -> Zone? {
match self {
Zoned(zone~, ..) => Some(zone)
_ => None
}
}
///|
/// How far the moment's clock is from UTC.
pub fn Moment::utcoffset(self : Moment) -> Span? {
match self.zone() {
Some(Utc) => Some(Span::of(minute, 0L))
Some(Offset(m)) => Some(Span::of(minute, m.to_int64()))
None => None
}
}
///|
/// 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.
pub fn Moment::astimezone(self : Moment, zone : Zone) -> Moment? {
match self.epoch() {
Some(secs) => Some(Moment::of_epoch(secs, nano=self.time().nano, zone~))
None => None
}
}
///|
/// The same moment with some parts replaced.
pub fn Moment::replace(
self : Moment,
year? : Int32,
month? : Int32,
day? : Int32,
hour? : Int32,
minute? : Int32,
second? : Int32,
nano? : Int32,
zone? : Zone,
) -> Moment raise Refused {
let d = match self.date() {
Some(d) => Some(d.replace(year?, month?, day?))
None => None
}
let t = self.time().replace(hour?, minute?, second?, nano?)
match (d, zone, self) {
(Some(date), Some(z), _) => Zoned(date~, time=t, zone=z)
(Some(date), None, Zoned(zone~, ..)) => Zoned(date~, time=t, zone~)
(Some(date), None, Day(_)) => Day(date~)
(Some(date), None, _) => Plain(date~, time=t)
(None, _, _) => Clock(time=t)
}
}
///|
/// The day of the week of the moment's date, Sunday being 0.
pub fn Moment::weekday(self : Moment) -> Int32? {
self.date().map(d => d.weekday())
}
///|
/// The ISO week date of the moment's date.
pub fn Moment::isocalendar(self : Moment) -> (Int32, Int32, Int32)? {
self.date().map(d => d.isocalendar())
}
///|
/// Days since 0001-01-01 of the moment's date.
pub fn Moment::ordinal(self : Moment) -> Int32? {
self.date().map(d => d.ordinal())
}
///|
/// 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"`.
pub fn Moment::isoformat(
self : Moment,
sep~ : Char = 'T',
fraction? : Int32,
offset~ : Bool = true,
) -> String {
match self {
Zoned(date~, time~, zone~) =>
text_of(date) +
sep.to_string() +
iso_time(time, fraction) +
iso_zone(Some(zone), offset)
Plain(date~, time~) =>
text_of(date) + sep.to_string() + iso_time(time, fraction)
Day(date~) => text_of(date)
Clock(time~) => iso_time(time, fraction)
}
}
///|
/// The time part with the fractional-second digits `fraction` asks for.
fn iso_time(t : Time, fraction : Int32?) -> String {
let head = pad(t.hour, 2) + ":" + pad(t.minute, 2) + ":" + pad(t.second, 2)
match fraction {
Some(n) if n <= 0 => head
Some(n) => {
let n = if n > 9 { 9 } else { n }
head + "." + pad(t.nano / power10(9 - n), n)
}
None => {
if t.nano == 0 {
return head
}
let mut frac = pad(t.nano, 9)
while frac.length() > 1 && frac[frac.length() - 1].to_int() == 48 {
frac = frac[0:frac.length() - 1].to_owned()
}
head + "." + frac
}
}
}
///|
/// The zone part, or nothing when the moment carries none or `show` is false.
fn iso_zone(z : Zone?, show : Bool) -> String {
if !show {
return ""
}
match z {
None => ""
Some(Utc) => "Z"
Some(Offset(m)) => {
let sign = if m < 0 { "-" } else { "+" }
let a = if m < 0 { -m } else { m }
sign + pad(a / 60, 2) + ":" + pad(a % 60, 2)
}
}
}
///|
/// 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.
pub fn Moment::to_iso_string(self : Moment) -> String {
let (d0, t0, z) = match self {
Zoned(date~, time~, zone~) => (date, time, Some(zone))
Plain(date~, time~) => (date, time, None)
Day(date~) => (date, midnight, None)
Clock(time~) => ({ year: 1900, month: 1, day: 1, }, time, None)
}
let (date, time) = match z {
Some(_) =>
match self.epoch() {
Some(secs) =>
match Moment::of_epoch(secs, nano=self.time().nano, zone=Utc) {
Zoned(date~, time~, ..) => (date, time)
_ => (d0, t0)
}
None => (d0, t0)
}
None => (d0, t0)
}
text_of(date) +
"T" +
pad(time.hour, 2) +
":" +
pad(time.minute, 2) +
":" +
pad(time.second, 2) +
"." +
pad(time.nano / 1_000_000, 3) +
"Z"
}
///|
/// 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.
pub fn Moment::to_utc_string(self : Moment) -> String raise Refused {
self.http_text()
}
///|
/// A moment read from ISO 8601 text, which is what
/// [`parse`] does.
pub fn of_isoformat(src : StringView) -> Moment raise Refused {
parse(src)
}
///|
/// 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.
pub fn Moment::ctime(self : Moment) -> String {
let d = match self.date() {
Some(d) => d
None => { year: 1900, month: 1, day: 1, }
}
let t = self.time()
let day_text = if d.day < 10 {
" " + d.day.to_string()
} else {
d.day.to_string()
}
weekdays[d.weekday()] +
" " +
months[d.month - 1] +
" " +
day_text +
" " +
pad(t.hour, 2) +
":" +
pad(t.minute, 2) +
":" +
pad(t.second, 2) +
" " +
d.year.to_string()
}
///|
/// 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.
pub fn Moment::timetuple(
self : Moment,
) -> (Int32, Int32, Int32, Int32, Int32, Int32, Int32, Int32, Int32) {
let d = match self.date() {
Some(d) => d
None => { year: 1900, month: 1, day: 1, }
}
let t = self.time()
(
d.year,
d.month,
d.day,
t.hour,
t.minute,
t.second,
d.isoweekday() - 1,
d.yearday(),
-1,
)
}
///|
/// Seconds since the epoch as a `Double`, fraction included.
pub fn Moment::timestamp(self : Moment) -> Double? {
self.epoch().map(s => s.to_double() + self.time().nano.to_double() / 1.0e9)
}
///|
/// A span made of the usual parts: days, hours, minutes, seconds and fractions.
pub fn Span::new(
days~ : Int64 = 0,
hours~ : Int64 = 0,
minutes~ : Int64 = 0,
seconds~ : Int64 = 0,
millis~ : Int64 = 0,
micros~ : Int64 = 0,
nanos~ : Int64 = 0,
) -> Span {
{
nanos: days * day.nanos +
hours * hour.nanos +
minutes * minute.nanos +
seconds * second.nanos +
millis * 1_000_000L +
micros * 1_000L +
nanos,
}
}
///|
/// The span negated.
pub fn Span::neg(self : Span) -> Span {
{ nanos: -self.nanos, }
}
///|
/// The span with its sign dropped.
pub fn Span::abs(self : Span) -> Span {
{ nanos: if self.nanos < 0L { -self.nanos } else { self.nanos }, }
}