Skip to content

Commit

Permalink
Limit offset time zones to minutes precision
Browse files Browse the repository at this point in the history
  • Loading branch information
justingrant committed Jun 16, 2023
1 parent 08d214e commit 53f8027
Show file tree
Hide file tree
Showing 12 changed files with 471 additions and 149 deletions.
91 changes: 50 additions & 41 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -360,24 +360,6 @@ export function RejectTemporalLikeObject(item) {
}
}

export function CanonicalizeTimeZoneOffsetString(offsetString) {
const offsetNs = ParseTimeZoneOffsetString(offsetString);
return FormatTimeZoneOffsetString(offsetNs);
}

export function ParseTemporalTimeZone(stringIdent) {
const { tzName, offset, z } = ParseTemporalTimeZoneString(stringIdent);
if (tzName) {
if (IsTimeZoneOffsetString(tzName)) return CanonicalizeTimeZoneOffsetString(tzName);
const record = GetAvailableNamedTimeZoneIdentifier(tzName);
if (!record) throw new RangeError(`Unrecognized time zone ${tzName}`);
return record.primaryIdentifier;
}
if (z) return 'UTC';
// if !tzName && !z then offset must be present
return CanonicalizeTimeZoneOffsetString(offset);
}

export function MaybeFormatCalendarAnnotation(calendar, showCalendar) {
if (showCalendar === 'never') return '';
return FormatCalendarAnnotation(ToTemporalCalendarIdentifier(calendar), showCalendar);
Expand Down Expand Up @@ -570,19 +552,46 @@ export function ParseTemporalMonthDayString(isoString) {
return { month, day, calendar, referenceISOYear };
}

const TIMEZONE_IDENTIFIER = new RegExp(`^${PARSE.timeZoneID.source}$`, 'i');
const OFFSET_IDENTIFIER = new RegExp(`^${PARSE.offsetIdentifier.source}$`);

export function ParseTimeZoneIdentifier(identifier) {
if (!TIMEZONE_IDENTIFIER.test(identifier)) return undefined;
if (OFFSET_IDENTIFIER.test(identifier)) {
// The regex limits the input to minutes precision
const { offsetNanoseconds } = ParseUTCOffsetString(identifier);
return { offsetMinutes: offsetNanoseconds / 6e10 };
}
return { tzName: identifier };
}

export function ParseTemporalTimeZoneString(stringIdent) {
const bareID = new RegExp(`^${PARSE.timeZoneID.source}$`, 'i');
if (bareID.test(stringIdent)) return { tzName: stringIdent };
if (bareID.test(stringIdent)) {
const identifierParseResult = ParseTimeZoneIdentifier(stringIdent);
if (!identifierParseResult) throw new RangeError(`Invalid time zone: ${stringIdent}`);
return identifierParseResult;
}

// Try parsing ISO string instead
let z, offset, tzName;
try {
// Try parsing ISO string instead
const result = ParseISODateTime(stringIdent);
if (result.z || result.offset || result.tzName) {
return result;
}
({ z, offset, tzName } = ParseISODateTime(stringIdent));
} catch {
// fall through
throw new RangeError(`Invalid time zone: ${stringIdent}`);
}
if (tzName) {
const identifierParseResult = ParseTimeZoneIdentifier(tzName);
if (!identifierParseResult) throw new RangeError(`Invalid time zone: ${tzName}`);
return identifierParseResult;
}
if (z) return { tzName: 'UTC' };
// if !tzName and !z then offset must be present
const offsetParseResult = ParseUTCOffsetString(offset);
if (offsetParseResult.hasSubMinutePrecision) {
throw new RangeError(`Seconds not allowed in offset time zone: ${offset}`);
}
throw new RangeError(`Invalid time zone: ${stringIdent}`);
return { offsetMinutes: offsetParseResult.offsetNanoseconds / 6e10 };
}

export function ParseTemporalDurationString(isoString) {
Expand Down Expand Up @@ -641,7 +650,7 @@ export function ParseTemporalInstant(isoString) {
ParseTemporalInstantString(isoString);

if (!z && !offset) throw new RangeError('Temporal.Instant requires a time zone offset');
const offsetNs = z ? 0 : ParseTimeZoneOffsetString(offset);
const offsetNs = z ? 0 : ParseUTCOffsetString(offset).offsetNanoseconds;
({ year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } = BalanceISODateTime(
year,
month,
Expand Down Expand Up @@ -1005,7 +1014,7 @@ export function ToRelativeTemporalObject(options) {
calendar = ASCIILowercase(calendar);
}
if (timeZone === undefined) return CreateTemporalDate(year, month, day, calendar);
const offsetNs = offsetBehaviour === 'option' ? ParseTimeZoneOffsetString(offset) : 0;
const offsetNs = offsetBehaviour === 'option' ? ParseUTCOffsetString(offset).offsetNanoseconds : 0;
const epochNanoseconds = InterpretISODateTimeOffset(
year,
month,
Expand Down Expand Up @@ -1469,7 +1478,7 @@ export function ToTemporalZonedDateTime(item, options) {
ToTemporalOverflow(options); // validate and ignore
}
let offsetNs = 0;
if (offsetBehaviour === 'option') offsetNs = ParseTimeZoneOffsetString(offset);
if (offsetBehaviour === 'option') offsetNs = ParseUTCOffsetString(offset).offsetNanoseconds;
const epochNanoseconds = InterpretISODateTimeOffset(
year,
month,
Expand Down Expand Up @@ -2099,7 +2108,12 @@ export function ToTemporalTimeZoneSlotValue(temporalTimeZoneLike) {
return temporalTimeZoneLike;
}
const identifier = ToString(temporalTimeZoneLike);
return ParseTemporalTimeZone(identifier);
const { tzName, offsetMinutes } = ParseTemporalTimeZoneString(identifier);
if (offsetMinutes !== undefined) return FormatTimeZoneOffsetString(Math.round(offsetMinutes * 6e10)); // TODO: -round

const record = GetAvailableNamedTimeZoneIdentifier(tzName);
if (!record) throw new RangeError(`Unrecognized time zone ${tzName}`);
return record.primaryIdentifier;
}

export function ToTemporalTimeZoneIdentifier(slotValue) {
Expand Down Expand Up @@ -2575,11 +2589,11 @@ export function TemporalZonedDateTimeToString(
return result;
}

export function IsTimeZoneOffsetString(string) {
export function IsOffsetTimeZoneIdentifier(string) {
return OFFSET.test(string);
}

export function ParseTimeZoneOffsetString(string) {
export function ParseUTCOffsetString(string) {
const match = OFFSET.exec(string);
if (!match) {
throw new RangeError(`invalid time zone offset: ${string}`);
Expand All @@ -2589,7 +2603,9 @@ export function ParseTimeZoneOffsetString(string) {
const minutes = +(match[3] || 0);
const seconds = +(match[4] || 0);
const nanoseconds = +((match[5] || 0) + '000000000').slice(0, 9);
return sign * (((hours * 60 + minutes) * 60 + seconds) * 1e9 + nanoseconds);
const offsetNanoseconds = sign * (((hours * 60 + minutes) * 60 + seconds) * 1e9 + nanoseconds);
const hasSubMinutePrecision = match[4] !== undefined || match[5] !== undefined;
return { offsetNanoseconds, hasSubMinutePrecision };
}

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

export function FormatISOTimeZoneOffsetString(offsetNanoseconds) {
offsetNanoseconds = RoundNumberToIncrement(bigInt(offsetNanoseconds), 60e9, 'halfExpand').toJSNumber();
const sign = offsetNanoseconds < 0 ? '-' : '+';
offsetNanoseconds = MathAbs(offsetNanoseconds);
const minutes = (offsetNanoseconds / 60e9) % 60;
const hours = MathFloor(offsetNanoseconds / 3600e9);

const hourString = ISODateTimePartString(hours);
const minuteString = ISODateTimePartString(minutes);
return `${sign}${hourString}:${minuteString}`;
return FormatTimeZoneOffsetString(offsetNanoseconds);
}

export function GetUTCEpochNanoseconds(year, month, day, hour, minute, second, millisecond, microsecond, nanosecond) {
Expand Down
2 changes: 1 addition & 1 deletion polyfill/lib/intl.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function DateTimeFormat(locale = undefined, options = undefined) {
this[TZ_ORIGINAL] = ro.timeZone;
} else {
const id = ES.ToString(timeZoneOption);
if (ES.IsTimeZoneOffsetString(id)) {
if (ES.IsOffsetTimeZoneIdentifier(id)) {
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
throw new RangeError('Intl.DateTimeFormat does not currently support offset time zones');
}
Expand Down
3 changes: 3 additions & 0 deletions polyfill/lib/regex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export const datesplit = new RegExp(
const timesplit = /(\d{2})(?::(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?|(\d{2})(?:(\d{2})(?:[.,](\d{1,9}))?)?)?/;
export const offset = /([+\u2212-])([01][0-9]|2[0-3])(?::?([0-5][0-9])(?::?([0-5][0-9])(?:[.,](\d{1,9}))?)?)?/;
const offsetpart = new RegExp(`([zZ])|${offset.source}?`);
// export const offsetIdentifier = /([+\u2212-])([01][0-9]|2[0-3])(?::?([0-5][0-9])?)?/;
// TODO: remove the line below and uncomment the line above when we're ready to update offset tests
export const offsetIdentifier = offset;
export const annotation = /\[(!)?([a-z_][a-z0-9_-]*)=([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)\]/g;

export const zoneddatetime = new RegExp(
Expand Down
20 changes: 10 additions & 10 deletions polyfill/lib/timezone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ export class TimeZone {
throw new RangeError('missing argument: identifier is required');
}
let stringIdentifier = ES.ToString(identifier);
if (ES.IsTimeZoneOffsetString(stringIdentifier)) {
stringIdentifier = ES.CanonicalizeTimeZoneOffsetString(stringIdentifier);
const parseResult = ES.ParseTimeZoneIdentifier(identifier);
if (parseResult?.offsetMinutes !== undefined) {
stringIdentifier = ES.FormatTimeZoneOffsetString(Math.round(parseResult.offsetMinutes * 6e10)); // TODO: -round
} else {
const record = ES.GetAvailableNamedTimeZoneIdentifier(stringIdentifier);
if (!record) throw new RangeError(`Invalid time zone identifier: ${stringIdentifier}`);
Expand All @@ -55,9 +56,8 @@ export class TimeZone {
instant = ES.ToTemporalInstant(instant);
const id = GetSlot(this, TIMEZONE_ID);

if (ES.IsTimeZoneOffsetString(id)) {
return ES.ParseTimeZoneOffsetString(id);
}
const offsetMinutes = ES.ParseTimeZoneIdentifier(id)?.offsetMinutes;
if (offsetMinutes !== undefined) return Math.round(offsetMinutes * 6e10); // TODO: -round

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

if (ES.IsTimeZoneOffsetString(id)) {
const offsetMinutes = ES.ParseTimeZoneIdentifier(id)?.offsetMinutes;
if (offsetMinutes !== undefined) {
const epochNs = ES.GetUTCEpochNanoseconds(
GetSlot(dateTime, ISO_YEAR),
GetSlot(dateTime, ISO_MONTH),
Expand All @@ -98,8 +99,7 @@ export class TimeZone {
GetSlot(dateTime, ISO_NANOSECOND)
);
if (epochNs === null) throw new RangeError('DateTime outside of supported range');
const offsetNs = ES.ParseTimeZoneOffsetString(id);
return [new Instant(epochNs.minus(offsetNs))];
return [new Instant(epochNs.minus(Math.round(offsetMinutes * 6e10)))]; // TODO: -round
}

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

// Offset time zones or UTC have no transitions
if (ES.IsTimeZoneOffsetString(id) || id === 'UTC') {
if (ES.IsOffsetTimeZoneIdentifier(id) || id === 'UTC') {
return null;
}

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

// Offset time zones or UTC have no transitions
if (ES.IsTimeZoneOffsetString(id) || id === 'UTC') {
if (ES.IsOffsetTimeZoneIdentifier(id) || id === 'UTC') {
return null;
}

Expand Down
4 changes: 2 additions & 2 deletions polyfill/lib/zoneddatetime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export class ZonedDateTime {

let { year, month, day, hour, minute, second, millisecond, microsecond, nanosecond } =
ES.InterpretTemporalDateTimeFields(calendar, fields, options);
const offsetNs = ES.ParseTimeZoneOffsetString(fields.offset);
const offsetNs = ES.ParseUTCOffsetString(fields.offset).offsetNanoseconds;
const timeZone = GetSlot(this, TIME_ZONE);
const epochNanoseconds = ES.InterpretISODateTimeOffset(
year,
Expand Down Expand Up @@ -472,7 +472,7 @@ export class ZonedDateTime {
}

const timeZoneIdentifier = ES.ToTemporalTimeZoneIdentifier(GetSlot(this, TIME_ZONE));
if (ES.IsTimeZoneOffsetString(timeZoneIdentifier)) {
if (ES.IsOffsetTimeZoneIdentifier(timeZoneIdentifier)) {
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
throw new RangeError('toLocaleString does not currently support offset time zones');
} else {
Expand Down
3 changes: 2 additions & 1 deletion polyfill/test/validStrings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ const temporalSign = withCode(
);
const temporalDecimalFraction = fraction;
function saveOffset(data, result) {
data.offset = ES.CanonicalizeTimeZoneOffsetString(result);
// TODO: -round
data.offset = ES.FormatTimeZoneOffsetString(Math.round(ES.ParseTimeZoneIdentifier(result).offsetMinutes * 6e10));
}
const utcOffset = withCode(
seq(
Expand Down
Loading

0 comments on commit 53f8027

Please sign in to comment.