Skip to content

Commit 1dd7030

Browse files
committed
Limit offset time zones to minutes precision
1 parent 8b94fd6 commit 1dd7030

File tree

8 files changed

+413
-78
lines changed

8 files changed

+413
-78
lines changed

polyfill/lib/ecmascript.mjs

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -361,20 +361,26 @@ export function RejectTemporalLikeObject(item) {
361361
}
362362

363363
export function CanonicalizeTimeZoneOffsetString(offsetString) {
364-
const offsetNs = ParseTimeZoneOffsetString(offsetString);
365-
return FormatTimeZoneOffsetString(offsetNs);
364+
const offsetMinutes = ParseTimeZoneOffsetStringMinutes(offsetString);
365+
return FormatTimeZoneOffsetMinutes(offsetMinutes);
366366
}
367367

368368
export function ParseTemporalTimeZone(stringIdent) {
369369
const { tzName, offset, z } = ParseTemporalTimeZoneString(stringIdent);
370370
if (tzName) {
371371
if (IsTimeZoneOffsetString(tzName)) return CanonicalizeTimeZoneOffsetString(tzName);
372+
if (IsTimeZoneOffsetStringNanosecondsPrecision(tzName)) {
373+
throw new RangeError(`seconds not allowed in offset string: ${tzName}`);
374+
}
372375
const record = GetAvailableNamedTimeZoneIdentifier(tzName);
373376
if (!record) throw new RangeError(`Unrecognized time zone ${tzName}`);
374377
return record.primaryIdentifier;
375378
}
376379
if (z) return 'UTC';
377380
// if !tzName && !z then offset must be present
381+
if (!IsTimeZoneOffsetString(tzName)) {
382+
throw new RangeError(`seconds not allowed in offset string: ${tzName}`);
383+
}
378384
return CanonicalizeTimeZoneOffsetString(offset);
379385
}
380386

@@ -641,7 +647,7 @@ export function ParseTemporalInstant(isoString) {
641647
ParseTemporalInstantString(isoString);
642648

643649
if (!z && !offset) throw new RangeError('Temporal.Instant requires a time zone offset');
644-
const offsetNs = z ? 0 : ParseTimeZoneOffsetString(offset);
650+
const offsetNs = z ? 0 : ParseTimeZoneOffsetStringNanoseconds(offset);
645651
({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = BalanceISODateTime(
646652
year,
647653
month,
@@ -1005,7 +1011,7 @@ export function ToRelativeTemporalObject(options) {
10051011
calendar = ASCIILowercase(calendar);
10061012
}
10071013
if (timeZone === undefined) return CreateTemporalDate(year, month, day, calendar);
1008-
const offsetNs = offsetBehaviour === 'option' ? ParseTimeZoneOffsetString(offset) : 0;
1014+
const offsetNs = offsetBehaviour === 'option' ? ParseTimeZoneOffsetStringNanoseconds(offset) : 0;
10091015
const epochNanoseconds = InterpretISODateTimeOffset(
10101016
year,
10111017
month,
@@ -1406,7 +1412,7 @@ export function InterpretISODateTimeOffset(
14061412
// the user-provided offset doesn't match any instants for this time
14071413
// zone and date/time.
14081414
if (offsetOpt === 'reject') {
1409-
const offsetStr = FormatTimeZoneOffsetString(offsetNs);
1415+
const offsetStr = FormatTimeZoneOffsetNanoseconds(offsetNs);
14101416
const timeZoneString = IsTemporalTimeZone(timeZone) ? GetSlot(timeZone, TIMEZONE_ID) : 'time zone';
14111417
throw new RangeError(`Offset ${offsetStr} is invalid for ${dt} in ${timeZoneString}`);
14121418
}
@@ -1469,7 +1475,7 @@ export function ToTemporalZonedDateTime(item, options) {
14691475
ToTemporalOverflow(options); // validate and ignore
14701476
}
14711477
let offsetNs = 0;
1472-
if (offsetBehaviour === 'option') offsetNs = ParseTimeZoneOffsetString(offset);
1478+
if (offsetBehaviour === 'option') offsetNs = ParseTimeZoneOffsetStringNanoseconds(offset);
14731479
const epochNanoseconds = InterpretISODateTimeOffset(
14741480
year,
14751481
month,
@@ -2162,7 +2168,7 @@ export function GetOffsetNanosecondsFor(timeZone, instant, getOffsetNanosecondsF
21622168

21632169
export function GetOffsetStringFor(timeZone, instant) {
21642170
const offsetNs = GetOffsetNanosecondsFor(timeZone, instant);
2165-
return FormatTimeZoneOffsetString(offsetNs);
2171+
return FormatTimeZoneOffsetNanoseconds(offsetNs);
21662172
}
21672173

21682174
export function GetPlainDateTimeFor(timeZone, instant, calendar) {
@@ -2384,7 +2390,7 @@ export function TemporalInstantToString(instant, timeZone, precision) {
23842390
let timeZoneString = 'Z';
23852391
if (timeZone !== undefined) {
23862392
const offsetNs = GetOffsetNanosecondsFor(outputTimeZone, instant);
2387-
timeZoneString = FormatISOTimeZoneOffsetString(offsetNs);
2393+
timeZoneString = FormatTimeZoneOffsetRoundToMinutes(offsetNs);
23882394
}
23892395
return `${year}-${month}-${day}T${hour}:${minute}${seconds}${timeZoneString}`;
23902396
}
@@ -2564,7 +2570,7 @@ export function TemporalZonedDateTimeToString(
25642570
let result = `${year}-${month}-${day}T${hour}:${minute}${seconds}`;
25652571
if (showOffset !== 'never') {
25662572
const offsetNs = GetOffsetNanosecondsFor(tz, instant);
2567-
result += FormatISOTimeZoneOffsetString(offsetNs);
2573+
result += FormatTimeZoneOffsetRoundToMinutes(offsetNs);
25682574
}
25692575
if (showTimeZone !== 'never') {
25702576
const identifier = ToTemporalTimeZoneIdentifier(tz);
@@ -2579,7 +2585,26 @@ export function IsTimeZoneOffsetString(string) {
25792585
return OFFSET.test(string);
25802586
}
25812587

2582-
export function ParseTimeZoneOffsetString(string) {
2588+
export function IsTimeZoneOffsetStringNanosecondsPrecision(string) {
2589+
const match = OFFSET.exec(string);
2590+
return match && !match[4] && !match[5];
2591+
}
2592+
2593+
export function ParseTimeZoneOffsetStringMinutes(string) {
2594+
const match = OFFSET.exec(string);
2595+
if (!match) {
2596+
throw new RangeError(`invalid time zone offset: ${string}`);
2597+
}
2598+
if (match[4] || match[5]) {
2599+
throw new RangeError(`seconds are not allowed in time zone offset: ${string}`);
2600+
}
2601+
const sign = match[1] === '-' || match[1] === '\u2212' ? -1 : +1;
2602+
const hours = +match[2];
2603+
const minutes = +(match[3] || 0);
2604+
return sign * (hours * 60 + minutes);
2605+
}
2606+
2607+
export function ParseTimeZoneOffsetStringNanoseconds(string) {
25832608
const match = OFFSET.exec(string);
25842609
if (!match) {
25852610
throw new RangeError(`invalid time zone offset: ${string}`);
@@ -2702,7 +2727,7 @@ export function GetNamedTimeZoneOffsetNanoseconds(id, epochNanoseconds) {
27022727
return +utc.minus(epochNanoseconds);
27032728
}
27042729

2705-
export function FormatTimeZoneOffsetString(offsetNanoseconds) {
2730+
export function FormatTimeZoneOffsetNanoseconds(offsetNanoseconds) {
27062731
const sign = offsetNanoseconds < 0 ? '-' : '+';
27072732
offsetNanoseconds = MathAbs(offsetNanoseconds);
27082733
const nanoseconds = offsetNanoseconds % 1e9;
@@ -2724,16 +2749,13 @@ export function FormatTimeZoneOffsetString(offsetNanoseconds) {
27242749
return `${sign}${hourString}:${minuteString}${post}`;
27252750
}
27262751

2727-
export function FormatISOTimeZoneOffsetString(offsetNanoseconds) {
2728-
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);
2752+
export function FormatTimeZoneOffsetMinutes(offsetMinutes) {
2753+
return FormatTimeZoneOffsetNanoseconds(offsetMinutes * 6e10);
2754+
}
27332755

2734-
const hourString = ISODateTimePartString(hours);
2735-
const minuteString = ISODateTimePartString(minutes);
2736-
return `${sign}${hourString}:${minuteString}`;
2756+
export function FormatTimeZoneOffsetRoundToMinutes(offsetNanoseconds) {
2757+
offsetNanoseconds = RoundNumberToIncrement(bigInt(offsetNanoseconds), 60e9, 'halfExpand').toJSNumber();
2758+
return FormatTimeZoneOffsetNanoseconds(offsetNanoseconds);
27372759
}
27382760

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

polyfill/lib/timezone.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export class TimeZone {
5656
const id = GetSlot(this, TIMEZONE_ID);
5757

5858
if (ES.IsTimeZoneOffsetString(id)) {
59-
return ES.ParseTimeZoneOffsetString(id);
59+
return ES.ParseTimeZoneOffsetStringMinutes(id) * 6e10;
6060
}
6161

6262
return ES.GetNamedTimeZoneOffsetNanoseconds(id, GetSlot(instant, EPOCHNANOSECONDS));
@@ -98,7 +98,7 @@ export class TimeZone {
9898
GetSlot(dateTime, ISO_NANOSECOND)
9999
);
100100
if (epochNs === null) throw new RangeError('DateTime outside of supported range');
101-
const offsetNs = ES.ParseTimeZoneOffsetString(id);
101+
const offsetNs = ES.ParseTimeZoneOffsetStringMinutes(id) * 6e10;
102102
return [new Instant(epochNs.minus(offsetNs))];
103103
}
104104

polyfill/lib/zoneddatetime.mjs

Lines changed: 1 addition & 1 deletion
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.ParseTimeZoneOffsetStringNanoseconds(fields.offset);
210210
const timeZone = GetSlot(this, TIME_ZONE);
211211
const epochNanoseconds = ES.InterpretISODateTimeOffset(
212212
year,

spec/abstractops.html

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -622,8 +622,8 @@ <h1>ToRelativeTemporalObject ( _options_ )</h1>
622622
1. If _timeZone_ is *undefined*, then
623623
1. Return ? CreateTemporalDate(_result_.[[Year]], _result_.[[Month]], _result_.[[Day]], _calendar_).
624624
1. If _offsetBehaviour_ is ~option~, then
625-
1. If IsTimeZoneOffsetString(_offsetString_) is *false*, throw a *RangeError* exception.
626-
1. Let _offsetNs_ be ParseTimeZoneOffsetString(_offsetString_).
625+
1. If IsTimeZoneOffsetStringNanosecondPrecision(_offsetString_) is *false*, throw a *RangeError* exception.
626+
1. Let _offsetNs_ be ParseTimeZoneOffsetStringNanoseconds(_offsetString_).
627627
1. Else,
628628
1. Let _offsetNs_ be 0.
629629
1. Let _epochNanoseconds_ be ? InterpretISODateTimeOffset(_result_.[[Year]], _result_.[[Month]], _result_.[[Day]], _result_.[[Hour]], _result_.[[Minute]], _result_.[[Second]], _result_.[[Millisecond]], _result_.[[Microsecond]], _result_.[[Nanosecond]], _offsetBehaviour_, _offsetNs_, _timeZone_, *"compatible"*, *"reject"*, _matchBehaviour_).
@@ -946,6 +946,7 @@ <h1>ISO 8601 grammar</h1>
946946
<li>Alphabetic designators may be in lower or upper case.</li>
947947
<li>Period or comma may be used as the decimal separator.</li>
948948
<li>A time zone offset of *"-00:00"* is allowed, and means the same thing as *"+00:00"*.</li>
949+
<li>UTC offsets may have seconds and up to 9 digits of sub-second decimals.</li>
949950
<li>
950951
In a combined representation, combinations of date, time, and time zone offset with Basic (no `-` or `:` separators) and Extended (with `-` or `:` separators) formatting are allowed.
951952
(The date, time, and time zone offset must each be fully in Basic format or Extended format.)
@@ -1098,14 +1099,25 @@ <h1>ISO 8601 grammar</h1>
10981099
TimeFraction :
10991100
Fraction
11001101

1101-
TimeZoneUTCOffset :
1102-
UTCOffset
1102+
UTCOffsetMinutePrecision :
1103+
TimeZoneUTCOffsetName[+Extended]
1104+
TimeZoneUTCOffsetName[~Extended]
1105+
1106+
UTCOffsetWithSubMinute[Extended] :
1107+
Sign Hour[+Padded] TimeSeparator[?Extended] MinuteSecond TimeSeparator[?Extended] MinuteSecond Fraction?
1108+
1109+
UTCOffsetNanosecondPrecision :
11031110
UTCDesignator
1111+
UTCOffsetMinutePrecision
1112+
UTCOffsetWithSubMinute[+Extended]
1113+
UTCOffsetWithSubMinute[~Extended]
1114+
1115+
NormalizedUTCOffset :
1116+
ASCIISign Hour[+Padded] `:` MinuteSecond
11041117

11051118
TimeZoneUTCOffsetName[Extended] :
11061119
Sign Hour[+Padded]
11071120
Sign Hour[+Padded] TimeSeparator[?Extended] MinuteSecond
1108-
Sign Hour[+Padded] TimeSeparator[?Extended] MinuteSecond TimeSeparator[?Extended] MinuteSecond Fraction?
11091121

11101122
TZLeadingChar :
11111123
Alpha
@@ -1186,21 +1198,21 @@ <h1>ISO 8601 grammar</h1>
11861198
TimeHour TimeMinute TimeSecond TimeFraction?
11871199

11881200
TimeSpecWithOptionalOffsetNotAmbiguous :
1189-
TimeSpec TimeZoneUTCOffset? but not one of ValidMonthDay or DateSpecYearMonth
1201+
TimeSpec UTCOffsetNanosecondPrecision? but not one of ValidMonthDay or DateSpecYearMonth
11901202

11911203
DateTime :
11921204
Date
1193-
Date DateTimeSeparator TimeSpec TimeZoneUTCOffset?
1205+
Date DateTimeSeparator TimeSpec UTCOffsetNanosecondPrecision?
11941206

11951207
AnnotatedTime :
1196-
TimeDesignator TimeSpec TimeZoneUTCOffset? TimeZoneAnnotation? Annotations?
1208+
TimeDesignator TimeSpec UTCOffsetNanosecondPrecision? TimeZoneAnnotation? Annotations?
11971209
TimeSpecWithOptionalOffsetNotAmbiguous TimeZoneAnnotation? Annotations?
11981210

11991211
AnnotatedDateTime:
12001212
DateTime TimeZoneAnnotation? Annotations?
12011213

12021214
AnnotatedDateTimeTimeRequired :
1203-
Date DateTimeSeparator TimeSpec TimeZoneUTCOffset? TimeZoneAnnotation? Annotations?
1215+
Date DateTimeSeparator TimeSpec UTCOffsetNanosecondPrecision? TimeZoneAnnotation? Annotations?
12041216

12051217
AnnotatedYearMonth:
12061218
DateSpecYearMonth TimeZoneAnnotation? Annotations?
@@ -1281,7 +1293,7 @@ <h1>ISO 8601 grammar</h1>
12811293
Sign? DurationDesignator DurationTime
12821294

12831295
TemporalInstantString :
1284-
Date DateTimeSeparator TimeSpec TimeZoneUTCOffset TimeZoneAnnotation? Annotations?
1296+
Date DateTimeSeparator TimeSpec UTCOffsetNanosecondPrecision TimeZoneAnnotation? Annotations?
12851297

12861298
TemporalDateTimeString :
12871299
AnnotatedDateTime
@@ -1409,8 +1421,8 @@ <h1>
14091421
1. If _parseResult_ contains a |UTCDesignator| Parse Node, then
14101422
1. Set _timeZoneResult_.[[Z]] to *true*.
14111423
1. Else,
1412-
1. If _parseResult_ contains a |UTCOffset| Parse Node, then
1413-
1. Let _offset_ be the source text matched by the |UTCOffset| Parse Node contained within _parseResult_.
1424+
1. If _parseResult_ contains a |UTCOffsetNanosecondPrecision| Parse Node, then
1425+
1. Let _offset_ be the source text matched by the |UTCOffsetNanosecondPrecision| Parse Node contained within _parseResult_.
14141426
1. Set _timeZoneResult_.[[OffsetString]] to CodePointsToString(_offset_).
14151427
1. Let _calendar_ be *undefined*.
14161428
1. Let _calendarWasCritical_ be *false*.

spec/instant.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -532,8 +532,8 @@ <h1>ParseTemporalInstant ( _isoString_ )</h1>
532532
1. Let _offsetString_ be _result_.[[TimeZoneOffsetString]].
533533
1. Assert: _offsetString_ is not *undefined*.
534534
1. Let _utc_ be GetUTCEpochNanoseconds(_result_.[[Year]], _result_.[[Month]], _result_.[[Day]], _result_.[[Hour]], _result_.[[Minute]], _result_.[[Second]], _result_.[[Millisecond]], _result_.[[Microsecond]], _result_.[[Nanosecond]]).
535-
1. If IsTimeZoneOffsetString(_offsetString_) is *false*, throw a *RangeError* exception.
536-
1. Let _offsetNanoseconds_ be ParseTimeZoneOffsetString(_offsetString_).
535+
1. If IsTimeZoneOffsetStringNanosecondPrecision(_offsetString_) is *false*, throw a *RangeError* exception.
536+
1. Let _offsetNanoseconds_ be ParseTimeZoneOffsetStringNanoseconds(_offsetString_).
537537
1. Let _result_ be _utc_ - ℤ(_offsetNanoseconds_).
538538
1. If ! IsValidEpochNanoseconds(_result_) is *false*, then
539539
1. Throw a *RangeError* exception.
@@ -658,7 +658,7 @@ <h1>TemporalInstantToString ( _instant_, _timeZone_, _precision_ )</h1>
658658
1. Let _timeZoneString_ be *"Z"*.
659659
1. Else,
660660
1. Let _offsetNs_ be ? GetOffsetNanosecondsFor(_timeZone_, _instant_).
661-
1. Let _timeZoneString_ be ! FormatISOTimeZoneOffsetString(_offsetNs_).
661+
1. Let _timeZoneString_ be FormatTimeZoneOffsetRoundToMinutes(_offsetNs_).
662662
1. Return the string-concatenation of _dateTimeString_ and _timeZoneString_.
663663
</emu-alg>
664664
</emu-clause>

0 commit comments

Comments
 (0)