diff --git a/Libraries/LibJS/Runtime/Temporal/TimeZone.cpp b/Libraries/LibJS/Runtime/Temporal/TimeZone.cpp index 63314942894..7e44cde1b61 100644 --- a/Libraries/LibJS/Runtime/Temporal/TimeZone.cpp +++ b/Libraries/LibJS/Runtime/Temporal/TimeZone.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -311,10 +312,81 @@ ThrowCompletionOr disambiguate_possible_epoch_nanoseco if (disambiguation == Disambiguation::Reject) return vm.throw_completion(ErrorType::TemporalDisambiguatePossibleEpochNSRejectZero); - // FIXME: GetNamedTimeZoneEpochNanoseconds currently does not produce zero instants. - (void)time_zone; - (void)iso_date_time; - TODO(); + // 6. Let before be the latest possible ISO Date-Time Record for which CompareISODateTime(before, isoDateTime) = -1 + // and ! GetPossibleEpochNanoseconds(timeZone, before) is not empty. + // 7. Let after be the earliest possible ISO Date-Time Record for which CompareISODateTime(after, isoDateTime) = 1 + // and ! GetPossibleEpochNanoseconds(timeZone, after) is not empty. + // 8. Let beforePossible be ! GetPossibleEpochNanoseconds(timeZone, before). + // 9. Assert: The number of elements in beforePossible = 1. + // 10. Let afterPossible be ! GetPossibleEpochNanoseconds(timeZone, after). + // 11. Assert: The number of elements in afterPossible = 1. + // NB: We implement this by finding the UTC offsets one day before and after the gap, which is guaranteed to be + // outside the transition period. We then use those offsets to determine the before/after epoch nanoseconds. + auto epoch_nanoseconds = get_utc_epoch_nanoseconds(iso_date_time); + auto before_possible = epoch_nanoseconds.minus(NANOSECONDS_PER_DAY); + auto after_possible = epoch_nanoseconds.plus(NANOSECONDS_PER_DAY); + + // 12. Let offsetBefore be GetOffsetNanosecondsFor(timeZone, the sole element of beforePossible). + auto offset_before = get_offset_nanoseconds_for(time_zone, before_possible); + + // 13. Let offsetAfter be GetOffsetNanosecondsFor(timeZone, the sole element of afterPossible). + auto offset_after = get_offset_nanoseconds_for(time_zone, after_possible); + + // 14. Let nanoseconds be offsetAfter - offsetBefore. + auto nanoseconds = offset_after - offset_before; + + // 15. Assert: abs(nanoseconds) ≤ nsPerDay. + + // 16. If disambiguation is EARLIER, then + if (disambiguation == Disambiguation::Earlier) { + // a. Let timeDuration be TimeDurationFromComponents(0, 0, 0, 0, 0, -nanoseconds). + auto time_duration = time_duration_from_components(0, 0, 0, 0, 0, -static_cast(nanoseconds)); + + // b. Let earlierTime be AddTime(isoDateTime.[[Time]], timeDuration). + auto earlier_time = add_time(iso_date_time.time, time_duration); + + // c. Let earlierDate be AddDaysToISODate(isoDateTime.[[ISODate]], earlierTime.[[Days]]). + auto earlier_date = add_days_to_iso_date(iso_date_time.iso_date, earlier_time.days); + + // d. Let earlierDateTime be CombineISODateAndTimeRecord(earlierDate, earlierTime). + auto earlier_date_time = combine_iso_date_and_time_record(earlier_date, earlier_time); + + // e. Set possibleEpochNs to ? GetPossibleEpochNanoseconds(timeZone, earlierDateTime). + possible_epoch_ns = TRY(get_possible_epoch_nanoseconds(vm, time_zone, earlier_date_time)); + + // f. Assert: possibleEpochNs is not empty. + VERIFY(!possible_epoch_ns.is_empty()); + + // g. Return possibleEpochNs[0]. + return move(possible_epoch_ns[0]); + } + + // 17. Assert: disambiguation is COMPATIBLE or LATER. + VERIFY(disambiguation == Disambiguation::Compatible || disambiguation == Disambiguation::Later); + + // 18. Let timeDuration be TimeDurationFromComponents(0, 0, 0, 0, 0, nanoseconds). + auto time_duration = time_duration_from_components(0, 0, 0, 0, 0, static_cast(nanoseconds)); + + // 19. Let laterTime be AddTime(isoDateTime.[[Time]], timeDuration). + auto later_time = add_time(iso_date_time.time, time_duration); + + // 20. Let laterDate be AddDaysToISODate(isoDateTime.[[ISODate]], laterTime.[[Days]]). + auto later_date = add_days_to_iso_date(iso_date_time.iso_date, later_time.days); + + // 21. Let laterDateTime be CombineISODateAndTimeRecord(laterDate, laterTime). + auto later_date_time = combine_iso_date_and_time_record(later_date, later_time); + + // 22. Set possibleEpochNs to ? GetPossibleEpochNanoseconds(timeZone, laterDateTime). + possible_epoch_ns = TRY(get_possible_epoch_nanoseconds(vm, time_zone, later_date_time)); + + // 23. Set n to the number of elements in possibleEpochNs. + n = possible_epoch_ns.size(); + + // 24. Assert: n ≠ 0. + VERIFY(n != 0); + + // 25. Return possibleEpochNs[n - 1]. + return move(possible_epoch_ns[n - 1]); } // 11.1.13 GetPossibleEpochNanoseconds ( timeZone, isoDateTime ), https://tc39.es/proposal-temporal/#sec-temporal-getpossibleepochnanoseconds @@ -378,8 +450,24 @@ ThrowCompletionOr get_start_of_day(VM& vm, String cons if (!possible_epoch_nanoseconds.is_empty()) return move(possible_epoch_nanoseconds[0]); - // FIXME: GetNamedTimeZoneEpochNanoseconds currently does not produce zero instants. - TODO(); + // 4. Assert: IsOffsetTimeZoneIdentifier(timeZone) is false. + VERIFY(!is_offset_time_zone_identifier(time_zone)); + + // 5. Let possibleEpochNsAfter be GetNamedTimeZoneEpochNanoseconds(timeZone, isoDateTimeAfter), where isoDateTimeAfter + // is the ISO Date-Time Record for which DifferenceISODateTime(isoDateTime, isoDateTimeAfter, "iso8601", hour).[[Time]] + // is the smallest possible value > 0 for which possibleEpochNsAfter is not empty (i.e., isoDateTimeAfter represents + // the first local time after the transition). + // NB: We implement this by finding the next UTC offset transition after one day before midnight, which is guaranteed + // to be before the gap. The transition instant is the first valid epoch nanoseconds of the day. + auto epoch_nanoseconds = get_utc_epoch_nanoseconds(iso_date_time); + auto day_before = epoch_nanoseconds.minus(NANOSECONDS_PER_DAY); + auto possible_epoch_nanoseconds_after = get_named_time_zone_next_transition(time_zone, day_before); + + // 6. Assert: The number of elements in possibleEpochNsAfter = 1. + VERIFY(possible_epoch_nanoseconds_after.has_value()); + + // 7. Return the sole element of possibleEpochNsAfter. + return possible_epoch_nanoseconds_after.release_value(); } // 11.1.15 TimeZoneEquals ( one, two ), https://tc39.es/proposal-temporal/#sec-temporal-timezoneequals diff --git a/Libraries/LibUnicode/TimeZone.cpp b/Libraries/LibUnicode/TimeZone.cpp index fc7e4e003a6..680c8726459 100644 --- a/Libraries/LibUnicode/TimeZone.cpp +++ b/Libraries/LibUnicode/TimeZone.cpp @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025, Tim Flynn + * Copyright (c) 2024-2026, Tim Flynn * * SPDX-License-Identifier: BSD-2-Clause */ @@ -211,10 +211,17 @@ Vector disambiguated_time_zone_offsets(StringView time_zone, Uni auto latter = get_offset(UCAL_TZ_LOCAL_LATTER); Vector offsets; - if (former.has_value()) + + if (former.has_value() && latter.has_value()) { + if (former->offset == latter->offset) { + offsets.append(*former); + } else if (former->offset > latter->offset) { + offsets.append(*former); + offsets.append(*latter); + } + } else if (former.has_value()) { offsets.append(*former); - if (latter.has_value() && latter->offset != former->offset) - offsets.append(*latter); + } return offsets; } diff --git a/Tests/LibJS/Runtime/builtins/Temporal/ZonedDateTime/ZonedDateTime.from.js b/Tests/LibJS/Runtime/builtins/Temporal/ZonedDateTime/ZonedDateTime.from.js index acae167ac5e..6505011e182 100644 --- a/Tests/LibJS/Runtime/builtins/Temporal/ZonedDateTime/ZonedDateTime.from.js +++ b/Tests/LibJS/Runtime/builtins/Temporal/ZonedDateTime/ZonedDateTime.from.js @@ -103,6 +103,41 @@ describe("correct behavior", () => { expect(zonedDateTime.offsetNanoseconds).toBe(0); }); + // In America/New_York, 2024-03-10T02:30 doesn't exist (spring-forward gap: 2:00 AM to 3:00 AM). + test("DST gap disambiguation with property bag", () => { + const gapTime = { + year: 2024, + month: 3, + day: 10, + hour: 2, + minute: 30, + timeZone: "America/New_York", + }; + + // "compatible": resolve to the later side of the gap (3:30 AM EDT). + const compatible = Temporal.ZonedDateTime.from(gapTime, { disambiguation: "compatible" }); + expect(compatible.hour).toBe(3); + expect(compatible.minute).toBe(30); + expect(compatible.offset).toBe("-04:00"); + + // "later": same as compatible for gaps. + const later = Temporal.ZonedDateTime.from(gapTime, { disambiguation: "later" }); + expect(later.hour).toBe(3); + expect(later.minute).toBe(30); + expect(later.offset).toBe("-04:00"); + + // "earlier": resolve to the earlier side of the gap (1:30 AM EST). + const earlier = Temporal.ZonedDateTime.from(gapTime, { disambiguation: "earlier" }); + expect(earlier.hour).toBe(1); + expect(earlier.minute).toBe(30); + expect(earlier.offset).toBe("-05:00"); + + // "reject": throw for non-existent times. + expect(() => { + Temporal.ZonedDateTime.from(gapTime, { disambiguation: "reject" }); + }).toThrowWithMessage(RangeError, "Cannot disambiguate zero possible epoch nanoseconds"); + }); + test("offsets", () => { [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].forEach(offset => { let timeZone = `Etc/GMT-${offset}`; diff --git a/Tests/LibJS/Runtime/builtins/Temporal/ZonedDateTime/ZonedDateTime.prototype.startOfDay.js b/Tests/LibJS/Runtime/builtins/Temporal/ZonedDateTime/ZonedDateTime.prototype.startOfDay.js index 8cd77b868d1..51b93e0c150 100644 --- a/Tests/LibJS/Runtime/builtins/Temporal/ZonedDateTime/ZonedDateTime.prototype.startOfDay.js +++ b/Tests/LibJS/Runtime/builtins/Temporal/ZonedDateTime/ZonedDateTime.prototype.startOfDay.js @@ -31,6 +31,22 @@ describe("correct behavior", () => { expect(startOfDayZonedDateTime.offset).toBe("+00:00"); expect(startOfDayZonedDateTime.offsetNanoseconds).toBe(0); }); + + // In America/Santiago, 2024-09-08T00:00 doesn't exist (spring-forward gap: midnight to 1:00 AM). + test("start of day when midnight is in a DST gap", () => { + const zonedDateTime = Temporal.ZonedDateTime.from({ + year: 2024, + month: 9, + day: 8, + hour: 12, + timeZone: "America/Santiago", + }); + + const startOfDay = zonedDateTime.startOfDay(); + expect(startOfDay.hour).toBe(1); + expect(startOfDay.minute).toBe(0); + expect(startOfDay.offset).toBe("-03:00"); + }); }); describe("errors", () => {