diff --git a/package-lock.json b/package-lock.json index 6cd39e9b74..b01b75f536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3008,6 +3008,15 @@ "check-error": "^1.0.2" } }, + "chai-moment": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chai-moment/-/chai-moment-0.1.0.tgz", + "integrity": "sha1-SpFoDPo6dc/aGEULK6tltIyQJAE=", + "dev": true, + "requires": { + "moment": "^2.10.6" + } + }, "chalk": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", diff --git a/package.json b/package.json index ff35d35ca2..fa504fbe82 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "axios": "^0.19.2", "chai": "^4.1.2", "chai-as-promised": "^7.1.1", + "chai-moment": "^0.1.0", "chalk": "^4.1.0", "cross-spawn": "^7.0.3", "expect.js": "^0.3.1", diff --git a/test/api/unit/libs/cron.test.js b/test/api/unit/libs/cron.test.js index 1544a8c373..ee0271d82f 100644 --- a/test/api/unit/libs/cron.test.js +++ b/test/api/unit/libs/cron.test.js @@ -42,13 +42,13 @@ describe('cron', () => { }); it('updates user.preferences.timezoneOffsetAtLastCron', () => { - const timezoneOffsetFromUserPrefs = 1; + const timezoneUtcOffsetFromUserPrefs = -1; cron({ - user, tasksByType, daysMissed, analytics, timezoneOffsetFromUserPrefs, + user, tasksByType, daysMissed, analytics, timezoneUtcOffsetFromUserPrefs, }); - expect(user.preferences.timezoneOffsetAtLastCron).to.equal(timezoneOffsetFromUserPrefs); + expect(user.preferences.timezoneOffsetAtLastCron).to.equal(1); }); it('resets user.items.lastDrop.count', () => { @@ -240,7 +240,7 @@ describe('cron', () => { user1.purchased.plan.consecutive.gemCapExtra = 0; it('does not increment consecutive benefits after the first month', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(1, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(1, 'months') .add(2, 'days') .toDate()); // Add 1 month to simulate what happens a month after the subscription was created. @@ -256,7 +256,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits after the second month', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(2, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(2, 'months') .add(2, 'days') .toDate()); // Add 1 month to simulate what happens a month after the subscription was created. @@ -272,7 +272,7 @@ describe('cron', () => { }); it('increments consecutive benefits after the third month', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(3, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(3, 'months') .add(2, 'days') .toDate()); // Add 1 month to simulate what happens a month after the subscription was created. @@ -288,7 +288,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits after the fourth month', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(4, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(4, 'months') .add(2, 'days') .toDate()); // Add 1 month to simulate what happens a month after the subscription was created. @@ -304,7 +304,7 @@ describe('cron', () => { }); it('increments consecutive benefits correctly if user has been absent with continuous subscription', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(10, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(10, 'months') .add(2, 'days') .toDate()); cron({ @@ -339,7 +339,7 @@ describe('cron', () => { user3.purchased.plan.consecutive.gemCapExtra = 5; it('does not increment consecutive benefits in the first month of the first paid period that they already have benefits for', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(1, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(1, 'months') .add(2, 'days') .toDate()); cron({ @@ -352,7 +352,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits in the middle of the period that they already have benefits for', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(2, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(2, 'months') .add(2, 'days') .toDate()); cron({ @@ -365,7 +365,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits in the final month of the period that they already have benefits for', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(3, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(3, 'months') .add(2, 'days') .toDate()); cron({ @@ -378,7 +378,7 @@ describe('cron', () => { }); it('increments consecutive benefits the month after the second paid period has started', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(4, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(4, 'months') .add(2, 'days') .toDate()); cron({ @@ -391,7 +391,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits in the second month of the second period that they already have benefits for', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(5, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(5, 'months') .add(2, 'days') .toDate()); cron({ @@ -404,7 +404,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits in the final month of the second period that they already have benefits for', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(6, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(6, 'months') .add(2, 'days') .toDate()); cron({ @@ -417,7 +417,7 @@ describe('cron', () => { }); it('increments consecutive benefits the month after the third paid period has started', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(7, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(7, 'months') .add(2, 'days') .toDate()); cron({ @@ -430,7 +430,7 @@ describe('cron', () => { }); it('increments consecutive benefits correctly if user has been absent with continuous subscription', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(10, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(10, 'months') .add(2, 'days') .toDate()); cron({ @@ -465,7 +465,7 @@ describe('cron', () => { user6.purchased.plan.consecutive.gemCapExtra = 10; it('does not increment consecutive benefits in the first month of the first paid period that they already have benefits for', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(1, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(1, 'months') .add(2, 'days') .toDate()); cron({ @@ -478,7 +478,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits in the final month of the period that they already have benefits for', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(6, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(6, 'months') .add(2, 'days') .toDate()); cron({ @@ -491,7 +491,7 @@ describe('cron', () => { }); it('increments consecutive benefits the month after the second paid period has started', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(7, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(7, 'months') .add(2, 'days') .toDate()); cron({ @@ -504,7 +504,7 @@ describe('cron', () => { }); it('increments consecutive benefits the month after the third paid period has started', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(13, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(13, 'months') .add(2, 'days') .toDate()); cron({ @@ -517,7 +517,7 @@ describe('cron', () => { }); it('increments consecutive benefits correctly if user has been absent with continuous subscription', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(19, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(19, 'months') .add(2, 'days') .toDate()); cron({ @@ -552,7 +552,7 @@ describe('cron', () => { user12.purchased.plan.consecutive.gemCapExtra = 20; it('does not increment consecutive benefits in the first month of the first paid period that they already have benefits for', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(1, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(1, 'months') .add(2, 'days') .toDate()); cron({ @@ -565,7 +565,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits in the final month of the period that they already have benefits for', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(12, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(12, 'months') .add(2, 'days') .toDate()); cron({ @@ -578,7 +578,7 @@ describe('cron', () => { }); it('increments consecutive benefits the month after the second paid period has started', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(13, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(13, 'months') .add(2, 'days') .toDate()); cron({ @@ -591,7 +591,7 @@ describe('cron', () => { }); it('increments consecutive benefits the month after the third paid period has started', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(25, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(25, 'months') .add(2, 'days') .toDate()); cron({ @@ -604,7 +604,7 @@ describe('cron', () => { }); it('increments consecutive benefits correctly if user has been absent with continuous subscription', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(37, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(37, 'months') .add(2, 'days') .toDate()); cron({ @@ -641,7 +641,7 @@ describe('cron', () => { user3g.purchased.plan.consecutive.gemCapExtra = 5; it('does not increment consecutive benefits in the first month of the gift subscription', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(1, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(1, 'months') .add(2, 'days') .toDate()); cron({ @@ -654,7 +654,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits in the second month of the gift subscription', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(2, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(2, 'months') .add(2, 'days') .toDate()); cron({ @@ -667,7 +667,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits in the third month of the gift subscription', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(3, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(3, 'months') .add(2, 'days') .toDate()); cron({ @@ -680,7 +680,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits in the month after the gift subscription has ended', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(4, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(4, 'months') .add(2, 'days') .toDate()); cron({ @@ -717,7 +717,7 @@ describe('cron', () => { user6x.purchased.plan.consecutive.gemCapExtra = 15; it('increments consecutive benefits in the first month since the fix for #4819 goes live', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(1, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(1, 'months') .add(2, 'days') .toDate()); cron({ @@ -730,7 +730,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits in the second month after the fix goes live', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(2, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(2, 'months') .add(2, 'days') .toDate()); cron({ @@ -743,7 +743,7 @@ describe('cron', () => { }); it('does not increment consecutive benefits in the third month after the fix goes live', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(3, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(3, 'months') .add(2, 'days') .toDate()); cron({ @@ -756,7 +756,7 @@ describe('cron', () => { }); it('increments consecutive benefits in the seventh month after the fix goes live', () => { - clock = sinon.useFakeTimers(moment().zone(0).startOf('month').add(7, 'months') + clock = sinon.useFakeTimers(moment().utcOffset(0).startOf('month').add(7, 'months') .add(2, 'days') .toDate()); cron({ diff --git a/test/api/unit/libs/preening.test.js b/test/api/unit/libs/preening.test.js index c2448c5df9..d458595fe2 100644 --- a/test/api/unit/libs/preening.test.js +++ b/test/api/unit/libs/preening.test.js @@ -9,7 +9,7 @@ describe('preenHistory', () => { beforeEach(() => { // Replace system clocks so we can get predictable results clock = sinon.useFakeTimers({ - now: Number(moment('2013-10-20').zone(0).startOf('day').toDate()), + now: Number(moment('2013-10-20').utcOffset(0).startOf('day').toDate()), toFake: ['Date'], }); }); diff --git a/test/api/unit/models/user.test.js b/test/api/unit/models/user.test.js index 50c5f8cc8a..f3345e5478 100644 --- a/test/api/unit/models/user.test.js +++ b/test/api/unit/models/user.test.js @@ -761,7 +761,7 @@ describe('User Model', () => { }); }); - context('days missed', () => { + describe('daysUserHasMissed', () => { // http://forbrains.co.uk/international_tools/earth_timezones let user; @@ -769,24 +769,51 @@ describe('User Model', () => { user = new User(); }); - it('should not cron early when going back a timezone', () => { - const yesterday = moment('2017-12-05T00:00:00.000-06:00'); // 11 pm on 4 Texas - const timezoneOffset = moment().zone('-06:00').zone(); - user.lastCron = yesterday; - user.preferences.timezoneOffset = timezoneOffset; + it('correctly calculates days missed since lastCron', () => { + const now = moment(); + user.lastCron = moment(now).subtract(5, 'days'); - const today = moment('2017-12-06T00:00:00.000-06:00'); // 11 pm on 4 Texas - const req = {}; - req.header = () => timezoneOffset + 60; + const { daysMissed } = user.daysUserHasMissed(now); - const { daysMissed } = user.daysUserHasMissed(today, req); + expect(daysMissed).to.eql(5); + }); + it('uses timezone from preferences to calculate days missed', () => { + const now = moment('2017-07-08 01:00:00Z'); + user.lastCron = moment('2017-07-04 13:00:00Z'); + user.preferences.timezoneOffset = 120; + + const { daysMissed } = user.daysUserHasMissed(now); + + expect(daysMissed).to.eql(3); + }); + + it('uses timezone at last cron to calculate days missed', () => { + const now = moment('2017-09-08 13:00:00Z'); + user.lastCron = moment('2017-09-06 01:00:00+02:00'); + user.preferences.timezoneOffset = 0; + user.preferences.timezoneOffsetAtLastCron = -120; + + const { daysMissed } = user.daysUserHasMissed(now); + + expect(daysMissed).to.eql(2); + }); + + it('respects new timezone that drags time into same day', () => { + user.lastCron = moment('2017-12-05T00:00:00.000-06:00'); + user.preferences.timezoneOffset = 360; + const today = moment('2017-12-06T00:00:00.000-06:00'); + const requestWithMinus7Timezone = { header: () => 420 }; + + const { daysMissed } = user.daysUserHasMissed(today, requestWithMinus7Timezone); + + expect(user.preferences.timezoneOffset).to.eql(420); expect(daysMissed).to.eql(0); }); it('should not cron early when going back a timezone with a custom day start', () => { const yesterday = moment('2017-12-05T02:00:00.000-08:00'); - const timezoneOffset = moment().zone('-08:00').zone(); + const timezoneOffset = 480; user.lastCron = yesterday; user.preferences.timezoneOffset = timezoneOffset; user.preferences.dayStart = 2; diff --git a/test/api/v3/integration/tasks/GET-tasks_user.test.js b/test/api/v3/integration/tasks/GET-tasks_user.test.js index e82a09e00b..85602ded26 100644 --- a/test/api/v3/integration/tasks/GET-tasks_user.test.js +++ b/test/api/v3/integration/tasks/GET-tasks_user.test.js @@ -153,12 +153,12 @@ describe('GET /tasks/user', () => { }); xit('returns dailies with isDue for the date specified and will add CDS offset if time is not supplied and assumes timezones', async () => { - const timezone = 420; + const timezoneOffset = 420; await user.update({ 'preferences.dayStart': 0, - 'preferences.timezoneOffset': timezone, + 'preferences.timezoneOffset': timezoneOffset, }); - const startDate = moment().zone(timezone).subtract('4', 'days').startOf('day') + const startDate = moment().utcOffset(-timezoneOffset).subtract('4', 'days').startOf('day') .toISOString(); await user.post('/tasks/user', [ { @@ -180,12 +180,12 @@ describe('GET /tasks/user', () => { }); xit('returns dailies with isDue for the date specified and will add CDS offset if time is not supplied and assumes timezones', async () => { - const timezone = 240; + const timezoneOffset = 240; await user.update({ 'preferences.dayStart': 0, - 'preferences.timezoneOffset': timezone, + 'preferences.timezoneOffset': timezoneOffset, }); - const startDate = moment().zone(timezone).subtract('4', 'days').startOf('day') + const startDate = moment().utcOffset(-timezoneOffset).subtract('4', 'days').startOf('day') .toISOString(); await user.post('/tasks/user', [ { @@ -207,12 +207,12 @@ describe('GET /tasks/user', () => { }); xit('returns dailies with isDue for the date specified and will add CDS offset if time is not supplied and assumes timezones', async () => { - const timezone = 540; + const timezoneOffset = 540; await user.update({ 'preferences.dayStart': 0, - 'preferences.timezoneOffset': timezone, + 'preferences.timezoneOffset': timezoneOffset, }); - const startDate = moment().zone(timezone).subtract('4', 'days').startOf('day') + const startDate = moment().utcOffset(-timezoneOffset).subtract('4', 'days').startOf('day') .toISOString(); await user.post('/tasks/user', [ { diff --git a/test/common/fns/getUtcOffset.test.js b/test/common/fns/getUtcOffset.test.js new file mode 100644 index 0000000000..a91c886407 --- /dev/null +++ b/test/common/fns/getUtcOffset.test.js @@ -0,0 +1,25 @@ +import getUtcOffset from '../../../website/common/script/fns/getUtcOffset'; + +describe('getUtcOffset', () => { + let user; + + beforeEach(() => { + user = { preferences: {} }; + }); + + it('returns 0 when user.timezoneOffset is not set', () => { + expect(getUtcOffset(user)).to.equal(0); + }); + + it('returns 0 when user.timezoneOffset is zero', () => { + user.preferences.timezoneOffset = 0; + + expect(getUtcOffset(user)).to.equal(0); + }); + + it('returns the opposite of user.timezoneOffset', () => { + user.preferences.timezoneOffset = -10; + + expect(getUtcOffset(user)).to.eql(10); + }); +}); diff --git a/test/common/libs/cron.test.js b/test/common/libs/cron.test.js new file mode 100644 index 0000000000..0894a6afa3 --- /dev/null +++ b/test/common/libs/cron.test.js @@ -0,0 +1,184 @@ +import moment from 'moment'; + +import { startOfDay, daysSince } from '../../../website/common/script/cron'; + +function localMoment (timeString, utcOffset) { + return moment(timeString).utcOffset(utcOffset, true); +} + +describe('cron utility functions', () => { + describe('startOfDay', () => { + it('is zero when no daystart configured', () => { + const options = { now: moment('2020-02-02 09:30:00Z'), timezoneOffset: 0 }; + + const result = startOfDay(options); + + expect(result).to.be.sameMoment('2020-02-02 00:00:00Z'); + }); + + it('is zero when negative daystart configured', () => { + const options = { + now: moment('2020-02-02 09:30:00Z'), + timezoneOffset: 0, + daystart: -5, + }; + + const result = startOfDay(options); + + expect(result).to.be.sameMoment('2020-02-02 00:00:00Z'); + }); + + it('is zero when daystart over 24 is configured', () => { + const options = { + now: moment('2020-02-02 09:30:00Z'), + timezoneOffset: 0, + daystart: 25, + }; + + const result = startOfDay(options); + + expect(result).to.be.sameMoment('2020-02-02 00:00:00Z'); + }); + + it('is equal to daystart o\'clock when daystart configured', () => { + const options = { + now: moment('2020-02-02 09:30:00Z'), + timezoneOffset: 0, + dayStart: 5, + }; + + const result = startOfDay(options); + + expect(result).to.be.sameMoment('2020-02-02 05:00:00Z'); + }); + + it('is previous day daystart o\'clock when daystart is after current time', () => { + const options = { + now: moment('2020-02-02 04:30:00Z'), + timezoneOffset: 0, + dayStart: 5, + }; + + const result = startOfDay(options); + + expect(result).to.be.sameMoment('2020-02-01 05:00:00Z'); + }); + + it('is daystart o\'clock when daystart is after current time due to timezone', () => { + const options = { + now: moment('2020-02-02 04:30:00Z'), + timezoneOffset: -120, + dayStart: 5, + }; + + const result = startOfDay(options); + + expect(result).to.be.sameMoment('2020-02-02 05:00:00+02:00'); + }); + + it('returns in default timezone if no timezone defined', () => { + const utcOffset = moment().utcOffset(); + const now = localMoment('2020-02-02 04:30:00', utcOffset).utc(); + + const result = startOfDay({ now }); + + expect(result).to.be.sameMoment(localMoment('2020-02-02', utcOffset)); + }); + + it('returns in default timezone if timezone lower than -12:00', () => { + const utcOffset = moment().utcOffset(); + const options = { + now: localMoment('2020-02-02 17:30:00', utcOffset).utc(), + timezoneOffset: 721, + }; + + const result = startOfDay(options); + + expect(result).to.be.sameMoment(localMoment('2020-02-02', utcOffset)); + }); + + it('returns in default timezone if timezone higher than +14:00', () => { + const utcOffset = moment().utcOffset(); + const options = { + now: localMoment('2020-02-02 07:32:25.376', utcOffset).utc(), + timezoneOffset: -841, + }; + + const result = startOfDay(options); + + expect(result).to.be.sameMoment(localMoment('2020-02-02', utcOffset)); + }); + + it('returns in overridden timezone if override present', () => { + const options = { + now: moment('2020-02-02 13:30:27Z'), + timezoneOffset: 0, + timezoneUtcOffsetOverride: -240, + }; + + const result = startOfDay(options); + + expect(result).to.be.sameMoment('2020-02-02 00:00:00-04:00'); + }); + + it('returns start of yesterday if timezone difference carries it over datelines', () => { + const offset = 300; + const options = { + now: moment('2020-02-02 04:30:00Z'), + timezoneOffset: offset, + }; + + const result = startOfDay(options); + + expect(result).to.be.sameMoment(localMoment('2020-02-01', -offset)); + }); + }); + + describe('daysSince', () => { + it('correctly calculates days between two dates', () => { + const now = moment(); + const dayBeforeYesterday = moment(now).subtract({ days: 2 }); + + expect(daysSince(dayBeforeYesterday, { now })).to.equal(2); + }); + + it('is one lower if current time is before dayStart', () => { + const oneWeekAgoAtOnePm = moment().hour(13).subtract({ days: 7 }); + const thisMorningThreeAm = moment().hour(3); + const options = { + now: thisMorningThreeAm, + dayStart: 6, + }; + + const result = daysSince(oneWeekAgoAtOnePm, options); + + expect(result).to.equal(6); + }); + + it('is one higher if reference time is before dayStart and current time after dayStart', () => { + const oneWeekAgoAtEightAm = moment().hour(8).subtract({ days: 7 }); + const todayAtFivePm = moment().hour(17); + const options = { + now: todayAtFivePm, + dayStart: 11, + }; + + const result = daysSince(oneWeekAgoAtEightAm, options); + + expect(result).to.equal(8); + }); + + // Variations in timezone configuration options are already covered by startOfDay tests. + it('uses now in user timezone as configured in options', () => { + const timezoneOffset = 120; + const options = { + now: moment('1989-11-09 02:53:00+01:00'), + timezoneOffset, + }; + + const result = daysSince(localMoment('1989-11-08', -timezoneOffset), options); + + expect(result).to.equal(0); + }); + }); +}); diff --git a/test/common/libs/taskDefaults.test.js b/test/common/libs/taskDefaults.test.js index e69bafa45d..479a87fa30 100644 --- a/test/common/libs/taskDefaults.test.js +++ b/test/common/libs/taskDefaults.test.js @@ -1,6 +1,7 @@ import moment from 'moment'; import taskDefaults from '../../../website/common/script/libs/taskDefaults'; +import getUtcOffset from '../../../website/common/script/fns/getUtcOffset'; import { generateUser } from '../../helpers/common.helper'; describe('taskDefaults', () => { @@ -72,7 +73,7 @@ describe('taskDefaults', () => { expect(task.startDate).to.eql( moment() - .zone(user.preferences.timezoneOffset, 'hour') + .utcOffset(getUtcOffset(user)) .startOf('day') .subtract(1, 'day') .toDate(), diff --git a/test/common/shouldDo.test.js b/test/common/shouldDo.test.js index b8f9a604ad..d7250f871a 100644 --- a/test/common/shouldDo.test.js +++ b/test/common/shouldDo.test.js @@ -5,6 +5,8 @@ import 'moment-recur'; describe('shouldDo', () => { let day; let dailyTask; + // Options is a mapping of user.preferences, therefor `timezoneOffset` still holds old zone + // values instead of utcOffset values. let options = {}; let nextDue = []; @@ -80,17 +82,17 @@ describe('shouldDo', () => { it('returns true if the user\'s current time is after start date and Custom Day Start', () => { options.dayStart = 4; - day = moment().zone(options.timezoneOffset).startOf('day').add(6, 'hours') + day = moment().utcOffset(-options.timezoneOffset).startOf('day').add(6, 'hours') .toDate(); - dailyTask.startDate = moment().zone(options.timezoneOffset).startOf('day').toDate(); + dailyTask.startDate = moment().utcOffset(-options.timezoneOffset).startOf('day').toDate(); expect(shouldDo(day, dailyTask, options)).to.equal(true); }); it('returns false if the user\'s current time is before Custom Day Start', () => { options.dayStart = 8; - day = moment().zone(options.timezoneOffset).startOf('day').add(2, 'hours') + day = moment().utcOffset(-options.timezoneOffset).startOf('day').add(2, 'hours') .toDate(); - dailyTask.startDate = moment().zone(options.timezoneOffset).startOf('day').toDate(); + dailyTask.startDate = moment().utcOffset(-options.timezoneOffset).startOf('day').toDate(); expect(shouldDo(day, dailyTask, options)).to.equal(false); }); }); @@ -112,14 +114,14 @@ describe('shouldDo', () => { it('returns true if the user\'s current time is after Custom Day Start', () => { options.dayStart = 4; - day = moment().zone(options.timezoneOffset).startOf('day').add(6, 'hours') + day = moment().utcOffset(-options.timezoneOffset).startOf('day').add(6, 'hours') .toDate(); expect(shouldDo(day, dailyTask, options)).to.equal(true); }); it('returns false if the user\'s current time is before Custom Day Start', () => { options.dayStart = 8; - day = moment().zone(options.timezoneOffset).startOf('day').add(2, 'hours') + day = moment().utcOffset(-options.timezoneOffset).startOf('day').add(2, 'hours') .toDate(); expect(shouldDo(day, dailyTask, options)).to.equal(false); }); diff --git a/test/helpers/globals.helper.js b/test/helpers/globals.helper.js index bca96eac2c..213efac698 100644 --- a/test/helpers/globals.helper.js +++ b/test/helpers/globals.helper.js @@ -8,8 +8,9 @@ //------------------------------ global._ = require('lodash'); global.chai = require('chai'); -chai.use(require('sinon-chai')); chai.use(require('chai-as-promised')); +chai.use(require('chai-moment')); +chai.use(require('sinon-chai')); global.expect = chai.expect; global.sinon = require('sinon'); diff --git a/website/client/src/app.vue b/website/client/src/app.vue index 08c79f349a..21d00e8c93 100644 --- a/website/client/src/app.vue +++ b/website/client/src/app.vue @@ -297,7 +297,7 @@ export default { }; }, computed: { - ...mapState(['isUserLoggedIn', 'browserTimezoneOffset', 'isUserLoaded', 'notificationsRemoved']), + ...mapState(['isUserLoggedIn', 'browserTimezoneUtcOffset', 'isUserLoaded', 'notificationsRemoved']), ...mapState({ user: 'user.data' }), isStaticPage () { return this.$route.meta.requiresLogin === false; @@ -493,9 +493,10 @@ export default { this.hideLoadingScreen(); // Adjust the timezone offset - if (this.user.preferences.timezoneOffset !== this.browserTimezoneOffset) { + const browserTimezoneOffset = -this.browserTimezoneUtcOffset; + if (this.user.preferences.timezoneOffset !== browserTimezoneOffset) { this.$store.dispatch('user:set', { - 'preferences.timezoneOffset': this.browserTimezoneOffset, + 'preferences.timezoneOffset': browserTimezoneOffset, }); } diff --git a/website/client/src/components/settings/site.vue b/website/client/src/components/settings/site.vue index 6abe0d5279..cdd8ee79b2 100644 --- a/website/client/src/components/settings/site.vue +++ b/website/client/src/components/settings/site.vue @@ -559,6 +559,7 @@ import resetModal from './resetModal'; import deleteModal from './deleteModal'; import { SUPPORTED_SOCIAL_NETWORKS } from '@/../../common/script/constants'; import changeClass from '@/../../common/script/ops/changeClass'; +import getUtcOffset from '@/../../common/script/fns/getUtcOffset'; import notificationsMixin from '../../mixins/notifications'; import sounds from '../../libs/sounds'; import { buildAppleAuthUrl } from '../../libs/auth'; @@ -616,17 +617,8 @@ export default { return ['off', ...this.content.audioThemes]; }, timezoneOffsetToUtc () { - let offset = this.user.preferences.timezoneOffset; - const sign = offset > 0 ? '-' : '+'; - - offset = Math.abs(offset) / 60; - - const hour = Math.floor(offset); - - const minutesInt = (offset - hour) * 60; - const minutes = minutesInt < 10 ? `0${minutesInt}` : minutesInt; - - return `UTC${sign}${hour}:${minutes}`; + const offsetString = moment().utcOffset(getUtcOffset(this.user)).format('Z'); + return `UTC${offsetString}`; }, dayStart () { return this.user.preferences.dayStart; diff --git a/website/client/src/libs/auth.js b/website/client/src/libs/auth.js index 917812f545..41ae06de45 100644 --- a/website/client/src/libs/auth.js +++ b/website/client/src/libs/auth.js @@ -8,13 +8,14 @@ export function setUpAxios (AUTH_SETTINGS) { // eslint-disable-line import/prefe AUTH_SETTINGS = JSON.parse(AUTH_SETTINGS); // eslint-disable-line no-param-reassign } - const browserTimezoneOffset = moment().zone(); + const browserTimezoneUtcOffset = moment().utcOffset(); if (AUTH_SETTINGS.auth && AUTH_SETTINGS.auth.apiId && AUTH_SETTINGS.auth.apiToken) { axios.defaults.headers.common['x-api-user'] = AUTH_SETTINGS.auth.apiId; axios.defaults.headers.common['x-api-key'] = AUTH_SETTINGS.auth.apiToken; - axios.defaults.headers.common['x-user-timezoneOffset'] = browserTimezoneOffset; + // Communicate in "old" timezone variant for backwards compatibility + axios.defaults.headers.common['x-user-timezoneOffset'] = -browserTimezoneUtcOffset; return true; } diff --git a/website/client/src/store/index.js b/website/client/src/store/index.js index 94405ae54f..8723b10ff7 100644 --- a/website/client/src/store/index.js +++ b/website/client/src/store/index.js @@ -17,8 +17,8 @@ const IS_TEST = process.env.NODE_ENV === 'test'; // eslint-disable-line no-proce // before trying to load data let isUserLoggedIn = false; -// eg, 240 - this will be converted on server as -(offset/60) -const browserTimezoneOffset = moment().zone(); +// eg, -240 - this will be converted on server as (offset/60) +const browserTimezoneUtcOffset = moment().utcOffset(); axios.defaults.headers.common['x-client'] = 'habitica-web'; @@ -71,7 +71,7 @@ export default function () { // store the timezone offset in case it's different than the one in // user.preferences.timezoneOffset and change it after the user is synced // in app.vue - browserTimezoneOffset, + browserTimezoneUtcOffset, tasks: asyncResourceFactory(), // user tasks // @TODO use asyncresource? completedTodosStatus: 'NOT_LOADED', diff --git a/website/common/script/cron.js b/website/common/script/cron.js index 8afca4bcda..1342e6b8d2 100644 --- a/website/common/script/cron.js +++ b/website/common/script/cron.js @@ -33,26 +33,29 @@ function sanitizeOptions (o) { const ref = Number(o.dayStart || 0); const dayStart = !Number.isNaN(ref) && ref >= 0 && ref <= 24 ? ref : 0; - let timezoneOffset; - const timezoneOffsetDefault = Number(moment().zone()); + let timezoneUtcOffset; + const timezoneUtcOffsetDefault = moment().utcOffset(); - if (Number.isFinite(o.timezoneOffsetOverride)) { - timezoneOffset = Number(o.timezoneOffsetOverride); + if (Number.isFinite(o.timezoneUtcOffset)) { + // Options were already sanitized + timezoneUtcOffset = o.timezoneUtcOffset; + } else if (Number.isFinite(o.timezoneUtcOffsetOverride)) { + timezoneUtcOffset = o.timezoneUtcOffsetOverride; } else if (Number.isFinite(o.timezoneOffset)) { - timezoneOffset = Number(o.timezoneOffset); + timezoneUtcOffset = -o.timezoneOffset; } else { - timezoneOffset = timezoneOffsetDefault; + timezoneUtcOffset = timezoneUtcOffsetDefault; } - if (timezoneOffset > 720 || timezoneOffset < -840) { - // timezones range from -12 (offset +720) to +14 (offset -840) - timezoneOffset = timezoneOffsetDefault; + if (timezoneUtcOffset < -720 || timezoneUtcOffset > 840) { + // timezones range from -12 (offset -720) to +14 (offset 840) + timezoneUtcOffset = timezoneUtcOffsetDefault; } - const now = o.now ? moment(o.now).zone(timezoneOffset) : moment().zone(timezoneOffset); + const now = moment(o.now).utcOffset(timezoneUtcOffset); // return a new object, we don't want to add "now" to user object return { dayStart, - timezoneOffset, + timezoneUtcOffset, now, }; } @@ -81,7 +84,7 @@ export function startOfDay (options = {}) { const o = sanitizeOptions(options); const dayStart = moment(o.now).startOf('day').add({ hours: o.dayStart }); - if (moment(o.now).hour() < o.dayStart) { + if (o.now.hour() < o.dayStart) { dayStart.subtract({ days: 1 }); } @@ -119,7 +122,7 @@ export function shouldDo (day, dailyTask, options = {}) { // NB: The user's day start date has already been converted to the PREVIOUS // day's date if the time portion was before CDS. - const startDate = moment(dailyTask.startDate).zone(o.timezoneOffset).startOf('day'); + const startDate = moment(dailyTask.startDate).utcOffset(o.timezoneUtcOffset).startOf('day'); if (startDate > startOfDayWithCDSTime.startOf('day') && !options.nextDue) { return false; // Daily starts in the future diff --git a/website/common/script/fns/getUtcOffset.js b/website/common/script/fns/getUtcOffset.js new file mode 100644 index 0000000000..7f17f9a363 --- /dev/null +++ b/website/common/script/fns/getUtcOffset.js @@ -0,0 +1,12 @@ +/** + * Converts from timezoneOffset (which corresponded to the value returned + * from moment.js's deprecated `moment#zone` method) to timezoneUtcOffset. + * + * This is done with conversion instead of changing it in the database to + * be backwards compatible with the database values and old clients. + * + * Not as a user method since it needs to work in the frontend as well. + */ +export default function getUtcOffset (user) { + return -(user.preferences.timezoneOffset || 0); +} diff --git a/website/common/script/index.js b/website/common/script/index.js index b2435c42d3..d3e05c26af 100644 --- a/website/common/script/index.js +++ b/website/common/script/index.js @@ -28,6 +28,7 @@ import apiErrors from './errors/apiErrorMessages'; import commonErrors from './errors/commonErrorMessages'; import autoAllocate from './fns/autoAllocate'; import crit from './fns/crit'; +import getUtcOffset from './fns/getUtcOffset'; import handleTwoHanded from './fns/handleTwoHanded'; import predictableRandom from './fns/predictableRandom'; import randomDrop from './fns/randomDrop'; @@ -151,6 +152,7 @@ api.fns = { resetGear, ultimateGear, updateStats, + getUtcOffset, }; api.ops = { diff --git a/website/common/script/libs/taskDefaults.js b/website/common/script/libs/taskDefaults.js index 69ba53824b..d966626e0b 100644 --- a/website/common/script/libs/taskDefaults.js +++ b/website/common/script/libs/taskDefaults.js @@ -1,6 +1,7 @@ import { v4 as uuid } from 'uuid'; import defaults from 'lodash/defaults'; import moment from 'moment'; +import getUtcOffset from '../fns/getUtcOffset'; // Even though Mongoose handles task defaults, // we want to make sure defaults are set on the client-side before @@ -66,7 +67,7 @@ export default function taskDefaults (task, user) { } if (task.type === 'daily') { - const now = moment().zone(user.preferences.timezoneOffset); + const now = moment().utcOffset(getUtcOffset(user)); const startOfDay = now.clone().startOf('day'); const startOfDayWithCDSTime = startOfDay .clone() diff --git a/website/common/script/ops/scoreTask.js b/website/common/script/ops/scoreTask.js index 1062d901e4..4bd3be7cae 100644 --- a/website/common/script/ops/scoreTask.js +++ b/website/common/script/ops/scoreTask.js @@ -8,6 +8,8 @@ import { import i18n from '../i18n'; import updateStats from '../fns/updateStats'; import crit from '../fns/crit'; +import getUtcOffset from '../fns/getUtcOffset'; + import statsComputed from '../libs/statsComputed'; import { checkOnboardingStatus } from '../libs/onboarding'; @@ -194,14 +196,14 @@ function _lastHistoryEntryWasToday (lastHistoryEntry, user) { return false; } - const { timezoneOffset } = user.preferences; + const timezoneUtcOffset = getUtcOffset(user); const { dayStart } = user.preferences; // Adjust the last entry date according to the user's timezone and CDS - const dateWithTimeZone = moment(lastHistoryEntry.date).zone(timezoneOffset); + const dateWithTimeZone = moment(lastHistoryEntry.date).utcOffset(timezoneUtcOffset); if (dateWithTimeZone.hour() < dayStart) dateWithTimeZone.subtract(1, 'day'); - return moment().zone(timezoneOffset).isSame(dateWithTimeZone, 'day'); + return moment().utcOffset(timezoneUtcOffset).isSame(dateWithTimeZone, 'day'); } function _updateLastHistoryEntry (lastHistoryEntry, task, direction, times) { diff --git a/website/server/controllers/top-level/dataexport.js b/website/server/controllers/top-level/dataexport.js index 9de16046ed..2a17f4e5e4 100644 --- a/website/server/controllers/top-level/dataexport.js +++ b/website/server/controllers/top-level/dataexport.js @@ -317,7 +317,7 @@ api.exportUserPrivateMessages = { async handler (req, res) { const { user } = res.locals; - const { timezoneOffset } = user.preferences; + const timezoneUtcOffset = user.getUtcOffset(); const dateFormat = user.preferences.dateFormat.toUpperCase(); const TO = res.t('to'); const FROM = res.t('from'); @@ -329,7 +329,7 @@ api.exportUserPrivateMessages = { inbox.forEach((message, index) => { const recipientLabel = message.sent ? TO : FROM; const messageUser = message.user; - const timestamp = moment.utc(message.timestamp).zone(timezoneOffset).format(`${dateFormat} HH:mm:ss`); + const timestamp = moment.utc(message.timestamp).utcOffset(timezoneUtcOffset).format(`${dateFormat} HH:mm:ss`); const text = md.render(message.text); const pageIndex = `(${index + 1}/${inbox.length})`; messages += ` diff --git a/website/server/libs/cron.js b/website/server/libs/cron.js index 2c9d266bc8..774309ecb5 100644 --- a/website/server/libs/cron.js +++ b/website/server/libs/cron.js @@ -172,7 +172,7 @@ function resetHabitCounters (user, tasksByType, now, daysMissed) { break; } const thatDay = moment(now) - .zone(user.preferences.timezoneOffset + user.preferences.dayStart * 60) + .utcOffset(user.getUtcOffset() - user.preferences.dayStart * 60) .subtract({ days: i }); if (thatDay.day() === 1) { resetWeekly = true; @@ -281,14 +281,14 @@ function awardLoginIncentives (user) { // Perform various beginning-of-day reset actions. export function cron (options = {}) { const { - user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs, + user, tasksByType, analytics, now = new Date(), daysMissed, timezoneUtcOffsetFromUserPrefs, } = options; let _progress = { down: 0, up: 0, collectedItems: 0 }; // Record pre-cron values of HP and MP to show notifications later const beforeCronStats = _.pick(user.stats, ['hp', 'mp']); - user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; + user.preferences.timezoneOffsetAtLastCron = -timezoneUtcOffsetFromUserPrefs; // User is only allowed a certain number of drops a day. This resets the count. if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0; diff --git a/website/server/libs/preening.js b/website/server/libs/preening.js index a6a4bb64e7..30228e2d29 100644 --- a/website/server/libs/preening.js +++ b/website/server/libs/preening.js @@ -2,10 +2,10 @@ import _ from 'lodash'; import moment from 'moment'; // Aggregate entries -function _aggregate (history, aggregateBy, timezoneOffset, dayStart) { +function _aggregate (history, aggregateBy, timezoneUtcOffset, dayStart) { return _.chain(history) .groupBy(entry => { // group entries by aggregateBy - const entryDate = moment(entry.date).zone(timezoneOffset); + const entryDate = moment(entry.date).utcOffset(timezoneUtcOffset); if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day'); return entryDate.format(aggregateBy); }) @@ -35,16 +35,16 @@ Subscribers and challenges: - 1 value each month for the previous 12 months - 1 value each year for the previous years */ -export function preenHistory (history, isSubscribed, timezoneOffset = 0, dayStart = 0) { +export function preenHistory (history, isSubscribed, timezoneUtcOffset = 0, dayStart = 0) { // history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries - const now = moment().zone(timezoneOffset); + const now = moment().utcOffset(timezoneUtcOffset); // Date after which to begin compressing data const cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day'); // Keep uncompressed entries (modifies history and returns removed items) const newHistory = _.remove(history, entry => { if (!entry) return true; // sometimes entries are `null` - const entryDate = moment(entry.date).zone(timezoneOffset); + const entryDate = moment(entry.date).utcOffset(timezoneUtcOffset); if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day'); return entryDate.isSame(cutOff) || entryDate.isAfter(cutOff); }); @@ -53,13 +53,13 @@ export function preenHistory (history, isSubscribed, timezoneOffset = 0, dayStar const monthsCutOff = cutOff.subtract(isSubscribed ? 12 : 10, 'months').startOf('day'); const aggregateByMonth = _.remove(history, entry => { if (!entry) return true; // sometimes entries are `null` - const entryDate = moment(entry.date).zone(timezoneOffset); + const entryDate = moment(entry.date).utcOffset(timezoneUtcOffset); if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day'); return entryDate.isSame(monthsCutOff) || entryDate.isAfter(monthsCutOff); }); // Aggregate remaining entries by month and year - if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM', timezoneOffset, dayStart)); - if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY', timezoneOffset, dayStart)); + if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM', timezoneUtcOffset, dayStart)); + if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY', timezoneUtcOffset, dayStart)); return newHistory; } @@ -67,13 +67,13 @@ export function preenHistory (history, isSubscribed, timezoneOffset = 0, dayStar // Preen history for users and tasks. export function preenUserHistory (user, tasksByType) { const isSubscribed = user.isSubscribed(); - const { timezoneOffset } = user.preferences; + const timezoneUtcOffset = user.getUtcOffset(); const { dayStart } = user.preferences; const minHistoryLength = isSubscribed ? 365 : 60; function _processTask (task) { if (task.history && task.history.length > minHistoryLength) { - task.history = preenHistory(task.history, isSubscribed, timezoneOffset, dayStart); + task.history = preenHistory(task.history, isSubscribed, timezoneUtcOffset, dayStart); task.markModified('history'); } } @@ -82,12 +82,14 @@ export function preenUserHistory (user, tasksByType) { tasksByType.dailys.forEach(_processTask); if (user.history.exp.length > minHistoryLength) { - user.history.exp = preenHistory(user.history.exp, isSubscribed, timezoneOffset, dayStart); + user.history.exp = preenHistory(user.history.exp, isSubscribed, timezoneUtcOffset, dayStart); user.markModified('history.exp'); } if (user.history.todos.length > minHistoryLength) { - user.history.todos = preenHistory(user.history.todos, isSubscribed, timezoneOffset, dayStart); + user.history.todos = preenHistory( + user.history.todos, isSubscribed, timezoneUtcOffset, dayStart, + ); user.markModified('history.todos'); } } diff --git a/website/server/middlewares/cron.js b/website/server/middlewares/cron.js index 365acaefd6..62e1e85a68 100644 --- a/website/server/middlewares/cron.js +++ b/website/server/middlewares/cron.js @@ -64,7 +64,7 @@ async function cronAsync (req, res) { user = await User.findOne({ _id: user._id }).exec(); res.locals.user = user; - const { daysMissed, timezoneOffsetFromUserPrefs } = user.daysUserHasMissed(now, req); + const { daysMissed, timezoneUtcOffsetFromUserPrefs } = user.daysUserHasMissed(now, req); await updateLastCron(user, now); @@ -94,7 +94,7 @@ async function cronAsync (req, res) { now, daysMissed, analytics, - timezoneOffsetFromUserPrefs, + timezoneUtcOffsetFromUserPrefs, headers: req.headers, }); diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 4bbe05decc..9c7d0d9a7c 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -347,35 +347,40 @@ schema.methods.cancelSubscription = async function cancelSubscription (options = return payments.cancelSubscription(options); }; +schema.methods.getUtcOffset = function getUtcOffset () { + return common.fns.getUtcOffset(this); +}; + schema.methods.daysUserHasMissed = function daysUserHasMissed (now, req = {}) { // If the user's timezone has changed (due to travel or daylight savings), // cron can be triggered twice in one day, so we check for that and use // both timezones to work out if cron should run. // CDS = Custom Day Start time. - let timezoneOffsetFromUserPrefs = this.preferences.timezoneOffset; - const timezoneOffsetAtLastCron = Number.isFinite(this.preferences.timezoneOffsetAtLastCron) - ? this.preferences.timezoneOffsetAtLastCron - : timezoneOffsetFromUserPrefs; - let timezoneOffsetFromBrowser = typeof req.header === 'function' && Number(req.header('x-user-timezoneoffset')); - timezoneOffsetFromBrowser = Number.isFinite(timezoneOffsetFromBrowser) - ? timezoneOffsetFromBrowser - : timezoneOffsetFromUserPrefs; + let timezoneUtcOffsetFromUserPrefs = this.getUtcOffset(); + const timezoneUtcOffsetAtLastCron = Number.isFinite(this.preferences.timezoneOffsetAtLastCron) + ? -this.preferences.timezoneOffsetAtLastCron + : timezoneUtcOffsetFromUserPrefs; + + let timezoneUtcOffsetFromBrowser = typeof req.header === 'function' && -Number(req.header('x-user-timezoneoffset')); + timezoneUtcOffsetFromBrowser = Number.isFinite(timezoneUtcOffsetFromBrowser) + ? timezoneUtcOffsetFromBrowser + : timezoneUtcOffsetFromUserPrefs; // NB: All timezone offsets can be 0, so can't use `... || ...` to apply non-zero defaults - if (timezoneOffsetFromBrowser !== timezoneOffsetFromUserPrefs) { + if (timezoneUtcOffsetFromBrowser !== timezoneUtcOffsetFromUserPrefs) { // The user's browser has just told Habitica that the user's timezone has // changed so store and use the new zone. - this.preferences.timezoneOffset = timezoneOffsetFromBrowser; - timezoneOffsetFromUserPrefs = timezoneOffsetFromBrowser; + this.preferences.timezoneOffset = -timezoneUtcOffsetFromBrowser; + timezoneUtcOffsetFromUserPrefs = timezoneUtcOffsetFromBrowser; } // How many days have we missed using the user's current timezone: let daysMissed = daysSince(this.lastCron, defaults({ now }, this.preferences)); - if (timezoneOffsetAtLastCron !== timezoneOffsetFromUserPrefs) { + if (timezoneUtcOffsetAtLastCron !== timezoneUtcOffsetFromUserPrefs) { // Give the user extra time based on the difference in timezones - if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) { - const differenceBetweenTimezonesInMinutes = timezoneOffsetFromUserPrefs - timezoneOffsetAtLastCron; // eslint-disable-line max-len + if (timezoneUtcOffsetAtLastCron > timezoneUtcOffsetFromUserPrefs) { + const differenceBetweenTimezonesInMinutes = timezoneUtcOffsetAtLastCron - timezoneUtcOffsetFromUserPrefs; // eslint-disable-line max-len now = moment(now).subtract(differenceBetweenTimezonesInMinutes, 'minutes'); // eslint-disable-line no-param-reassign, max-len } @@ -384,13 +389,13 @@ schema.methods.daysUserHasMissed = function daysUserHasMissed (now, req = {}) { const daysMissedNewZone = daysMissed; const daysMissedOldZone = daysSince(this.lastCron, defaults({ now, - timezoneOffsetOverride: timezoneOffsetAtLastCron, + timezoneUtcOffsetOverride: timezoneUtcOffsetAtLastCron, }, this.preferences)); - if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) { + if (timezoneUtcOffsetAtLastCron > timezoneUtcOffsetFromUserPrefs) { // The timezone change was in the unsafe direction. - // E.g., timezone changes from UTC+1 (offset -60) to UTC+0 (offset 0). - // or timezone changes from UTC-4 (offset 240) to UTC-5 (offset 300). + // E.g., timezone changes from UTC+1 (utcOffset 60) to UTC+0 (offset 0). + // or timezone changes from UTC-4 (utcOffset -240) to UTC-5 (utcOffset -300). // Local time changed from, for example, 03:00 to 02:00. if (daysMissedOldZone > 0 && daysMissedNewZone > 0) { @@ -419,20 +424,21 @@ schema.methods.daysUserHasMissed = function daysUserHasMissed (now, req = {}) { // timezone interprets as being in today. daysMissed = 0; // prevent cron running now - const timezoneOffsetDiff = timezoneOffsetAtLastCron - timezoneOffsetFromUserPrefs; - // e.g., for dangerous zone change: 240 - 300 = -60 or -660 - -600 = -60 + const timezoneOffsetDiff = timezoneUtcOffsetFromUserPrefs - timezoneUtcOffsetAtLastCron; + // e.g., for dangerous zone change: -300 - -240 = -60 or 600 - 660= -60 this.lastCron = moment(this.lastCron).subtract(timezoneOffsetDiff, 'minutes'); // NB: We don't change this.auth.timestamps.loggedin so that will still record // the time that the previous cron actually ran. // From now on we can ignore the old timezone: - this.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs; + // This is still timezoneOffset for backwards compatibility reasons. + this.preferences.timezoneOffsetAtLastCron = -timezoneUtcOffsetAtLastCron; } else { // Both old and new timezones indicate that cron should // NOT run. daysMissed = 0; // prevent cron running now } - } else if (timezoneOffsetAtLastCron > timezoneOffsetFromUserPrefs) { + } else if (timezoneUtcOffsetAtLastCron < timezoneUtcOffsetFromUserPrefs) { daysMissed = daysMissedNewZone; // TODO: Either confirm that there is nothing that could possibly go wrong // here and remove the need for this else branch, or fix stuff. @@ -445,7 +451,7 @@ schema.methods.daysUserHasMissed = function daysUserHasMissed (now, req = {}) { } } - return { daysMissed, timezoneOffsetFromUserPrefs }; + return { daysMissed, timezoneUtcOffsetFromUserPrefs }; }; async function getUserGroupData (user) {