From 477046f4da20942c872a0a1c04ba5e4f783ad5e2 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 17 Feb 2021 10:55:37 -0800 Subject: [PATCH 1/4] Fix asserts in TimeZone transition tests equal() was forgotten here, leading to these tests always passing. --- polyfill/test/timezone.mjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/polyfill/test/timezone.mjs b/polyfill/test/timezone.mjs index d1ca71bf97..320da3e117 100644 --- a/polyfill/test/timezone.mjs +++ b/polyfill/test/timezone.mjs @@ -177,8 +177,8 @@ describe('TimeZone', () => { it(`(${zone}).getPlainDateTimeFor(${inst})`, () => assert(zone.getPlainDateTimeFor(inst) instanceof Temporal.PlainDateTime)); it(`(${zone}).getInstantFor(${dtm})`, () => assert(zone.getInstantFor(dtm) instanceof Temporal.Instant)); - it(`(${zone}).getNextTransition(${inst})`, () => zone.getNextTransition(inst), null); - it(`(${zone}).getPreviousTransition(${inst})`, () => zone.getPreviousTransition(inst), null); + it(`(${zone}).getNextTransition(${inst})`, () => equal(zone.getNextTransition(inst), null)); + it(`(${zone}).getPreviousTransition(${inst})`, () => equal(zone.getPreviousTransition(inst), null)); it('wraps around to the next day', () => equal(`${zone.getPlainDateTimeFor(Temporal.Instant.from('2020-02-06T23:59Z'))}`, '2020-02-07T00:59:00')); }); @@ -192,8 +192,8 @@ describe('TimeZone', () => { it(`(${zone}).getPlainDateTimeFor(${inst})`, () => assert(zone.getPlainDateTimeFor(inst) instanceof Temporal.PlainDateTime)); it(`(${zone}).getInstantFor(${dtm})`, () => assert(zone.getInstantFor(dtm) instanceof Temporal.Instant)); - it(`(${zone}).getNextTransition(${inst})`, () => zone.getNextTransition(inst), null); - it(`(${zone}).getPreviousTransition(${inst})`, () => zone.getPreviousTransition(inst), null); + it(`(${zone}).getNextTransition(${inst})`, () => equal(zone.getNextTransition(inst), null)); + it(`(${zone}).getPreviousTransition(${inst})`, () => equal(zone.getPreviousTransition(inst), null)); }); describe('America/Los_Angeles', () => { const zone = new Temporal.TimeZone('America/Los_Angeles'); @@ -228,8 +228,8 @@ describe('TimeZone', () => { it(`(${zone}).getPlainDateTimeFor(${inst})`, () => equal(`${zone.getPlainDateTimeFor(inst)}`, '1900-01-01T12:19:32')); it(`(${zone}).getInstantFor(${dtm})`, () => equal(`${zone.getInstantFor(dtm)}`, '1900-01-01T11:40:28Z')); - it(`(${zone}).getNextTransition(${inst})`, () => zone.getNextTransition(inst), null); - it(`(${zone}).getPreviousTransition(${inst})`, () => zone.getPreviousTransition(inst), null); + it(`(${zone}).getNextTransition(${inst})`, () => equal(`${zone.getNextTransition(inst)}`, '1916-04-30T23:40:28Z')); + it(`(${zone}).getPreviousTransition(${inst})`, () => equal(zone.getPreviousTransition(inst), null)); }); describe('with DST change', () => { it('clock moving forward', () => { From 2c48cd3913fd6931ae6986fed44e3a4cc5bc6fed Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 17 Feb 2021 11:03:54 -0800 Subject: [PATCH 2/4] Fix loops in TimeZone tests These tests called getNextTransition() and getPreviousTransition() four times on the same instant, instead of chaining four calls. --- polyfill/test/timezone.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/polyfill/test/timezone.mjs b/polyfill/test/timezone.mjs index 320da3e117..81eac1ed6f 100644 --- a/polyfill/test/timezone.mjs +++ b/polyfill/test/timezone.mjs @@ -209,12 +209,14 @@ describe('TimeZone', () => { for (let i = 0, txn = inst; i < 4; i++) { const transition = zone.getNextTransition(txn); assert(transition); + txn = transition; } }); it(`(${zone}).getPreviousTransition() x 4 transitions`, () => { for (let i = 0, txn = inst; i < 4; i++) { const transition = zone.getPreviousTransition(txn); assert(transition); + txn = transition; } }); }); From 022f6f0456fb443171b26161c8c46683cc5efb8f Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 17 Feb 2021 11:04:21 -0800 Subject: [PATCH 3/4] TimeZone.getPreviousTransition() should never return its input getPreviousTransition() returns the last transition before the instant passed into it, not the last transition before or equal to. Otherwise, you would have to manually subtract a nanosecond in between each call if you wanted to get a series of previous transitions. Add similar tests for getNextTransition(), although that wasn't broken. No change needed to the spec text or documentation, which are already correct. --- polyfill/lib/ecmascript.mjs | 2 +- polyfill/test/timezone.mjs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/polyfill/lib/ecmascript.mjs b/polyfill/lib/ecmascript.mjs index 6a3afce3c4..5a4232c538 100644 --- a/polyfill/lib/ecmascript.mjs +++ b/polyfill/lib/ecmascript.mjs @@ -1902,7 +1902,7 @@ export const ES = ObjectAssign({}, ES2020, { }, GetIANATimeZonePreviousTransition: (epochNanoseconds, id) => { const lowercap = BEFORE_FIRST_DST; // 1847-01-01T00:00:00Z - let rightNanos = epochNanoseconds; + let rightNanos = bigInt(epochNanoseconds).minus(1); let rightOffsetNs = ES.GetIANATimeZoneOffsetNanoseconds(rightNanos, id); let leftNanos = rightNanos; let leftOffsetNs = rightOffsetNs; diff --git a/polyfill/test/timezone.mjs b/polyfill/test/timezone.mjs index 81eac1ed6f..397ee39417 100644 --- a/polyfill/test/timezone.mjs +++ b/polyfill/test/timezone.mjs @@ -209,6 +209,7 @@ describe('TimeZone', () => { for (let i = 0, txn = inst; i < 4; i++) { const transition = zone.getNextTransition(txn); assert(transition); + assert(!transition.equals(txn)); txn = transition; } }); @@ -216,6 +217,7 @@ describe('TimeZone', () => { for (let i = 0, txn = inst; i < 4; i++) { const transition = zone.getPreviousTransition(txn); assert(transition); + assert(!transition.equals(txn)); txn = transition; } }); @@ -423,6 +425,11 @@ describe('TimeZone', () => { equal(nyc.getNextTransition(a1).toString(), '2019-11-03T06:00:00Z'); equal(nyc.getNextTransition(a2).toString(), '1883-11-18T17:00:00Z'); }); + it('should not return the same as its input if the input is a transition point', () => { + const inst = Temporal.Instant.from('2019-01-01T00:00Z'); + equal(`${nyc.getNextTransition(inst)}`, '2019-03-10T07:00:00Z'); + equal(`${nyc.getNextTransition(nyc.getNextTransition(inst))}`, '2019-11-03T06:00:00Z'); + }); it('casts argument', () => { equal(`${nyc.getNextTransition('2019-04-16T21:01Z')}`, '2019-11-03T06:00:00Z'); }); @@ -441,6 +448,11 @@ describe('TimeZone', () => { equal(london.getPreviousTransition(a1).toString(), '2020-03-29T01:00:00Z'); equal(london.getPreviousTransition(a2).toString(), '1847-12-01T00:01:15Z'); }); + it('should not return the same as its input if the input is a transition point', () => { + const inst = Temporal.Instant.from('2020-06-01T00:00Z'); + equal(`${london.getPreviousTransition(inst)}`, '2020-03-29T01:00:00Z'); + equal(`${london.getPreviousTransition(london.getPreviousTransition(inst))}`, '2019-10-27T01:00:00Z'); + }); it('casts argument', () => { equal(`${london.getPreviousTransition('2020-06-11T21:01Z')}`, '2020-03-29T01:00:00Z'); }); From 4fc9cf54c8ac8e4200d7e030dc687cf6e4ac58dc Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 17 Feb 2021 12:24:51 -0800 Subject: [PATCH 4/4] cookbook: Add "Time zone from tzdata rules" example An example of parsing the rules from the tzdata (sometimes called "Olson database") directly, and creating a Temporal.TimeZone object out of them. Closes: #605 --- docs/cookbook-tzdata.md | 14 + docs/cookbook.md | 6 + docs/cookbook/all.mjs | 1 + docs/cookbook/getTimeZoneObjectFromRules.mjs | 545 +++++++++++++++++++ 4 files changed, 566 insertions(+) create mode 100644 docs/cookbook-tzdata.md create mode 100644 docs/cookbook/getTimeZoneObjectFromRules.mjs diff --git a/docs/cookbook-tzdata.md b/docs/cookbook-tzdata.md new file mode 100644 index 0000000000..18caa9eda2 --- /dev/null +++ b/docs/cookbook-tzdata.md @@ -0,0 +1,14 @@ +## Time zone directly from tzdata rules + +This is an example of building your own `Temporal.TimeZone` object from [tzdata-compatible rules](https://data.iana.org/time-zones/tz-how-to.html). + +This could be useful for testing, for example, or for using other versions of the tzdata than are installed on the host system. + +The code in this example is inefficient. +In real production code, it would make more sense to load the data from a compiled form, not directly from the rules themselves. + +> **NOTE**: This is a very specialized use of Temporal and is not something you would normally need to do. + +```javascript +{{cookbook/getTimeZoneObjectFromRules.mjs}} +``` diff --git a/docs/cookbook.md b/docs/cookbook.md index 765aeb78dd..9957d2d00a 100644 --- a/docs/cookbook.md +++ b/docs/cookbook.md @@ -471,3 +471,9 @@ Extend Temporal to support arbitrarily-large years (e.g., **+635427810-02-02**) An example of using `Temporal.TimeZone` for other purposes than a standard time zne. → [NYSE time zone](cookbook-nyse.md) + +### Time zone directly from tzdata rules + +An example of building your own `Temporal.TimeZone` object from [tzdata-compatible rules](https://data.iana.org/time-zones/tz-how-to.html). + +→ [Time zone from tzdata](cookbook-tzdata.md) diff --git a/docs/cookbook/all.mjs b/docs/cookbook/all.mjs index cb5430e2eb..9d3b8bfc90 100644 --- a/docs/cookbook/all.mjs +++ b/docs/cookbook/all.mjs @@ -18,6 +18,7 @@ import './getParseableZonedStringAtInstant.mjs'; import './getSortedLocalDateTimes.mjs'; import './getTimeStamp.mjs'; import './getTimeZoneObjectFromIanaName.mjs'; +import './getTimeZoneObjectFromRules.mjs'; import './getTripDurationInHrMinSec.mjs'; import './getUtcOffsetDifferenceSecondsAtInstant.mjs'; import './getUtcOffsetSecondsAtInstant.mjs'; diff --git a/docs/cookbook/getTimeZoneObjectFromRules.mjs b/docs/cookbook/getTimeZoneObjectFromRules.mjs new file mode 100644 index 0000000000..f838b63f93 --- /dev/null +++ b/docs/cookbook/getTimeZoneObjectFromRules.mjs @@ -0,0 +1,545 @@ +const { Duration, Instant, PlainDate, PlainDateTime, PlainTime, TimeZone } = Temporal; +const utc = TimeZone.from('UTC'); + +// Utility functions /////////////////////////////////////////////////////////// + +function formatOffsetString(offsetNs) { + const sign = offsetNs < 0 ? '-' : '+'; + offsetNs = Math.abs(offsetNs); + const nanoseconds = offsetNs % 1e9; + const seconds = Math.floor(offsetNs / 1e9) % 60; + const minutes = Math.floor(offsetNs / 60e9) % 60; + const hours = Math.floor(offsetNs / 3600e9); + + const hourString = `${hours}`.padStart(2, '0'); + const minuteString = `${minutes}`.padStart(2, '0'); + const secondString = `${seconds}`.padStart(2, '0'); + let post = ''; + if (nanoseconds) { + let fraction = `${nanoseconds}`.padStart(9, '0'); + while (fraction[fraction.length - 1] === '0') fraction = fraction.slice(0, -1); + post = `:${secondString}.${fraction}`; + } else if (seconds) { + post = `:${secondString}`; + } + return `${sign}${hourString}:${minuteString}${post}`; +} + +// Return a constant-offset time zone object, given a UTC offset in nanoseconds. +function timeZoneFromOffsetNs(offsetNs) { + const string = formatOffsetString(offsetNs); + return TimeZone.from(string); +} + +function earlierOfTwoInstants(one, two) { + return [one, two].sort(Instant.compare)[0]; +} + +// Load TimeZone rules data ////////////////////////////////////////////////// + +// Here's the data that we'll be parsing: +// (Taken from the example at https://data.iana.org/time-zones/tz-how-to.html) +const data = `\ +#Rule NAME FROM TO TYPE IN ON AT SAVE LETTER +Rule Chicago 1920 only - Jun 13 2:00 1:00 D +Rule Chicago 1920 1921 - Oct lastSun 2:00 0 S +Rule Chicago 1921 only - Mar lastSun 2:00 1:00 D +Rule Chicago 1922 1966 - Apr lastSun 2:00 1:00 D +Rule Chicago 1922 1954 - Sep lastSun 2:00 0 S +Rule Chicago 1955 1966 - Oct lastSun 2:00 0 S + +#Rule NAME FROM TO TYPE IN ON AT SAVE LETTER/S +Rule US 1918 1919 - Mar lastSun 2:00 1:00 D +Rule US 1918 1919 - Oct lastSun 2:00 0 S +Rule US 1942 only - Feb 9 2:00 1:00 W # War +Rule US 1945 only - Aug 14 23:00u 1:00 P # Peace +Rule US 1945 only - Sep 30 2:00 0 S +Rule US 1967 2006 - Oct lastSun 2:00 0 S +Rule US 1967 1973 - Apr lastSun 2:00 1:00 D +Rule US 1974 only - Jan 6 2:00 1:00 D +Rule US 1975 only - Feb 23 2:00 1:00 D +Rule US 1976 1986 - Apr lastSun 2:00 1:00 D +Rule US 1987 2006 - Apr Sun>=1 2:00 1:00 D +Rule US 2007 max - Mar Sun>=8 2:00 1:00 D +Rule US 2007 max - Nov Sun>=1 2:00 0 S + +#Zone NAME STDOFF RULES FORMAT [UNTIL] +Zone America/Chicago -5:50:36 - LMT 1883 Nov 18 12:09:24 + -6:00 US C%sT 1920 + -6:00 Chicago C%sT 1936 Mar 1 2:00 + -5:00 - EST 1936 Nov 15 2:00 + -6:00 Chicago C%sT 1942 + -6:00 US C%sT 1946 + -6:00 Chicago C%sT 1967 + -6:00 US C%sT + +# Rule NAME FROM TO - IN ON AT SAVE LETTER/S +Rule WS 2010 only - Sep lastSun 0:00 1 - +Rule WS 2011 only - Apr Sat>=1 4:00 0 - +Rule WS 2011 only - Sep lastSat 3:00 1 - +Rule WS 2012 max - Apr Sun>=1 4:00 0 - +Rule WS 2012 max - Sep lastSun 3:00 1 - +# Zone NAME STDOFF RULES FORMAT [UNTIL] +Zone Pacific/Apia 12:33:04 - LMT 1892 Jul 5 + -11:26:56 - LMT 1911 + -11:30 - -1130 1950 + -11:00 WS -11/-10 2011 Dec 29 24:00 + 13:00 WS +13/+14 +`; + +class TZDataRules { + static MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + static WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + + _rules = {}; + _zones = {}; + + constructor(data) { + this._load(data); + } + + // Parse a "Zone" line (or a continuation of one) into a Zone entry, which + // takes the form: + // { + // // "standard" UTC offset for this zone during this time (excluding DST) + // stdoff: Temporal.Duration; + // // string identifier for the DST rules applying during this time + // rules: string | null; + // // local wall-clock time at which the next Zone entry takes effect + // until: Temporal.PlainDateTime | null; + // } + _parseZoneLine(line) { + let [stdoff, rules, , ...until] = line.split(/\s+/); + + let sign = 1; + if (stdoff.startsWith('-')) { + sign = -1; + stdoff = stdoff.slice(1); + } + let [hours, minutes, seconds = '0'] = stdoff.split(':'); + hours = +hours; + minutes = +minutes; + seconds = +seconds; + let nanoseconds = 0; + if (!Number.isInteger(seconds)) { + const rounded = Math.floor(seconds); + nanoseconds = (seconds - rounded) * 1e9; + seconds = rounded; + } + stdoff = Duration.from({ hours, minutes, seconds, nanoseconds }).round({ largestUnit: 'hours' }); + if (sign === -1) stdoff = stdoff.negated(); + + if (rules === '-') rules = null; + + if (until.length) { + let [year, month = 'Jan', day = 1, time = ''] = until; + year = +year; + month = TZDataRules.MONTHS.indexOf(month) + 1; + day = +day; + let [hour = 0, minute = 0, second = 0] = time.split(':'); + hour = +hour; + minute = +minute; + second = +second; + until = PlainDateTime.from({ year, month, day, hour, minute, second }); + } else { + until = null; + } + + return { stdoff, rules, until }; + } + + // Parse a Rule line into a DST rules entry, which takes the form: + // { + // from: number; // year + // to: number | null; // year, or null means no ending year + // // month and day indicate the day every year in which the DST transition + // // takes place. for example, month: 10, day: 'lastSun' means "the last + // // Sunday of every October" + // month: number; // 1-12 + // day: number | string; // day of the month, or special string + // time: Temporal.PlainTime; // clock time at which transition takes place + // // usually "clock time" means local time, including the current DST. If + // // timeZone is 'standard', then it means standard local time, excluding + // // the current DST. If it's a Temporal.TimeZone, then the clock time is + // // in that time zone. + // timeZone: null | Temporal.TimeZone | 'standard'; + // save: Temporal.Duration; // DST shift that goes into effect + // } + _parseRuleLine(line) { + let [, , from, to, , month, day, time, save] = line.split(/\s+/); + + from = +from; + if (to === 'only') { + to = from; + } else if (to === 'max') { + to = null; + } else { + to = +to; + } + month = TZDataRules.MONTHS.indexOf(month) + 1; + if (!Number.isNaN(+day)) day = +day; + let timeZone = null; // default is wall-clock time + if (time.endsWith('u')) { + timeZone = utc; + time = time.slice(0, -1); + } + if (time.endsWith('s')) { + timeZone = 'standard'; + time = time.slice(0, -1); + } + let [hour, minute] = time.split(':'); + hour = +hour; + minute = +minute; + time = PlainTime.from({ hour, minute }); + let [hours, minutes = 0] = save.split(':'); + hours = +hours; + minutes = +minutes; + save = Duration.from({ hours, minutes }); + + return { from, to, month, day, time, timeZone, save }; + } + + _load(data) { + let activeZone = null; + const lines = data.split('\n'); + for (let line of lines) { + // Strip comments + const commentIndex = line.indexOf('#'); + if (commentIndex > -1) line = line.slice(0, commentIndex); + + // Skip blanks + line = line.trim(); + if (line.length === 0) continue; + + // Zone records may span more than one line and are ended by a line with a + // blank in the "until" position. + // Rule records are just one line. + if (activeZone) { + const result = this._parseZoneLine(line); + this._zones[activeZone].push(result); + if (!result.until) activeZone = null; + } else if (line.startsWith('Zone')) { + const [, name, ...rest] = line.split(/\s+/); + if (!(name in this._zones)) this._zones[name] = []; + const result = this._parseZoneLine(rest.join(' ')); + this._zones[name].push(result); + if (result.until) activeZone = name; // following lines belong to this zone + } else if (line.startsWith('Rule')) { + const [, name] = line.split(/\s+/); + if (!(name in this._rules)) this._rules[name] = []; + const result = this._parseRuleLine(line); + this._rules[name].push(result); + } + } + } + + validateID(id) { + if (!(id in this._zones)) { + throw new RangeError(`identifier ${id} not present in time zone data`); + } + } + + _getZoneRecord(id) { + this.validateID(id); + return this._zones[id]; + } + + // Return the Zone record in effect for the time zone `id` at time `time`. + // If `time` is a Temporal.PlainDateTime, then it's treated as the clock time + // in "standard time" (DST not in effect) in that time zone. + getRuleSetInEffect(id, time) { + for (const record of this._getZoneRecord(id)) { + const { stdoff, until } = record; + if (until === null) return record; // last line + + const stateEndsUTC = until.subtract(stdoff); + const stateEndsInstant = utc.getInstantFor(stateEndsUTC); + + let instant = time; + if (time instanceof PlainDateTime) { + instant = utc.getInstantFor(time.subtract(stdoff)); + } + + if (Instant.compare(instant, stateEndsInstant) < 0) return record; + } + } + + // Return all the DST rules of `ruleSetID` that apply during the year `year`. + // Returns an array of records of the following form: + // { + // transitionDateTime: Temporal.PlainDateTime; + // save: Temporal.Duration; + // timeZone: null | Temporal.TimeZone | 'standard'; + // } + // The array is sorted by `transitionDateTime`. + // This is used for determining the next transition given a particular date. + getDSTRules(ruleSetID, year) { + const ruleSet = this._rules[ruleSetID]; + const result = []; + for (const { from, to, month, day, time, timeZone, save } of ruleSet) { + if (year < from || (to != null && year > to)) continue; + + let transitionDate; + if (typeof day === 'string' && day.startsWith('last')) { + const weekday = TZDataRules.WEEKDAYS.indexOf(day.slice(4)) + 1; + const lastPossible = PlainDate.from({ year, month, day: 31 }, { disambiguation: 'constrain' }); + transitionDate = lastPossible.subtract({ days: (7 + lastPossible.dayOfWeek - weekday) % 7 }); + } else if (typeof day === 'string' && day.includes('>=')) { + const split = day.split('>='); + const weekday = TZDataRules.WEEKDAYS.indexOf(split[0]) + 1; + const firstPossible = PlainDate.from({ year, month, day: +split[1] }); + transitionDate = firstPossible.add({ days: (7 + weekday - firstPossible.dayOfWeek) % 7 }); + } else { + transitionDate = PlainDate.from({ year, month, day }); + } + + const transitionDateTime = transitionDate.toPlainDateTime(time); + result.push({ transitionDateTime, save, timeZone }); + } + return result.sort((a, b) => PlainDateTime.compare(a.transitionDateTime, b.transitionDateTime)); + } +} + +// This is the custom time zone class that works exactly like a built-in +// Temporal.TimeZone but is generated from our custom time zone data. +class RulesTimeZone extends TimeZone { + // Computing transitions is expensive, and due to the form of the data it's + // easier to compute them forward than backward, so we cache them once they + // are computed. + // The array stores records of the form + // { + // offset: number; // total UTC offset in nanoseconds (including dstShift) + // dstShift: Temporal.Duration; // daylight saving shift + // until: Temporal.Instant | null; // time at which next state takes effect + // } + _cachedTransitions = []; + _rules; + _id; + + constructor(rules, id) { + super('UTC'); + this._rules = rules; + this._rules.validateID(id); + this._id = id; + } + + // Private helper methods //////////////////////////////////////////////////// + + // Computes the next UTC offset transition after `startingPoint`. This doesn't + // cache anything. + _computeNextTransition(startingPoint, prevOffsetNs, prevDSTShift) { + const { stdoff, rules, until } = this._rules.getRuleSetInEffect(this._id, startingPoint); + const stdoffNs = stdoff.total({ unit: 'nanoseconds' }); + + const stdoffZone = timeZoneFromOffsetNs(stdoffNs); + + // no DST rules in effect? + if (!rules) { + // end of transitions? + if (!until) return { offset: stdoffNs, until: null, dstShift: new Duration() }; + + return { offset: stdoffNs, until: stdoffZone.getInstantFor(until), dstShift: new Duration() }; + } + + let dateTime = stdoffZone.getPlainDateTimeFor(startingPoint); + let dstRules = this._rules.getDSTRules(rules, dateTime.year); + + let dstShift = prevDSTShift; + for (const { transitionDateTime, save, timeZone } of dstRules) { + if (PlainDateTime.compare(dateTime, transitionDateTime) < 0) { + const offset = stdoff.add(dstShift).total({ unit: 'nanoseconds' }); + if (offset !== prevOffsetNs) { + let transitionZone = timeZone; + if (transitionZone === 'standard') transitionZone = stdoffZone; + // default if no specific time zone given is wall-clock time + if (!transitionZone) transitionZone = timeZoneFromOffsetNs(offset); + let transitionInstant = transitionZone.getInstantFor(transitionDateTime); + if (until !== null) { + const ruleSetUntilInstant = transitionZone.getInstantFor(until); + transitionInstant = earlierOfTwoInstants(transitionInstant, ruleSetUntilInstant); + } + return { offset, until: transitionInstant, dstShift }; + } + } + dstShift = save; + } + // If we get to this point, dateTime occurs after the last DST transition in + // that calendar year. Try again with next year's transitions, unless this + // standard offset ends during this calendar year. + dateTime = dateTime.add({ years: 1 }).with({ month: 1, day: 1 }); + if (until && PlainDateTime.compare(dateTime, until) > 0) { + const offset = stdoff.add(dstShift).total({ unit: 'nanoseconds' }); + const nextRuleSetBeginsInstant = stdoffZone.getInstantFor(until); + const { stdoff: newStdoff } = this._rules.getRuleSetInEffect(this._id, nextRuleSetBeginsInstant); + const newOffset = newStdoff.add(dstShift).total({ unit: 'nanoseconds' }); + if (offset !== newOffset) { + return { offset, until: nextRuleSetBeginsInstant, dstShift }; + } + } + return this._computeNextTransition(stdoffZone.getInstantFor(dateTime), prevOffsetNs, dstShift); + } + + // Computes whatever is the next transition after the last entry currently in + // the cache, caches it, and returns it. (If there are no more transitions, + // this returns the final one.) + _computeNextTransitionCached() { + let until = Instant.fromEpochSeconds(-1e8 * 86400); + let offset = null; + let dstShift = new Duration(); + if (this._cachedTransitions.length) { + let lastEntry = this._cachedTransitions[this._cachedTransitions.length - 1]; + if (lastEntry.until === null) return lastEntry; // don't compute more transitions, they've ended + ({ until, offset, dstShift } = lastEntry); + } + const result = this._computeNextTransition(until, offset, dstShift); + this._cachedTransitions.push(result); + return result; + } + + // Returns the index into the cache for the DST state + _getIndexOfStateApplyingTo(instant) { + const stateIndex = this._cachedTransitions.findIndex(({ until }) => Instant.compare(instant, until) < 0); + if (stateIndex !== -1) return stateIndex; + + // compute as many more transitions as we need, or until they run out + let state; + do { + state = this._computeNextTransitionCached(); + } while (state.until !== null && Instant.compare(instant, state.until) > 0); + + return this._cachedTransitions.length - 1; + } + + // Implementations of TimeZone methods /////////////////////////////////////// + + // For the first three, we simply consult the cache, computing more entries + // and caching them as necessary. + + getOffsetNanosecondsFor(instant) { + const index = this._getIndexOfStateApplyingTo(instant); + return this._cachedTransitions[index].offset; + } + + getNextTransition(startingPoint) { + const index = this._getIndexOfStateApplyingTo(startingPoint); + return this._cachedTransitions[index].until; + } + + getPreviousTransition(startingPoint) { + const justBeforeStartingPoint = startingPoint.subtract({ nanoseconds: 1 }); + const index = this._getIndexOfStateApplyingTo(justBeforeStartingPoint); + if (index === 0) return null; + return this._cachedTransitions[index - 1].until; + } + + getPossibleInstantsFor(plainDateTime) { + const { stdoff, rules } = this._rules.getRuleSetInEffect(this._id, plainDateTime); + const stdoffNs = stdoff.total({ unit: 'nanoseconds' }); + const stdoffZone = timeZoneFromOffsetNs(stdoffNs); + const stdoffInstant = stdoffZone.getInstantFor(plainDateTime); + + // No DST rules in effect, return the instant at standard offset. + if (!rules) return [stdoffInstant]; + + // Check the UTC offset 24 hours before and 24 hours after the instant at + // standard offset. If the two offsets are the same, there's no DST + // transition, so return the instant at that offset. + const indexBefore = this._getIndexOfStateApplyingTo(stdoffInstant.subtract({ hours: 24 })); + const stateBefore = this._cachedTransitions[indexBefore]; + const indexAfter = this._getIndexOfStateApplyingTo(stdoffInstant.add({ hours: 24 })); + const stateAfter = this._cachedTransitions[indexAfter]; + + if (stateBefore.offset === stateAfter.offset) { + const localZone = timeZoneFromOffsetNs(stateBefore.offset); + return [localZone.getInstantFor(plainDateTime)]; + } + + // If the two offsets are different, compute instants at both of them, and + // return both of them if they convert back to the original PlainDateTime. + // If they don't convert back, then this was a skipped time, so return an + // empty array. + return [stateBefore.offset, stateAfter.offset] + .map((offsetNs) => timeZoneFromOffsetNs(offsetNs).getInstantFor(plainDateTime)) + .filter((instant) => this.getPlainDateTimeFor(instant).equals(plainDateTime)); + } +} + +// Demonstration and testing /////////////////////////////////////////////////// + +// Parse our tzdata into a rules object +const rules = new TZDataRules(data); + +// Compare the America/Chicago time zone from our tzdata with the built-in one +const chicago = Temporal.TimeZone.from('America/Chicago'); +const fauxChicago = new RulesTimeZone(rules, 'America/Chicago'); + +let start = Temporal.Instant.from('1800-01-01T00:00Z'); +let result1 = start; +let result2 = start; + +for (let ix = 0; ix < 20; ix++) { + result1 = chicago.getNextTransition(result1); + result2 = fauxChicago.getNextTransition(result2); + if (result1 === null) { + assert.equal(result2, null); + break; + } + assert.equal(result1.toString(), result2.toString()); + const offset1 = chicago.getOffsetStringFor(result1); + const offset2 = fauxChicago.getOffsetStringFor(result2); + assert.equal(offset1, offset2); +} + +// Ditto for a time zone with an international date line transition +const samoa = Temporal.TimeZone.from('Pacific/Apia'); +const fauxSamoa = new RulesTimeZone(rules, 'Pacific/Apia'); + +start = Temporal.Instant.from('1800-01-01T00:00Z'); +result1 = start; +result2 = start; + +for (let ix = 0; ix < 20; ix++) { + result1 = samoa.getNextTransition(result1); + result2 = fauxSamoa.getNextTransition(result2); + if (result1 === null) { + assert.equal(result2, null); + break; + } + assert.equal(result1.toString(), result2.toString()); + const offset1 = samoa.getOffsetStringFor(result1); + const offset2 = fauxSamoa.getOffsetStringFor(result2); + assert.equal(offset1, offset2); +} + +// Ditto for getting the previous transition, by listing the last 20 transitions +// occurring before 2021 +start = Temporal.Instant.from('2021-01-01T00:00Z'); +result1 = start; +result2 = start; + +for (let ix = 0; ix < 20; ix++) { + result1 = chicago.getPreviousTransition(result1); + result2 = fauxChicago.getPreviousTransition(result2); + if (result1 === null) { + assert.equal(result2, null); + break; + } + assert.equal(result1.toString(), result2.toString()); + const offset1 = chicago.getOffsetStringFor(result1); + const offset2 = fauxChicago.getOffsetStringFor(result2); + assert.equal(offset1, offset2); +} + +// Test converting PlainDateTime to Instant at three times: one where there is +// no DST transition, one that doesn't exist due to a DST transition, and one +// that exists twice. +const unambiguous = Temporal.PlainDateTime.from('2019-01-01T02:00'); +const springForward = Temporal.PlainDateTime.from('2019-03-10T02:30'); +const fallBack = Temporal.PlainDateTime.from('2019-11-03T01:30'); +[unambiguous, springForward, fallBack].forEach((local) => { + ['earlier', 'later'].forEach((disambiguation) => { + const result1 = chicago.getInstantFor(local, { disambiguation }); + const result2 = fauxChicago.getInstantFor(local, { disambiguation }); + assert.equal(result1.toString(), result2.toString()); + }); +});