Move from deprecated moment#zone to moment#utcOffset (#12207)

* Issue 10209 - Remove read usages of zone

* Issue 10209 - Add coverage on daysSince and startOfDay cron utility functions

* Issue 10209 - Add unit test for daysUserHasMissed method

* Issue 10209 - Remove usages of deprecated `moment.js#zone` method.

* Issue 10209 - Add helper function to centralise logic

Also simplify timezoneOffsetToUtc function in site.vue

* Issue 10209 - Also add getUtcOffset as method on user

Co-authored-by: Matteo Pagliazzi <matteopagliazzi@gmail.com>
This commit is contained in:
Bart Enkelaar
2020-07-25 13:22:41 +02:00
committed by GitHub
parent c10b9b7993
commit 234258b41e
25 changed files with 413 additions and 141 deletions

9
package-lock.json generated
View File

@@ -3008,6 +3008,15 @@
"check-error": "^1.0.2" "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": { "chalk": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",

View File

@@ -112,6 +112,7 @@
"axios": "^0.19.2", "axios": "^0.19.2",
"chai": "^4.1.2", "chai": "^4.1.2",
"chai-as-promised": "^7.1.1", "chai-as-promised": "^7.1.1",
"chai-moment": "^0.1.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"expect.js": "^0.3.1", "expect.js": "^0.3.1",

View File

@@ -42,13 +42,13 @@ describe('cron', () => {
}); });
it('updates user.preferences.timezoneOffsetAtLastCron', () => { it('updates user.preferences.timezoneOffsetAtLastCron', () => {
const timezoneOffsetFromUserPrefs = 1; const timezoneUtcOffsetFromUserPrefs = -1;
cron({ 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', () => { it('resets user.items.lastDrop.count', () => {
@@ -240,7 +240,7 @@ describe('cron', () => {
user1.purchased.plan.consecutive.gemCapExtra = 0; user1.purchased.plan.consecutive.gemCapExtra = 0;
it('does not increment consecutive benefits after the first month', () => { 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') .add(2, 'days')
.toDate()); .toDate());
// Add 1 month to simulate what happens a month after the subscription was created. // 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
// Add 1 month to simulate what happens a month after the subscription was created. // 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
// Add 1 month to simulate what happens a month after the subscription was created. // 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
// Add 1 month to simulate what happens a month after the subscription was created. // 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -339,7 +339,7 @@ describe('cron', () => {
user3.purchased.plan.consecutive.gemCapExtra = 5; 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -378,7 +378,7 @@ describe('cron', () => {
}); });
it('increments consecutive benefits the month after the second paid period has started', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -417,7 +417,7 @@ describe('cron', () => {
}); });
it('increments consecutive benefits the month after the third paid period has started', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -430,7 +430,7 @@ describe('cron', () => {
}); });
it('increments consecutive benefits correctly if user has been absent with continuous subscription', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -465,7 +465,7 @@ describe('cron', () => {
user6.purchased.plan.consecutive.gemCapExtra = 10; 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -491,7 +491,7 @@ describe('cron', () => {
}); });
it('increments consecutive benefits the month after the second paid period has started', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -504,7 +504,7 @@ describe('cron', () => {
}); });
it('increments consecutive benefits the month after the third paid period has started', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -517,7 +517,7 @@ describe('cron', () => {
}); });
it('increments consecutive benefits correctly if user has been absent with continuous subscription', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -552,7 +552,7 @@ describe('cron', () => {
user12.purchased.plan.consecutive.gemCapExtra = 20; 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ 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', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -578,7 +578,7 @@ describe('cron', () => {
}); });
it('increments consecutive benefits the month after the second paid period has started', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -591,7 +591,7 @@ describe('cron', () => {
}); });
it('increments consecutive benefits the month after the third paid period has started', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -604,7 +604,7 @@ describe('cron', () => {
}); });
it('increments consecutive benefits correctly if user has been absent with continuous subscription', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -641,7 +641,7 @@ describe('cron', () => {
user3g.purchased.plan.consecutive.gemCapExtra = 5; user3g.purchased.plan.consecutive.gemCapExtra = 5;
it('does not increment consecutive benefits in the first month of the gift subscription', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -654,7 +654,7 @@ describe('cron', () => {
}); });
it('does not increment consecutive benefits in the second month of the gift subscription', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -667,7 +667,7 @@ describe('cron', () => {
}); });
it('does not increment consecutive benefits in the third month of the gift subscription', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -680,7 +680,7 @@ describe('cron', () => {
}); });
it('does not increment consecutive benefits in the month after the gift subscription has ended', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -717,7 +717,7 @@ describe('cron', () => {
user6x.purchased.plan.consecutive.gemCapExtra = 15; user6x.purchased.plan.consecutive.gemCapExtra = 15;
it('increments consecutive benefits in the first month since the fix for #4819 goes live', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -730,7 +730,7 @@ describe('cron', () => {
}); });
it('does not increment consecutive benefits in the second month after the fix goes live', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -743,7 +743,7 @@ describe('cron', () => {
}); });
it('does not increment consecutive benefits in the third month after the fix goes live', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({
@@ -756,7 +756,7 @@ describe('cron', () => {
}); });
it('increments consecutive benefits in the seventh month after the fix goes live', () => { 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') .add(2, 'days')
.toDate()); .toDate());
cron({ cron({

View File

@@ -9,7 +9,7 @@ describe('preenHistory', () => {
beforeEach(() => { beforeEach(() => {
// Replace system clocks so we can get predictable results // Replace system clocks so we can get predictable results
clock = sinon.useFakeTimers({ 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'], toFake: ['Date'],
}); });
}); });

View File

@@ -761,7 +761,7 @@ describe('User Model', () => {
}); });
}); });
context('days missed', () => { describe('daysUserHasMissed', () => {
// http://forbrains.co.uk/international_tools/earth_timezones // http://forbrains.co.uk/international_tools/earth_timezones
let user; let user;
@@ -769,24 +769,51 @@ describe('User Model', () => {
user = new User(); user = new User();
}); });
it('should not cron early when going back a timezone', () => { it('correctly calculates days missed since lastCron', () => {
const yesterday = moment('2017-12-05T00:00:00.000-06:00'); // 11 pm on 4 Texas const now = moment();
const timezoneOffset = moment().zone('-06:00').zone(); user.lastCron = moment(now).subtract(5, 'days');
user.lastCron = yesterday;
user.preferences.timezoneOffset = timezoneOffset;
const today = moment('2017-12-06T00:00:00.000-06:00'); // 11 pm on 4 Texas const { daysMissed } = user.daysUserHasMissed(now);
const req = {};
req.header = () => timezoneOffset + 60;
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); expect(daysMissed).to.eql(0);
}); });
it('should not cron early when going back a timezone with a custom day start', () => { 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 yesterday = moment('2017-12-05T02:00:00.000-08:00');
const timezoneOffset = moment().zone('-08:00').zone(); const timezoneOffset = 480;
user.lastCron = yesterday; user.lastCron = yesterday;
user.preferences.timezoneOffset = timezoneOffset; user.preferences.timezoneOffset = timezoneOffset;
user.preferences.dayStart = 2; user.preferences.dayStart = 2;

View File

@@ -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 () => { 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({ await user.update({
'preferences.dayStart': 0, '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(); .toISOString();
await user.post('/tasks/user', [ 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 () => { 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({ await user.update({
'preferences.dayStart': 0, '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(); .toISOString();
await user.post('/tasks/user', [ 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 () => { 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({ await user.update({
'preferences.dayStart': 0, '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(); .toISOString();
await user.post('/tasks/user', [ await user.post('/tasks/user', [
{ {

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -1,6 +1,7 @@
import moment from 'moment'; import moment from 'moment';
import taskDefaults from '../../../website/common/script/libs/taskDefaults'; import taskDefaults from '../../../website/common/script/libs/taskDefaults';
import getUtcOffset from '../../../website/common/script/fns/getUtcOffset';
import { generateUser } from '../../helpers/common.helper'; import { generateUser } from '../../helpers/common.helper';
describe('taskDefaults', () => { describe('taskDefaults', () => {
@@ -72,7 +73,7 @@ describe('taskDefaults', () => {
expect(task.startDate).to.eql( expect(task.startDate).to.eql(
moment() moment()
.zone(user.preferences.timezoneOffset, 'hour') .utcOffset(getUtcOffset(user))
.startOf('day') .startOf('day')
.subtract(1, 'day') .subtract(1, 'day')
.toDate(), .toDate(),

View File

@@ -5,6 +5,8 @@ import 'moment-recur';
describe('shouldDo', () => { describe('shouldDo', () => {
let day; let let day; let
dailyTask; dailyTask;
// Options is a mapping of user.preferences, therefor `timezoneOffset` still holds old zone
// values instead of utcOffset values.
let options = {}; let options = {};
let nextDue = []; 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', () => { it('returns true if the user\'s current time is after start date and Custom Day Start', () => {
options.dayStart = 4; options.dayStart = 4;
day = moment().zone(options.timezoneOffset).startOf('day').add(6, 'hours') day = moment().utcOffset(-options.timezoneOffset).startOf('day').add(6, 'hours')
.toDate(); .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); expect(shouldDo(day, dailyTask, options)).to.equal(true);
}); });
it('returns false if the user\'s current time is before Custom Day Start', () => { it('returns false if the user\'s current time is before Custom Day Start', () => {
options.dayStart = 8; options.dayStart = 8;
day = moment().zone(options.timezoneOffset).startOf('day').add(2, 'hours') day = moment().utcOffset(-options.timezoneOffset).startOf('day').add(2, 'hours')
.toDate(); .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); 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', () => { it('returns true if the user\'s current time is after Custom Day Start', () => {
options.dayStart = 4; options.dayStart = 4;
day = moment().zone(options.timezoneOffset).startOf('day').add(6, 'hours') day = moment().utcOffset(-options.timezoneOffset).startOf('day').add(6, 'hours')
.toDate(); .toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true); expect(shouldDo(day, dailyTask, options)).to.equal(true);
}); });
it('returns false if the user\'s current time is before Custom Day Start', () => { it('returns false if the user\'s current time is before Custom Day Start', () => {
options.dayStart = 8; options.dayStart = 8;
day = moment().zone(options.timezoneOffset).startOf('day').add(2, 'hours') day = moment().utcOffset(-options.timezoneOffset).startOf('day').add(2, 'hours')
.toDate(); .toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false); expect(shouldDo(day, dailyTask, options)).to.equal(false);
}); });

View File

@@ -8,8 +8,9 @@
//------------------------------ //------------------------------
global._ = require('lodash'); global._ = require('lodash');
global.chai = require('chai'); global.chai = require('chai');
chai.use(require('sinon-chai'));
chai.use(require('chai-as-promised')); chai.use(require('chai-as-promised'));
chai.use(require('chai-moment'));
chai.use(require('sinon-chai'));
global.expect = chai.expect; global.expect = chai.expect;
global.sinon = require('sinon'); global.sinon = require('sinon');

View File

@@ -297,7 +297,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapState(['isUserLoggedIn', 'browserTimezoneOffset', 'isUserLoaded', 'notificationsRemoved']), ...mapState(['isUserLoggedIn', 'browserTimezoneUtcOffset', 'isUserLoaded', 'notificationsRemoved']),
...mapState({ user: 'user.data' }), ...mapState({ user: 'user.data' }),
isStaticPage () { isStaticPage () {
return this.$route.meta.requiresLogin === false; return this.$route.meta.requiresLogin === false;
@@ -493,9 +493,10 @@ export default {
this.hideLoadingScreen(); this.hideLoadingScreen();
// Adjust the timezone offset // 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', { this.$store.dispatch('user:set', {
'preferences.timezoneOffset': this.browserTimezoneOffset, 'preferences.timezoneOffset': browserTimezoneOffset,
}); });
} }

View File

@@ -559,6 +559,7 @@ import resetModal from './resetModal';
import deleteModal from './deleteModal'; import deleteModal from './deleteModal';
import { SUPPORTED_SOCIAL_NETWORKS } from '@/../../common/script/constants'; import { SUPPORTED_SOCIAL_NETWORKS } from '@/../../common/script/constants';
import changeClass from '@/../../common/script/ops/changeClass'; import changeClass from '@/../../common/script/ops/changeClass';
import getUtcOffset from '@/../../common/script/fns/getUtcOffset';
import notificationsMixin from '../../mixins/notifications'; import notificationsMixin from '../../mixins/notifications';
import sounds from '../../libs/sounds'; import sounds from '../../libs/sounds';
import { buildAppleAuthUrl } from '../../libs/auth'; import { buildAppleAuthUrl } from '../../libs/auth';
@@ -616,17 +617,8 @@ export default {
return ['off', ...this.content.audioThemes]; return ['off', ...this.content.audioThemes];
}, },
timezoneOffsetToUtc () { timezoneOffsetToUtc () {
let offset = this.user.preferences.timezoneOffset; const offsetString = moment().utcOffset(getUtcOffset(this.user)).format('Z');
const sign = offset > 0 ? '-' : '+'; return `UTC${offsetString}`;
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}`;
}, },
dayStart () { dayStart () {
return this.user.preferences.dayStart; return this.user.preferences.dayStart;

View File

@@ -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 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) { 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-user'] = AUTH_SETTINGS.auth.apiId;
axios.defaults.headers.common['x-api-key'] = AUTH_SETTINGS.auth.apiToken; 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; return true;
} }

View File

@@ -17,8 +17,8 @@ const IS_TEST = process.env.NODE_ENV === 'test'; // eslint-disable-line no-proce
// before trying to load data // before trying to load data
let isUserLoggedIn = false; let isUserLoggedIn = false;
// eg, 240 - this will be converted on server as -(offset/60) // eg, -240 - this will be converted on server as (offset/60)
const browserTimezoneOffset = moment().zone(); const browserTimezoneUtcOffset = moment().utcOffset();
axios.defaults.headers.common['x-client'] = 'habitica-web'; 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 // store the timezone offset in case it's different than the one in
// user.preferences.timezoneOffset and change it after the user is synced // user.preferences.timezoneOffset and change it after the user is synced
// in app.vue // in app.vue
browserTimezoneOffset, browserTimezoneUtcOffset,
tasks: asyncResourceFactory(), // user tasks tasks: asyncResourceFactory(), // user tasks
// @TODO use asyncresource? // @TODO use asyncresource?
completedTodosStatus: 'NOT_LOADED', completedTodosStatus: 'NOT_LOADED',

View File

@@ -33,26 +33,29 @@ function sanitizeOptions (o) {
const ref = Number(o.dayStart || 0); const ref = Number(o.dayStart || 0);
const dayStart = !Number.isNaN(ref) && ref >= 0 && ref <= 24 ? ref : 0; const dayStart = !Number.isNaN(ref) && ref >= 0 && ref <= 24 ? ref : 0;
let timezoneOffset; let timezoneUtcOffset;
const timezoneOffsetDefault = Number(moment().zone()); const timezoneUtcOffsetDefault = moment().utcOffset();
if (Number.isFinite(o.timezoneOffsetOverride)) { if (Number.isFinite(o.timezoneUtcOffset)) {
timezoneOffset = Number(o.timezoneOffsetOverride); // Options were already sanitized
timezoneUtcOffset = o.timezoneUtcOffset;
} else if (Number.isFinite(o.timezoneUtcOffsetOverride)) {
timezoneUtcOffset = o.timezoneUtcOffsetOverride;
} else if (Number.isFinite(o.timezoneOffset)) { } else if (Number.isFinite(o.timezoneOffset)) {
timezoneOffset = Number(o.timezoneOffset); timezoneUtcOffset = -o.timezoneOffset;
} else { } else {
timezoneOffset = timezoneOffsetDefault; timezoneUtcOffset = timezoneUtcOffsetDefault;
} }
if (timezoneOffset > 720 || timezoneOffset < -840) { if (timezoneUtcOffset < -720 || timezoneUtcOffset > 840) {
// timezones range from -12 (offset +720) to +14 (offset -840) // timezones range from -12 (offset -720) to +14 (offset 840)
timezoneOffset = timezoneOffsetDefault; 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 a new object, we don't want to add "now" to user object
return { return {
dayStart, dayStart,
timezoneOffset, timezoneUtcOffset,
now, now,
}; };
} }
@@ -81,7 +84,7 @@ export function startOfDay (options = {}) {
const o = sanitizeOptions(options); const o = sanitizeOptions(options);
const dayStart = moment(o.now).startOf('day').add({ hours: o.dayStart }); 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 }); 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 // NB: The user's day start date has already been converted to the PREVIOUS
// day's date if the time portion was before CDS. // 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) { if (startDate > startOfDayWithCDSTime.startOf('day') && !options.nextDue) {
return false; // Daily starts in the future return false; // Daily starts in the future

View File

@@ -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);
}

View File

@@ -28,6 +28,7 @@ import apiErrors from './errors/apiErrorMessages';
import commonErrors from './errors/commonErrorMessages'; import commonErrors from './errors/commonErrorMessages';
import autoAllocate from './fns/autoAllocate'; import autoAllocate from './fns/autoAllocate';
import crit from './fns/crit'; import crit from './fns/crit';
import getUtcOffset from './fns/getUtcOffset';
import handleTwoHanded from './fns/handleTwoHanded'; import handleTwoHanded from './fns/handleTwoHanded';
import predictableRandom from './fns/predictableRandom'; import predictableRandom from './fns/predictableRandom';
import randomDrop from './fns/randomDrop'; import randomDrop from './fns/randomDrop';
@@ -151,6 +152,7 @@ api.fns = {
resetGear, resetGear,
ultimateGear, ultimateGear,
updateStats, updateStats,
getUtcOffset,
}; };
api.ops = { api.ops = {

View File

@@ -1,6 +1,7 @@
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import defaults from 'lodash/defaults'; import defaults from 'lodash/defaults';
import moment from 'moment'; import moment from 'moment';
import getUtcOffset from '../fns/getUtcOffset';
// Even though Mongoose handles task defaults, // Even though Mongoose handles task defaults,
// we want to make sure defaults are set on the client-side before // 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') { if (task.type === 'daily') {
const now = moment().zone(user.preferences.timezoneOffset); const now = moment().utcOffset(getUtcOffset(user));
const startOfDay = now.clone().startOf('day'); const startOfDay = now.clone().startOf('day');
const startOfDayWithCDSTime = startOfDay const startOfDayWithCDSTime = startOfDay
.clone() .clone()

View File

@@ -8,6 +8,8 @@ import {
import i18n from '../i18n'; import i18n from '../i18n';
import updateStats from '../fns/updateStats'; import updateStats from '../fns/updateStats';
import crit from '../fns/crit'; import crit from '../fns/crit';
import getUtcOffset from '../fns/getUtcOffset';
import statsComputed from '../libs/statsComputed'; import statsComputed from '../libs/statsComputed';
import { checkOnboardingStatus } from '../libs/onboarding'; import { checkOnboardingStatus } from '../libs/onboarding';
@@ -194,14 +196,14 @@ function _lastHistoryEntryWasToday (lastHistoryEntry, user) {
return false; return false;
} }
const { timezoneOffset } = user.preferences; const timezoneUtcOffset = getUtcOffset(user);
const { dayStart } = user.preferences; const { dayStart } = user.preferences;
// Adjust the last entry date according to the user's timezone and CDS // 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'); 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) { function _updateLastHistoryEntry (lastHistoryEntry, task, direction, times) {

View File

@@ -317,7 +317,7 @@ api.exportUserPrivateMessages = {
async handler (req, res) { async handler (req, res) {
const { user } = res.locals; const { user } = res.locals;
const { timezoneOffset } = user.preferences; const timezoneUtcOffset = user.getUtcOffset();
const dateFormat = user.preferences.dateFormat.toUpperCase(); const dateFormat = user.preferences.dateFormat.toUpperCase();
const TO = res.t('to'); const TO = res.t('to');
const FROM = res.t('from'); const FROM = res.t('from');
@@ -329,7 +329,7 @@ api.exportUserPrivateMessages = {
inbox.forEach((message, index) => { inbox.forEach((message, index) => {
const recipientLabel = message.sent ? TO : FROM; const recipientLabel = message.sent ? TO : FROM;
const messageUser = message.user; 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 text = md.render(message.text);
const pageIndex = `(${index + 1}/${inbox.length})`; const pageIndex = `(${index + 1}/${inbox.length})`;
messages += ` messages += `

View File

@@ -172,7 +172,7 @@ function resetHabitCounters (user, tasksByType, now, daysMissed) {
break; break;
} }
const thatDay = moment(now) const thatDay = moment(now)
.zone(user.preferences.timezoneOffset + user.preferences.dayStart * 60) .utcOffset(user.getUtcOffset() - user.preferences.dayStart * 60)
.subtract({ days: i }); .subtract({ days: i });
if (thatDay.day() === 1) { if (thatDay.day() === 1) {
resetWeekly = true; resetWeekly = true;
@@ -281,14 +281,14 @@ function awardLoginIncentives (user) {
// Perform various beginning-of-day reset actions. // Perform various beginning-of-day reset actions.
export function cron (options = {}) { export function cron (options = {}) {
const { const {
user, tasksByType, analytics, now = new Date(), daysMissed, timezoneOffsetFromUserPrefs, user, tasksByType, analytics, now = new Date(), daysMissed, timezoneUtcOffsetFromUserPrefs,
} = options; } = options;
let _progress = { down: 0, up: 0, collectedItems: 0 }; let _progress = { down: 0, up: 0, collectedItems: 0 };
// Record pre-cron values of HP and MP to show notifications later // Record pre-cron values of HP and MP to show notifications later
const beforeCronStats = _.pick(user.stats, ['hp', 'mp']); 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. // 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; if (user.items.lastDrop.count > 0) user.items.lastDrop.count = 0;

View File

@@ -2,10 +2,10 @@ import _ from 'lodash';
import moment from 'moment'; import moment from 'moment';
// Aggregate entries // Aggregate entries
function _aggregate (history, aggregateBy, timezoneOffset, dayStart) { function _aggregate (history, aggregateBy, timezoneUtcOffset, dayStart) {
return _.chain(history) return _.chain(history)
.groupBy(entry => { // group entries by aggregateBy .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'); if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day');
return entryDate.format(aggregateBy); return entryDate.format(aggregateBy);
}) })
@@ -35,16 +35,16 @@ Subscribers and challenges:
- 1 value each month for the previous 12 months - 1 value each month for the previous 12 months
- 1 value each year for the previous years - 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 // 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 // Date after which to begin compressing data
const cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day'); const cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day');
// Keep uncompressed entries (modifies history and returns removed items) // Keep uncompressed entries (modifies history and returns removed items)
const newHistory = _.remove(history, entry => { const newHistory = _.remove(history, entry => {
if (!entry) return true; // sometimes entries are `null` 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'); if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day');
return entryDate.isSame(cutOff) || entryDate.isAfter(cutOff); 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 monthsCutOff = cutOff.subtract(isSubscribed ? 12 : 10, 'months').startOf('day');
const aggregateByMonth = _.remove(history, entry => { const aggregateByMonth = _.remove(history, entry => {
if (!entry) return true; // sometimes entries are `null` 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'); if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day');
return entryDate.isSame(monthsCutOff) || entryDate.isAfter(monthsCutOff); return entryDate.isSame(monthsCutOff) || entryDate.isAfter(monthsCutOff);
}); });
// Aggregate remaining entries by month and year // Aggregate remaining entries by month and year
if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM', timezoneOffset, dayStart)); if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM', timezoneUtcOffset, dayStart));
if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY', timezoneOffset, dayStart)); if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY', timezoneUtcOffset, dayStart));
return newHistory; return newHistory;
} }
@@ -67,13 +67,13 @@ export function preenHistory (history, isSubscribed, timezoneOffset = 0, dayStar
// Preen history for users and tasks. // Preen history for users and tasks.
export function preenUserHistory (user, tasksByType) { export function preenUserHistory (user, tasksByType) {
const isSubscribed = user.isSubscribed(); const isSubscribed = user.isSubscribed();
const { timezoneOffset } = user.preferences; const timezoneUtcOffset = user.getUtcOffset();
const { dayStart } = user.preferences; const { dayStart } = user.preferences;
const minHistoryLength = isSubscribed ? 365 : 60; const minHistoryLength = isSubscribed ? 365 : 60;
function _processTask (task) { function _processTask (task) {
if (task.history && task.history.length > minHistoryLength) { 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'); task.markModified('history');
} }
} }
@@ -82,12 +82,14 @@ export function preenUserHistory (user, tasksByType) {
tasksByType.dailys.forEach(_processTask); tasksByType.dailys.forEach(_processTask);
if (user.history.exp.length > minHistoryLength) { 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'); user.markModified('history.exp');
} }
if (user.history.todos.length > minHistoryLength) { 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'); user.markModified('history.todos');
} }
} }

View File

@@ -64,7 +64,7 @@ async function cronAsync (req, res) {
user = await User.findOne({ _id: user._id }).exec(); user = await User.findOne({ _id: user._id }).exec();
res.locals.user = user; res.locals.user = user;
const { daysMissed, timezoneOffsetFromUserPrefs } = user.daysUserHasMissed(now, req); const { daysMissed, timezoneUtcOffsetFromUserPrefs } = user.daysUserHasMissed(now, req);
await updateLastCron(user, now); await updateLastCron(user, now);
@@ -94,7 +94,7 @@ async function cronAsync (req, res) {
now, now,
daysMissed, daysMissed,
analytics, analytics,
timezoneOffsetFromUserPrefs, timezoneUtcOffsetFromUserPrefs,
headers: req.headers, headers: req.headers,
}); });

View File

@@ -347,35 +347,40 @@ schema.methods.cancelSubscription = async function cancelSubscription (options =
return payments.cancelSubscription(options); return payments.cancelSubscription(options);
}; };
schema.methods.getUtcOffset = function getUtcOffset () {
return common.fns.getUtcOffset(this);
};
schema.methods.daysUserHasMissed = function daysUserHasMissed (now, req = {}) { schema.methods.daysUserHasMissed = function daysUserHasMissed (now, req = {}) {
// If the user's timezone has changed (due to travel or daylight savings), // 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 // cron can be triggered twice in one day, so we check for that and use
// both timezones to work out if cron should run. // both timezones to work out if cron should run.
// CDS = Custom Day Start time. // CDS = Custom Day Start time.
let timezoneOffsetFromUserPrefs = this.preferences.timezoneOffset; let timezoneUtcOffsetFromUserPrefs = this.getUtcOffset();
const timezoneOffsetAtLastCron = Number.isFinite(this.preferences.timezoneOffsetAtLastCron) const timezoneUtcOffsetAtLastCron = Number.isFinite(this.preferences.timezoneOffsetAtLastCron)
? this.preferences.timezoneOffsetAtLastCron ? -this.preferences.timezoneOffsetAtLastCron
: timezoneOffsetFromUserPrefs; : timezoneUtcOffsetFromUserPrefs;
let timezoneOffsetFromBrowser = typeof req.header === 'function' && Number(req.header('x-user-timezoneoffset'));
timezoneOffsetFromBrowser = Number.isFinite(timezoneOffsetFromBrowser) let timezoneUtcOffsetFromBrowser = typeof req.header === 'function' && -Number(req.header('x-user-timezoneoffset'));
? timezoneOffsetFromBrowser timezoneUtcOffsetFromBrowser = Number.isFinite(timezoneUtcOffsetFromBrowser)
: timezoneOffsetFromUserPrefs; ? timezoneUtcOffsetFromBrowser
: timezoneUtcOffsetFromUserPrefs;
// NB: All timezone offsets can be 0, so can't use `... || ...` to apply non-zero defaults // 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 // The user's browser has just told Habitica that the user's timezone has
// changed so store and use the new zone. // changed so store and use the new zone.
this.preferences.timezoneOffset = timezoneOffsetFromBrowser; this.preferences.timezoneOffset = -timezoneUtcOffsetFromBrowser;
timezoneOffsetFromUserPrefs = timezoneOffsetFromBrowser; timezoneUtcOffsetFromUserPrefs = timezoneUtcOffsetFromBrowser;
} }
// How many days have we missed using the user's current timezone: // How many days have we missed using the user's current timezone:
let daysMissed = daysSince(this.lastCron, defaults({ now }, this.preferences)); 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 // Give the user extra time based on the difference in timezones
if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) { if (timezoneUtcOffsetAtLastCron > timezoneUtcOffsetFromUserPrefs) {
const differenceBetweenTimezonesInMinutes = timezoneOffsetFromUserPrefs - timezoneOffsetAtLastCron; // eslint-disable-line max-len const differenceBetweenTimezonesInMinutes = timezoneUtcOffsetAtLastCron - timezoneUtcOffsetFromUserPrefs; // eslint-disable-line max-len
now = moment(now).subtract(differenceBetweenTimezonesInMinutes, 'minutes'); // eslint-disable-line no-param-reassign, 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 daysMissedNewZone = daysMissed;
const daysMissedOldZone = daysSince(this.lastCron, defaults({ const daysMissedOldZone = daysSince(this.lastCron, defaults({
now, now,
timezoneOffsetOverride: timezoneOffsetAtLastCron, timezoneUtcOffsetOverride: timezoneUtcOffsetAtLastCron,
}, this.preferences)); }, this.preferences));
if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) { if (timezoneUtcOffsetAtLastCron > timezoneUtcOffsetFromUserPrefs) {
// The timezone change was in the unsafe direction. // The timezone change was in the unsafe direction.
// E.g., timezone changes from UTC+1 (offset -60) to UTC+0 (offset 0). // E.g., timezone changes from UTC+1 (utcOffset 60) to UTC+0 (offset 0).
// or timezone changes from UTC-4 (offset 240) to UTC-5 (offset 300). // or timezone changes from UTC-4 (utcOffset -240) to UTC-5 (utcOffset -300).
// Local time changed from, for example, 03:00 to 02:00. // Local time changed from, for example, 03:00 to 02:00.
if (daysMissedOldZone > 0 && daysMissedNewZone > 0) { if (daysMissedOldZone > 0 && daysMissedNewZone > 0) {
@@ -419,20 +424,21 @@ schema.methods.daysUserHasMissed = function daysUserHasMissed (now, req = {}) {
// timezone interprets as being in today. // timezone interprets as being in today.
daysMissed = 0; // prevent cron running now daysMissed = 0; // prevent cron running now
const timezoneOffsetDiff = timezoneOffsetAtLastCron - timezoneOffsetFromUserPrefs; const timezoneOffsetDiff = timezoneUtcOffsetFromUserPrefs - timezoneUtcOffsetAtLastCron;
// e.g., for dangerous zone change: 240 - 300 = -60 or -660 - -600 = -60 // e.g., for dangerous zone change: -300 - -240 = -60 or 600 - 660= -60
this.lastCron = moment(this.lastCron).subtract(timezoneOffsetDiff, 'minutes'); this.lastCron = moment(this.lastCron).subtract(timezoneOffsetDiff, 'minutes');
// NB: We don't change this.auth.timestamps.loggedin so that will still record // NB: We don't change this.auth.timestamps.loggedin so that will still record
// the time that the previous cron actually ran. // the time that the previous cron actually ran.
// From now on we can ignore the old timezone: // 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 { } else {
// Both old and new timezones indicate that cron should // Both old and new timezones indicate that cron should
// NOT run. // NOT run.
daysMissed = 0; // prevent cron running now daysMissed = 0; // prevent cron running now
} }
} else if (timezoneOffsetAtLastCron > timezoneOffsetFromUserPrefs) { } else if (timezoneUtcOffsetAtLastCron < timezoneUtcOffsetFromUserPrefs) {
daysMissed = daysMissedNewZone; daysMissed = daysMissedNewZone;
// TODO: Either confirm that there is nothing that could possibly go wrong // TODO: Either confirm that there is nothing that could possibly go wrong
// here and remove the need for this else branch, or fix stuff. // 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) { async function getUserGroupData (user) {