-
Notifications
You must be signed in to change notification settings - Fork 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
ZonedDateTime.startOfDay: is it guaranteed to be the first instant in a calendar day? #2910
Comments
Hmmm, good catch @BurntSushi! I think you're correct that this is a spec bug. We should be resolving to the first instant of the calendar day, but your example shows how we're not doing that. @ptomato @arshaw FYI this looks like a bug we may want to fix. I added to the next meeting agenda. Note that (because we no longer support custom time zones) it may not be a normative change if there are no IANA time zones that have a transition that spans (but does not begin or end at) midnight in any time zones. |
There's only a single transition that matches this characteristic: From https://github.com/eggert/tz/blob/7eb5bf887927e35079e5cc12e2252819a7c47bb0/northamerica#L1684:
Code to find this transition (will have to be updated after the removal of let start = new Temporal.PlainDate(1800, 1, 1).toZonedDateTime("UTC").toInstant();
let end = new Temporal.PlainDate(2050, 1, 1).toZonedDateTime("UTC").toInstant();
for (let timeZone of Intl.supportedValuesOf("timeZone")) {
let tz = new Temporal.TimeZone(timeZone);
let instant = start;
while (true) {
let next = tz.getNextTransition(instant);
if (next === null || Temporal.Instant.compare(next, end) > 0) {
break;
}
instant = next;
let zdt = next.toZonedDateTimeISO(timeZone);
let start = zdt.startOfDay();
if (Temporal.ZonedDateTime.compare(zdt, start) !== 0 && start.hour !== 0) {
console.log(timeZone, zdt, start, start.hour);
}
}
} |
Good catch @BurntSushi. I acknowledge this is a bug. A similar algorithm is used in The solution might entail introducing a new internal utility function called function GetStartOfDay(y, m, d, nativeTimeZone) { // returns an Instant
const instants = nativeTimeZone.getPossibleInstants(
new PlainDateTime(y, m, d, 0, 0, 0, 0, 0, 0)
)
// if one or more instants, always return the earlier
if (instants.length) {
return instants[0]
}
// otherwise, 00:00:00 lies within a DST gap
// compute an instant that's guaranteed to be before the transition-instant,
// similar to what DisambiguatePossibleInstants already does
const utcns = GetUTCEpochNanoseconds(y, m, d, 0, 0, 0, 0, 0, 0)
const dayBefore = new Instant(utcns.minus(DAY_NANOS))
// the next transition is guaranteed to be the start of the day
// (a.k.a. the instant the transition that swallows the 00:00:00 wallclock time ends)
return nativeTimeZone.getNextTransition(dayBefore)
} Anyone see any shortcomings with this? I'm happy to write a proof of concept in the reference-polyfill and then write the spec modifications once it's sound. |
@arshaw In terms of the underlying data model, I think TZif would technically permit a second transition on the day before, such that the next transition in that code could come before the "midnight jump" transition you're looking for. But I don't think the Maybe another technique is to find the instant immediately after the gap via the "later" disambiguation strategy and then ask for the previous transition? |
@BurntSushi, I know the current spec is assuming that two consecutive timezone transitions cannot be closer than 48 hours apart, here: proposal-temporal/polyfill/lib/ecmascript.mjs Lines 2463 to 2465 in 727211d
( nanoseconds is later uses to derive the the "earlier" or "later" instants outside of the gap)
Though I'm not sure the basis for this presumption. Maybe someone else on the team with more context knows. But if it turns out we cannot assume this, yes, your modification to the algorithm sounds like a good idea. Could also be done by doing getting the "earlier" moment outside the gap and calling getNextTransition. |
Discussed in the champions meeting of 2024-07-11. This is a bug that needs to be fixed. When fixing this we should examine whether there's anywhere else besides |
Possibly
But I guess that should probably be |
Note that CLDR and ICU don't necessarily use historical data for time zones like This doesn't mean that there isn't also a bug in the spec that we need to fix for this case, but it might be a data issue not a spec issue. |
I found the following additional places where we previously used GetInstantFor on a PlainDateTime record with midnight and
I'd say (3) is definitely in the "bug" category along with (1) and (2) are in a gray area IMO, maybe along with the string parsing case that @BurntSushi identified. Because it means that we start treating absent time semantically differently than we were before. Previously it always meant "midnight". With the proposed change it'd mean "start of day", but you could still specify "midnight" explicitly. In other words: const z = Temporal.ZonedDateTime.from('1919-03-31T12:00[America/Toronto]')
z.withPlainTime() // => jump to start of day, 00:30
z.withPlainTime('00:00')
// => use 'compatible' disambiguation strategy from skipped 00:00, giving 01:00
z.withPlainTime('00:01')
// => use 'compatible' disambiguation strategy from skipped 00:01, giving 01:01 I'm not opposed to this and I think it makes sense, but wanted to check, because it's odd to have a parameter with a default value that can't be specified explicitly. (other than explicitly specifying I'd lean towards changing them though, because who on earth is going to go to the trouble of calling an extra method if it only makes a difference for one single day in one single time zone in all of recorded history. Better just to return what will almost certainly be the desired use case and avoid the countless Stack Overflow answers saying, "well ACTUALLY you have to tack Could go either way on parsing |
...although, do we treat |
I agree that changing everything (including the string parsing case) to match Also seems fine to treat |
These tests add coverage for a corner case in the TZDB. In spring 1919, the America/Toronto time zone switched to DST at 23:30 on March 30th, skipping an hour ahead to 00:30 on March 31st. This meant that both March 30th and March 31st were 23.5-hour days. See: tc39/proposal-temporal#2910
Fixes calculations of start of day in ZonedDateTime.prototype.hoursInDay, ZonedDateTime.prototype.startOfDay(), and ZonedDateTime.prototype.round() with smallestUnit: "day". Updates the following operations to explicitly mean "start of day" and not "midnight": - PlainDate.prototype.toZonedDateTime(timeZoneString) - PlainDate.prototype.toZonedDateTime(options) with omitted or undefined plainTime option - ZonedDateTime.prototype.withPlainTime() with no argument or undefined - Parsing 'YYYY-MM-DD[Zone]' anywhere a ZonedDateTime string is expected This is in order to handle one corner case from the TZDB where clocks were set one hour ahead in America/Toronto at 23:30 on March 30, 1919, meaning that the DST skipped hour encompassed midnight but did not start or end at midnight. (This was probably just in the city of Toronto and maybe some other towns, because time was still locally administered at that time.) Closes: #2910
Unfortunately, this was way more complex than I expected. Here's the list of what changed in #2918. None of this makes a difference except for this particular America/Toronto edge case on March 30-31, 1919.
Note that the You'll notice that the tests in tc39/test262#4158 contain two more cases in addition to the above, for Duration.p.round and Duration.p.total. Essentially, they boil down to // DST spring-forward hour skipped at 1919-03-30T23:30 (23.5 hour day)
// 11.75 hours is 0.5
const duration = Temporal.Duration.from('PT11H45M');
const relativeTo = Temporal.ZonedDateTime.from('1919-03-30T00:00[America/Toronto]');
assert.sameValue(duration.total({ relativeTo, unit: "days" }), 0.5); These test cases currently fail. I'm actually not sure whether they are correct. It seems to me like the above result should be expected, but I'm not clear on whether that should be affected by the way the semantics of |
Hi @ptomato, I think it should fail, based on this breakdown of the internal operations: const duration = Temporal.Duration.from('PT11H45M');
const relativeTo = Temporal.ZonedDateTime.from('1919-03-30T00:00[America/Toronto]');
const epochStart = relativeTo.epochNanoseconds;
const epochEnd = relativeTo.add({ days: 1 }).epochNanoseconds; // instant at 1919-03-31T01:00:00-04:00[America/Toronto]
const epochMid = relativeTo.add(duration).epochNanoseconds;
const frac = Number(epochMid - epochStart) / Number(epochEnd - epochStart); // 0.4895833333333333
const effectiveHoursInDay = Number(epochEnd - epochStart) / 3_600_000_000_000 // 24 The new start-of-day resolution in the PR isn't applying to the relativeTo.toString() // 1919-03-30T00:00:00-05:00[America/Toronto]
relativeTo.add({ days: 1 }).toString() // 1919-03-31T01:00:00-04:00[America/Toronto] The difference between those two instants is 24 hours, not 23:30 :( To make these operations adhere to the new start-of-day stuff, you'd need to ensure Do you think we should make that change? I'm happy to help if so. You're right, this stuff is ballooning in complexity. |
The thing is, in the normal case I think we actually do want I was thinking we could do something like this (pseudocode) let nextDay = relativeTo.add({ days: 1 });
if (relativeTo.equals(relativeTo.startOfDay()) {
nextDay = nextDay.startOfDay();
}
const epochEnd = nextDay.epochNanoseconds; That gives the correct result for the case I mentioned above, but does not fix the equally weird case where you round PT11H45M relative to |
For the record, if we are considering alternative solutions, I'd also support:
|
@ptomato, I'm in favor of leaving round and total as-is. So, I'm only in favor of using this new A) When start-of-day in a timezone is pretty explicitly needed:
B) When needing to resolve an "undefined" time
For the |
That reasoning seems sound to me. Unless anyone else weighs in soon to say otherwise, I think we should go with that. |
These tests add coverage for a corner case in the TZDB. In spring 1919, the America/Toronto time zone switched to DST at 23:30 on March 30th, skipping an hour ahead to 00:30 on March 31st. This meant that both March 30th and March 31st were 23.5-hour days. See: tc39/proposal-temporal#2910
Fixes calculations of start of day in ZonedDateTime.prototype.hoursInDay, ZonedDateTime.prototype.startOfDay(), and ZonedDateTime.prototype.round() with smallestUnit: "day". Updates the following operations to explicitly mean "start of day" and not "midnight": - PlainDate.prototype.toZonedDateTime(timeZoneString) - PlainDate.prototype.toZonedDateTime(options) with omitted or undefined plainTime option - ZonedDateTime.prototype.withPlainTime() with no argument or undefined - Parsing 'YYYY-MM-DD[Zone]' anywhere a ZonedDateTime string is expected This is in order to handle one corner case from the TZDB where clocks were set one hour ahead in America/Toronto at 23:30 on March 30, 1919, meaning that the DST skipped hour encompassed midnight but did not start or end at midnight. (This was probably just in the city of Toronto and maybe some other towns, because time was still locally administered at that time.) Closes: #2910
These tests add coverage for a corner case in the TZDB. In spring 1919, the America/Toronto time zone switched to DST at 23:30 on March 30th, skipping an hour ahead to 00:30 on March 31st. This meant that both March 30th and March 31st were 23.5-hour days. See: tc39/proposal-temporal#2910
These tests add coverage for a corner case in the TZDB. In spring 1919, the America/Toronto time zone switched to DST at 23:30 on March 30th, skipping an hour ahead to 00:30 on March 31st. This meant that both March 30th and March 31st were 23.5-hour days. See: tc39/proposal-temporal#2910
These tests add coverage for a corner case in the TZDB. In spring 1919, the America/Toronto time zone switched to DST at 23:30 on March 30th, skipping an hour ahead to 00:30 on March 31st. This meant that both March 30th and March 31st were 23.5-hour days. See: tc39/proposal-temporal#2910
Fixes calculations of start of day in ZonedDateTime.prototype.hoursInDay, ZonedDateTime.prototype.startOfDay(), and ZonedDateTime.prototype.round() with smallestUnit: "day". Updates the following operations to explicitly mean "start of day" and not "midnight": - PlainDate.prototype.toZonedDateTime(timeZoneString) - PlainDate.prototype.toZonedDateTime(options) with omitted or undefined plainTime option - ZonedDateTime.prototype.withPlainTime() with no argument or undefined - Parsing 'YYYY-MM-DD[Zone]' anywhere a ZonedDateTime string is expected This is in order to handle one corner case from the TZDB where clocks were set one hour ahead in America/Toronto at 23:30 on March 30, 1919, meaning that the DST skipped hour encompassed midnight but did not start or end at midnight. (This was probably just in the city of Toronto and maybe some other towns, because time was still locally administered at that time.) Closes: #2910
I've been trying to write down a contract for the analog to
ZonedDateTime.startOfDay
in my own Temporal-inspired Rust library. The main contract in the Temporal docs is:I am wondering whether this is true in all cases. I don't know of any such real world case where the above contract wouldn't be upheld, so it's not easy for me to test, but what happens if there is a time zone transition forwards that includes midnight but doesn't start at midnight? For example, maybe there is a 1 hour gap starting at
23:30:00
. SincestartOfDay
will use the "compatible" disambiguation strategy and since the implementation (as I understand it) works by looking for an instant at00:00:00
, I believe this will result in returning the instant corresponding to01:00:00
. But the first instant of the day in this particular example is00:30:00
.cc @arshaw I believe the fullcalendar polyfill uses the same implementation technique of starting from midnight and using the "compatible" disambiguation strategy.
The text was updated successfully, but these errors were encountered: