Repeatables (#8444)

* Added initial should do weekly tests

* Added support back in for days of the week and every x day

* Added better week day mapper

* Added initial monthly

* Added every x months

* Added yearlies

* Fixed every nth weekdy of month

* Fixed tests to check every x week on weekday

* Began combining x month with nth weekday

* Added every x month combined with date and weekday

* Fixed lint issues

* Saved moment-recurr to package.json

* Added new repeat fields

* Added UI for repeatables

* Ensured only dalies are affected by summary

* Added local strings

* Updated npm shrinkwrap

* Shared day map constant

* Updated shrinkwrap

* Added ui back

* Updated copy of test cases

* Added new translation strings

* Updated shrinkwrap

* Fixed broken test

* Made should do tests static for better consitency

* Fixed issue with no repeat

* Fixed line endings

* Added frequency enum values

* Fixed spacing
This commit is contained in:
Keith Holliday
2017-02-27 15:41:21 -07:00
committed by GitHub
parent 93befcebcc
commit ef02e59590
12 changed files with 636 additions and 3407 deletions

3478
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,7 @@
"merge-stream": "^1.0.0",
"method-override": "^2.3.5",
"moment": "^2.13.0",
"moment-recur": "^1.0.5",
"mongoose": "^4.7.1",
"mongoose-id-autoinc": "~2013.7.14-4",
"morgan": "^1.7.0",

View File

@@ -496,6 +496,8 @@ describe('POST /tasks/user', () => {
frequency: 'daily',
everyX: 5,
startDate: now,
daysOfMonth: [15],
weeksOfMonth: [3],
});
expect(task.userId).to.equal(user._id);
@@ -504,6 +506,8 @@ describe('POST /tasks/user', () => {
expect(task.type).to.eql('daily');
expect(task.frequency).to.eql('daily');
expect(task.everyX).to.eql(5);
expect(task.daysOfMonth).to.eql([15]);
expect(task.weeksOfMonth).to.eql([3]);
expect(new Date(task.startDate)).to.eql(now);
});

View File

@@ -0,0 +1,310 @@
import { shouldDo, DAY_MAPPING } from '../../website/common/script/cron';
import moment from 'moment';
import 'moment-recur';
describe('shouldDo', () => {
let day, dailyTask;
let options = {};
beforeEach(() => {
day = new Date();
dailyTask = {
completed: 'false',
everyX: 1,
frequency: 'weekly',
type: 'daily',
repeat: {
su: true,
s: true,
f: true,
th: true,
w: true,
t: true,
m: true,
},
startDate: new Date(),
};
});
it('leaves Daily inactive before start date', () => {
dailyTask.startDate = moment().add(1, 'days').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
context('Every X Days', () => {
it('leaves Daily inactive in between X Day intervals', () => {
dailyTask.startDate = moment().subtract(1, 'days').toDate();
dailyTask.frequency = 'daily';
dailyTask.everyX = 2;
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('activates Daily on multiples of X Days', () => {
dailyTask.startDate = moment().subtract(7, 'days').toDate();
dailyTask.frequency = 'daily';
dailyTask.everyX = 7;
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
});
context('Certain Days of the Week', () => {
it('leaves Daily inactive if day of the week does not match', () => {
dailyTask.repeat = {
su: false,
s: false,
f: false,
th: false,
w: false,
t: false,
m: false,
};
for (let weekday of [0, 1, 2, 3, 4, 5, 6]) {
day = moment().day(weekday).toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
}
});
it('leaves Daily inactive if day of the week does not match and active on the day it matches', () => {
dailyTask.repeat = {
su: false,
s: false,
f: false,
th: true,
w: false,
t: false,
m: false,
};
for (let weekday of [0, 1, 2, 3, 4, 5, 6]) {
day = moment().add(1, 'weeks').day(weekday).toDate();
if (weekday === 4) {
expect(shouldDo(day, dailyTask, options)).to.equal(true);
} else {
expect(shouldDo(day, dailyTask, options)).to.equal(false);
}
}
});
it('activates Daily on matching days of the week', () => {
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
});
context('Every X Weeks', () => {
it('leaves daily inactive if it has not been the specified number of weeks', () => {
dailyTask.everyX = 3;
let tomorrow = moment().add(1, 'day').toDate();
expect(shouldDo(tomorrow, dailyTask, options)).to.equal(false);
});
it('leaves daily inactive if on every (x) week on weekday it is incorrect weekday', () => {
dailyTask.repeat = {
su: false,
s: false,
f: false,
th: false,
w: false,
t: false,
m: false,
};
day = moment();
dailyTask.repeat[DAY_MAPPING[day.day()]] = true;
dailyTask.everyX = 3;
let threeWeeksFromTodayPlusOne = day.add(1, 'day').add(3, 'weeks').toDate();
expect(shouldDo(threeWeeksFromTodayPlusOne, dailyTask, options)).to.equal(false);
});
it('activates Daily on matching week', () => {
dailyTask.everyX = 3;
let threeWeeksFromToday = moment().add(3, 'weeks').toDate();
expect(shouldDo(threeWeeksFromToday, dailyTask, options)).to.equal(true);
});
it('activates Daily on every (x) week on weekday', () => {
dailyTask.repeat = {
su: false,
s: false,
f: false,
th: false,
w: false,
t: false,
m: false,
};
day = moment();
dailyTask.repeat[DAY_MAPPING[day.day()]] = true;
dailyTask.everyX = 3;
let threeWeeksFromToday = day.add(6, 'weeks').day(day.day()).toDate();
expect(shouldDo(threeWeeksFromToday, dailyTask, options)).to.equal(true);
});
});
context('Monthly - Every X Months on a specified date', () => {
it('leaves daily inactive if not day of the month', () => {
dailyTask.everyX = 1;
dailyTask.frequency = 'monthly';
dailyTask.daysOfMonth = [15];
let tomorrow = moment().add(1, 'day').toDate();// @TODO: make sure this is not the 15
expect(shouldDo(tomorrow, dailyTask, options)).to.equal(false);
});
it('activates Daily on matching day of month', () => {
day = moment();
dailyTask.everyX = 1;
dailyTask.frequency = 'monthly';
dailyTask.daysOfMonth = [day.date()];
day = day.add(1, 'months').date(day.date()).toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('leaves daily inactive if not on date of the x month', () => {
dailyTask.everyX = 2;
dailyTask.frequency = 'monthly';
dailyTask.daysOfMonth = [15];
let tomorrow = moment().add(2, 'months').add(1, 'day').toDate();
expect(shouldDo(tomorrow, dailyTask, options)).to.equal(false);
});
it('activates Daily if on date of the x month', () => {
dailyTask.everyX = 2;
dailyTask.frequency = 'monthly';
dailyTask.daysOfMonth = [15];
day = moment().add(2, 'months').date(15).toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
});
context('Monthly - Certain days of the nth Week', () => {
it('leaves daily inactive if not the correct week of the month on the day of the start date', () => {
dailyTask.repeat = {
su: false,
s: false,
f: false,
th: false,
w: false,
t: false,
m: false,
};
let today = moment('01/27/2017');
let week = today.monthWeek();
let dayOfWeek = today.day();
dailyTask.startDate = today.toDate();
dailyTask.weeksOfMonth = [week];
dailyTask.repeat[DAY_MAPPING[dayOfWeek]] = true;
dailyTask.everyX = 1;
dailyTask.frequency = 'monthly';
day = moment('02/23/2017');
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('activates Daily if correct week of the month on the day of the start date', () => {
dailyTask.repeat = {
su: false,
s: false,
f: false,
th: false,
w: false,
t: false,
m: false,
};
let today = moment('01/27/2017');
let week = today.monthWeek();
let dayOfWeek = today.day();
dailyTask.startDate = today.toDate();
dailyTask.weeksOfMonth = [week];
dailyTask.repeat[DAY_MAPPING[dayOfWeek]] = true;
dailyTask.everyX = 1;
dailyTask.frequency = 'monthly';
day = moment('02/24/2017');
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
it('leaves daily inactive if not day of the month with every x month on weekday', () => {
dailyTask.repeat = {
su: false,
s: false,
f: false,
th: false,
w: false,
t: false,
m: false,
};
let today = moment('01/26/2017');
let week = today.monthWeek();
let dayOfWeek = today.day();
dailyTask.startDate = today.toDate();
dailyTask.weeksOfMonth = [week];
dailyTask.repeat[DAY_MAPPING[dayOfWeek]] = true;
dailyTask.everyX = 2;
dailyTask.frequency = 'monthly';
day = moment('03/24/2017');
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('activates Daily if on nth weekday of the x month', () => {
dailyTask.repeat = {
su: false,
s: false,
f: false,
th: false,
w: false,
t: false,
m: false,
};
let today = moment('01/27/2017');
let week = today.monthWeek();
let dayOfWeek = today.day();
dailyTask.startDate = today.toDate();
dailyTask.weeksOfMonth = [week];
dailyTask.repeat[DAY_MAPPING[dayOfWeek]] = true;
dailyTask.everyX = 2;
dailyTask.frequency = 'monthly';
day = moment('03/24/2017');
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
});
context('Every X Years', () => {
it('leaves daily inactive if not the correct year', () => {
day = moment();
dailyTask.everyX = 2;
dailyTask.frequency = 'yearly';
day = day.add(1, 'day').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(false);
});
it('activates Daily on matching year', () => {
day = moment();
dailyTask.everyX = 2;
dailyTask.frequency = 'yearly';
day = day.add(2, 'years').toDate();
expect(shouldDo(day, dailyTask, options)).to.equal(true);
});
});
});

View File

@@ -281,12 +281,108 @@ angular.module('habitrpg')
}
modalScope.cancelTaskEdit = cancelTaskEdit;
$rootScope.openModal('task-edit', {scope: modalScope })
.result.catch(function() {
cancelTaskEdit(task);
});
modalScope.task._edit.repeatsOn = 'dayOfMonth';
if (modalScope.task === 'daily' && modalScope.task._edit.weeksOfMonth.length > 0) {
modalScope.task._edit.repeatsOn = 'dayOfWeek';
}
$rootScope.openModal('task-edit', {
scope: modalScope,
controller: function ($scope) {
$scope.$watch('task._edit', function (newValue, oldValue) {
if ($scope.task.type !== 'daily') return;
$scope.summary = generateSummary($scope.task);
$scope.repeatSuffix = generateRepeatSuffix($scope.task);
if ($scope.task._edit.repeatsOn == 'dayOfMonth') {
var date = moment().date();
$scope.task._edit.weeksOfMonth = [];
$scope.task._edit.dayOfMonth = [date]; // @TODO This can handle multiple dates later
} else if ($scope.task._edit.repeatsOn == 'dayOfWeek') {
var week = Math.ceil(moment().date() / 7) - 1;
var dayOfWeek = moment().day();
var shortDay = numberToShortDay[dayOfWeek];
$scope.task._edit.dayOfMonth = [];
$scope.task._edit.weeksOfMonth = [week]; // @TODO: This can handle multiple weeks
for (var key in $scope.task._edit.repeat) {
$scope.task._edit.repeat[key] = false;
}
$scope.task._edit.repeat[shortDay] = true;
}
}, true);
},
})
.result.catch(function() {
cancelTaskEdit(task);
});
}
/*
* Summary
*/
var frequencyMap = {
'daily': 'days',
'weekly': 'weeks',
'monthly': 'months',
'yearly': 'years',
};
var shortDayToLongDayMap = {
'su': moment().day(0).format('dddd'),
's': moment().day(6).format('dddd'),
'f': moment().day(5).format('dddd'),
'th': moment().day(4).format('dddd'),
'w': moment().day(3).format('dddd'),
't': moment().day(2).format('dddd'),
'm': moment().day(1).format('dddd'),
};
var numberToShortDay = Shared.DAY_MAPPING;
function generateSummary(task) {
var frequencyPlural = frequencyMap[task._edit.frequency];
var repeatDays = '';
for (var key in task._edit.repeat) {
if (task._edit.repeat[key]) {
repeatDays += shortDayToLongDayMap[key] + ', ';
}
}
var summary = 'Repeats ' + task._edit.frequency + ' every ' + task._edit.everyX + ' ' + frequencyPlural;
if (task._edit.frequency === 'weekly') summary += ' on ' + repeatDays;
if (task._edit.frequency === 'monthly' && task._edit.repeatsOn == 'dayOfMonth') {
var date = moment().date();
summary += ' on the ' + date;
} else if (task._edit.frequency === 'monthly' && task._edit.repeatsOn == 'dayOfWeek') {
var week = Math.ceil(moment().date() / 7) - 1;
var dayOfWeek = moment().day();
var shortDay = numberToShortDay[dayOfWeek];
var longDay = shortDayToLongDayMap[shortDay];
summary += ' on the ' + (week + 1) + ' ' + longDay;
}
return summary;
}
function generateRepeatSuffix (task) {
if (task._edit.frequency === 'daily') {
return task._edit.everyX == 1 ? window.env.t('day') : window.env.t('days');
} else if (task._edit.frequency === 'weekly') {
return task._edit.everyX == 1 ? window.env.t('week') : window.env.t('weeks');
} else if (task._edit.frequency === 'monthly') {
return task._edit.everyX == 1 ? window.env.t('month') : window.env.t('months');
} else if (task._edit.frequency === 'yearly') {
return task._edit.everyX == 1 ? window.env.t('year') : window.env.t('years');
}
};
function cancelTaskEdit(task) {
task._edit = undefined;
task._editing = false;

View File

@@ -147,6 +147,20 @@
"taskApprovalHasBeenRequested": "Approval has been requested",
"approvals": "Approvals",
"approvalRequired": "Approval Required",
"weekly": "Weekly",
"monthly": "Monthly",
"yearly": "Yearly",
"onDays": "On Days",
"summary": "Summary",
"repeatsOn": "Repeats On",
"dayOfWeek": "Day of the Week",
"dayOfMonth": "Day of the Month",
"month": "Month",
"months": "Months",
"week": "Week",
"weeks": "Weeks",
"year": "Year",
"years": "Years",
"confirmScoreNotes": "Confirm task scoring with notes",
"taskScoreNotesTooLong": "Task score notes must be less than 256 characters",
"groupTasksByChallenge": "Group tasks by challenge title"

View File

@@ -6,6 +6,7 @@
*/
import _ from 'lodash';
import moment from 'moment';
import 'moment-recur';
export const DAY_MAPPING = {
0: 'su',
@@ -17,6 +18,8 @@ export const DAY_MAPPING = {
6: 's',
};
export const DAY_MAPPING_STRING_TO_NUMBER = _.invert(DAY_MAPPING);
/*
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.
@@ -88,37 +91,56 @@ export function daysSince (yesterday, options = {}) {
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 = {}) {
export function shouldDo (day, dailyTask) {
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);
let daysOfTheWeek = [];
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
if (dailyTask.repeat) {
for (let [repeatDay, active] of Object.entries(dailyTask.repeat)) {
if (active) daysOfTheWeek.push(parseInt(DAY_MAPPING_STRING_TO_NUMBER[repeatDay], 10));
}
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
}
if (dailyTask.frequency === 'daily') {
if (!dailyTask.everyX) return false; // error condition
let schedule = moment(dailyTask.startDate).recur()
.every(dailyTask.everyX).days();
return schedule.matches(day);
} else if (dailyTask.frequency === 'weekly') {
let schedule = moment(dailyTask.startDate).recur();
if (dailyTask.everyX > 1) {
schedule = schedule.every(dailyTask.everyX).weeks();
}
schedule = schedule.every(daysOfTheWeek).daysOfWeek();
return schedule.matches(day);
} else if (dailyTask.frequency === 'monthly') {
let schedule = moment(dailyTask.startDate).recur();
let differenceInMonths = moment(day).month() + 1 - moment(dailyTask.startDate).month() + 1;
let matchEveryX = differenceInMonths % dailyTask.everyX === 0;
if (dailyTask.weeksOfMonth) {
schedule = schedule.every(daysOfTheWeek).daysOfWeek()
.every(dailyTask.weeksOfMonth).weeksOfMonthByDay();
} else if (dailyTask.daysOfMonth) {
schedule = schedule.every(dailyTask.daysOfMonth).daysOfMonth();
}
return schedule.matches(day) && matchEveryX;
} else if (dailyTask.frequency === 'yearly') {
let schedule = moment(dailyTask.startDate).recur();
schedule = schedule.every(dailyTask.everyX).years();
return schedule.matches(day);
}
return false;
}

View File

@@ -13,9 +13,10 @@ import i18n from './i18n';
api.i18n = i18n;
// TODO under api.libs.cron?
import { shouldDo, daysSince } from './cron';
import { shouldDo, daysSince, DAY_MAPPING } from './cron';
api.shouldDo = shouldDo;
api.daysSince = daysSince;
api.DAY_MAPPING = DAY_MAPPING;
import {
MAX_HEALTH,

View File

@@ -1,4 +1,4 @@
import _ from 'lodash' ;
import _ from 'lodash';
import analytics from './analyticsService';
import {
getUserInfo,
@@ -6,7 +6,7 @@ import {
} from './email';
import moment from 'moment';
import { sendNotification as sendPushNotification } from './pushNotifications';
import shared from '../../common' ;
import shared from '../../common';
import {
model as Group,
basicFields as basicGroupFields,

View File

@@ -222,7 +222,7 @@ export let HabitSchema = new Schema(_.defaults({
export let habit = Task.discriminator('habit', HabitSchema);
export let DailySchema = new Schema(_.defaults({
frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly']},
frequency: {type: String, default: 'weekly', enum: ['daily', 'weekly', 'monthly', 'yearly']},
everyX: {type: Number, default: 1}, // e.g. once every X weeks
startDate: {
type: Date,
@@ -240,6 +240,8 @@ export let DailySchema = new Schema(_.defaults({
su: {type: Boolean, default: true},
},
streak: {type: Number, default: 0},
daysOfMonth: {type: [Number], default: []}, // Days of the month that the daily should repeat on
weeksOfMonth: {type: [Number], default: []}, // Weeks of the month that the daily should repeat on
}, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions);
export let daily = Task.discriminator('daily', DailySchema);

View File

@@ -27,8 +27,36 @@ div(ng-if='(task.type !== "reward") || (!obj.auth && obj.purchased && obj.purcha
hr
fieldset.option-group.advanced-option(ng-show="task._edit._advanced")
.form-group
legend.option-title=env.t('repeat')
select.form-control(ng-model='task._edit.frequency', ng-disabled='!canEdit(task)')
option(value='daily')=env.t('daily')
option(value='weekly')=env.t('weekly')
option(value='monthly')=env.t('monthly')
option(value='yearly')=env.t('yearly')
//- select.form-control(ng-model='task._edit.frequency', ng-disabled='!canEdit(task)')
//- option(value='weekly')=env.t('repeatWeek')
//- option(value='daily')=env.t('repeatDays')
include ./dailies/repeat_options
.form-group(ng-show='task._edit.frequency === "monthly"')
legend.option-title=env.t('repeatsOn')
label
input(type="radio", ng-model='task._edit.repeatsOn', value='dayOfMonth')
=env.t('dayOfMonth')
label
input(type="radio", ng-model='task._edit.repeatsOn', value='dayOfWeek')
=env.t('dayOfWeek')
.form-group
legend.option-title=env.t('summary')
div {{summary}}
hr
fieldset.option-group.advanced-option(ng-show="task._edit._advanced")
legend.option-title
a.hint.priority-multiplier-help(href='http://habitica.wikia.com/wiki/Difficulty', target='_blank', popover-title=env.t('difficultyHelpTitle'), popover-trigger='mouseenter', popover=env.t('difficultyHelpContent'))=env.t('difficulty')
ul.priority-multiplier

View File

@@ -1,21 +1,22 @@
.form-group
legend.option-title=env.t('repeat')
select.form-control(ng-model='task._edit.frequency', ng-disabled='!canEdit(task)')
option(value='weekly')=env.t('repeatWeek')
option(value='daily')=env.t('repeatDays')
//- .form-group
//- legend.option-title=env.t('repeat')
//- select.form-control(ng-model='task._edit.frequency', ng-disabled='!canEdit(task)')
//- option(value='weekly')=env.t('repeatWeek')
//- option(value='daily')=env.t('repeatDays')
legend.option-title
span.hint(popover-trigger='mouseenter', popover-title=env.t('repeatHelpTitle'),
popover='{{env.t(task._edit.frequency + "RepeatHelpContent")}}')=env.t('repeatEvery')
// If frequency is daily
ng-form.form-group(name='everyX', ng-if='task._edit.frequency=="daily"')
ng-form.form-group(name='everyX')
.input-group
input.form-control(type='number', ng-model='task._edit.everyX', min='0', ng-disabled='!canEdit(task)', required)
span.input-group-addon {{task._edit.everyX == 1 ? env.t('day') : env.t('days')}}
span.input-group-addon {{repeatSuffix}}
// If frequency is weekly
ng-form.form-group(ng-if='task._edit.frequency=="weekly"')
.form-group(ng-if='task._edit.frequency=="weekly"')
legend.option-title=env.t('onDays')
ul.repeat-days
// note, does not use data-toggle="buttons-checkbox" - it would interfere with our own click binding
mixin dayOfWeek(day, num)