Skip to content

Commit 53f8027

Browse files
committed
Limit offset time zones to minutes precision
1 parent 08d214e commit 53f8027

File tree

12 files changed

+471
-149
lines changed

12 files changed

+471
-149
lines changed

polyfill/lib/ecmascript.mjs

Lines changed: 50 additions & 41 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,19 +552,46 @@ 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)) return undefined;
560+
if (OFFSET_IDENTIFIER.test(identifier)) {
561+
// The regex limits the input to minutes precision
562+
const { offsetNanoseconds } = ParseUTCOffsetString(identifier);
563+
return { offsetMinutes: offsetNanoseconds / 6e10 };
564+
}
565+
return { tzName: identifier };
566+
}
567+
573568
export function ParseTemporalTimeZoneString(stringIdent) {
574569
const bareID = new RegExp(`^${PARSE.timeZoneID.source}$`, 'i');
575-
if (bareID.test(stringIdent)) return { tzName: stringIdent };
570+
if (bareID.test(stringIdent)) {
571+
const identifierParseResult = ParseTimeZoneIdentifier(stringIdent);
572+
if (!identifierParseResult) throw new RangeError(`Invalid time zone: ${stringIdent}`);
573+
return identifierParseResult;
574+
}
575+
576+
// Try parsing ISO string instead
577+
let z, offset, tzName;
576578
try {
577-
// Try parsing ISO string instead
578-
const result = ParseISODateTime(stringIdent);
579-
if (result.z || result.offset || result.tzName) {
580-
return result;
581-
}
579+
({ z, offset, tzName } = ParseISODateTime(stringIdent));
582580
} catch {
583-
// fall through
581+
throw new RangeError(`Invalid time zone: ${stringIdent}`);
582+
}
583+
if (tzName) {
584+
const identifierParseResult = ParseTimeZoneIdentifier(tzName);
585+
if (!identifierParseResult) throw new RangeError(`Invalid time zone: ${tzName}`);
586+
return identifierParseResult;
587+
}
588+
if (z) return { tzName: 'UTC' };
589+
// if !tzName and !z then offset must be present
590+
const offsetParseResult = ParseUTCOffsetString(offset);
591+
if (offsetParseResult.hasSubMinutePrecision) {
592+
throw new RangeError(`Seconds not allowed in offset time zone: ${offset}`);
584593
}
585-
throw new RangeError(`Invalid time zone: ${stringIdent}`);
594+
return { offsetMinutes: offsetParseResult.offsetNanoseconds / 6e10 };
586595
}
587596

588597
export function ParseTemporalDurationString(isoString) {
@@ -641,7 +650,7 @@ export function ParseTemporalInstant(isoString) {
641650
ParseTemporalInstantString(isoString);
642651

643652
if (!z && !offset) throw new RangeError('Temporal.Instant requires a time zone offset');
644-
const offsetNs = z ? 0 : ParseTimeZoneOffsetString(offset);
653+
const offsetNs = z ? 0 : ParseUTCOffsetString(offset).offsetNanoseconds;
645654
({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = BalanceISODateTime(
646655
year,
647656
month,
@@ -1005,7 +1014,7 @@ export function ToRelativeTemporalObject(options) {
10051014
calendar = ASCIILowercase(calendar);
10061015
}
10071016
if (timeZone === undefined) return CreateTemporalDate(year, month, day, calendar);
1008-
const offsetNs = offsetBehaviour === 'option' ? ParseTimeZoneOffsetString(offset) : 0;
1017+
const offsetNs = offsetBehaviour === 'option' ? ParseUTCOffsetString(offset).offsetNanoseconds : 0;
10091018
const epochNanoseconds = InterpretISODateTimeOffset(
10101019
year,
10111020
month,
@@ -1469,7 +1478,7 @@ export function ToTemporalZonedDateTime(item, options) {
14691478
ToTemporalOverflow(options); // validate and ignore
14701479
}
14711480
let offsetNs = 0;
1472-
if (offsetBehaviour === 'option') offsetNs = ParseTimeZoneOffsetString(offset);
1481+
if (offsetBehaviour === 'option') offsetNs = ParseUTCOffsetString(offset).offsetNanoseconds;
14731482
const epochNanoseconds = InterpretISODateTimeOffset(
14741483
year,
14751484
month,
@@ -2099,7 +2108,12 @@ export function ToTemporalTimeZoneSlotValue(temporalTimeZoneLike) {
20992108
return temporalTimeZoneLike;
21002109
}
21012110
const identifier = ToString(temporalTimeZoneLike);
2102-
return ParseTemporalTimeZone(identifier);
2111+
const { tzName, offsetMinutes } = ParseTemporalTimeZoneString(identifier);
2112+
if (offsetMinutes !== undefined) return FormatTimeZoneOffsetString(Math.round(offsetMinutes * 6e10)); // TODO: -round
2113+
2114+
const record = GetAvailableNamedTimeZoneIdentifier(tzName);
2115+
if (!record) throw new RangeError(`Unrecognized time zone ${tzName}`);
2116+
return record.primaryIdentifier;
21032117
}
21042118

21052119
export function ToTemporalTimeZoneIdentifier(slotValue) {
@@ -2575,11 +2589,11 @@ export function TemporalZonedDateTimeToString(
25752589
return result;
25762590
}
25772591

2578-
export function IsTimeZoneOffsetString(string) {
2592+
export function IsOffsetTimeZoneIdentifier(string) {
25792593
return OFFSET.test(string);
25802594
}
25812595

2582-
export function ParseTimeZoneOffsetString(string) {
2596+
export function ParseUTCOffsetString(string) {
25832597
const match = OFFSET.exec(string);
25842598
if (!match) {
25852599
throw new RangeError(`invalid time zone offset: ${string}`);
@@ -2589,7 +2603,9 @@ export function ParseTimeZoneOffsetString(string) {
25892603
const minutes = +(match[3] || 0);
25902604
const seconds = +(match[4] || 0);
25912605
const nanoseconds = +((match[5] || 0) + '000000000').slice(0, 9);
2592-
return sign * (((hours * 60 + minutes) * 60 + seconds) * 1e9 + nanoseconds);
2606+
const offsetNanoseconds = sign * (((hours * 60 + minutes) * 60 + seconds) * 1e9 + nanoseconds);
2607+
const hasSubMinutePrecision = match[4] !== undefined || match[5] !== undefined;
2608+
return { offsetNanoseconds, hasSubMinutePrecision };
25932609
}
25942610

25952611
let canonicalTimeZoneIdsCache = undefined;
@@ -2726,14 +2742,7 @@ export function FormatTimeZoneOffsetString(offsetNanoseconds) {
27262742

27272743
export function FormatISOTimeZoneOffsetString(offsetNanoseconds) {
27282744
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}`;
2745+
return FormatTimeZoneOffsetString(offsetNanoseconds);
27372746
}
27382747

27392748
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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ 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 = /([+\u2212-])([01][0-9]|2[0-3])(?::?([0-5][0-9])?)?/;
28+
// TODO: remove the line below and uncomment the line above when we're ready to update offset tests
29+
export const offsetIdentifier = offset;
2730
export const annotation = /\[(!)?([a-z_][a-z0-9_-]*)=([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)\]/g;
2831

2932
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?.offsetMinutes !== undefined) {
32+
stringIdentifier = ES.FormatTimeZoneOffsetString(Math.round(parseResult.offsetMinutes * 6e10)); // TODO: -round
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 offsetMinutes = ES.ParseTimeZoneIdentifier(id)?.offsetMinutes;
60+
if (offsetMinutes !== undefined) return Math.round(offsetMinutes * 6e10); // TODO: -round
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 offsetMinutes = ES.ParseTimeZoneIdentifier(id)?.offsetMinutes;
89+
if (offsetMinutes !== 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(Math.round(offsetMinutes * 6e10)))]; // TODO: -round
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.ParseUTCOffsetString(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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,8 @@ const temporalSign = withCode(
248248
);
249249
const temporalDecimalFraction = fraction;
250250
function saveOffset(data, result) {
251-
data.offset = ES.CanonicalizeTimeZoneOffsetString(result);
251+
// TODO: -round
252+
data.offset = ES.FormatTimeZoneOffsetString(Math.round(ES.ParseTimeZoneIdentifier(result).offsetMinutes * 6e10));
252253
}
253254
const utcOffset = withCode(
254255
seq(

0 commit comments

Comments
 (0)