Skip to content

Commit

Permalink
fix(jkbrzt#391): timezones
Browse files Browse the repository at this point in the history
rrule time base is UTC whereas Luxon requires dates in local format to
calculate the correct time offset.

New tests where introduced to prove correct behaviour in different
system timezones. Run those with `npm run test:tz`
  • Loading branch information
spurreiter committed Jun 9, 2020
1 parent 3dc6983 commit 90359ed
Show file tree
Hide file tree
Showing 8 changed files with 91 additions and 39 deletions.
20 changes: 11 additions & 9 deletions README.md
Expand Up @@ -217,7 +217,7 @@ For more examples see

Optionally, it also supports use of the `TZID` parameter in the
[RFC](https://tools.ietf.org/html/rfc5545#section-3.2.19)
when the [Luxon](https://github.com/moment/luxon) library is provided. The
when the [Luxon](https://github.com/moment/luxon) library is provided. The
[specification](https://moment.github.io/luxon/docs/manual/zones.html#specifying-a-zone)
and [support matrix](https://moment.github.io/luxon/docs/manual/matrix.html) for Luxon apply.

Expand All @@ -230,9 +230,9 @@ new RRule({
tzid: 'Asia/Tokyo'
}).all()

// assuming the system timezone is set to America/Los_Angeles, you get:
[ '2018-01-31T17:30:00.000Z' ]
// which is the time in Los Angeles when it's 2018-02-01T10:30:00 in Tokyo.
// regardless of the system timezone, you get:
[ '2018-02-01T01:30:00.000Z' ]
// which is the time in UTC when it's 2018-02-01T10:30:00 in Tokyo.
```

Whether or not you use the `TZID` param, make sure to only use JS `Date` objects that are
Expand All @@ -243,19 +243,22 @@ represented in UTC to avoid unexpected timezone offsets being applied, for examp
new RRule({
freq: RRule.MONTHLY,
dtstart: new Date(2018, 1, 1, 10, 30),
until: new Date(2018, 2, 31)
until: new Date(2018, 2, 31),
tzid: 'Asia/Tokyo'
}).all()

[ '2018-02-01T18:30:00.000Z', '2018-03-01T18:30:00.000Z' ]
// assuming your local timezone is Asia/Tokyo
[ '2018-01-31T16:30:00.000Z', '2018-02-28T16:30:00.000Z' ]

// RIGHT: Will produce dates with recurrences at the correct time
new RRule({
freq: RRule.MONTHLY,
dtstart: new Date(Date.UTC(2018, 1, 1, 10, 30)),
until: new Date(Date.UTC(2018, 2, 31))
until: new Date(Date.UTC(2018, 2, 31)),
tzid: 'Asia/Tokyo'
}).all()

[ '2018-02-01T10:30:00.000Z', '2018-03-01T10:30:00.000Z' ]
[ '2018-02-01T01:30:00.000Z', '2018-03-01T01:30:00.000Z' ]
```

### API
Expand Down Expand Up @@ -824,4 +827,3 @@ more details.
#### Related projects

* https://rrules.com/ — RESTful API to get back occurrences of RRULEs that conform to RFC 5545.

6 changes: 5 additions & 1 deletion package.json
Expand Up @@ -28,7 +28,11 @@
"build": "yarn lint && tsc && webpack && tsc dist/esm/**/*.d.ts",
"lint": "yarn tslint --project . --fix --config tslint.json",
"test": "TS_NODE_PROJECT=tsconfig.test.json mocha **/*.test.ts",
"test-ci": "TS_NODE_PROJECT=tsconfig.test.json nyc mocha **/*.test.ts"
"test-ci": "TS_NODE_PROJECT=tsconfig.test.json nyc mocha **/*.test.ts",
"test:tz": "npm run test:tz1 && npm run test:tz2 && npm run test:tz3",
"test:tz1": "TZ=Asia/Tokyo npm t",
"test:tz2": "TZ=Europe/Madrid npm t",
"test:tz3": "TZ=America/Chicago npm t"
},
"nyc": {
"extension": [
Expand Down
4 changes: 2 additions & 2 deletions src/dateutil.ts
Expand Up @@ -67,7 +67,7 @@ export namespace dateutil {
/**
* @return {Number} the date's timezone offset in ms
*/
export const tzOffset = function (date: Date) {
export const tzOffset = function (date: Date): number {
return date.getTimezoneOffset() * 60 * 1000
}

Expand Down Expand Up @@ -109,7 +109,7 @@ export namespace dateutil {
/**
* @return {Number} python-like weekday
*/
export const getWeekday = function (date: Date) {
export const getWeekday = function (date: Date): number {
return PY_WEEKDAYS[date.getUTCDay()]
}

Expand Down
13 changes: 11 additions & 2 deletions src/datewithzone.ts
Expand Up @@ -33,8 +33,17 @@ export class DateWithZone {
}

try {
const datetime = DateTime
.fromJSDate(this.date)
const { date } = this
const local = new Date(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.valueOf() % 1000
)
const datetime = DateTime.fromJSDate(local)

const rezoned = datetime.setZone(this.tzid!, { keepLocalTime: true })

Expand Down
5 changes: 3 additions & 2 deletions test/datewithzone.test.ts
Expand Up @@ -37,8 +37,9 @@ describe('rezonedDate', () => {
const currentLocalDate = DateTime.local(2000, 2, 6, 1, 0, 0)
setMockDate(currentLocalDate.toJSDate())

const d = DateTime.fromISO('20101005T110000').toJSDate()
const d = DateTime.fromISO('20101005T110000Z').toJSDate()
const dt = new DateWithZone(d, targetZone)

expect(dt.rezonedDate()).to.deep.equal(
expectedDate(DateTime.fromISO('20101005T110000'), currentLocalDate, targetZone)
)
Expand All @@ -63,4 +64,4 @@ describe('rezonedDate', () => {
DateTime.fromJSDate = origfromJSDate
resetMockDate()
})
})
})
23 changes: 8 additions & 15 deletions test/rrule.test.ts
@@ -1,9 +1,8 @@
import { parse, datetime, testRecurring, expectedDate } from './lib/utils'
import { parse, datetime, testRecurring } from './lib/utils'
import { expect } from 'chai'
import { RRule, rrulestr, Frequency } from '../src/index'
import { DateTime } from 'luxon'
import { set as setMockDate, reset as resetMockDate } from 'mockdate'
import { optionsToString } from '../src/optionstostring';

describe('RRule', function () {
// Enable additional toString() / fromString() tests
Expand All @@ -27,7 +26,7 @@ describe('RRule', function () {
const s2 = rrulestr(s1).toString()
expect(s1).equals(s2, s1 + ' => ' + s2)
})

it('rrulestr itteration not infinite when interval 0', function () {
['FREQ=YEARLY;INTERVAL=0;BYSETPOS=1;BYDAY=MO',
'FREQ=MONTHLY;INTERVAL=0;BYSETPOS=1;BYDAY=MO',
Expand Down Expand Up @@ -3664,12 +3663,9 @@ describe('RRule', function () {
tzid: targetZone
})
const recurrence = rule.all()[0]
const expected = expectedDate(startDate, currentLocalDate, targetZone)
const expected = new Date('2013-08-06T18:00:00.000Z') // regardless in which timezone the local time is, the result in UTC must be the same!

expect(recurrence)
.to.deep.equal(
expected
)
expect(recurrence).to.deep.equal(expected)

resetMockDate()
})
Expand All @@ -3684,12 +3680,9 @@ describe('RRule', function () {
tzid: targetZone
})
const recurrence = rule.all()[0]
const expected = expectedDate(startDate, currentLocalDate, targetZone)
const expected = new Date('2013-08-06T18:00:00.000Z') // regardless in which timezone the local time is, the result in UTC must be the same!

expect(recurrence)
.to.deep.equal(
expected
)
expect(recurrence).to.deep.equal(expected)

resetMockDate()
})
Expand All @@ -3704,11 +3697,11 @@ describe('RRule', function () {
tzid: targetZone
})
const recurrence = rule.after(new Date(0))
const expected = expectedDate(startDate, currentLocalDate, targetZone)
const expected = new Date('2013-08-06T18:00:00.000Z') // regardless in which timezone the local time is, the result in UTC must be the same!

expect(recurrence)
.to.deep.equal(
expected
expected
)

resetMockDate()
Expand Down
16 changes: 8 additions & 8 deletions test/rruleset.test.ts
Expand Up @@ -520,17 +520,17 @@ describe('RRuleSet', function () {
set.rrule(new RRule({
freq: RRule.YEARLY,
count: 4,
dtstart: DateTime.fromISO('20000101T090000').toJSDate(),
dtstart: DateTime.fromISO('20000101T090000Z').toJSDate(), // always use date in UTC
tzid: targetZone
}))

set.exdate(
DateTime.fromISO('20010101T090000').toJSDate(),
DateTime.fromISO('20010101T090000Z').toJSDate(),
)

set.rdate(
DateTime.fromISO('20020301T090000').toJSDate(),
)
DateTime.fromISO('20020301T090000Z').toJSDate(),
)

expect(set.all()).to.deep.equal([
expectedDate(DateTime.fromISO('20000101T090000'), currentLocalDate, targetZone),
Expand Down Expand Up @@ -564,7 +564,7 @@ describe('RRuleSet', function () {
set.tzid(targetZone)

set.rdate(
DateTime.fromISO('20020301T090000').toJSDate(),
DateTime.fromISO('20020301T090000Z').toJSDate(),
)

expect(set.all()).to.deep.equal([
Expand Down Expand Up @@ -755,7 +755,7 @@ describe('RRuleSet', function () {

expect(set.rrules().map(e => e.toString())).eql([rrule.toString()]);
});

it('exrules()', () => {
let set = new RRuleSet();
let rrule = new RRule({
Expand All @@ -773,7 +773,7 @@ describe('RRuleSet', function () {
let set = new RRuleSet();
let dt = parse('19610201T090000');
set.rdate(dt);

expect(set.rdates()).eql([dt]);
});

Expand All @@ -785,4 +785,4 @@ describe('RRuleSet', function () {
expect(set.exdates()).eql([dt]);
});
});
});
});
43 changes: 43 additions & 0 deletions test/timezones.test.ts
@@ -0,0 +1,43 @@
import { RRule } from '../src/index'
import { expect } from "chai"

const ruleByTzid = (tzid: string) => new RRule({
freq: RRule.WEEKLY,
dtstart: new Date(Date.UTC(2020, 1, 1, 0, 0, 0)), // always use UTC dates!!!
tzid,
byweekday: [RRule.TU],
count: 2
})

const mapToIso = (arr: Array<Date>): Array<string> =>
arr.map(d => d.toISOString())

describe('timezones', () => {
it('Europe/Paris', () => {
const tzid = 'Europe/Paris'
const rule = ruleByTzid(tzid)
expect(rule.toString()).to.equal('DTSTART;TZID=Europe/Paris:20200201T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=2')
expect(mapToIso(rule.all())).to.deep.equal([
'2020-02-03T23:00:00.000Z',
'2020-02-10T23:00:00.000Z'
])
})

it('America/New_York', () => {
const rule = ruleByTzid('America/New_York')
expect(rule.toString()).to.equal('DTSTART;TZID=America/New_York:20200201T000000\nRRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=2')
expect(mapToIso(rule.all())).to.deep.equal([
'2020-02-04T05:00:00.000Z',
'2020-02-11T05:00:00.000Z'
])
})

it('UTC', () => {
const rule = ruleByTzid('UTC')
expect(rule.toString()).to.equal('DTSTART:20200201T000000Z\nRRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=2')
expect(mapToIso(rule.all())).to.deep.equal([
'2020-02-04T00:00:00.000Z',
'2020-02-11T00:00:00.000Z'
])
})
})

0 comments on commit 90359ed

Please sign in to comment.