Skip to content

Commit fd7583b

Browse files
committed
Editorial: refactor time zone offset handling
This commit refactors spec text and polyfill code for time zone offsets, especially to split the handling of offsets in ISO strings from offsets used as time zone identifiers. This will help prepare for a later normative commit where time zone identifiers are limited to minutes precision while ISO string offset inputs and ZonedDateTime's `offset` property still support nanosecond precision.
1 parent 3bf0cfe commit fd7583b

14 files changed

+475
-154
lines changed

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"@tc39/ecma262-biblio": "=2.1.2577",
1212
"@typescript-eslint/eslint-plugin": "^5.59.9",
1313
"@typescript-eslint/parser": "^5.59.9",
14-
"ecmarkup": "^17.0.0",
14+
"ecmarkup": "^17.0.1",
1515
"eslint": "^8.42.0",
1616
"eslint-config-prettier": "^8.8.0",
1717
"eslint-plugin-prettier": "^4.2.1",

polyfill/lib/ecmascript.mjs

Lines changed: 44 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -360,24 +360,6 @@ export function RejectTemporalLikeObject(item) {
360360
}
361361
}
362362

363-
export function CanonicalizeTimeZoneOffsetString(offsetString) {
364-
const offsetNs = ParseTimeZoneOffsetString(offsetString);
365-
return FormatTimeZoneOffsetString(offsetNs);
366-
}
367-
368-
export function ParseTemporalTimeZone(stringIdent) {
369-
const { tzName, offset, z } = ParseTemporalTimeZoneString(stringIdent);
370-
if (tzName) {
371-
if (IsTimeZoneOffsetString(tzName)) return CanonicalizeTimeZoneOffsetString(tzName);
372-
const record = GetAvailableNamedTimeZoneIdentifier(tzName);
373-
if (!record) throw new RangeError(`Unrecognized time zone ${tzName}`);
374-
return record.primaryIdentifier;
375-
}
376-
if (z) return 'UTC';
377-
// if !tzName && !z then offset must be present
378-
return CanonicalizeTimeZoneOffsetString(offset);
379-
}
380-
381363
export function MaybeFormatCalendarAnnotation(calendar, showCalendar) {
382364
if (showCalendar === 'never') return '';
383365
return FormatCalendarAnnotation(ToTemporalCalendarIdentifier(calendar), showCalendar);
@@ -570,6 +552,18 @@ export function ParseTemporalMonthDayString(isoString) {
570552
return { month, day, calendar, referenceISOYear };
571553
}
572554

555+
const TIMEZONE_IDENTIFIER = new RegExp(`^${PARSE.timeZoneID.source}$`, 'i');
556+
const OFFSET_IDENTIFIER = new RegExp(`^${PARSE.offsetIdentifier.source}$`);
557+
558+
export function ParseTimeZoneIdentifier(identifier) {
559+
if (!TIMEZONE_IDENTIFIER.test(identifier)) throw new RangeError(`Invalid time zone identifier: ${identifier}`);
560+
if (OFFSET_IDENTIFIER.test(identifier)) {
561+
const { offsetNanoseconds } = ParseDateTimeUTCOffset(identifier);
562+
return { offsetNanoseconds };
563+
}
564+
return { tzName: identifier };
565+
}
566+
573567
export function ParseTemporalTimeZoneString(stringIdent) {
574568
const bareID = new RegExp(`^${PARSE.timeZoneID.source}$`, 'i');
575569
if (bareID.test(stringIdent)) return { tzName: stringIdent };
@@ -641,7 +635,7 @@ export function ParseTemporalInstant(isoString) {
641635
ParseTemporalInstantString(isoString);
642636

643637
if (!z && !offset) throw new RangeError('Temporal.Instant requires a time zone offset');
644-
const offsetNs = z ? 0 : ParseTimeZoneOffsetString(offset);
638+
const offsetNs = z ? 0 : ParseDateTimeUTCOffset(offset).offsetNanoseconds;
645639
({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = BalanceISODateTime(
646640
year,
647641
month,
@@ -1005,7 +999,7 @@ export function ToRelativeTemporalObject(options) {
1005999
calendar = ASCIILowercase(calendar);
10061000
}
10071001
if (timeZone === undefined) return CreateTemporalDate(year, month, day, calendar);
1008-
const offsetNs = offsetBehaviour === 'option' ? ParseTimeZoneOffsetString(offset) : 0;
1002+
const offsetNs = offsetBehaviour === 'option' ? ParseDateTimeUTCOffset(offset).offsetNanoseconds : 0;
10091003
const epochNanoseconds = InterpretISODateTimeOffset(
10101004
year,
10111005
month,
@@ -1406,7 +1400,7 @@ export function InterpretISODateTimeOffset(
14061400
// the user-provided offset doesn't match any instants for this time
14071401
// zone and date/time.
14081402
if (offsetOpt === 'reject') {
1409-
const offsetStr = FormatTimeZoneOffsetString(offsetNs);
1403+
const offsetStr = FormatOffsetTimeZoneIdentifier(offsetNs);
14101404
const timeZoneString = IsTemporalTimeZone(timeZone) ? GetSlot(timeZone, TIMEZONE_ID) : 'time zone';
14111405
throw new RangeError(`Offset ${offsetStr} is invalid for ${dt} in ${timeZoneString}`);
14121406
}
@@ -1469,7 +1463,7 @@ export function ToTemporalZonedDateTime(item, options) {
14691463
ToTemporalOverflow(options); // validate and ignore
14701464
}
14711465
let offsetNs = 0;
1472-
if (offsetBehaviour === 'option') offsetNs = ParseTimeZoneOffsetString(offset);
1466+
if (offsetBehaviour === 'option') offsetNs = ParseDateTimeUTCOffset(offset).offsetNanoseconds;
14731467
const epochNanoseconds = InterpretISODateTimeOffset(
14741468
year,
14751469
month,
@@ -2099,7 +2093,20 @@ export function ToTemporalTimeZoneSlotValue(temporalTimeZoneLike) {
20992093
return temporalTimeZoneLike;
21002094
}
21012095
const identifier = ToString(temporalTimeZoneLike);
2102-
return ParseTemporalTimeZone(identifier);
2096+
const { tzName, offset, z } = ParseTemporalTimeZoneString(identifier);
2097+
if (tzName) {
2098+
// tzName is any valid identifier string in brackets, and could be an offset identifier
2099+
const { offsetNanoseconds } = ParseTimeZoneIdentifier(tzName);
2100+
if (offsetNanoseconds !== undefined) return FormatOffsetTimeZoneIdentifier(offsetNanoseconds);
2101+
2102+
const record = GetAvailableNamedTimeZoneIdentifier(tzName);
2103+
if (!record) throw new RangeError(`Unrecognized time zone ${tzName}`);
2104+
return record.primaryIdentifier;
2105+
}
2106+
if (z) return 'UTC';
2107+
// if !tzName && !z then offset must be present
2108+
const { offsetNanoseconds } = ParseDateTimeUTCOffset(offset);
2109+
return FormatOffsetTimeZoneIdentifier(offsetNanoseconds);
21032110
}
21042111

21052112
export function ToTemporalTimeZoneIdentifier(slotValue) {
@@ -2162,7 +2169,7 @@ export function GetOffsetNanosecondsFor(timeZone, instant, getOffsetNanosecondsF
21622169

21632170
export function GetOffsetStringFor(timeZone, instant) {
21642171
const offsetNs = GetOffsetNanosecondsFor(timeZone, instant);
2165-
return FormatTimeZoneOffsetString(offsetNs);
2172+
return FormatOffsetTimeZoneIdentifier(offsetNs);
21662173
}
21672174

21682175
export function GetPlainDateTimeFor(timeZone, instant, calendar) {
@@ -2384,7 +2391,7 @@ export function TemporalInstantToString(instant, timeZone, precision) {
23842391
let timeZoneString = 'Z';
23852392
if (timeZone !== undefined) {
23862393
const offsetNs = GetOffsetNanosecondsFor(outputTimeZone, instant);
2387-
timeZoneString = FormatISOTimeZoneOffsetString(offsetNs);
2394+
timeZoneString = FormatDateTimeUTCOffsetRounded(offsetNs);
23882395
}
23892396
return `${year}-${month}-${day}T${hour}:${minute}${seconds}${timeZoneString}`;
23902397
}
@@ -2564,7 +2571,7 @@ export function TemporalZonedDateTimeToString(
25642571
let result = `${year}-${month}-${day}T${hour}:${minute}${seconds}`;
25652572
if (showOffset !== 'never') {
25662573
const offsetNs = GetOffsetNanosecondsFor(tz, instant);
2567-
result += FormatISOTimeZoneOffsetString(offsetNs);
2574+
result += FormatDateTimeUTCOffsetRounded(offsetNs);
25682575
}
25692576
if (showTimeZone !== 'never') {
25702577
const identifier = ToTemporalTimeZoneIdentifier(tz);
@@ -2575,11 +2582,11 @@ export function TemporalZonedDateTimeToString(
25752582
return result;
25762583
}
25772584

2578-
export function IsTimeZoneOffsetString(string) {
2585+
export function IsOffsetTimeZoneIdentifier(string) {
25792586
return OFFSET.test(string);
25802587
}
25812588

2582-
export function ParseTimeZoneOffsetString(string) {
2589+
export function ParseDateTimeUTCOffset(string) {
25832590
const match = OFFSET.exec(string);
25842591
if (!match) {
25852592
throw new RangeError(`invalid time zone offset: ${string}`);
@@ -2589,7 +2596,9 @@ export function ParseTimeZoneOffsetString(string) {
25892596
const minutes = +(match[3] || 0);
25902597
const seconds = +(match[4] || 0);
25912598
const nanoseconds = +((match[5] || 0) + '000000000').slice(0, 9);
2592-
return sign * (((hours * 60 + minutes) * 60 + seconds) * 1e9 + nanoseconds);
2599+
const offsetNanoseconds = sign * (((hours * 60 + minutes) * 60 + seconds) * 1e9 + nanoseconds);
2600+
const hasSubMinutePrecision = match[4] !== undefined || match[5] !== undefined;
2601+
return { offsetNanoseconds, hasSubMinutePrecision };
25932602
}
25942603

25952604
let canonicalTimeZoneIdsCache = undefined;
@@ -2702,17 +2711,16 @@ export function GetNamedTimeZoneOffsetNanoseconds(id, epochNanoseconds) {
27022711
return +utc.minus(epochNanoseconds);
27032712
}
27042713

2705-
export function FormatTimeZoneOffsetString(offsetNanoseconds) {
2714+
export function FormatOffsetTimeZoneIdentifier(offsetNanoseconds) {
27062715
const sign = offsetNanoseconds < 0 ? '-' : '+';
27072716
offsetNanoseconds = MathAbs(offsetNanoseconds);
2708-
const nanoseconds = offsetNanoseconds % 1e9;
2709-
const seconds = MathFloor(offsetNanoseconds / 1e9) % 60;
2710-
const minutes = MathFloor(offsetNanoseconds / 60e9) % 60;
27112717
const hours = MathFloor(offsetNanoseconds / 3600e9);
2712-
27132718
const hourString = ISODateTimePartString(hours);
2719+
const minutes = MathFloor(offsetNanoseconds / 60e9) % 60;
27142720
const minuteString = ISODateTimePartString(minutes);
2721+
const seconds = MathFloor(offsetNanoseconds / 1e9) % 60;
27152722
const secondString = ISODateTimePartString(seconds);
2723+
const nanoseconds = offsetNanoseconds % 1e9;
27162724
let post = '';
27172725
if (nanoseconds) {
27182726
let fraction = `${nanoseconds}`.padStart(9, '0');
@@ -2724,16 +2732,9 @@ export function FormatTimeZoneOffsetString(offsetNanoseconds) {
27242732
return `${sign}${hourString}:${minuteString}${post}`;
27252733
}
27262734

2727-
export function FormatISOTimeZoneOffsetString(offsetNanoseconds) {
2735+
export function FormatDateTimeUTCOffsetRounded(offsetNanoseconds) {
27282736
offsetNanoseconds = RoundNumberToIncrement(bigInt(offsetNanoseconds), 60e9, 'halfExpand').toJSNumber();
2729-
const sign = offsetNanoseconds < 0 ? '-' : '+';
2730-
offsetNanoseconds = MathAbs(offsetNanoseconds);
2731-
const minutes = (offsetNanoseconds / 60e9) % 60;
2732-
const hours = MathFloor(offsetNanoseconds / 3600e9);
2733-
2734-
const hourString = ISODateTimePartString(hours);
2735-
const minuteString = ISODateTimePartString(minutes);
2736-
return `${sign}${hourString}:${minuteString}`;
2737+
return FormatOffsetTimeZoneIdentifier(offsetNanoseconds);
27372738
}
27382739

27392740
export function GetUTCEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond) {

polyfill/lib/intl.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export function DateTimeFormat(locale = undefined, options = undefined) {
101101
this[TZ_ORIGINAL] = ro.timeZone;
102102
} else {
103103
const id = ES.ToString(timeZoneOption);
104-
if (ES.IsTimeZoneOffsetString(id)) {
104+
if (ES.IsOffsetTimeZoneIdentifier(id)) {
105105
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
106106
throw new RangeError('Intl.DateTimeFormat does not currently support offset time zones');
107107
}

polyfill/lib/regex.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const datesplit = new RegExp(
2424
const timesplit = /(\d{2})(?::(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?|(\d{2})(?:(\d{2})(?:[.,](\d{1,9}))?)?)?/;
2525
export const offset = /([+\u2212-])([01][0-9]|2[0-3])(?::?([0-5][0-9])(?::?([0-5][0-9])(?:[.,](\d{1,9}))?)?)?/;
2626
const offsetpart = new RegExp(`([zZ])|${offset.source}?`);
27+
export const offsetIdentifier = offset;
2728
export const annotation = /\[(!)?([a-z_][a-z0-9_-]*)=([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)\]/g;
2829

2930
export const zoneddatetime = new RegExp(

polyfill/lib/timezone.mjs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ export class TimeZone {
2727
throw new RangeError('missing argument: identifier is required');
2828
}
2929
let stringIdentifier = ES.ToString(identifier);
30-
if (ES.IsTimeZoneOffsetString(stringIdentifier)) {
31-
stringIdentifier = ES.CanonicalizeTimeZoneOffsetString(stringIdentifier);
30+
const parseResult = ES.ParseTimeZoneIdentifier(identifier);
31+
if (parseResult.offsetNanoseconds !== undefined) {
32+
stringIdentifier = ES.FormatOffsetTimeZoneIdentifier(parseResult.offsetNanoseconds);
3233
} else {
3334
const record = ES.GetAvailableNamedTimeZoneIdentifier(stringIdentifier);
3435
if (!record) throw new RangeError(`Invalid time zone identifier: ${stringIdentifier}`);
@@ -55,9 +56,8 @@ export class TimeZone {
5556
instant = ES.ToTemporalInstant(instant);
5657
const id = GetSlot(this, TIMEZONE_ID);
5758

58-
if (ES.IsTimeZoneOffsetString(id)) {
59-
return ES.ParseTimeZoneOffsetString(id);
60-
}
59+
const offsetNanoseconds = ES.ParseTimeZoneIdentifier(id).offsetNanoseconds;
60+
if (offsetNanoseconds !== undefined) return offsetNanoseconds;
6161

6262
return ES.GetNamedTimeZoneOffsetNanoseconds(id, GetSlot(instant, EPOCHNANOSECONDS));
6363
}
@@ -85,7 +85,8 @@ export class TimeZone {
8585
const Instant = GetIntrinsic('%Temporal.Instant%');
8686
const id = GetSlot(this, TIMEZONE_ID);
8787

88-
if (ES.IsTimeZoneOffsetString(id)) {
88+
const offsetNanoseconds = ES.ParseTimeZoneIdentifier(id).offsetNanoseconds;
89+
if (offsetNanoseconds !== undefined) {
8990
const epochNs = ES.GetUTCEpochNanoseconds(
9091
GetSlot(dateTime, ISO_YEAR),
9192
GetSlot(dateTime, ISO_MONTH),
@@ -98,8 +99,7 @@ export class TimeZone {
9899
GetSlot(dateTime, ISO_NANOSECOND)
99100
);
100101
if (epochNs === null) throw new RangeError('DateTime outside of supported range');
101-
const offsetNs = ES.ParseTimeZoneOffsetString(id);
102-
return [new Instant(epochNs.minus(offsetNs))];
102+
return [new Instant(epochNs.minus(offsetNanoseconds))];
103103
}
104104

105105
const possibleEpochNs = ES.GetNamedTimeZoneEpochNanoseconds(
@@ -122,7 +122,7 @@ export class TimeZone {
122122
const id = GetSlot(this, TIMEZONE_ID);
123123

124124
// Offset time zones or UTC have no transitions
125-
if (ES.IsTimeZoneOffsetString(id) || id === 'UTC') {
125+
if (ES.IsOffsetTimeZoneIdentifier(id) || id === 'UTC') {
126126
return null;
127127
}
128128

@@ -137,7 +137,7 @@ export class TimeZone {
137137
const id = GetSlot(this, TIMEZONE_ID);
138138

139139
// Offset time zones or UTC have no transitions
140-
if (ES.IsTimeZoneOffsetString(id) || id === 'UTC') {
140+
if (ES.IsOffsetTimeZoneIdentifier(id) || id === 'UTC') {
141141
return null;
142142
}
143143

polyfill/lib/zoneddatetime.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export class ZonedDateTime {
206206

207207
let { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } =
208208
ES.InterpretTemporalDateTimeFields(calendar, fields, options);
209-
const offsetNs = ES.ParseTimeZoneOffsetString(fields.offset);
209+
const offsetNs = ES.ParseDateTimeUTCOffset(fields.offset).offsetNanoseconds;
210210
const timeZone = GetSlot(this, TIME_ZONE);
211211
const epochNanoseconds = ES.InterpretISODateTimeOffset(
212212
year,
@@ -472,7 +472,7 @@ export class ZonedDateTime {
472472
}
473473

474474
const timeZoneIdentifier = ES.ToTemporalTimeZoneIdentifier(GetSlot(this, TIME_ZONE));
475-
if (ES.IsTimeZoneOffsetString(timeZoneIdentifier)) {
475+
if (ES.IsOffsetTimeZoneIdentifier(timeZoneIdentifier)) {
476476
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
477477
throw new RangeError('toLocaleString does not currently support offset time zones');
478478
} else {

polyfill/test/validStrings.mjs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,9 @@ const temporalSign = withCode(
248248
);
249249
const temporalDecimalFraction = fraction;
250250
function saveOffset(data, result) {
251-
data.offset = ES.CanonicalizeTimeZoneOffsetString(result);
251+
data.offset = ES.FormatOffsetTimeZoneIdentifier(ES.ParseDateTimeUTCOffset(result).offsetNanoseconds);
252252
}
253-
const utcOffset = withCode(
253+
const utcOffsetSubMinutePrecision = withCode(
254254
seq(
255255
temporalSign,
256256
hour,
@@ -261,7 +261,7 @@ const utcOffset = withCode(
261261
),
262262
saveOffset
263263
);
264-
const timeZoneUTCOffset = choice(utcDesignator, utcOffset);
264+
const dateTimeUTCOffset = choice(utcDesignator, utcOffsetSubMinutePrecision);
265265
const timeZoneUTCOffsetName = seq(
266266
sign,
267267
hour,
@@ -294,7 +294,7 @@ const timeSpec = seq(
294294
timeHour,
295295
choice([':', timeMinute, [':', timeSecond, [timeFraction]]], seq(timeMinute, [timeSecond, [timeFraction]]))
296296
);
297-
const timeSpecWithOptionalOffsetNotAmbiguous = withSyntaxConstraints(seq(timeSpec, [timeZoneUTCOffset]), (result) => {
297+
const timeSpecWithOptionalOffsetNotAmbiguous = withSyntaxConstraints(seq(timeSpec, [dateTimeUTCOffset]), (result) => {
298298
if (/^(?:(?!02-?30)(?:0[1-9]|1[012])-?(?:0[1-9]|[12][0-9]|30)|(?:0[13578]|10|12)-?31)$/.test(result)) {
299299
throw new SyntaxError('valid PlainMonthDay');
300300
}
@@ -312,17 +312,17 @@ const date = withSyntaxConstraints(
312312
choice(seq(dateYear, '-', dateMonth, '-', dateDay), seq(dateYear, dateMonth, dateDay)),
313313
validateDayOfMonth
314314
);
315-
const dateTime = seq(date, [dateTimeSeparator, timeSpec, [timeZoneUTCOffset]]);
315+
const dateTime = seq(date, [dateTimeSeparator, timeSpec, [dateTimeUTCOffset]]);
316316
const annotatedTime = choice(
317-
seq(timeDesignator, timeSpec, [timeZoneUTCOffset], [timeZoneAnnotation], [annotations]),
317+
seq(timeDesignator, timeSpec, [dateTimeUTCOffset], [timeZoneAnnotation], [annotations]),
318318
seq(timeSpecWithOptionalOffsetNotAmbiguous, [timeZoneAnnotation], [annotations])
319319
);
320320
const annotatedDateTime = seq(dateTime, [timeZoneAnnotation], [annotations]);
321321
const annotatedDateTimeTimeRequired = seq(
322322
date,
323323
dateTimeSeparator,
324324
timeSpec,
325-
[timeZoneUTCOffset],
325+
[dateTimeUTCOffset],
326326
[timeZoneAnnotation],
327327
[annotations]
328328
);
@@ -411,7 +411,7 @@ const duration = seq(
411411
choice(durationDate, durationTime)
412412
);
413413

414-
const instant = seq(date, dateTimeSeparator, timeSpec, timeZoneUTCOffset, [timeZoneAnnotation], [annotations]);
414+
const instant = seq(date, dateTimeSeparator, timeSpec, dateTimeUTCOffset, [timeZoneAnnotation], [annotations]);
415415
const zonedDateTime = seq(dateTime, timeZoneAnnotation, [annotations]);
416416

417417
// goal elements

0 commit comments

Comments
 (0)