From 6d0df7844168916c2a948e521eff84e73b2be9fa Mon Sep 17 00:00:00 2001 From: astolat Date: Mon, 27 Feb 2017 13:15:45 -0500 Subject: [PATCH] Habits v2: adding counter to habits (cleaned up branch) - fixes #8113 (#8198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Clean version of PR 8175 The original PR for this was here: https://github.com/HabitRPG/habitica/pull/8175 Unfortunately while fixing a conflict in tasks.json, I messed up the rebase and wound up pulling in too many commits and making a giant mess. Sorry. :P * Fixing test failure This test seems to occasionally start failing (another coder reported the same thing happening to them in the blacksmiths’ guild) because the order in which the tasks are created can sometimes not match the order in the array. So I have sorted the tasks array after creation by the task name to ensure a consistent ordering, and slightly reordered the expect statements to match. --- test/api/v3/unit/libs/cron.test.js | 61 +++++++++++++++++++ test/common/libs/taskDefaults.test.js | 3 + test/common/ops/addTask.js | 3 + test/common/ops/scoreTask.test.js | 5 ++ website/common/locales/en/tasks.json | 7 +++ website/common/script/libs/taskDefaults.js | 3 + website/common/script/ops/scoreTask.js | 10 +++ website/server/libs/cron.js | 37 ++++++++++- website/server/models/task.js | 3 + website/views/shared/footer.jade | 2 + .../shared/tasks/edit/advanced_options.jade | 2 + .../shared/tasks/edit/habits/frequency.jade | 8 +++ website/views/shared/tasks/meta_controls.jade | 8 +++ 13 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 website/views/shared/tasks/edit/habits/frequency.jade diff --git a/test/api/v3/unit/libs/cron.test.js b/test/api/v3/unit/libs/cron.test.js index 8664874b8c..ee79dd0e7c 100644 --- a/test/api/v3/unit/libs/cron.test.js +++ b/test/api/v3/unit/libs/cron.test.js @@ -481,6 +481,67 @@ describe('cron', () => { expect(tasksByType.habits[0].value).to.equal(1); }); + + describe('counters', () => { + let notStartOfWeekOrMonth = new Date(2016, 9, 28).getTime(); // a Friday + let clock; + + beforeEach(() => { + // Replace system clocks so we can get predictable results + clock = sinon.useFakeTimers(notStartOfWeekOrMonth); + }); + afterEach(() => { + return clock.restore(); + }); + + it('should reset a daily habit counter each day', () => { + tasksByType.habits[0].counterUp = 1; + tasksByType.habits[0].counterDown = 1; + + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.habits[0].counterUp).to.equal(0); + expect(tasksByType.habits[0].counterDown).to.equal(0); + }); + + it('should reset a weekly habit counter each Monday', () => { + tasksByType.habits[0].frequency = 'weekly'; + tasksByType.habits[0].counterUp = 1; + tasksByType.habits[0].counterDown = 1; + + // should not reset + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.habits[0].counterUp).to.equal(1); + expect(tasksByType.habits[0].counterDown).to.equal(1); + + // should reset + daysMissed = 8; + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.habits[0].counterUp).to.equal(0); + expect(tasksByType.habits[0].counterDown).to.equal(0); + }); + + it('should reset a monthly habit counter the first day of each month', () => { + tasksByType.habits[0].frequency = 'monthly'; + tasksByType.habits[0].counterUp = 1; + tasksByType.habits[0].counterDown = 1; + + // should not reset + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.habits[0].counterUp).to.equal(1); + expect(tasksByType.habits[0].counterDown).to.equal(1); + + // should reset + daysMissed = 32; + cron({user, tasksByType, daysMissed, analytics}); + + expect(tasksByType.habits[0].counterUp).to.equal(0); + expect(tasksByType.habits[0].counterDown).to.equal(0); + }); + }); }); describe('perfect day', () => { diff --git a/test/common/libs/taskDefaults.test.js b/test/common/libs/taskDefaults.test.js index edc151ff67..756447a8df 100644 --- a/test/common/libs/taskDefaults.test.js +++ b/test/common/libs/taskDefaults.test.js @@ -12,6 +12,9 @@ describe('taskDefaults', () => { expect(task.up).to.eql(true); expect(task.down).to.eql(true); expect(task.history).to.eql([]); + expect(task.frequency).to.equal('daily'); + expect(task.counterUp).to.equal(0); + expect(task.counterDown).to.equal(0); }); it('applies defaults to a daily', () => { diff --git a/test/common/ops/addTask.js b/test/common/ops/addTask.js index ebe9c1a563..771b09a2f5 100644 --- a/test/common/ops/addTask.js +++ b/test/common/ops/addTask.js @@ -33,6 +33,9 @@ describe('shared.ops.addTask', () => { expect(habit.down).to.equal(false); expect(habit.history).to.eql([]); expect(habit.checklist).to.not.exist; + expect(habit.frequency).to.equal('daily'); + expect(habit.counterUp).to.equal(0); + expect(habit.counterDown).to.equal(0); }); it('adds an habtit when type is invalid', () => { diff --git a/test/common/ops/scoreTask.test.js b/test/common/ops/scoreTask.test.js index 99bec71b75..e5466105bf 100644 --- a/test/common/ops/scoreTask.test.js +++ b/test/common/ops/scoreTask.test.js @@ -138,6 +138,9 @@ describe('shared.ops.scoreTask', () => { todo = generateTodo({ userId: ref.afterUser._id, text: 'some todo' }); expect(habit.history.length).to.eql(0); + expect(habit.frequency).to.equal('daily'); + expect(habit.counterUp).to.equal(0); + expect(habit.counterDown).to.equal(0); // before and after are the same user expect(ref.beforeUser._id).to.exist; @@ -202,6 +205,7 @@ describe('shared.ops.scoreTask', () => { expect(habit.history.length).to.eql(1); expect(habit.value).to.be.greaterThan(0); + expect(habit.counterUp).to.equal(5); expect(ref.afterUser.stats.hp).to.eql(50); expect(ref.afterUser.stats.exp).to.be.greaterThan(ref.beforeUser.stats.exp); @@ -213,6 +217,7 @@ describe('shared.ops.scoreTask', () => { expect(habit.history.length).to.eql(1); expect(habit.value).to.be.lessThan(0); + expect(habit.counterDown).to.equal(5); expect(ref.afterUser.stats.hp).to.be.lessThan(ref.beforeUser.stats.hp); expect(ref.afterUser.stats.exp).to.eql(0); diff --git a/website/common/locales/en/tasks.json b/website/common/locales/en/tasks.json index 8899fb6bab..55b0e098ee 100644 --- a/website/common/locales/en/tasks.json +++ b/website/common/locales/en/tasks.json @@ -136,6 +136,13 @@ "intelligenceExample": "Relating to academic or mentally challenging pursuits", "perceptionExample": "Relating to work or financial tasks", "constitutionExample": "Relating to health, wellness, and social interaction", + "counterPeriod": "Counter Resets Every", + "counterPeriodDay": "Day", + "counterPeriodWeek": "Week", + "counterPeriodMonth": "Month", + "habitCounter": "Counter", + "habitCounterUp": "Positive Counter", + "habitCounterDown": "Negative Counter", "taskRequiresApproval": "This task must be approved before you can complete it. Approval has already been requested", "taskApprovalHasBeenRequested": "Approval has been requested", "approvals": "Approvals", diff --git a/website/common/script/libs/taskDefaults.js b/website/common/script/libs/taskDefaults.js index e6bdba3def..f5d8c4ad85 100644 --- a/website/common/script/libs/taskDefaults.js +++ b/website/common/script/libs/taskDefaults.js @@ -49,6 +49,9 @@ module.exports = function taskDefaults (task = {}) { _.defaults(task, { up: true, down: true, + frequency: 'daily', + counterUp: 0, + counterDown: 0, }); } diff --git a/website/common/script/ops/scoreTask.js b/website/common/script/ops/scoreTask.js index 92d13b414b..8e3a04958f 100644 --- a/website/common/script/ops/scoreTask.js +++ b/website/common/script/ops/scoreTask.js @@ -172,6 +172,14 @@ function _changeTaskValue (user, task, direction, times, cron) { return addToDelta; } +function _updateCounter (task, direction, times) { + if (direction === 'up') { + task.counterUp += times; + } else { + task.counterDown += times; + } +} + module.exports = function scoreTask (options = {}, req = {}) { let {user, task, direction, times = 1, cron = false} = options; let delta = 0; @@ -207,6 +215,8 @@ module.exports = function scoreTask (options = {}, req = {}) { date: Number(new Date()), value: task.value, }); + + _updateCounter(task, direction, times); } else if (task.type === 'daily') { if (cron) { delta += _changeTaskValue(user, task, direction, times, cron); diff --git a/website/server/libs/cron.js b/website/server/libs/cron.js index 842303d51b..86e3fa03c2 100644 --- a/website/server/libs/cron.js +++ b/website/server/libs/cron.js @@ -316,8 +316,41 @@ export function cron (options = {}) { } }); - // move singleton Habits towards yellow. - tasksByType.habits.forEach((task) => { // slowly reset 'onlies' value to 0 + // check if we've passed a day on which we should reset the habit counters, including today + let resetWeekly = false; + let resetMonthly = false; + for (let i = 0; i <= daysMissed; i++) { + if (resetWeekly === true && resetMonthly === true) { + break; + } + let thatDay = moment(now).subtract({days: i}).toDate(); + if (thatDay.getDay() === 1) { + resetWeekly = true; + } + if (thatDay.getDate() === 1) { + resetMonthly = true; + } + } + + tasksByType.habits.forEach((task) => { + // reset counters if appropriate + + // this enormously clunky thing brought to you by lint + let reset = false; + if (task.frequency === 'daily') { + reset = true; + } else if (task.frequency === 'weekly' && resetWeekly === true) { + reset = true; + } else if (task.frequency === 'monthly' && resetMonthly === true) { + reset = true; + } + if (reset === true) { + task.counterUp = 0; + task.counterDown = 0; + } + + // slowly reset value to 0 for "onlies" (Habits with + or - but not both) + // move singleton Habits towards yellow. if (task.up === false || task.down === false) { task.value = Math.abs(task.value) < 0.1 ? 0 : task.value = task.value / 2; } diff --git a/website/server/models/task.js b/website/server/models/task.js index 83d17eb009..b1af8efc6a 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -214,6 +214,9 @@ let dailyTodoSchema = () => { export let HabitSchema = new Schema(_.defaults({ up: {type: Boolean, default: true}, down: {type: Boolean, default: true}, + counterUp: {type: Number, default: 0}, + counterDown: {type: Number, default: 0}, + frequency: {type: String, default: 'daily', enum: ['daily', 'weekly', 'monthly']}, }, habitDailySchema()), subDiscriminatorOptions); export let habit = Task.discriminator('habit', HabitSchema); diff --git a/website/views/shared/footer.jade b/website/views/shared/footer.jade index 2f042084b1..aec92fc3c3 100644 --- a/website/views/shared/footer.jade +++ b/website/views/shared/footer.jade @@ -93,6 +93,8 @@ footer.footer(ng-controller='FooterCtrl') a.btn.btn-default(ng-click='setHealthLow()') Health = 1 a.btn.btn-default(ng-click='addMissedDay(1)') +1 Missed Day a.btn.btn-default(ng-click='addMissedDay(2)') +2 Missed Days + a.btn.btn-default(ng-click='addMissedDay(8)') +8 Missed Days + a.btn.btn-default(ng-click='addMissedDay(32)') +32 Missed Days a.btn.btn-default(ng-click='addTenGems()') +10 Gems a.btn.btn-default(ng-click='addHourglass()') +1 Mystic Hourglass a.btn.btn-default(ng-click='addGold()') +500GP diff --git a/website/views/shared/tasks/edit/advanced_options.jade b/website/views/shared/tasks/edit/advanced_options.jade index 9549b12874..4173a1151c 100644 --- a/website/views/shared/tasks/edit/advanced_options.jade +++ b/website/views/shared/tasks/edit/advanced_options.jade @@ -9,6 +9,8 @@ div(ng-if='(task.type !== "reward") || (!obj.auth && obj.purchased && obj.purcha a.hint(href='http://habitica.wikia.com/wiki/Task_Alias', target='_blank', popover-trigger='mouseenter', popover="{{::env.t('taskAliasPopover')}} {{::task._edit.alias ? '\n\n\' + env.t('taskAliasPopoverWarning') : ''}}")=env.t('taskAlias') input.form-control(ng-model='task._edit.alias' type='text' placeholder=env.t('taskAliasPlaceholder')) + include ./habits/frequency + fieldset.option-group.advanced-option(ng-show="task._edit._advanced", ng-if="!obj.auth && obj.purchased && obj.purchased.active") group-tasks-actions(task='task', group='obj') diff --git a/website/views/shared/tasks/edit/habits/frequency.jade b/website/views/shared/tasks/edit/habits/frequency.jade new file mode 100644 index 0000000000..e10d671af3 --- /dev/null +++ b/website/views/shared/tasks/edit/habits/frequency.jade @@ -0,0 +1,8 @@ +fieldset.option-group.counter_period(ng-if='task.type === "habit" && canEdit(task)') + .form-group + legend.option-title=env.t('counterPeriod') + select.form-control(ng-model='task._edit.frequency', ng-disabled='!canEdit(task)') + option(value='daily')=env.t('counterPeriodDay') + option(value='weekly')=env.t('counterPeriodWeek') + option(value='monthly')=env.t('counterPeriodMonth') + \ No newline at end of file diff --git a/website/views/shared/tasks/meta_controls.jade b/website/views/shared/tasks/meta_controls.jade index 96e0f5caca..1fd153f15c 100644 --- a/website/views/shared/tasks/meta_controls.jade +++ b/website/views/shared/tasks/meta_controls.jade @@ -1,5 +1,13 @@ .task-meta-controls + // Counter + span(ng-if='task.up && task.down') + span(tooltip=env.t('habitCounterUp')) +{{task.counterUp}}| + span(tooltip=env.t('habitCounterDown')) -{{task.counterDown}}  + + span(ng-if='task.type=="habit" && (!task.up || !task.down)') + span(tooltip=env.t('habitCounter')) {{task.up ? task.counterUp : task.counterDown}}  + // Due Date span(ng-if='task.type=="todo" && task.date') span(ng-class='{"label label-danger":(moment(task.date).isBefore(_today, "days") && !task.completed)}') {{task.date | date:(user.preferences.dateFormat.indexOf('yyyy') == 0 ? user.preferences.dateFormat.substr(5) : user.preferences.dateFormat.substr(0,5))}}