///|
/// An exact instant paired with a time zone and a calendar, so that it has a
/// meaningful wall-clock reading.
///
/// This is the only Temporal type that is both an exact time and a calendar
/// date, which makes it the one where daylight-saving transitions are visible:
/// a day may be 23 or 25 hours long, and adding "one day" is not the same as
/// adding 24 hours.
///
/// Reference:
pub struct ZonedDateTime {
instant : Instant
time_zone : TimeZone
calendar : Calendar
/// The offset in force at `instant`, cached so that reading the wall-clock
/// fields does not need the provider again.
offset : UtcOffset
} derive(Eq)
///|
pub impl Show for ZonedDateTime with fn output(self, logger) {
logger.write_string(
try! self.to_string_with_options(
ToStringRoundingOptions::default(),
DisplayOffset::default(),
DisplayTimeZone::default(),
DisplayCalendar::default(),
),
)
}
///|
pub impl Debug for ZonedDateTime with fn to_repr(self) {
Repr::Repr(self.to_string())
}
///|
/// Creates a zoned date-time from an instant and a time zone.
///
/// ```mbt check
/// test {
/// let zdt = @temporal.ZonedDateTime::try_new(
/// @int128.of_int64(1740827770000000000L),
/// @temporal.TimeZone::OffsetZone(@temporal.UtcOffset::from_minutes(-360)),
/// @temporal.utc_only_provider,
/// )
/// inspect(zdt.hour(), content="5")
/// }
/// ```
pub fn[P : TimeZoneProvider] ZonedDateTime::try_new(
epoch_nanoseconds : @int128.Int128,
time_zone : TimeZone,
provider : P,
calendar? : Calendar = Calendar::ISO,
) -> ZonedDateTime raise TemporalError {
let instant = Instant::from_epoch_nanoseconds(epoch_nanoseconds)
let offset = time_zone.utc_offset_for(epoch_nanoseconds, provider)
{ instant, time_zone, calendar, offset }
}
///|
/// Returns the underlying instant.
pub fn ZonedDateTime::to_instant(self : ZonedDateTime) -> Instant {
self.instant
}
///|
/// Returns the nanoseconds since the epoch.
pub fn ZonedDateTime::epoch_nanoseconds(self : ZonedDateTime) -> @int128.Int128 {
self.instant.epoch_nanoseconds
}
///|
/// Returns the milliseconds since the epoch.
pub fn ZonedDateTime::epoch_milliseconds(self : ZonedDateTime) -> Int64 {
self.instant.epoch_milliseconds()
}
///|
/// Returns the time zone.
pub fn ZonedDateTime::time_zone(self : ZonedDateTime) -> TimeZone {
self.time_zone
}
///|
/// Returns the calendar.
pub fn ZonedDateTime::calendar(self : ZonedDateTime) -> Calendar {
self.calendar
}
///|
/// Returns the UTC offset in force at this instant.
pub fn ZonedDateTime::offset(self : ZonedDateTime) -> UtcOffset {
self.offset
}
///|
/// Returns the local wall-clock date and time.
pub fn ZonedDateTime::to_plain_date_time(self : ZonedDateTime) -> PlainDateTime {
PlainDateTime::from_iso(self.local_iso(), self.calendar)
}
///|
/// Returns the local date and time as an ISO record.
fn ZonedDateTime::local_iso(self : ZonedDateTime) -> IsoDateTime {
IsoDateTime::from_epoch_nanoseconds(
self.instant.epoch_nanoseconds,
self.offset.nanoseconds(),
)
}
///|
/// Returns the local date.
pub fn ZonedDateTime::to_plain_date(self : ZonedDateTime) -> PlainDate {
PlainDate::from_iso(self.local_iso().date, self.calendar)
}
///|
/// Returns the local time.
pub fn ZonedDateTime::to_plain_time(self : ZonedDateTime) -> PlainTime {
PlainTime::from_iso(self.local_iso().time)
}
///|
/// Returns the local year.
pub fn ZonedDateTime::year(self : ZonedDateTime) -> Int {
self.local_iso().date.year
}
///|
/// Returns the local month, 1-based.
pub fn ZonedDateTime::month(self : ZonedDateTime) -> Int {
self.local_iso().date.month
}
///|
/// Returns the local day of the month.
pub fn ZonedDateTime::day(self : ZonedDateTime) -> Int {
self.local_iso().date.day
}
///|
/// Returns the local hour.
pub fn ZonedDateTime::hour(self : ZonedDateTime) -> Int {
self.local_iso().time.hour
}
///|
/// Returns the local minute.
pub fn ZonedDateTime::minute(self : ZonedDateTime) -> Int {
self.local_iso().time.minute
}
///|
/// Returns the local second.
pub fn ZonedDateTime::second(self : ZonedDateTime) -> Int {
self.local_iso().time.second
}
///|
/// Returns the local millisecond.
pub fn ZonedDateTime::millisecond(self : ZonedDateTime) -> Int {
self.local_iso().time.millisecond
}
///|
/// Returns the local microsecond within the millisecond.
pub fn ZonedDateTime::microsecond(self : ZonedDateTime) -> Int {
self.local_iso().time.microsecond
}
///|
/// Returns the local nanosecond within the microsecond.
pub fn ZonedDateTime::nanosecond(self : ZonedDateTime) -> Int {
self.local_iso().time.nanosecond
}
///|
/// Returns the local ISO day of the week, Monday = 1 through Sunday = 7.
pub fn ZonedDateTime::day_of_week(self : ZonedDateTime) -> Int {
let date = self.local_iso().date
iso_day_of_week(date.year, date.month, date.day)
}
///|
/// Returns the local 1-based day of the year.
pub fn ZonedDateTime::day_of_year(self : ZonedDateTime) -> Int {
let date = self.local_iso().date
iso_day_of_year(date.year, date.month, date.day)
}
///|
/// Compares two zoned date-times by the instant they name, ignoring their
/// time zones and calendars.
pub fn ZonedDateTime::compare_instant(
self : ZonedDateTime,
other : ZonedDateTime,
) -> Int {
self.instant.compare(other.instant)
}
///|
/// Returns whether both name the same instant in the same zone and calendar.
pub fn ZonedDateTime::equals(
self : ZonedDateTime,
other : ZonedDateTime,
) -> Bool {
self.instant == other.instant &&
self.time_zone == other.time_zone &&
self.calendar == other.calendar
}
///|
/// Returns a copy in a different time zone, naming the same instant.
pub fn[P : TimeZoneProvider] ZonedDateTime::with_time_zone(
self : ZonedDateTime,
time_zone : TimeZone,
provider : P,
) -> ZonedDateTime raise TemporalError {
ZonedDateTime::try_new(
self.instant.epoch_nanoseconds,
time_zone,
provider,
calendar=self.calendar,
)
}
///|
/// Returns the number of hours in the local day, which is 23 or 25 across a
/// daylight-saving transition.
pub fn[P : TimeZoneProvider] ZonedDateTime::hours_in_day(
self : ZonedDateTime,
provider : P,
) -> Double raise TemporalError {
let local_iso = self.local_iso()
let today = IsoDateTime::new_unchecked(local_iso.date, iso_time_midnight)
let tomorrow_date = IsoDate::balance(
local_iso.date.year,
local_iso.date.month,
local_iso.date.day + 1,
)
let tomorrow = IsoDateTime::new_unchecked(tomorrow_date, iso_time_midnight)
let start = self.time_zone.epoch_nanoseconds_for(today, Compatible, provider).nanoseconds
let end = self.time_zone.epoch_nanoseconds_for(tomorrow, Compatible, provider).nanoseconds
exact_ratio_to_double(end.sub(start), @int128.of_int64(NS_PER_HOUR))
}
///|
/// `AddDurationToZonedDateTime`: adds a duration.
///
/// The calendar part is applied to the local wall-clock date and re-resolved
/// through the time zone, so that "one day later" stays at the same local
/// time even across a transition. The time part is then added as exact
/// elapsed time.
pub fn[P : TimeZoneProvider] ZonedDateTime::add(
self : ZonedDateTime,
duration : Duration,
provider : P,
overflow? : Overflow = Constrain,
) -> ZonedDateTime raise TemporalError {
let internal = duration.to_internal()
let epoch = self.add_internal_duration(internal, provider, overflow)
ZonedDateTime::try_new(
epoch,
self.time_zone,
provider,
calendar=self.calendar,
)
}
///|
/// Subtracts a duration.
pub fn[P : TimeZoneProvider] ZonedDateTime::subtract(
self : ZonedDateTime,
duration : Duration,
provider : P,
overflow? : Overflow = Constrain,
) -> ZonedDateTime raise TemporalError {
self.add(duration.negated(), provider, overflow~)
}
///|
/// `AddZonedDateTime`: returns the instant reached by adding an internal
/// duration record.
fn[P : TimeZoneProvider] ZonedDateTime::add_internal_duration(
self : ZonedDateTime,
duration : InternalDurationRecord,
provider : P,
overflow : Overflow,
) -> @int128.Int128 raise TemporalError {
// With no calendar part there is nothing zone-dependent to resolve, so the
// time can be added straight to the instant.
if duration.date.sign() is Zero {
return self.instant.epoch_nanoseconds.add(duration.time.nanoseconds())
}
let local_iso = self.local_iso()
let added_date = local_iso.date.add_date_duration(duration.date, overflow)
let intermediate = IsoDateTime::new_unchecked(added_date, local_iso.time)
let intermediate_ns = self.time_zone.epoch_nanoseconds_for(
intermediate,
Compatible,
provider,
).nanoseconds
intermediate_ns.add(duration.time.nanoseconds())
}
///|
/// `DifferenceTemporalZonedDateTime`.
fn[P : TimeZoneProvider] ZonedDateTime::diff(
self : ZonedDateTime,
operation : DifferenceOperation,
other : ZonedDateTime,
settings : DifferenceSettings,
provider : P,
) -> Duration raise TemporalError {
if self.calendar != other.calendar {
raise RangeError("cannot compare zoned date-times in different calendars")
}
let resolved = ResolvedRoundingOptions::from_diff_settings(
settings,
operation,
UnitGroup::DateTime,
Hour,
Nanosecond,
)
// A difference measured purely in time units does not depend on the zone,
// so it reduces to a difference between the two instants.
if resolved.largest_unit.is_time_unit() {
let internal = self.instant.diff_internal(other.instant, resolved)
let result = Duration::from_internal(internal, resolved.largest_unit)
return match operation {
Until => result
Since => result.negated()
}
}
// Day lengths differ between zones, so a difference that reaches days or
// larger is only meaningful within one zone.
if self.time_zone != other.time_zone {
raise RangeError(
"time zones must match when the difference uses date units",
)
}
if self.instant == other.instant {
return duration_zero
}
let internal = self.diff_with_rounding(other.instant, resolved, provider)
// The date part is already in calendar units; only the time part is
// expanded, and it never exceeds a day here.
let result = Duration::from_internal(internal, Hour)
match operation {
Until => result
Since => result.negated()
}
}
///|
/// `DifferenceZonedDateTimeWithRounding`.
fn[P : TimeZoneProvider] ZonedDateTime::diff_with_rounding(
self : ZonedDateTime,
other : Instant,
options : ResolvedRoundingOptions,
provider : P,
) -> InternalDurationRecord raise TemporalError {
if options.largest_unit.is_time_unit() {
return self.instant.diff_internal(other, options)
}
let diff = self.diff_zoned_date_time(other, options.largest_unit, provider)
if options.smallest_unit is Nanosecond && options.increment.get() == 1 {
return diff
}
diff.round_relative_duration(
self.instant.epoch_nanoseconds,
other.epoch_nanoseconds,
self.to_plain_date_time(),
Some(self.time_zone),
provider,
options,
)
}
///|
/// `DifferenceZonedDateTime`: the unrounded difference between two instants in
/// this zone.
///
/// The calendar walk is done on local wall-clock dates, but a day boundary in
/// local time need not be a whole day of elapsed time. The loop below backs the
/// end date off by up to two days until the leftover time agrees in sign with
/// the overall difference, which is what keeps the two parts consistent across
/// a daylight-saving transition.
fn[P : TimeZoneProvider] ZonedDateTime::diff_zoned_date_time(
self : ZonedDateTime,
other : Instant,
largest_unit : DateTimeUnit,
provider : P,
) -> InternalDurationRecord raise TemporalError {
if self.instant.epoch_nanoseconds == other.epoch_nanoseconds {
return internal_duration_zero
}
let start = self.local_iso()
let end = IsoDateTime::from_epoch_nanoseconds(
other.epoch_nanoseconds,
self.time_zone.offset_nanoseconds_for(other.epoch_nanoseconds, provider),
)
if start.date == end.date {
let time_duration = TimeDuration::from_nanosecond_difference(
other.epoch_nanoseconds,
self.instant.epoch_nanoseconds,
)
return InternalDurationRecord::new(DateDuration::default(), time_duration)
}
let difference = other.epoch_nanoseconds.sub(self.instant.epoch_nanoseconds)
let sign = if difference.is_negative() { -1 } else { 1 }
// Going forwards may need two corrections, because the end time can land
// before the start time in wall-clock terms and then again after the shift.
let max_correction = if sign > 0 { 2 } else { 1 }
let initial_time_duration = start.time.diff(end.time)
let mut day_correction = if initial_time_duration.sign() ==
Sign::of_int(sign).negate() {
1
} else {
0
}
let mut intermediate_date = end.date
let mut time_duration = time_duration_zero
let mut success = false
while day_correction <= max_correction && !success {
intermediate_date = IsoDate::balance(
end.date.year,
end.date.month,
end.date.day - day_correction * sign,
)
let intermediate = IsoDateTime::new_unchecked(intermediate_date, start.time)
let intermediate_ns = self.time_zone.epoch_nanoseconds_for(
intermediate,
Compatible,
provider,
).nanoseconds
time_duration = TimeDuration::from_nanosecond_difference(
other.epoch_nanoseconds,
intermediate_ns,
)
if sign != -time_duration.sign().to_int() {
success = true
}
day_correction = day_correction + 1
}
if !success {
raise AssertError("no day correction reconciled the zoned difference")
}
let date_largest = largest_unit.larger(Day)
let date_diff = start.date.diff_iso_date(intermediate_date, date_largest)
InternalDurationRecord::new(date_diff, time_duration)
}
///|
/// Returns the duration from this zoned date-time until `other`.
pub fn[P : TimeZoneProvider] ZonedDateTime::until(
self : ZonedDateTime,
other : ZonedDateTime,
provider : P,
settings? : DifferenceSettings = DifferenceSettings::default(),
) -> Duration raise TemporalError {
self.diff(Until, other, settings, provider)
}
///|
/// Returns the duration from `other` until this zoned date-time.
pub fn[P : TimeZoneProvider] ZonedDateTime::since(
self : ZonedDateTime,
other : ZonedDateTime,
provider : P,
settings? : DifferenceSettings = DifferenceSettings::default(),
) -> Duration raise TemporalError {
self.diff(Since, other, settings, provider)
}
///|
/// Returns the instant at the start of the local day.
///
/// This is usually midnight, but on a day whose midnight does not exist it is
/// the first instant the day does reach.
pub fn[P : TimeZoneProvider] ZonedDateTime::start_of_day(
self : ZonedDateTime,
provider : P,
) -> ZonedDateTime raise TemporalError {
let local_iso = self.local_iso()
let midnight = IsoDateTime::new_unchecked(local_iso.date, iso_time_midnight)
let epoch = self.time_zone.epoch_nanoseconds_for(
midnight,
Compatible,
provider,
).nanoseconds
ZonedDateTime::try_new(
epoch,
self.time_zone,
provider,
calendar=self.calendar,
)
}
///|
/// Parses a zoned date-time from an RFC 9557 string.
///
/// The string must carry a time zone annotation, such as
/// `2025-03-01T11:16:10Z[UTC]`.
///
/// ```mbt check
/// test {
/// let zdt = @temporal.ZonedDateTime::of_string(
/// "2025-03-01T11:16:10Z[UTC]", @temporal.utc_only_provider,
/// )
/// inspect(zdt.hour(), content="11")
/// }
/// ```
pub fn[P : TimeZoneProvider] ZonedDateTime::of_string(
source : String,
provider : P,
disambiguation? : Disambiguation = Compatible,
offset_option? : OffsetDisambiguation = Reject,
) -> ZonedDateTime raise TemporalError {
let record = parse_zoned_date_time_string(source)
let date = temporal_unwrap(record.date, "date component")
let time_zone = temporal_unwrap(record.time_zone, "time zone annotation")
let calendar = calendar_of(record)
let iso_date = IsoDate::new_with_overflow(
date.year,
date.month,
date.day,
Reject,
)
let iso = IsoDateTime::new(iso_date, time_or_midnight(record))
let epoch = match record.offset {
// With no offset in the string, the local time is resolved through the
// zone's own rules.
None =>
time_zone.epoch_nanoseconds_for(iso, disambiguation, provider).nanoseconds
Some(ZDesignator) =>
// `Z` names an exact instant directly.
iso.as_nanoseconds()
Some(NumericOffset(offset)) =>
resolve_offset(
iso, offset, time_zone, disambiguation, offset_option, provider,
)
}
ZonedDateTime::try_new(epoch, time_zone, provider, calendar~)
}
///|
/// Reconciles an explicit offset in a string against the zone's own rules.
fn[P : TimeZoneProvider] resolve_offset(
iso : IsoDateTime,
offset : UtcOffset,
time_zone : TimeZone,
disambiguation : Disambiguation,
offset_option : OffsetDisambiguation,
provider : P,
) -> @int128.Int128 raise TemporalError {
match offset_option {
// `use` trusts the string's offset outright.
Use => iso.as_nanoseconds().sub(@int128.of_int64(offset.nanoseconds()))
// `ignore` discards it and resolves through the zone.
Ignore =>
time_zone.epoch_nanoseconds_for(iso, disambiguation, provider).nanoseconds
Prefer | Reject => {
let candidates = time_zone.possible_epoch_nanoseconds_for(iso, provider)
let target = iso
.as_nanoseconds()
.sub(@int128.of_int64(offset.nanoseconds()))
let matched = match candidates {
One(ns, _) => if ns == target { Some(ns) } else { None }
Two(first, _, second, _) =>
if first == target {
Some(first)
} else if second == target {
Some(second)
} else {
None
}
Zero(_, _) => None
}
match matched {
Some(ns) => ns
None =>
if offset_option is Reject {
raise RangeError(
"the UTC offset in the string does not match the time zone",
)
} else {
time_zone.epoch_nanoseconds_for(iso, disambiguation, provider).nanoseconds
}
}
}
}
}
///|
/// Renders the zoned date-time in RFC 9557 form.
pub fn ZonedDateTime::to_string_with_options(
self : ZonedDateTime,
options : ToStringRoundingOptions,
display_offset : DisplayOffset,
display_time_zone : DisplayTimeZone,
display_calendar : DisplayCalendar,
) -> String raise TemporalError {
let resolved = options.resolve()
let rounding = ResolvedRoundingOptions::from_to_string_options(resolved)
let length = temporal_unwrap(
rounding.smallest_unit.as_nanoseconds(),
"time unit length",
)
let increment = @int128.of_int64(length).mul(
@int128.of_int(rounding.increment.get()),
)
let rounded = IncrementRounder::from_signed_num(
self.instant.epoch_nanoseconds,
increment,
).round_as_if_positive(rounding.rounding_mode)
let iso = IsoDateTime::from_epoch_nanoseconds(
rounded,
self.offset.nanoseconds(),
)
let buf = StringBuilder::new()
write_date(buf, iso.date.year, iso.date.month, iso.date.day)
buf.write_string("T")
write_time(
buf,
iso.time.hour,
iso.time.minute,
iso.time.second,
iso.time.subsecond_nanoseconds(),
resolved.precision,
true,
)
if display_offset is Auto {
buf.write_string(self.offset.to_string())
}
match display_time_zone {
Never => ()
Auto => buf.write_string("[\{self.time_zone.identifier()}]")
Critical => buf.write_string("[!\{self.time_zone.identifier()}]")
}
write_calendar_annotation(buf, self.calendar, display_calendar)
buf.to_string()
}
///|
/// Renders the zoned date-time in RFC 9557 form with default options.
pub fn ZonedDateTime::to_string(self : ZonedDateTime) -> String {
try! self.to_string_with_options(
ToStringRoundingOptions::default(),
DisplayOffset::default(),
DisplayTimeZone::default(),
DisplayCalendar::default(),
)
}
///|
/// Rounds the zoned date-time to a multiple of the given unit.
///
/// Rounding to whole days uses the actual length of the local day, which may
/// be 23 or 25 hours across a daylight-saving transition.
pub fn[P : TimeZoneProvider] ZonedDateTime::round(
self : ZonedDateTime,
options : RoundingOptions,
provider : P,
) -> ZonedDateTime raise TemporalError {
let resolved = ResolvedRoundingOptions::from_datetime_options(options)
if resolved.is_noop() {
return self
}
let this_ns = self.instant.epoch_nanoseconds
let local_iso = self.local_iso()
if resolved.smallest_unit is Day {
let start_date = local_iso.date
let end_date = IsoDate::balance(
start_date.year,
start_date.month,
start_date.day + 1,
)
let start = self.start_of_day_for(start_date, provider)
let end = self.start_of_day_for(end_date, provider)
if this_ns.compare(start.nanoseconds) < 0 {
raise RangeError("the instant is outside the bounds of its local day")
}
// Clamp to just before the next day so that an instant exactly on the
// boundary rounds within this day rather than the next.
let clamped = this_ns.min(end.nanoseconds.sub(@int128.one))
let day_length = end.nanoseconds.sub(start.nanoseconds)
let progress = clamped.sub(start.nanoseconds)
let rounded = if day_length.is_zero() {
@int128.zero
} else {
IncrementRounder::from_signed_num(progress, day_length.abs()).round(
resolved.rounding_mode,
)
}
return ZonedDateTime::try_new(
start.nanoseconds.add(rounded),
self.time_zone,
provider,
calendar=self.calendar,
)
}
let rounded = local_iso.round(resolved)
// The rounded wall-clock time is re-anchored using the offset that was in
// force, so that rounding does not silently jump across a transition.
let epoch = resolve_offset(
rounded,
self.offset,
self.time_zone,
Compatible,
Prefer,
provider,
)
ZonedDateTime::try_new(
epoch,
self.time_zone,
provider,
calendar=self.calendar,
)
}
///|
/// Returns the first instant of the given local date in this time zone.
fn[P : TimeZoneProvider] ZonedDateTime::start_of_day_for(
self : ZonedDateTime,
date : IsoDate,
provider : P,
) -> EpochNanosecondsAndOffset raise TemporalError {
self.time_zone.epoch_nanoseconds_for(
IsoDateTime::new_unchecked(date, iso_time_midnight),
Compatible,
provider,
)
}