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