Skip to content

Commit

Permalink
Normative: Handle "start of day" separately from "midnight"
Browse files Browse the repository at this point in the history
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
  • Loading branch information
ptomato committed Jul 16, 2024
1 parent 0ed92ec commit 7fbb958
Show file tree
Hide file tree
Showing 12 changed files with 270 additions and 219 deletions.
2 changes: 1 addition & 1 deletion docs/plaindate.md
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,7 @@ Use `Temporal.PlainDate.compare()` for this, or `date.equals()` for equality.

This method can be used to convert `Temporal.PlainDate` into a `Temporal.ZonedDateTime`, by supplying the time zone and time of day.
The default `plainTime`, if it's not provided, is the first valid local time in `timeZone` on the calendar date `date`.
Usually this is midnight (`00:00`), but may be a different time in rare circumstances like DST starting at midnight or calendars like `ethiopic` where each day doesn't start at midnight.
Usually this is midnight (`00:00`), but may be a different time in rare circumstances like DST skipping midnight.

For a list of IANA time zone names, see the current version of the [IANA time zone database](https://www.iana.org/time-zones).
A convenient list is also available [on Wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), although it might not reflect the latest official status.
Expand Down
8 changes: 5 additions & 3 deletions docs/zoneddatetime.md
Original file line number Diff line number Diff line change
Expand Up @@ -801,11 +801,13 @@ zdt.with({ year: 2015, minute: 31 }); // => 2015-12-07T03:31:00-06:00[America/Ch
**Parameters:**

- `plainTime` (optional `Temporal.PlainTime` or plain object or string): The clock time that should replace the current clock time of `zonedDateTime`.
If omitted, the clock time of the result will be `00:00:00`.

**Returns:** a new `Temporal.ZonedDateTime` object which replaces the clock time of `zonedDateTime` with the clock time represented by `plainTime`.

Valid input to `withPlainTime` is the same as valid input to `Temporal.PlainTime.from`, including strings like `12:15:36`, plain object property bags like `{ hour: 20, minute: 30 }`, or `Temporal` objects that contain time fields: `Temporal.PlainTime`, `Temporal.ZonedDateTime`, or `Temporal.PlainDateTime`.
The default `plainTime`, if it's not provided, is the first valid local time in `zonedDateTime`'s time zone on its calendar date.
Usually this is midnight (`00:00`), but may be a different time in rare circumstances like DST skipping midnight.

If provided, valid input to `withPlainTime` is the same as valid input to `Temporal.PlainTime.from`, including strings like `12:15:36`, plain object property bags like `{ hour: 20, minute: 30 }`, or `Temporal` objects that contain time fields: `Temporal.PlainTime`, `Temporal.ZonedDateTime`, or `Temporal.PlainDateTime`.

This method is similar to `with`, but with a few important differences:

Expand Down Expand Up @@ -1177,7 +1179,7 @@ zdt.round({ roundingIncrement: 30, smallestUnit: 'minute', roundingMode: 'floor'
**Returns:** A new `Temporal.ZonedDateTime` instance representing the earliest valid local clock time during the current calendar day and time zone of `zonedDateTime`.

This method returns a new `Temporal.ZonedDateTime` indicating the start of the day.
The local time of the result is almost always `00:00`, but in rare cases it could be a later time e.g. if DST starts at midnight in a time zone. For example:
The local time of the result is almost always `00:00`, but in rare cases it could be a later time e.g. if DST skips midnight in a time zone. For example:

```javascript
const zdt = Temporal.ZonedDateTime.from('2015-10-18T12:00-02:00[America/Sao_Paulo]');
Expand Down
208 changes: 94 additions & 114 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -479,13 +479,7 @@ export function ParseISODateTime(isoString) {
year,
month,
day,
hasTime,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond,
time: hasTime ? { hour, minute, second, millisecond, microsecond, nanosecond } : 'start-of-day',
tzAnnotation,
offset,
z,
Expand Down Expand Up @@ -528,10 +522,10 @@ export function ParseTemporalTimeString(isoString) {
nanosecond = +fraction.slice(6, 9);
if (match[8]) throw new RangeError('Z designator not supported for PlainTime');
} else {
let z, hasTime;
({ hasTime, hour, minute, second, millisecond, microsecond, nanosecond, z } = ParseISODateTime(isoString));
if (!hasTime) throw new RangeError(`time is missing in string: ${isoString}`);
const { time, z } = ParseISODateTime(isoString);
if (time === 'start-of-day') throw new RangeError(`time is missing in string: ${isoString}`);
if (z) throw new RangeError('Z designator not supported for PlainTime');
({ hour, minute, second, millisecond, microsecond, nanosecond } = time);
}
// if it's a date-time string, OK
if (/[tT ][0-9][0-9]/.test(isoString)) {
Expand Down Expand Up @@ -999,7 +993,7 @@ export function GetTemporalRelativeToOption(options) {

let offsetBehaviour = 'option';
let matchMinutes = false;
let year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, calendar, timeZone, offset;
let year, month, day, time, calendar, timeZone, offset;
if (Type(relativeTo) === 'Object') {
if (IsTemporalZonedDateTime(relativeTo)) {
return { zonedRelativeTo: relativeTo };
Expand All @@ -1014,32 +1008,14 @@ export function GetTemporalRelativeToOption(options) {
['hour', 'microsecond', 'millisecond', 'minute', 'nanosecond', 'offset', 'second', 'timeZone'],
[]
);
({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = InterpretTemporalDateTimeFields(
calendar,
fields,
'constrain'
));
({ year, month, day, time } = InterpretTemporalDateTimeFields(calendar, fields, 'constrain'));
offset = fields.offset;
if (offset === undefined) offsetBehaviour = 'wall';
timeZone = fields.timeZone;
if (timeZone !== undefined) timeZone = ToTemporalTimeZoneIdentifier(timeZone);
} else {
let tzAnnotation, z;
({
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond,
calendar,
tzAnnotation,
offset,
z
} = ParseISODateTime(RequireString(relativeTo)));
({ year, month, day, time, calendar, tzAnnotation, offset, z } = ParseISODateTime(RequireString(relativeTo)));
if (tzAnnotation) {
timeZone = ToTemporalTimeZoneIdentifier(tzAnnotation);
if (z) {
Expand All @@ -1063,12 +1039,7 @@ export function GetTemporalRelativeToOption(options) {
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond,
time,
offsetBehaviour,
offsetNs,
timeZone,
Expand Down Expand Up @@ -1256,25 +1227,25 @@ export function ToTemporalDate(item, overflow = 'constrain') {
}

export function InterpretTemporalDateTimeFields(calendar, fields, overflow) {
let { hour, minute, second, millisecond, microsecond, nanosecond } = ToTemporalTimeRecord(fields);
let time = ToTemporalTimeRecord(fields);
const date = CalendarDateFromFields(calendar, fields, overflow);
const year = GetSlot(date, ISO_YEAR);
const month = GetSlot(date, ISO_MONTH);
const day = GetSlot(date, ISO_DAY);
({ hour, minute, second, millisecond, microsecond, nanosecond } = RegulateTime(
hour,
minute,
second,
millisecond,
microsecond,
nanosecond,
time = RegulateTime(
time.hour,
time.minute,
time.second,
time.millisecond,
time.microsecond,
time.nanosecond,
overflow
));
return { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond };
);
return { year, month, day, time };
}

export function ToTemporalDateTime(item, overflow = 'constrain') {
let year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, calendar;
let year, month, day, time, calendar;

if (Type(item) === 'Object') {
if (IsTemporalDateTime(item)) return item;
Expand Down Expand Up @@ -1316,21 +1287,30 @@ export function ToTemporalDateTime(item, overflow = 'constrain') {
['hour', 'microsecond', 'millisecond', 'minute', 'nanosecond', 'second'],
[]
);
({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = InterpretTemporalDateTimeFields(
calendar,
fields,
overflow
));
({ year, month, day, time } = InterpretTemporalDateTimeFields(calendar, fields, overflow));
} else {
let z;
({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, calendar, z } =
ParseTemporalDateTimeString(RequireString(item)));
({ year, month, day, time, calendar, z } = ParseTemporalDateTimeString(RequireString(item)));
if (z) throw new RangeError('Z designator not supported for PlainDateTime');
RejectDateTime(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond);
if (time === 'start-of-day') {
time = { hour: 0, minute: 0, second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 };
}
RejectDateTime(
year,
month,
day,
time.hour,
time.minute,
time.second,
time.millisecond,
time.microsecond,
time.nanosecond
);
if (!calendar) calendar = 'iso8601';
if (!IsBuiltinCalendar(calendar)) throw new RangeError(`invalid calendar identifier ${calendar}`);
calendar = CanonicalizeCalendar(calendar);
}
const { hour, minute, second, millisecond, microsecond, nanosecond } = time;
return CreateTemporalDateTime(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, calendar);
}

Expand Down Expand Up @@ -1362,8 +1342,15 @@ export function ToTemporalInstant(item) {
}
item = ToPrimitive(item, StringCtor);
}
const { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, offset, z } =
ParseTemporalInstantString(RequireString(item));
const { year, month, day, time, offset, z } = ParseTemporalInstantString(RequireString(item));
const {
hour = 0,
minute = 0,
second = 0,
millisecond = 0,
microsecond = 0,
nanosecond = 0
} = time === 'start-of-day' ? {} : time;

// ParseTemporalInstantString ensures that either `z` is true or or `offset` is non-undefined
const offsetNanoseconds = z ? 0 : ParseDateTimeUTCOffset(offset);
Expand Down Expand Up @@ -1486,12 +1473,7 @@ export function InterpretISODateTimeOffset(
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond,
time,
offsetBehaviour,
offsetNs,
timeZone,
Expand All @@ -1500,16 +1482,27 @@ export function InterpretISODateTimeOffset(
matchMinute
) {
// getPossibleInstantsFor and getOffsetNanosecondsFor should be looked up.

// start-of-day signifies that we had a string such as YYYY-MM-DD[Zone]. It is
// grammatically not possible to specify a UTC offset in that string, so the
// behaviour collapses into ~WALL~, which is equivalent to offset: "ignore".
if (time === 'start-of-day') {
if (offsetBehaviour !== 'wall' || offsetNs !== 0) {
throw new Error('assertion failure: offset cannot be provided in YYYY-MM-DD[Zone] string');
}
return GetStartOfDay(timeZone, { year, month, day });
}

const dt = CreateTemporalDateTime(
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond,
time.hour,
time.minute,
time.second,
time.millisecond,
time.microsecond,
time.nanosecond,
'iso8601'
);

Expand All @@ -1528,12 +1521,12 @@ export function InterpretISODateTimeOffset(
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond,
time.hour,
time.minute,
time.second,
time.millisecond,
time.microsecond,
time.nanosecond,
offsetNs
);
ValidateEpochNanoseconds(epochNs);
Expand Down Expand Up @@ -1571,7 +1564,7 @@ export function ToTemporalZonedDateTime(
offsetOpt = 'reject',
overflow = 'constrain'
) {
let year, month, day, hour, minute, second, millisecond, microsecond, nanosecond, timeZone, offset, calendar;
let year, month, day, time, timeZone, offset, calendar;
let matchMinute = false;
let offsetBehaviour = 'option';
if (Type(item) === 'Object') {
Expand All @@ -1589,28 +1582,12 @@ export function ToTemporalZonedDateTime(
if (offset === undefined) {
offsetBehaviour = 'wall';
}
({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = InterpretTemporalDateTimeFields(
calendar,
fields,
overflow
));
({ year, month, day, time } = InterpretTemporalDateTimeFields(calendar, fields, overflow));
} else {
let tzAnnotation, z;
({
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond,
tzAnnotation,
offset,
z,
calendar
} = ParseTemporalZonedDateTimeString(RequireString(item)));
({ year, month, day, time, tzAnnotation, offset, z, calendar } = ParseTemporalZonedDateTimeString(
RequireString(item)
));
timeZone = ToTemporalTimeZoneIdentifier(tzAnnotation);
if (z) {
offsetBehaviour = 'exact';
Expand All @@ -1628,12 +1605,7 @@ export function ToTemporalZonedDateTime(
year,
month,
day,
hour,
minute,
second,
millisecond,
microsecond,
nanosecond,
time,
offsetBehaviour,
offsetNs,
timeZone,
Expand Down Expand Up @@ -2135,17 +2107,7 @@ export function DisambiguatePossibleEpochNanoseconds(possibleEpochNs, timeZone,
}

export function GetPossibleEpochNanoseconds(timeZone, isoDateTime) {
const {
year,
month,
day,
hour = 0,
minute = 0,
second = 0,
millisecond = 0,
microsecond = 0,
nanosecond = 0
} = isoDateTime;
const { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = isoDateTime;
const offsetMinutes = ParseTimeZoneIdentifier(timeZone).offsetMinutes;
if (offsetMinutes !== undefined) {
return [
Expand Down Expand Up @@ -2178,6 +2140,24 @@ export function GetPossibleEpochNanoseconds(timeZone, isoDateTime) {
);
}

export function GetStartOfDay(timeZone, isoDate) {
const isoDateTime = { ...isoDate, hour: 0, minute: 0, second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 };
const possibleEpochNs = GetPossibleEpochNanoseconds(timeZone, isoDateTime);
// If not a DST gap, return the single or earlier epochNs
if (possibleEpochNs.length) return possibleEpochNs[0];

// Otherwise, 00:00:00 lies within a DST gap. Compute an epochNs that's
// guaranteed to be before the transition
if (IsOffsetTimeZoneIdentifier(timeZone)) {
throw new Error('assertion failure: should only be reached with named time zone');
}

const utcns = GetUTCEpochNanoseconds(isoDate.year, isoDate.month, isoDate.day, 0, 0, 0, 0, 0, 0);
const dayBefore = utcns.minus(DAY_NANOS);
ValidateEpochNanoseconds(dayBefore);
return GetNamedTimeZoneNextTransition(timeZone, dayBefore);
}

export function ISOYearString(year) {
let yearString;
if (year < 0 || year > 9999) {
Expand Down
Loading

0 comments on commit 7fbb958

Please sign in to comment.