///|
/// The point a duration's calendar units are measured against.
///
/// Years, months, weeks and days have no fixed length, so rounding or totalling
/// a duration that uses them needs a starting point to resolve them from.
pub(all) enum RelativeTo {
/// Measure from a calendar date, treating every day as 24 hours.
DateRelative(PlainDate)
/// Measure from an exact instant in a time zone, so that daylight-saving
/// transitions make some days longer or shorter than 24 hours.
ZonedRelative(ZonedDateTime)
} derive(Debug)
///|
/// Parses a `relativeTo` value, preferring a zoned reading when the string
/// carries a time zone annotation.
///
/// ```mbt check
/// test {
/// let relative = @temporal.RelativeTo::of_string(
/// "2020-01-01", @temporal.utc_only_provider,
/// )
/// inspect(relative is DateRelative(_), content="true")
/// }
/// ```
pub fn[P : TimeZoneProvider] RelativeTo::of_string(
source : String,
provider : P,
) -> RelativeTo raise TemporalError {
let record = parse_date_time_string(source) catch {
_ => parse_zoned_date_time_string(source)
}
match record.time_zone {
None => {
let date = temporal_unwrap(record.date, "date component")
DateRelative(
PlainDate::new_with_overflow(
date.year,
date.month,
date.day,
Reject,
calendar_of(record),
),
)
}
Some(_) =>
ZonedRelative(
ZonedDateTime::of_string(source, provider, offset_option=Reject),
)
}
}
///|
/// `GetTemporalRoundingOptions` for `Duration.round`.
///
/// Rounding a duration is unusual among the round methods in that both
/// `largestUnit` and `smallestUnit` may be given, and at least one must be.
fn resolve_duration_rounding_options(
duration : Duration,
options : RoundingOptions,
) -> ResolvedRoundingOptions raise TemporalError {
let increment = options.increment.unwrap_or(rounding_increment_one)
let rounding_mode = options.rounding_mode.unwrap_or(HalfExpand)
UnitGroup::DateTime.validate_unit(options.smallest_unit, None) |> ignore
let smallest_unit = options.smallest_unit.unwrap_or(Nanosecond)
let existing_largest_unit = duration.default_largest_unit()
let default_largest_unit = existing_largest_unit.larger(smallest_unit)
let largest_unit = match options.largest_unit {
None | Some(Auto) => default_largest_unit
Some(unit) => unit
}
if options.largest_unit is None && options.smallest_unit is None {
raise RangeError("at least one of smallestUnit and largestUnit is required")
}
if largest_unit.larger(smallest_unit) != largest_unit {
raise RangeError("smallestUnit must not be larger than largestUnit")
}
if smallest_unit.to_maximum_rounding_increment() is Some(maximum) {
increment.validate(maximum.to_int64(), false)
}
// An increment above one only makes sense for the smallest unit in play; a
// date unit that is not also the largest would leave an ambiguous remainder.
if increment > rounding_increment_one &&
largest_unit != smallest_unit &&
smallest_unit.is_date_unit() {
raise RangeError(
"a roundingIncrement above 1 requires smallestUnit to equal largestUnit for date units",
)
}
{ largest_unit, smallest_unit, increment, rounding_mode }
}
///|
/// Rounds the duration.
///
/// `relative_to` is required whenever the duration uses calendar units, or
/// when rounding to one.
///
/// ```mbt check
/// test {
/// let d = @temporal.Duration::of(hours=1, minutes=45)
/// let options = @temporal.RoundingOptions::new(smallest_unit=Hour)
/// inspect(d.round(options, @temporal.utc_only_provider), content="PT2H")
/// }
/// ```
pub fn[P : TimeZoneProvider] Duration::round(
self : Duration,
options : RoundingOptions,
provider : P,
relative_to? : RelativeTo,
) -> Duration raise TemporalError {
let resolved = resolve_duration_rounding_options(self, options)
match relative_to {
Some(ZonedRelative(zoned)) => {
let internal = self.to_internal()
let target = zoned.add_internal_duration(internal, provider, Constrain)
let target_zoned = ZonedDateTime::try_new(
target,
zoned.time_zone,
provider,
calendar=zoned.calendar,
)
let rounded = zoned.diff_with_rounding(
target_zoned.to_instant(),
resolved,
provider,
)
// The result is reported in exact time units, because a date unit would
// need re-resolving against the zone to mean anything.
let largest_unit = if resolved.largest_unit.is_date_unit() {
Hour
} else {
resolved.largest_unit
}
Duration::from_internal(rounded, largest_unit)
}
Some(DateRelative(date)) => {
let internal = InternalDurationRecord::from_duration_with_24_hour_days(
self,
)
let (target_days, target_time) = iso_time_midnight.add(internal.time)
let date_duration = internal.date.adjust(target_days)
let target_date = date.iso.add_date_duration(date_duration, Constrain)
let start = IsoDateTime::new_unchecked(date.iso, iso_time_midnight)
let end = IsoDateTime::new_unchecked(target_date, target_time)
let rounded = PlainDateTime::new_unchecked(start, date.calendar).diff_with_rounding(
PlainDateTime::new_unchecked(end, date.calendar),
resolved,
)
Duration::from_internal(rounded, resolved.largest_unit)
}
None => {
if self.default_largest_unit().is_calendar_unit() ||
resolved.largest_unit.is_calendar_unit() {
raise RangeError(
"rounding a duration with calendar units requires a relativeTo reference",
)
}
let internal = InternalDurationRecord::from_duration_with_24_hour_days(
self,
)
let rounded = if resolved.smallest_unit is Day {
let days = internal.time.round_to_fractional_days(
resolved.increment,
resolved.rounding_mode,
)
InternalDurationRecord::new(
DateDuration::new(0L, 0L, 0L, days),
time_duration_zero,
)
} else {
InternalDurationRecord::new(
DateDuration::default(),
internal.time.round(resolved),
)
}
Duration::from_internal(rounded, resolved.largest_unit)
}
}
}
///|
/// Returns the duration expressed as a fractional count of `unit`.
///
/// `relative_to` is required whenever the duration uses calendar units, or
/// when totalling into one.
///
/// ```mbt check
/// test {
/// let d = @temporal.Duration::of(hours=1, minutes=30)
/// inspect(d.total(Minute, @temporal.utc_only_provider), content="90")
/// }
/// ```
pub fn[P : TimeZoneProvider] Duration::total(
self : Duration,
unit : DateTimeUnit,
provider : P,
relative_to? : RelativeTo,
) -> Double raise TemporalError {
match relative_to {
Some(ZonedRelative(zoned)) => {
let internal = self.to_internal()
let target = zoned.add_internal_duration(internal, provider, Constrain)
let target_zoned = ZonedDateTime::try_new(
target,
zoned.time_zone,
provider,
calendar=zoned.calendar,
)
zoned.diff_with_total(target_zoned.to_instant(), unit, provider)
}
Some(DateRelative(date)) => {
// Only the sub-day components are balanced into a time-of-day here; the
// duration's own days stay in the date part so the calendar walk sees
// them.
let (target_days, target_time) = iso_time_midnight.add(
TimeDuration::from_duration(self),
)
let date_duration = DateDuration::new(
self.years,
self.months,
self.weeks,
self.days + target_days,
)
let target_date = date.iso.add_date_duration(date_duration, Constrain)
let start = IsoDateTime::new_unchecked(date.iso, iso_time_midnight)
let end = IsoDateTime::new_unchecked(target_date, target_time)
PlainDateTime::new_unchecked(start, date.calendar).diff_with_total(
PlainDateTime::new_unchecked(end, date.calendar),
unit,
)
}
None => {
let largest_unit = self.default_largest_unit()
if largest_unit.is_calendar_unit() || unit.is_calendar_unit() {
raise RangeError(
"totalling a duration with calendar units requires a relativeTo reference",
)
}
InternalDurationRecord::from_duration_with_24_hour_days(self).time.total(
unit,
)
}
}
}
///|
/// `DifferenceZonedDateTimeWithTotal`.
fn[P : TimeZoneProvider] ZonedDateTime::diff_with_total(
self : ZonedDateTime,
other : Instant,
unit : DateTimeUnit,
provider : P,
) -> Double raise TemporalError {
// A time unit has a fixed length, so the zone does not enter into it.
if unit.is_time_unit() {
return TimeDuration::from_nanosecond_difference(
other.epoch_nanoseconds,
self.instant.epoch_nanoseconds,
).total(unit)
}
let diff = self.diff_zoned_date_time(other, unit, provider)
diff.total_relative_duration(
self.instant.epoch_nanoseconds,
other.epoch_nanoseconds,
self.to_plain_date_time(),
Some(self.time_zone),
provider,
unit,
)
}