From 90a0f40b092f83a2819e0f54f6ec9df4d04cbf07 Mon Sep 17 00:00:00 2001 From: James Clarke Date: Wed, 22 May 2019 13:45:17 +0100 Subject: [PATCH] Use Intl.DateTimeFormat to fix DST offsets --- package.json | 3 +++ src/tz-offset.js | 60 +++++++++++++++++++++++++++++++++++------- test/tz-offset-test.js | 26 ++++++++++++++---- 3 files changed, 74 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 3f1995d..ad0b588 100644 --- a/package.json +++ b/package.json @@ -31,5 +31,8 @@ "eslint": "^5.16.0", "mocha": "^6.1.4", "nyc": "^14.0.0" + }, + "engines": { + "node": ">=8.0.0" } } diff --git a/src/tz-offset.js b/src/tz-offset.js index bf18cef..56bf681 100644 --- a/src/tz-offset.js +++ b/src/tz-offset.js @@ -1,15 +1,54 @@ 'use strict'; -const offsets = require('../generated/offsets.json'); module.exports = (() => { - const offsetOf = (timezone) => { - const offset = offsets[timezone]; - if (offset != undefined && offset != null) { - return offset; - } else { - throw Error('Invalid timezone ' + timezone); + const _DTFCache = {}; + + function _getDTF(timezone) { + if (!_DTFCache[timezone]) { + try { + const dtf = new Intl.DateTimeFormat('en-US', { + hour12: false, + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + _DTFCache[timezone] = dtf; + } catch(err) { + throw new Error('Invalid timezone ' + timezone); + } } + return _DTFCache[timezone]; + } + + function _fromStringFormat(dtf, date) { + const formatted = dtf.format(date).replace(/\u200E/g, ''), + parsed = formatted.match(/(\d+)\/(\d+)\/(\d+),? (\d+):(\d+):(\d+)/), + [, month, day, year, hour, minute, second] = parsed.map(n => parseInt(n, 10)); + return new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + } + + function _fromPartsFormat(dtf, date) { + const formatted = dtf.formatToParts(date); + return new Date(Date.UTC( + parseInt(formatted[4].value, 10), + parseInt(formatted[0].value, 10) - 1, + parseInt(formatted[2].value, 10), + parseInt(formatted[6].value, 10), + parseInt(formatted[8].value, 10), + parseInt(formatted[10].value, 10) + )); + } + + const offsetOf = (timezone, date) => { + if (!date) date = new Date(); + + const timestamp = date.getTime(); + return ((timestamp - (timestamp % 1000)) - timeAt(date, timezone)) / 60000; }; const removeOffset = (date) => { @@ -18,9 +57,10 @@ module.exports = (() => { }; const timeAt = (date, timezone) => { - const timeUtc = removeOffset(date); - const offset = offsetOf(timezone) * -60000; - return new Date(timeUtc + offset); + const dtf = _getDTF(timezone); + return dtf.formatToParts + ? _fromPartsFormat(dtf, date) + : _fromStringFormat(dtf, date); }; return { diff --git a/test/tz-offset-test.js b/test/tz-offset-test.js index 296e472..fdbba62 100644 --- a/test/tz-offset-test.js +++ b/test/tz-offset-test.js @@ -5,16 +5,24 @@ const { assert } = require('chai'); process.env.TZ = 'America/Sao_Paulo'; describe('tz-offset', () => { - it('should return 180 to America/Sao_Paulo', () => { - assert.equal(tzOffset.offsetOf('America/Sao_Paulo'), 180); + it('should return 180 to America/Sao_Paulo (non DST)', () => { + assert.equal(tzOffset.offsetOf('America/Sao_Paulo', new Date(2019, 7, 1)), 180); }); - it('should return -60 to Africa/Algiers', () => { + it('should return 120 to America/Sao_Paulo (DST)', () => { + assert.equal(tzOffset.offsetOf('America/Sao_Paulo', new Date(2019, 0, 1)), 120); + }); + + it('should return -60 to Africa/Algiers (Doesn\'t have DST)', () => { assert.equal(tzOffset.offsetOf('Africa/Algiers'), -60); }); - it('should return 0 to Europe/London', () => { - assert.equal(tzOffset.offsetOf('Europe/London'), 0); + it('should return 0 to Europe/London (non DST)', () => { + assert.equal(tzOffset.offsetOf('Europe/London', new Date(2019, 0, 1)), 0); + }); + + it('should return -60 to Europe/London (DST)', () => { + assert.equal(tzOffset.offsetOf('Europe/London', new Date(2019, 7, 1)), -60); }); it('should fail with invalid timezone', () => { @@ -35,4 +43,12 @@ describe('tz-offset', () => { const result = tzOffset.timeAt(date, 'Etc/UTC'); assert.equal(result.toString(), expectedDate.toString()); }); + + it('should return the time at zone (using fromStringFormat)', () => { + Intl.DateTimeFormat.prototype.formatToParts = undefined; + const date = new Date(2018, 8, 20, 0, 0, 0, 0); + const expectedDate = new Date(2018, 8, 20, 3, 0, 0, 0); + const result = tzOffset.timeAt(date, 'Etc/UTC'); + assert.equal(result.toString(), expectedDate.toString()); + }); });