Skip to content

Fix edge case for disambiguating ZonedDateTimes on DSTs skipping midnight #156

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions src/components/timezone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ use core::{iter::Peekable, str::Chars};

use num_traits::ToPrimitive;

use crate::components::duration::DateDuration;
use crate::Calendar;
use crate::{
components::{duration::normalized::NormalizedTimeDuration, EpochNanoseconds, Instant},
iso::{IsoDate, IsoDateTime, IsoTime},
Expand All @@ -23,6 +25,8 @@ use std::sync::{LazyLock, Mutex};
pub static TZ_PROVIDER: LazyLock<Mutex<FsTzdbProvider>> =
LazyLock::new(|| Mutex::new(FsTzdbProvider::default()));

const NS_IN_HOUR: i128 = 60 * 60 * 1000 * 1000 * 1000;

// NOTE: It may be a good idea to eventually move this into it's
// own individual crate rather than having it tied directly into `temporal_rs`
pub trait TzProvider {
Expand All @@ -31,7 +35,7 @@ pub trait TzProvider {
fn get_named_tz_epoch_nanoseconds(
&self,
identifier: &str,
iso_datetime: IsoDateTime,
local_datetime: IsoDateTime,
) -> TemporalResult<Vec<EpochNanoseconds>>;

fn get_named_tz_offset_nanoseconds(
Expand Down Expand Up @@ -258,13 +262,22 @@ impl TimeZone {
// which CompareISODateTime(before, isoDateTime) = -1 and !
// GetPossibleEpochNanoseconds(timeZone, before) is not
// empty.
let mut before = iso;
before.time.hour -= 3;
let before = iso.add_date_duration(
Calendar::default(),
&DateDuration::default(),
NormalizedTimeDuration(-3 * NS_IN_HOUR),
None,
)?;

// 7. Let after be the earliest possible ISO Date-Time Record
// for which CompareISODateTime(after, isoDateTime) = 1 and !
// GetPossibleEpochNanoseconds(timeZone, after) is not empty.
let mut after = iso;
after.time.hour += 3;
let after = iso.add_date_duration(
Calendar::default(),
&DateDuration::default(),
NormalizedTimeDuration(3 * NS_IN_HOUR),
None,
)?;

// 8. Let beforePossible be !
// GetPossibleEpochNanoseconds(timeZone, before).
Expand Down
21 changes: 18 additions & 3 deletions src/components/zoneddatetime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use crate::{
use crate::components::timezone::TZ_PROVIDER;

/// A struct representing a partial `ZonedDateTime`.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct PartialZonedDateTime {
/// The `PartialDate` portion of a `PartialZonedDateTime`
pub date: PartialDate,
Expand Down Expand Up @@ -1117,7 +1118,7 @@ pub(crate) fn interpret_isodatetime_offset(
// i. Let roundedCandidateNanoseconds be RoundNumberToIncrement(candidateOffset, 60 × 10**9, half-expand).
let rounded_candidate = IncrementRounder::from_potentially_negative_parts(
candidate_offset,
unsafe { NonZeroU128::new_unchecked(60_000_000_000) },
const { NonZeroU128::new(60_000_000_000).expect("cannot be zero") },
)?
.round(TemporalRoundingMode::HalfExpand);
// ii. If roundedCandidateNanoseconds = offsetNanoseconds, then
Expand Down Expand Up @@ -1173,8 +1174,7 @@ pub(crate) fn nanoseconds_to_formattable_offset_minutes(
Ok((sign, hour as u8, minute as u8))
}

#[cfg(feature = "tzdb")]
#[cfg(test)]
#[cfg(all(test, feature = "tzdb"))]
mod tests {
use crate::{
options::{Disambiguation, OffsetDisambiguation},
Expand Down Expand Up @@ -1344,4 +1344,19 @@ mod tests {
);
assert!(result.is_ok());
}

#[test]
// https://github.com/tc39/test262/blob/d9b10790bc4bb5b3e1aa895f11cbd2d31a5ec743/test/intl402/Temporal/ZonedDateTime/from/dst-skipped-cross-midnight.js
fn dst_skipped_cross_midnight() {
let provider = &FsTzdbProvider::default();
let midnight_disambiguated = ZonedDateTime::from_str_with_provider(
"1919-03-31T00[America/Toronto]",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, shoot. Toronto 1919 🫠

Disambiguation::Compatible,
OffsetDisambiguation::Reject,
provider,
)
.unwrap();

assert_eq!(midnight_disambiguated.epoch_milliseconds(), -1601751600000);
}
}
11 changes: 6 additions & 5 deletions src/tzdb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ impl From<LocalTimeTypeRecord> for LocalTimeRecord {

// TODO: Workshop record name?
/// The `LocalTimeRecord` result represents the result of searching for a
/// a for a time zone transition without the offset seconds applied to the
/// time zone transition without the offset seconds applied to the
/// epoch seconds.
///
/// As a result of the search, it is possible for the resulting search to be either
Expand Down Expand Up @@ -295,7 +295,8 @@ impl Tzif {
match offset_range.contains(&current_diff.0) {
true if next_record.is_dst => Ok(LocalTimeRecordResult::Empty),
true => Ok((next_record, initial_record).into()),
false => Ok(initial_record.into()),
false if current_diff <= initial_record.utoff => Ok(initial_record.into()),
false => Ok(next_record.into()),
}
}
}
Expand Down Expand Up @@ -557,14 +558,14 @@ impl TzProvider for FsTzdbProvider {
LocalTimeRecordResult::Empty => Vec::default(),
LocalTimeRecordResult::Single(r) => {
let epoch_ns =
EpochNanoseconds::try_from(epoch_nanos.0 + seconds_to_nanoseconds(r.offset))?;
EpochNanoseconds::try_from(epoch_nanos.0 - seconds_to_nanoseconds(r.offset))?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, is this correct? I think the tests below due assert that the time zones are not inverted like in PosixTzString, so I'm pretty sure the sign needs to be minus (I could definitely be off here). Ex: EST is -04:00, to move the local back to UTC you local - -04:00.

Copy link
Member Author

@jedel1043 jedel1043 Jan 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The input is a local time, and the output is an UT. Hence, since local = UT + offset, then local - offset = UT

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OH wait, I had put plus oh god 🤣

vec![epoch_ns]
}
LocalTimeRecordResult::Ambiguous { std, dst } => {
let std_epoch_ns =
EpochNanoseconds::try_from(epoch_nanos.0 + seconds_to_nanoseconds(std.offset))?;
EpochNanoseconds::try_from(epoch_nanos.0 - seconds_to_nanoseconds(std.offset))?;
let dst_epoch_ns =
EpochNanoseconds::try_from(epoch_nanos.0 + seconds_to_nanoseconds(dst.offset))?;
EpochNanoseconds::try_from(epoch_nanos.0 - seconds_to_nanoseconds(dst.offset))?;
vec![std_epoch_ns, dst_epoch_ns]
}
};
Expand Down
Loading