From 9d69d4b86322c8e52e607d9c8cea919f3100257d Mon Sep 17 00:00:00 2001 From: Asif Mallik Date: Wed, 8 Nov 2017 00:56:46 +0600 Subject: [PATCH] Implements repeat every X days since last completion (Fixes #6941) (#8962) * Implemented repeat after completion * Added tests for repeat after completion in shouldDo.test.js * Remove lastTicked * Undoes removal of website/client/README.md --- test/common/shouldDo.test.js | 62 +++++++++++++++++++ website/client/components/tasks/taskModal.vue | 6 ++ website/common/locales/en/tasks.json | 1 + website/common/script/cron.js | 28 +++++++-- website/server/libs/cron.js | 1 + website/server/models/task.js | 2 + 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/test/common/shouldDo.test.js b/test/common/shouldDo.test.js index 4159d5133e..6e85787afd 100644 --- a/test/common/shouldDo.test.js +++ b/test/common/shouldDo.test.js @@ -278,6 +278,68 @@ describe('shouldDo', () => { }); }); + context('When repeat after completion is on', () => { + beforeEach(() => { + dailyTask.repeatAfterCompletion = true; + dailyTask.everyX = 5; + day = moment('2017-05-01').toDate(); + dailyTask.startDate = day; + }); + + context('last completed is set', () => { + beforeEach(() => { + day = moment('2017-05-03').toDate(); + dailyTask.lastCompleted = day; + }); + + it('should compute daily nextDue values', () => { + options.timezoneOffset = 0; + options.nextDue = true; + + nextDue = shouldDo(day, dailyTask, options); + expect(nextDue.length).to.eql(6); + expect(moment(nextDue[0]).toDate()).to.eql(moment.utc('2017-05-08').toDate()); + expect(moment(nextDue[1]).toDate()).to.eql(moment.utc('2017-05-09').toDate()); + expect(moment(nextDue[2]).toDate()).to.eql(moment.utc('2017-05-10').toDate()); + expect(moment(nextDue[3]).toDate()).to.eql(moment.utc('2017-05-11').toDate()); + expect(moment(nextDue[4]).toDate()).to.eql(moment.utc('2017-05-12').toDate()); + expect(moment(nextDue[5]).toDate()).to.eql(moment.utc('2017-05-13').toDate()); + }); + + it('returns false before X Days passes after completion', () => { + day = moment('2017-05-05').toDate(); + expect(shouldDo(day, dailyTask, options)).to.equal(false); + }); + + it('returns true after X Days passes after completion', () => { + day = moment('2017-05-10').toDate(); + expect(shouldDo(day, dailyTask, options)).to.equal(true); + }); + }); + + context('last completed is not set', () => { + it('should compute daily nextDue values', () => { + options.timezoneOffset = 0; + options.nextDue = true; + + nextDue = shouldDo(day, dailyTask, options); + expect(nextDue.length).to.eql(6); + expect(moment(nextDue[0]).toDate()).to.eql(moment.utc('2017-05-02').toDate()); + expect(moment(nextDue[1]).toDate()).to.eql(moment.utc('2017-05-03').toDate()); + expect(moment(nextDue[2]).toDate()).to.eql(moment.utc('2017-05-04').toDate()); + expect(moment(nextDue[3]).toDate()).to.eql(moment.utc('2017-05-05').toDate()); + expect(moment(nextDue[4]).toDate()).to.eql(moment.utc('2017-05-06').toDate()); + expect(moment(nextDue[5]).toDate()).to.eql(moment.utc('2017-05-07').toDate()); + }); + + it('returns true after start date', () => { + day = moment('2017-05-04').toDate(); + expect(shouldDo(day, dailyTask, options)).to.equal(true); + }); + }); + }); + + context('If number of X days is zero', () => { beforeEach(() => { dailyTask.everyX = 0; diff --git a/website/client/components/tasks/taskModal.vue b/website/client/components/tasks/taskModal.vue index a563290d37..cd09734f56 100644 --- a/website/client/components/tasks/taskModal.vue +++ b/website/client/components/tasks/taskModal.vue @@ -95,6 +95,12 @@ input(type="number", v-model="task.everyX", min="0", required) | {{ repeatSuffix }} br + template(v-if="task.frequency === 'daily'") + .form-check + label.custom-control.custom-checkbox + input.custom-control-input(type="checkbox", v-model="task.repeatAfterCompletion") + span.custom-control-indicator + span.custom-control-description {{ $t('repeatAfterCompletionTitle', {everyX: task.everyX}) }} template(v-if="task.frequency === 'weekly'") .form-check-inline.weekday-check( v-for="(day, dayNumber) in ['su','m','t','w','th','f','s']", diff --git a/website/common/locales/en/tasks.json b/website/common/locales/en/tasks.json index c7eea26af1..76342a8dd4 100644 --- a/website/common/locales/en/tasks.json +++ b/website/common/locales/en/tasks.json @@ -169,6 +169,7 @@ "taskApprovalHasBeenRequested": "Approval has been requested", "approvals": "Approvals", "approvalRequired": "Approval Required", + "repeatAfterCompletionTitle": "Repeat Every <%= everyX %> Days Since Last Completed", "repeatZero": "Daily is never due", "repeatType": "Repeat Type", "repeatTypeHelpTitle": "What kind of repeat is this?", diff --git a/website/common/script/cron.js b/website/common/script/cron.js index fa1f2a388b..ce4c9c3f38 100644 --- a/website/common/script/cron.js +++ b/website/common/script/cron.js @@ -122,19 +122,39 @@ export function shouldDo (day, dailyTask, options = {}) { if (dailyTask.frequency === 'daily') { if (!dailyTask.everyX) return false; // error condition - let schedule = moment(startDate).recur() - .every(dailyTask.everyX).days(); + let lastCompletedDate; + if (dailyTask.repeatAfterCompletion && dailyTask.lastCompleted) { + lastCompletedDate = moment(dailyTask.lastCompleted).zone(o.timezoneOffset).startOf('day'); + } if (options.nextDue) { let filteredDates = []; for (let i = 1; filteredDates.length < 6; i++) { - let calcDate = moment(startDate).add(dailyTask.everyX * i, 'days'); + let calcDate; + if (dailyTask.repeatAfterCompletion) { + if (lastCompletedDate) { + calcDate = moment(lastCompletedDate).add(dailyTask.everyX + i - 1, 'days'); + } else { + calcDate = moment(startDate).add(i, 'days'); + } + } else { + calcDate = moment(startDate).add(dailyTask.everyX * i, 'days'); + } if (calcDate > startOfDayWithCDSTime) filteredDates.push(calcDate); } return filteredDates; } - return schedule.matches(startOfDayWithCDSTime); + if (dailyTask.repeatAfterCompletion) { + if (lastCompletedDate) { + return moment(lastCompletedDate).add(dailyTask.everyX, 'days').isSameOrBefore(startOfDayWithCDSTime); + } else { + return moment(startDate).isSameOrBefore(startOfDayWithCDSTime); + } + } else { + let schedule = moment(startDate).recur().every(dailyTask.everyX).days(); + return schedule.matches(startOfDayWithCDSTime); + } } else if (dailyTask.frequency === 'weekly') { let schedule = moment(startDate).recur(); diff --git a/website/server/libs/cron.js b/website/server/libs/cron.js index 989d8ff66c..5686948b3b 100644 --- a/website/server/libs/cron.js +++ b/website/server/libs/cron.js @@ -306,6 +306,7 @@ export function cron (options = {}) { if (dailiesDaysMissed > 1) dailiesDaysMissed = 1; if (completed) { + task.lastCompleted = moment(now).subtract({days: 1}).toDate(); dailyChecked += 1; if (!atLeastOneDailyDue) { // only bother checking until the first thing is found let thatDay = moment(now).subtract({days: daysMissed}); diff --git a/website/server/models/task.js b/website/server/models/task.js index e17809ad8a..bc2c94c260 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -224,6 +224,8 @@ export let habit = Task.discriminator('habit', HabitSchema); export let DailySchema = new Schema(_.defaults({ frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly', 'monthly', 'yearly']}, everyX: {type: Number, default: 1}, // e.g. once every X weeks + repeatAfterCompletion: {type: Boolean, default: false}, + lastCompleted: Date, startDate: { type: Date, default () {