Enabled repeatables (#8572)

* Enabled repeatables

* Added every x to weekly

* Updated new recur logic to work with tests

* Added repeatable tests back

* Added custom day start support

* Moved back to zone function

* Added zone back

* Added nextDue field

* Abstracted set next due logic, set offset, and mapped to ISO

* Removed extra codes

* Removed clone deep

* Added summary local

* Fixed every x weekly

* Prevented edit of repeats on

* Added next due date

* Fixed display of next due dates

* Fixed broken tests

* added next due date as today for weekly

* Fixed integration tests

* Updated common test

* Use user's format

* Allow user to deselect all days during week

* Removed let from front end
This commit is contained in:
Keith Holliday
2017-05-24 18:49:33 -06:00
committed by Sabe Jones
parent ba66a1c098
commit cc532fa993
14 changed files with 993 additions and 269 deletions

View File

@@ -215,6 +215,13 @@ describe('POST /tasks/:id/score/:direction', () => {
expect(task.isDue).to.equal(true); expect(task.isDue).to.equal(true);
}); });
it('computes nextDue', async () => {
await user.post(`/tasks/${daily._id}/score/up`);
let task = await user.get(`/tasks/${daily._id}`);
expect(task.nextDue.length).to.eql(6);
});
it('scores up daily even if it is already completed'); // Yes? it('scores up daily even if it is already completed'); // Yes?
it('scores down daily even if it is already uncompleted'); // Yes? it('scores down daily even if it is already uncompleted'); // Yes?

View File

@@ -510,6 +510,7 @@ describe('POST /tasks/user', () => {
expect(task.weeksOfMonth).to.eql([3]); expect(task.weeksOfMonth).to.eql([3]);
expect(new Date(task.startDate)).to.eql(now); expect(new Date(task.startDate)).to.eql(now);
expect(task.isDue).to.be.true; expect(task.isDue).to.be.true;
expect(task.nextDue.length).to.eql(6);
}); });
it('creates multiple dailys', async () => { it('creates multiple dailys', async () => {

View File

@@ -404,6 +404,7 @@ describe('PUT /tasks/:id', () => {
expect(savedDaily.frequency).to.eql('daily'); expect(savedDaily.frequency).to.eql('daily');
expect(savedDaily.everyX).to.eql(5); expect(savedDaily.everyX).to.eql(5);
expect(savedDaily.isDue).to.be.false; expect(savedDaily.isDue).to.be.false;
expect(savedDaily.nextDue.length).to.eql(6);
}); });
it('can update checklists (replace it)', async () => { it('can update checklists (replace it)', async () => {

View File

@@ -366,6 +366,14 @@ describe('cron', () => {
expect(tasksByType.dailys[0].isDue).to.be.false; expect(tasksByType.dailys[0].isDue).to.be.false;
}); });
it('computes nextDue', () => {
tasksByType.dailys[0].frequency = 'daily';
tasksByType.dailys[0].everyX = 5;
tasksByType.dailys[0].startDate = moment().add(1, 'days').toDate();
cron({user, tasksByType, daysMissed, analytics});
expect(tasksByType.dailys[0].nextDue.length).to.eql(6);
});
it('should add history', () => { it('should add history', () => {
cron({user, tasksByType, daysMissed, analytics}); cron({user, tasksByType, daysMissed, analytics});
expect(tasksByType.dailys[0].history).to.be.lengthOf(1); expect(tasksByType.dailys[0].history).to.be.lengthOf(1);

File diff suppressed because it is too large Load Diff

View File

@@ -263,6 +263,7 @@ angular.module('habitrpg')
modalScope.task._tags = !user.preferences.tagsCollapsed; modalScope.task._tags = !user.preferences.tagsCollapsed;
modalScope.task._advanced = !user.preferences.advancedCollapsed; modalScope.task._advanced = !user.preferences.advancedCollapsed;
modalScope.task._edit = angular.copy(task); modalScope.task._edit = angular.copy(task);
modalScope.user = user;
if($rootScope.charts[task._id]) $rootScope.charts[task.id] = false; if($rootScope.charts[task._id]) $rootScope.charts[task.id] = false;
modalScope.taskStatus = taskStatus; modalScope.taskStatus = taskStatus;
@@ -294,6 +295,7 @@ angular.module('habitrpg')
$scope.$watch('task._edit', function (newValue, oldValue) { $scope.$watch('task._edit', function (newValue, oldValue) {
if ($scope.task.type !== 'daily' || !task._edit) return; if ($scope.task.type !== 'daily' || !task._edit) return;
$scope.summary = generateSummary($scope.task); $scope.summary = generateSummary($scope.task);
$scope.nextDue = generateNextDue($scope.task._edit, $scope.user);
$scope.repeatSuffix = generateRepeatSuffix($scope.task); $scope.repeatSuffix = generateRepeatSuffix($scope.task);
if ($scope.task._edit.repeatsOn == 'dayOfMonth') { if ($scope.task._edit.repeatsOn == 'dayOfMonth') {
@@ -352,7 +354,11 @@ angular.module('habitrpg')
} }
} }
var summary = 'Repeats ' + task._edit.frequency + ' every ' + task._edit.everyX + ' ' + frequencyPlural; var summary = window.env.t('summaryStart', {
frequency: task._edit.frequency,
everyX: task._edit.everyX,
frequencyPlural: frequencyPlural,
});
if (task._edit.frequency === 'weekly') summary += ' on ' + repeatDays; if (task._edit.frequency === 'weekly') summary += ' on ' + repeatDays;
@@ -381,9 +387,24 @@ angular.module('habitrpg')
} else if (task._edit.frequency === 'yearly') { } else if (task._edit.frequency === 'yearly') {
return task._edit.everyX == 1 ? window.env.t('year') : window.env.t('years'); return task._edit.everyX == 1 ? window.env.t('year') : window.env.t('years');
} }
}; };
function generateNextDue (task, user) {
var options = angular.copy(user);
options.nextDue = true;
var nextDueDates = Shared.shouldDo(new Date, task, options);
if (!nextDueDates) return '';
var dateFormat = 'MM-DD-YYYY';
if (user.preferences.dateFormat) dateFormat = user.preferences.dateFormat.toUpperCase();
var nextDue = nextDueDates.map(function (date) {
return date.format(dateFormat);
});
return nextDue.join(', ');
}
function cancelTaskEdit(task) { function cancelTaskEdit(task) {
task._edit = undefined; task._edit = undefined;
task._editing = false; task._editing = false;

View File

@@ -167,5 +167,7 @@
"taskNotes": "Task Notes", "taskNotes": "Task Notes",
"monthlyRepeatHelpContent": "This task will be due every X months", "monthlyRepeatHelpContent": "This task will be due every X months",
"yearlyRepeatHelpContent": "This task will be due every X years", "yearlyRepeatHelpContent": "This task will be due every X years",
"resets": "Resets" "resets": "Resets",
"summaryStart": "Repeats <%= frequency %> every <%= everyX %> <%= frequencyPlural %> ",
"nextDue": "Next Due Dates"
} }

View File

@@ -4,8 +4,10 @@
Cron and time / day functions Cron and time / day functions
------------------------------------------------------ ------------------------------------------------------
*/ */
import _ from 'lodash'; // eslint-disable-line lodash/import-scope import defaults from 'lodash/defaults';
import invert from 'lodash/invert';
import moment from 'moment'; import moment from 'moment';
import 'moment-recur';
export const DAY_MAPPING = { export const DAY_MAPPING = {
0: 'su', 0: 'su',
@@ -17,6 +19,8 @@ export const DAY_MAPPING = {
6: 's', 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. 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. Specifically {dayStart} (custom day start) and {timezoneOffset}. This function sanitizes / defaults those values.
@@ -25,13 +29,14 @@ export const DAY_MAPPING = {
function sanitizeOptions (o) { function sanitizeOptions (o) {
let ref = Number(o.dayStart || 0); let ref = Number(o.dayStart || 0);
let dayStart = !_.isNaN(ref) && ref >= 0 && ref <= 24 ? ref : 0; let dayStart = !Number.isNaN(ref) && ref >= 0 && ref <= 24 ? ref : 0;
let timezoneOffset; let timezoneOffset;
let timezoneOffsetDefault = Number(moment().zone()); let timezoneOffsetDefault = Number(moment().zone());
if (_.isFinite(o.timezoneOffsetOverride)) {
if (isFinite(o.timezoneOffsetOverride)) {
timezoneOffset = Number(o.timezoneOffsetOverride); timezoneOffset = Number(o.timezoneOffsetOverride);
} else if (_.isFinite(o.timezoneOffset)) { } else if (Number.isFinite(o.timezoneOffset)) {
timezoneOffset = Number(o.timezoneOffset); timezoneOffset = Number(o.timezoneOffset);
} else { } else {
timezoneOffset = timezoneOffsetDefault; timezoneOffset = timezoneOffsetDefault;
@@ -81,7 +86,7 @@ export function startOfDay (options = {}) {
export function daysSince (yesterday, options = {}) { export function daysSince (yesterday, options = {}) {
let o = sanitizeOptions(options); let o = sanitizeOptions(options);
return startOfDay(_.defaults({ now: o.now }, o)).diff(startOfDay(_.defaults({ now: yesterday }, o)), 'days'); return startOfDay(defaults({ now: o.now }, o)).diff(startOfDay(defaults({ now: yesterday }, o)), 'days');
} }
/* /*
@@ -93,32 +98,94 @@ export function shouldDo (day, dailyTask, options = {}) {
return false; return false;
} }
let o = sanitizeOptions(options); let o = sanitizeOptions(options);
let startOfDayWithCDSTime = startOfDay(_.defaults({ now: day }, o)); 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. // 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. // 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. // 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'); let startDate = moment(dailyTask.startDate).zone(o.timezoneOffset).startOf('day');
if (taskStartDate > startOfDayWithCDSTime.startOf('day')) {
if (startDate > startOfDayWithCDSTime.startOf('day') && !options.nextDue) {
return false; // Daily starts in the future 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; let daysOfTheWeek = [];
} 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]]; if (dailyTask.repeat) {
} else { for (let [repeatDay, active] of Object.entries(dailyTask.repeat)) {
return false; // error condition - unexpected frequency string if (active) daysOfTheWeek.push(parseInt(DAY_MAPPING_STRING_TO_NUMBER[repeatDay], 10));
}
} }
}
if (dailyTask.frequency === 'daily') {
if (!dailyTask.everyX) return false; // error condition
let schedule = moment(startDate).recur()
.every(dailyTask.everyX).days();
if (options.nextDue) return schedule.fromDate(startOfDayWithCDSTime).next(6);
return schedule.matches(startOfDayWithCDSTime);
} else if (dailyTask.frequency === 'weekly') {
let schedule = moment(startDate).recur();
let differenceInWeeks = moment(startOfDayWithCDSTime).week() - moment(startDate).week();
let matchEveryX = differenceInWeeks % dailyTask.everyX === 0;
if (daysOfTheWeek.length === 0) return false;
schedule = schedule.every(daysOfTheWeek).daysOfWeek();
if (options.nextDue) {
let dates = schedule.fromDate(startOfDayWithCDSTime.subtract('1', 'days')).next(6);
let filterDates = dates.filter((momentDate) => {
let weekDiff = momentDate.week() - moment(startDate).week();
let matchX = weekDiff % dailyTask.everyX === 0;
return matchX;
});
return filterDates;
}
return schedule.matches(startOfDayWithCDSTime) && matchEveryX;
} else if (dailyTask.frequency === 'monthly') {
let schedule = moment(startDate).recur();
let differenceInMonths = moment(startOfDayWithCDSTime).month() - moment(startDate).month();
let matchEveryX = differenceInMonths % dailyTask.everyX === 0;
if (dailyTask.weeksOfMonth && dailyTask.weeksOfMonth.length > 0) {
schedule = schedule.every(daysOfTheWeek).daysOfWeek()
.every(dailyTask.weeksOfMonth).weeksOfMonthByDay();
} else if (dailyTask.daysOfMonth && dailyTask.daysOfMonth.length > 0) {
schedule = schedule.every(dailyTask.daysOfMonth).daysOfMonth();
}
if (options.nextDue) {
let dates = schedule.fromDate(startOfDayWithCDSTime).next(6);
let filterDates = dates.filter((momentDate) => {
let monthDiff = momentDate.month() - moment(startDate).month();
let matchX = monthDiff % dailyTask.everyX === 0;
return matchX;
});
return filterDates;
}
return schedule.matches(startOfDayWithCDSTime) && matchEveryX;
} else if (dailyTask.frequency === 'yearly') {
let schedule = moment(startDate).recur();
schedule = schedule.every(dailyTask.everyX).years();
if (options.nextDue) {
let dates = schedule.fromDate(startOfDayWithCDSTime).next(6);
let filterDates = dates.filter((momentDate) => {
let monthDiff = momentDate.years() - moment(startDate).years();
let matchX = monthDiff % dailyTask.everyX === 0;
return matchX;
});
return filterDates;
}
return schedule.matches(startOfDayWithCDSTime);
}
return false;
}

View File

@@ -17,6 +17,7 @@ import {
createTasks, createTasks,
getTasks, getTasks,
moveTask, moveTask,
setNextDue,
} from '../../libs/taskManager'; } from '../../libs/taskManager';
import common from '../../../common'; import common from '../../../common';
import Bluebird from 'bluebird'; import Bluebird from 'bluebird';
@@ -456,9 +457,7 @@ api.updateTask = {
task.group.approval.required = true; task.group.approval.required = true;
} }
if (sanitizedObj.type === 'daily') { setNextDue(task, user);
task.isDue = common.shouldDo(Date.now(), sanitizedObj, user.preferences);
}
let savedTask = await task.save(); let savedTask = await task.save();
@@ -589,6 +588,8 @@ api.scoreTask = {
} }
} }
setNextDue(task, user);
if (user._ABtests && user._ABtests.guildReminder && user._ABtests.counter !== -1) { if (user._ABtests && user._ABtests.guildReminder && user._ABtests.counter !== -1) {
user._ABtests.counter++; user._ABtests.counter++;
if (user._ABtests.counter > 1) { if (user._ABtests.counter > 1) {
@@ -601,10 +602,6 @@ api.scoreTask = {
user.markModified('_ABtests'); user.markModified('_ABtests');
} }
if (task.type === 'daily') {
task.isDue = common.shouldDo(Date.now(), task, user.preferences);
}
let results = await Bluebird.all([ let results = await Bluebird.all([
user.save(), user.save(),
task.save(), task.save(),

View File

@@ -4,6 +4,7 @@ import { model as User } from '../models/user';
import common from '../../common/'; import common from '../../common/';
import { preenUserHistory } from '../libs/preening'; import { preenUserHistory } from '../libs/preening';
import _ from 'lodash'; import _ from 'lodash';
import cloneDeep from 'lodash/cloneDeep';
import nconf from 'nconf'; import nconf from 'nconf';
const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true'; const CRON_SAFE_MODE = nconf.get('CRON_SAFE_MODE') === 'true';
@@ -314,7 +315,15 @@ export function cron (options = {}) {
value: task.value, value: task.value,
}); });
task.completed = false; task.completed = false;
task.isDue = common.shouldDo(Date.now(), task, user.preferences);
let optionsForShouldDo = cloneDeep(user.preferences.toObject());
task.isDue = common.shouldDo(now, task, optionsForShouldDo);
optionsForShouldDo.nextDue = true;
let nextDue = common.shouldDo(now, task, optionsForShouldDo);
if (nextDue && nextDue.length > 0) {
task.nextDue = nextDue;
}
if (completed || scheduleMisses > 0) { if (completed || scheduleMisses > 0) {
if (task.checklist) { if (task.checklist) {

View File

@@ -22,6 +22,19 @@ async function _validateTaskAlias (tasks, res) {
}); });
} }
export function setNextDue (task, user) {
if (task.type !== 'daily') return;
let optionsForShouldDo = user.preferences.toObject();
task.isDue = shared.shouldDo(Date.now(), task, optionsForShouldDo);
optionsForShouldDo.nextDue = true;
let nextDue = shared.shouldDo(Date.now(), task, optionsForShouldDo);
if (nextDue && nextDue.length > 0) {
task.nextDue = nextDue.map((dueDate) => {
return dueDate.toISOString();
});
}
}
/** /**
* Creates tasks for a user, challenge or group. * Creates tasks for a user, challenge or group.
@@ -64,7 +77,7 @@ export async function createTasks (req, res, options = {}) {
newTask.userId = user._id; newTask.userId = user._id;
} }
if (newTask.type === 'daily') newTask.isDue = shared.shouldDo(Date.now(), newTask, user.preferences); setNextDue(newTask, user);
// Validate that the task is valid and throw if it isn't // Validate that the task is valid and throw if it isn't
// otherwise since we're saving user/challenge/group and task in parallel it could save the user/challenge/group with a tasksOrder that doens't match reality // otherwise since we're saving user/challenge/group and task in parallel it could save the user/challenge/group with a tasksOrder that doens't match reality

View File

@@ -92,7 +92,7 @@ export let TaskSchema = new Schema({
}, discriminatorOptions)); }, discriminatorOptions));
TaskSchema.plugin(baseModel, { TaskSchema.plugin(baseModel, {
noSet: ['challenge', 'userId', 'completed', 'history', 'dateCompleted', '_legacyId', 'group', 'isDue'], noSet: ['challenge', 'userId', 'completed', 'history', 'dateCompleted', '_legacyId', 'group', 'isDue', 'nextDue'],
sanitizeTransform (taskObj) { sanitizeTransform (taskObj) {
if (taskObj.type && taskObj.type !== 'reward') { // value should be settable directly only for rewards if (taskObj.type && taskObj.type !== 'reward') { // value should be settable directly only for rewards
delete taskObj.value; delete taskObj.value;
@@ -243,6 +243,7 @@ export let DailySchema = new Schema(_.defaults({
daysOfMonth: {type: [Number], default: []}, // Days of the month that the daily should repeat on 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 weeksOfMonth: {type: [Number], default: []}, // Weeks of the month that the daily should repeat on
isDue: {type: Boolean}, isDue: {type: Boolean},
nextDue: [{type: String}],
}, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions); }, habitDailySchema(), dailyTodoSchema()), subDiscriminatorOptions);
export let daily = Task.discriminator('daily', DailySchema); export let daily = Task.discriminator('daily', DailySchema);

View File

@@ -1,4 +1,4 @@
.form-group(ng-if='task._edit.frequency !== "weekly"') .form-group
legend.option-title legend.option-title
span.hint(popover-trigger='mouseenter', popover-title=env.t('repeatHelpTitle'), span.hint(popover-trigger='mouseenter', popover-title=env.t('repeatHelpTitle'),
popover='{{env.t(task._edit.frequency + "RepeatHelpContent")}}')=env.t('repeatEvery') popover='{{env.t(task._edit.frequency + "RepeatHelpContent")}}')=env.t('repeatEvery')

View File

@@ -8,27 +8,31 @@ fieldset.option-group.advanced-option(ng-show="task.type === 'daily'")
br br
//- 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)') select.form-control(ng-model='task._edit.frequency', ng-disabled='!canEdit(task)')
option(value='weekly')=env.t('repeatWeek') option(value='daily')=env.t('daily')
option(value='daily')=env.t('repeatDays') 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 include ./dailies/repeat_options
.form-group(ng-show='task._edit.frequency === "monthly"') .form-group(ng-show='task._edit.frequency === "monthly"')
legend.option-title=env.t('repeatsOn') legend.option-title=env.t('repeatsOn')
label label
input(type="radio", ng-model='task._edit.repeatsOn', value='dayOfMonth') input(type="radio", ng-model='task._edit.repeatsOn', value='dayOfMonth', ng-disabled='!canEdit(task)')
=env.t('dayOfMonth') =env.t('dayOfMonth')
label label
input(type="radio", ng-model='task._edit.repeatsOn', value='dayOfWeek') input(type="radio", ng-model='task._edit.repeatsOn', value='dayOfWeek', ng-disabled='!canEdit(task)')
=env.t('dayOfWeek') =env.t('dayOfWeek')
//- .form-group .form-group
//- legend.option-title=env.t('summary') legend.option-title=env.t('summary')
//- div {{summary}} div {{summary}}
.form-group(ng-if='nextDue')
legend.option-title=env.t('nextDue')
div {{nextDue}}