diff --git a/common/script/cron.js b/common/script/cron.js new file mode 100644 index 0000000000..53a0e1d6fa --- /dev/null +++ b/common/script/cron.js @@ -0,0 +1,157 @@ +/* + ------------------------------------------------------ + Cron and time / day functions + ------------------------------------------------------ + */ +import _ from 'lodash'; +import moment from 'moment'; + +export const DAY_MAPPING = { + 0: 'su', + 1: 'm', + 2: 't', + 3: 'w', + 4: 'th', + 5: 'f', + 6: 's', +}; + +/* + Each time we perform date maths (cron, task-due-days, etc), we need to consider user preferences. + Specifically {dayStart} (custom day start) and {timezoneOffset}. This function sanitizes / defaults those values. + {now} is also passed in for various purposes, one example being the test scripts scripts testing different "now" times. + */ + +function sanitizeOptions (o) { + let ref = Number(o.dayStart || 0); + let dayStart = !_.isNaN(ref) && ref >= 0 && ref <= 24 ? ref : 0; + let timezoneOffset = o.timezoneOffset ? Number(o.timezoneOffset) : Number(moment().zone()); + // TODO: check and clean timezoneOffset as for dayStart (e.g., might not be a number) + let now = o.now ? moment(o.now).zone(timezoneOffset) : moment().zone(timezoneOffset); + + // return a new object, we don't want to add "now" to user object + return { + dayStart, + timezoneOffset, + now, + }; +} + +export function startOfWeek (options = {}) { + let o = sanitizeOptions(options); + + return moment(o.now).startOf('week'); +} + +/* + This is designed for use with any date that has an important time portion (e.g., when comparing the current date-time with the previous cron's date-time for determing if cron should run now). + It changes the time portion of the date-time to be the Custom Day Start hour, so that the date-time is now the user's correct start of day. + It SUBTRACTS a day if the date-time's original hour is before CDS (e.g., if your CDS is 5am and it's currently 4am, it's still the previous day). + This is NOT suitable for manipulating any dates that are displayed to the user as a date with no time portion, such as a Daily's Start Dates (e.g., a Start Date of today shows only the date, so it should be considered to be today even if the hidden time portion is before CDS). + */ + +export function startOfDay (options = {}) { + let o = sanitizeOptions(options); + let dayStart = moment(o.now).startOf('day').add({ hours: o.dayStart }); + + if (moment(o.now).hour() < o.dayStart) { + dayStart.subtract({ days: 1 }); + } + return dayStart; +} + +/* + Absolute diff from "yesterday" till now + */ + +export function daysSince (yesterday, options = {}) { + let o = sanitizeOptions(options); + + return startOfDay(_.defaults({ now: o.now }, o)).diff(startOfDay(_.defaults({ now: yesterday }, o)), 'days'); +} + +/* + Should the user do this task on this date, given the task's repeat options and user.preferences.dayStart? + */ + +export function shouldDo (day, dailyTask, options = {}) { + if (dailyTask.type !== 'daily') { + return false; + } + let o = sanitizeOptions(options); + let startOfDayWithCDSTime = startOfDay(_.defaults({ now: day }, o)); + + // The time portion of the Start Date is never visible to or modifiable by the user so we must ignore it. + // Therefore, we must also ignore the time portion of the user's day start (startOfDayWithCDSTime), otherwise the date comparison will be wrong for some times. + // NB: The user's day start date has already been converted to the PREVIOUS day's date if the time portion was before CDS. + let taskStartDate = moment(dailyTask.startDate).zone(o.timezoneOffset); + + taskStartDate = moment(taskStartDate).startOf('day'); + if (taskStartDate > startOfDayWithCDSTime.startOf('day')) { + return false; // Daily starts in the future + } + if (dailyTask.frequency === 'daily') { // "Every X Days" + if (!dailyTask.everyX) { + return false; // error condition + } + let daysSinceTaskStart = startOfDayWithCDSTime.startOf('day').diff(taskStartDate, 'days'); + + return daysSinceTaskStart % dailyTask.everyX === 0; + } else if (dailyTask.frequency === 'weekly') { // "On Certain Days of the Week" + if (!dailyTask.repeat) { + return false; // error condition + } + let dayOfWeekNum = startOfDayWithCDSTime.day(); // e.g., 0 for Sunday + + return dailyTask.repeat[DAY_MAPPING[dayOfWeekNum]]; + } else { + return false; // error condition - unexpected frequency string + } +} + + +/* + Preen history for users with > 7 history entries + This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array of averages, condensing more the further back in time we go. + Eg, 7 entries each for last 7 days; 1 entry each week of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] + */ + +export function preenHistory (history) { + // 'export' is temporary pending further refactoring + history = _.filter(history, function discardNulls (h) { + return Boolean(h); // nulls are from corruption somehow - TODO is this still needed? + }); + let newHistory = []; + + function preen (amount, groupBy) { + let groups = _.chain(history).groupBy(function getDateGroupings (h) { + return moment(h.date).format(groupBy); + }).sortBy(function sortByDate (h, k) { + return k; + }).value(); // turn into an array + + groups = groups.slice(-amount); + groups.pop(); // get rid of 'this week', 'this month', etc (except for case of days) + return _.each(groups, function recreateHistory (group) { + newHistory.push({ + date: moment(group[0].date).toDate(), + value: _.reduce(group, function makeAverage (m, obj) { + return m + obj.value; + }, 0) / group.length, + }); + return true; + }); + } + + // Keep the last: + preen(50, 'YYYY'); // 50 years (habit will toootally be around that long!) + preen(moment().format('MM'), 'YYYYMM'); // last MM months (eg, if today is 05, keep the last 5 months) + // Then keep all days of this month. Note, the extra logic is to account for Habits, which can be counted multiple times per day + // FIXME I'd rather keep 1-entry/week of this month, then last 'd' days in this week. However, I'm having issues where the 1st starts mid week + let thisMonth = moment().format('YYYYMM'); + + newHistory = newHistory.concat(_.filter(history, function keepThisMonthsData (h) { + return moment(h.date).format('YYYYMM') === thisMonth; + })); + return newHistory; +} diff --git a/common/script/index.js b/common/script/index.js index bed6215b28..1fee62e436 100644 --- a/common/script/index.js +++ b/common/script/index.js @@ -1,4 +1,11 @@ -var $w, _, api, content, i18n, moment, preenHistory, sanitizeOptions, sortOrder, +import { + daysSince, + shouldDo, + preenHistory, // temporary pending further refactoring +} from '../../common/script/cron'; +import * as statHelpers from './statHelpers'; + +var $w, _, api, content, i18n, moment, sortOrder, indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; moment = require('moment'); @@ -12,8 +19,7 @@ i18n = require('./i18n'); api = module.exports = {}; api.i18n = i18n; - -import * as statHelpers from './statHelpers'; +api.shouldDo = shouldDo; api.maxLevel = statHelpers.MAX_LEVEL; api.capByLevel = statHelpers.capByLevel; @@ -69,169 +75,6 @@ api.planGemLimits = { convCap: 25 }; - -/* - ------------------------------------------------------ - Time / Day - ------------------------------------------------------ - */ - - -/* - Each time we're performing date math (cron, task-due-days, etc), we need to take user preferences into consideration. - Specifically {dayStart} (custom day start) and {timezoneOffset}. This function sanitizes / defaults those values. - {now} is also passed in for various purposes, one example being the test scripts scripts testing different "now" times - */ - -sanitizeOptions = function(o) { - var dayStart, now, ref, timezoneOffset; - dayStart = !_.isNaN(+o.dayStart) && (0 <= (ref = +o.dayStart) && ref <= 24) ? +o.dayStart : 0; - timezoneOffset = o.timezoneOffset ? +o.timezoneOffset : +moment().zone(); - now = o.now ? moment(o.now).zone(timezoneOffset) : moment(+(new Date)).zone(timezoneOffset); - return { - dayStart: dayStart, - timezoneOffset: timezoneOffset, - now: now - }; -}; - -api.startOfWeek = api.startOfWeek = function(options) { - var o; - if (options == null) { - options = {}; - } - o = sanitizeOptions(options); - return moment(o.now).startOf('week'); -}; - -api.startOfDay = function(options) { - var dayStart, o; - if (options == null) { - options = {}; - } - o = sanitizeOptions(options); - dayStart = moment(o.now).startOf('day').add({ - hours: o.dayStart - }); - if (moment(o.now).hour() < o.dayStart) { - dayStart.subtract({ - days: 1 - }); - } - return dayStart; -}; - -api.dayMapping = { - 0: 'su', - 1: 'm', - 2: 't', - 3: 'w', - 4: 'th', - 5: 'f', - 6: 's' -}; - - -/* - Absolute diff from "yesterday" till now - */ - -api.daysSince = function(yesterday, options) { - var o; - if (options == null) { - options = {}; - } - o = sanitizeOptions(options); - return api.startOfDay(_.defaults({ - now: o.now - }, o)).diff(api.startOfDay(_.defaults({ - now: yesterday - }, o)), 'days'); -}; - - -/* - Should the user do this task on this date, given the task's repeat options and user.preferences.dayStart? - */ - -api.shouldDo = function(day, dailyTask, options) { - var dayOfWeekCheck, dayOfWeekNum, daysSinceTaskStart, everyXCheck, o, startOfDayWithCDSTime, taskStartDate; - if (options == null) { - options = {}; - } - if (dailyTask.type !== 'daily') { - return false; - } - o = sanitizeOptions(options); - startOfDayWithCDSTime = api.startOfDay(_.defaults({ - now: day - }, o)); - taskStartDate = moment(dailyTask.startDate).zone(o.timezoneOffset); - taskStartDate = moment(taskStartDate).startOf('day'); - if (taskStartDate > startOfDayWithCDSTime.startOf('day')) { - return false; - } - if (dailyTask.frequency === 'daily') { - if (!dailyTask.everyX) { - return false; - } - daysSinceTaskStart = startOfDayWithCDSTime.startOf('day').diff(taskStartDate, 'days'); - everyXCheck = daysSinceTaskStart % dailyTask.everyX === 0; - return everyXCheck; - } else if (dailyTask.frequency === 'weekly') { - if (!dailyTask.repeat) { - return false; - } - dayOfWeekNum = startOfDayWithCDSTime.day(); - dayOfWeekCheck = dailyTask.repeat[api.dayMapping[dayOfWeekNum]]; - return dayOfWeekCheck; - } else { - return false; - } -}; - -/* -Preen history for users with > 7 history entries -This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array -of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week -of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite] - */ - -preenHistory = function(history) { - var newHistory, preen, thisMonth; - history = _.filter(history, function(h) { - return !!h; - }); - newHistory = []; - preen = function(amount, groupBy) { - var groups; - groups = _.chain(history).groupBy(function(h) { - return moment(h.date).format(groupBy); - }).sortBy(function(h, k) { - return k; - }).value(); - groups = groups.slice(-amount); - groups.pop(); - return _.each(groups, function(group) { - newHistory.push({ - date: moment(group[0].date).toDate(), - value: _.reduce(group, (function(m, obj) { - return m + obj.value; - }), 0) / group.length - }); - return true; - }); - }; - preen(50, "YYYY"); - preen(moment().format('MM'), "YYYYMM"); - thisMonth = moment().format('YYYYMM'); - newHistory = newHistory.concat(_.filter(history, function(h) { - return moment(h.date).format('YYYYMM') === thisMonth; - })); - return newHistory; -}; - - /* Preen 3-day past-completed To-Dos from Angular & mobile app */ @@ -244,7 +87,6 @@ api.preenTodos = function(tasks) { }); }; - /* Update the in-browser store with new gear. FIXME this was in user.fns, but it was causing strange issues there */ @@ -486,7 +328,7 @@ api.taskClasses = function(task, filters, dayStart, lastCron, showCompleted, mai classes += " beingEdited"; } if (type === 'todo' || type === 'daily') { - if (completed || (type === 'daily' && !api.shouldDo(+(new Date), task, { + if (completed || (type === 'daily' && !shouldDo(+(new Date), task, { dayStart: dayStart }))) { classes += " completed"; @@ -2277,7 +2119,7 @@ api.wrap = function(user, main) { } } dropMultiplier = ((ref1 = user.purchased) != null ? (ref2 = ref1.plan) != null ? ref2.customerId : void 0 : void 0) ? 2 : 1; - if ((api.daysSince(user.items.lastDrop.date, user.preferences) === 0) && (user.items.lastDrop.count >= dropMultiplier * (5 + Math.floor(user._statsComputed.per / 25) + (user.contributor.level || 0)))) { + if ((daysSince(user.items.lastDrop.date, user.preferences) === 0) && (user.items.lastDrop.count >= dropMultiplier * (5 + Math.floor(user._statsComputed.per / 25) + (user.contributor.level || 0)))) { return; } if (((ref3 = user.flags) != null ? ref3.dropsEnabled : void 0) && user.fns.predictableRandom(user.stats.exp) < chance) { @@ -2484,7 +2326,7 @@ api.wrap = function(user, main) { options = {}; } now = +options.now || +(new Date); - daysMissed = api.daysSince(user.lastCron, _.defaults({ + daysMissed = daysSince(user.lastCron, _.defaults({ now: now }, user.preferences)); if (!(daysMissed > 0)) { @@ -2550,7 +2392,7 @@ api.wrap = function(user, main) { thatDay = moment(now).subtract({ days: 1 }); - if (api.shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { + if (shouldDo(thatDay.toDate(), daily, user.preferences) || completed) { _.each(daily.checklist, (function(box) { box.completed = false; return true; @@ -2604,7 +2446,7 @@ api.wrap = function(user, main) { thatDay = moment(now).subtract({ days: n + 1 }); - if (api.shouldDo(thatDay.toDate(), task, user.preferences)) { + if (shouldDo(thatDay.toDate(), task, user.preferences)) { scheduleMisses++; if (user.stats.buffs.stealth) { user.stats.buffs.stealth--; diff --git a/tasks/gulp-tests.js b/tasks/gulp-tests.js index bbaf401bfe..c553fdcd3f 100644 --- a/tasks/gulp-tests.js +++ b/tasks/gulp-tests.js @@ -39,6 +39,13 @@ let testBin = (string) => { return `NODE_ENV=testing ./node_modules/.bin/${string}`; }; +gulp.task('test:nodemon', (done) => { + process.env.PORT = TEST_SERVER_PORT; + process.env.NODE_DB_URI=TEST_DB_URI; + + runSequence('nodemon') +}); + gulp.task('test:prepare:mongo', (cb) => { mongoose.connect(TEST_DB_URI, (err) => { if (err) return cb(`Unable to connect to mongo database. Are you sure it's running? \n\n${err}`); diff --git a/test/common/algos.mocha.js b/test/common/algos.mocha.js index af902bd46c..b398a5bb72 100644 --- a/test/common/algos.mocha.js +++ b/test/common/algos.mocha.js @@ -1,4 +1,11 @@ /* eslint-disable camelcase, func-names, no-shadow */ +import { + DAY_MAPPING, + startOfWeek, + startOfDay, + daysSince, +} from '../../common/script/cron'; + let expect = require('expect.js'); let sinon = require('sinon'); let moment = require('moment'); @@ -205,7 +212,7 @@ let repeatWithoutLastWeekday = () => { s: true, }; - if (shared.startOfWeek(moment().zone(0)).isoWeekday() === 1) { + if (startOfWeek(moment().zone(0)).isoWeekday() === 1) { repeat.su = false; } else { repeat.s = false; @@ -301,7 +308,7 @@ describe('User', () => { let yesterday = moment().subtract(1, 'days'); - user.dailys[0].repeat[shared.dayMapping[yesterday.day()]] = false; + user.dailys[0].repeat[DAY_MAPPING[yesterday.day()]] = false; _.each(user.dailys.slice(1), (d) => { d.completed = true; }); @@ -383,7 +390,7 @@ describe('User', () => { it('does not reset checklist on grey incomplete dailies', () => { let yesterday = moment().subtract(1, 'days'); - user.dailys[0].repeat[shared.dayMapping[yesterday.day()]] = false; + user.dailys[0].repeat[DAY_MAPPING[yesterday.day()]] = false; user.dailys[0].checklist = [ { text: '1', @@ -407,7 +414,7 @@ describe('User', () => { it('resets checklist on complete grey complete dailies', () => { let yesterday = moment().subtract(1, 'days'); - user.dailys[0].repeat[shared.dayMapping[yesterday.day()]] = false; + user.dailys[0].repeat[DAY_MAPPING[yesterday.day()]] = false; user.dailys[0].checklist = [ { text: '1', @@ -1185,7 +1192,7 @@ describe('Cron', () => { let fstr = 'YYYY-MM-DD HH: mm: ss'; it('startOfDay before dayStart', () => { - let start = shared.startOfDay({ + let start = startOfDay({ now: moment('2014-10-09 02: 30: 00'), dayStart, }); @@ -1193,7 +1200,7 @@ describe('Cron', () => { expect(start.format(fstr)).to.eql('2014-10-08 04: 00: 00'); }); it('startOfDay after dayStart', () => { - let start = shared.startOfDay({ + let start = startOfDay({ now: moment('2014-10-09 05: 30: 00'), dayStart, }); @@ -1202,7 +1209,7 @@ describe('Cron', () => { }); it('daysSince cron before, now after', () => { let lastCron = moment('2014-10-09 02: 30: 00'); - let days = shared.daysSince(lastCron, { + let days = daysSince(lastCron, { now: moment('2014-10-09 11: 30: 00'), dayStart, }); @@ -1211,7 +1218,7 @@ describe('Cron', () => { }); it('daysSince cron before, now before', () => { let lastCron = moment('2014-10-09 02: 30: 00'); - let days = shared.daysSince(lastCron, { + let days = daysSince(lastCron, { now: moment('2014-10-09 03: 30: 00'), dayStart, }); @@ -1220,7 +1227,7 @@ describe('Cron', () => { }); it('daysSince cron after, now after', () => { let lastCron = moment('2014-10-09 05: 30: 00'); - let days = shared.daysSince(lastCron, { + let days = daysSince(lastCron, { now: moment('2014-10-09 06: 30: 00'), dayStart, }); @@ -1229,7 +1236,7 @@ describe('Cron', () => { }); it('daysSince cron after, now tomorrow before', () => { let lastCron = moment('2014-10-09 12: 30: 00'); - let days = shared.daysSince(lastCron, { + let days = daysSince(lastCron, { now: moment('2014-10-10 01: 30: 00'), dayStart, }); @@ -1238,7 +1245,7 @@ describe('Cron', () => { }); it('daysSince cron after, now tomorrow after', () => { let lastCron = moment('2014-10-09 12: 30: 00'); - let days = shared.daysSince(lastCron, { + let days = daysSince(lastCron, { now: moment('2014-10-10 10: 30: 00'), dayStart, }); @@ -1247,7 +1254,7 @@ describe('Cron', () => { }); xit('daysSince, last cron before new dayStart', () => { let lastCron = moment('2014-10-09 01: 00: 00'); - let days = shared.daysSince(lastCron, { + let days = daysSince(lastCron, { now: moment('2014-10-09 05: 00: 00'), dayStart, }); @@ -1266,7 +1273,7 @@ describe('Cron', () => { function runCron (options) { _.each([480, 240, 0, -120], function (timezoneOffset) { - let now = shared.startOfWeek({ + let now = startOfWeek({ timezoneOffset, }).add(options.currentHour || 0, 'hours'); @@ -1496,17 +1503,17 @@ describe('Helper', () => { let today = '2013-01-01 00: 00: 00'; let zone = moment(today).zone(); - expect(shared.startOfDay({ + expect(startOfDay({ now: new Date(2013, 0, 1, 0), }, { timezoneOffset: zone, }).format(fstr)).to.eql(today); - expect(shared.startOfDay({ + expect(startOfDay({ now: new Date(2013, 0, 1, 5), }, { timezoneOffset: zone, }).format(fstr)).to.eql(today); - expect(shared.startOfDay({ + expect(startOfDay({ now: new Date(2013, 0, 1, 23, 59, 59), timezoneOffset: zone, }).format(fstr)).to.eql(today); diff --git a/test/common/dailies.js b/test/common/dailies.js index d9c1621904..079ecb59e9 100644 --- a/test/common/dailies.js +++ b/test/common/dailies.js @@ -1,4 +1,7 @@ /* eslint-disable camelcase */ +import { + startOfWeek, +} from '../../common/script/cron'; let expect = require('expect.js'); // eslint-disable-line no-shadow let moment = require('moment'); @@ -17,7 +20,7 @@ let repeatWithoutLastWeekday = () => { // eslint-disable-line no-unused-vars s: true, }; - if (shared.startOfWeek(moment().zone(0)).isoWeekday() === 1) { + if (startOfWeek(moment().zone(0)).isoWeekday() === 1) { repeat.su = false; } else { repeat.s = false;