Habits: store one history entry per day (#10442)

* initial refactor

* add scoredUp and scoredDown values for habits history entries, one entry per habit per day

* fix lint and add initial migration

* update old test

* remove scoreNotes

* dry run for migration

* migration fixes

* update migration and remove old test

* fix

* add challenges migration (read only)

* fix challenges migration

* handle custom day start

* update tasks in migration

* scoring: support cds

* add new test
This commit is contained in:
Matteo Pagliazzi
2018-06-21 21:25:19 +02:00
committed by GitHub
parent 8437b916c4
commit c1bd7f5dc5
11 changed files with 421 additions and 67 deletions

View File

@@ -17,5 +17,5 @@ function setUpServer () {
setUpServer();
// Replace this with your migration
const processUsers = require('./users/mystery-items.js');
const processUsers = require('./tasks/habits-one-history-entry-per-day-challenges.js');
processUsers();

View File

@@ -0,0 +1,138 @@
// const migrationName = 'habits-one-history-entry-per-day';
// const authorName = 'paglias'; // in case script author needs to know when their ...
// const authorUuid = 'ed4c688c-6652-4a92-9d03-a5a79844174a'; // ... own data is done
/*
* Iterates over all habits and condense multiple history entries for the same day into a single entry
*/
const monk = require('monk');
const _ = require('lodash');
const moment = require('moment');
const connectionString = 'mongodb://localhost:27017/habitrpg?auto_reconnect=true'; // FOR TEST DATABASE
const dbTasks = monk(connectionString).get('tasks', { castIds: false });
function processChallengeHabits (lastId) {
let query = {
'challenge.id': {$exists: true},
userId: {$exists: false},
type: 'habit',
};
if (lastId) {
query._id = {
$gt: lastId,
};
}
dbTasks.find(query, {
sort: {_id: 1},
limit: 500,
})
.then(updateChallengeHabits)
.catch((err) => {
console.log(err);
return exiting(1, `ERROR! ${ err}`);
});
}
let progressCount = 1000;
let count = 0;
function updateChallengeHabits (habits) {
if (!habits || habits.length === 0) {
console.warn('All appropriate challenge habits found and modified.');
displayData();
return;
}
let habitsPromises = habits.map(updateChallengeHabit);
let lastHabit = habits[habits.length - 1];
return Promise.all(habitsPromises)
.then(() => {
return processChallengeHabits(lastHabit._id);
});
}
function updateChallengeHabit (habit) {
count++;
if (habit && habit.history && habit.history.length > 0) {
// First remove missing entries
habit.history = habit.history.filter(entry => Boolean(entry));
habit.history = _.chain(habit.history)
// processes all entries to identify an up or down score
.forEach((entry, index) => {
if (index === 0) { // first entry doesn't have a previous one
// first value < 0 identifies a negative score as the first action
entry.scoreDirection = entry.value >= 0 ? 'up' : 'down';
} else {
// could be missing if the previous entry was null and thus excluded
const previousEntry = habit.history[index - 1];
const previousValue = previousEntry.value;
entry.scoreDirection = entry.value > previousValue ? 'up' : 'down';
}
})
.groupBy(entry => { // group entries by aggregateBy
return moment(entry.date).format('YYYYMMDD');
})
.toPairs() // [key, entry]
.sortBy(([key]) => key) // sort by date
.map(keyEntryPair => {
let entries = keyEntryPair[1]; // 1 is entry, 0 is key
let scoredUp = 0;
let scoredDown = 0;
entries.forEach(entry => {
if (entry.scoreDirection === 'up') {
scoredUp += 1;
} else {
scoredDown += 1;
}
// delete the unnecessary scoreDirection and scoreNotes prop
delete entry.scoreDirection;
delete entry.scoreNotes;
});
return {
date: Number(entries[entries.length - 1].date), // keep last value
value: entries[entries.length - 1].value, // keep last value,
scoredUp,
scoredDown,
};
})
.value();
return dbTasks.update({_id: habit._id}, {
$set: {history: habit.history},
});
}
if (count % progressCount === 0) console.warn(`${count } habits processed`);
}
function displayData () {
console.warn(`\n${ count } tasks processed\n`);
return exiting(0);
}
function exiting (code, msg) {
code = code || 0; // 0 = success
if (code && !msg) {
msg = 'ERROR!';
}
if (msg) {
if (code) {
console.error(msg);
} else {
console.log(msg);
}
}
process.exit(code);
}
module.exports = processChallengeHabits;

View File

@@ -0,0 +1,156 @@
// const migrationName = 'habits-one-history-entry-per-day';
const authorName = 'paglias'; // in case script author needs to know when their ...
const authorUuid = 'ed4c688c-6652-4a92-9d03-a5a79844174a'; // ... own data is done
/*
* Iterates over all habits and condense multiple history entries for the same day into a single entry
*/
const monk = require('monk');
const _ = require('lodash');
const moment = require('moment');
const connectionString = 'mongodb://localhost:27017/habitrpg?auto_reconnect=true'; // FOR TEST DATABASE
const dbTasks = monk(connectionString).get('tasks', { castIds: false });
const dbUsers = monk(connectionString).get('users', { castIds: false });
function processUsers (lastId) {
let query = {};
if (lastId) {
query._id = {
$gt: lastId,
};
}
dbUsers.find(query, {
sort: {_id: 1},
limit: 50, // just 50 users per time since we have to process all their habits as well
fields: ['_id', 'preferences.timezoneOffset', 'preferences.dayStart'],
})
.then(updateUsers)
.catch((err) => {
console.log(err);
return exiting(1, `ERROR! ${ err}`);
});
}
let progressCount = 1000;
let count = 0;
function updateUsers (users) {
if (!users || users.length === 0) {
console.warn('All appropriate users and their tasks found and modified.');
displayData();
return;
}
let usersPromises = users.map(updateUser);
let lastUser = users[users.length - 1];
return Promise.all(usersPromises)
.then(() => {
return processUsers(lastUser._id);
});
}
function updateHabit (habit, timezoneOffset, dayStart) {
if (habit && habit.history && habit.history.length > 0) {
// First remove missing entries
habit.history = habit.history.filter(entry => Boolean(entry));
habit.history = _.chain(habit.history)
// processes all entries to identify an up or down score
.forEach((entry, index) => {
if (index === 0) { // first entry doesn't have a previous one
// first value < 0 identifies a negative score as the first action
entry.scoreDirection = entry.value >= 0 ? 'up' : 'down';
} else {
// could be missing if the previous entry was null and thus excluded
const previousEntry = habit.history[index - 1];
const previousValue = previousEntry.value;
entry.scoreDirection = entry.value > previousValue ? 'up' : 'down';
}
})
.groupBy(entry => { // group entries by aggregateBy
const entryDate = moment(entry.date).zone(timezoneOffset || 0);
if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day');
return entryDate.format('YYYYMMDD');
})
.toPairs() // [key, entry]
.sortBy(([key]) => key) // sort by date
.map(keyEntryPair => {
let entries = keyEntryPair[1]; // 1 is entry, 0 is key
let scoredUp = 0;
let scoredDown = 0;
entries.forEach(entry => {
if (entry.scoreDirection === 'up') {
scoredUp += 1;
} else {
scoredDown += 1;
}
// delete the unnecessary scoreDirection and scoreNotes prop
delete entry.scoreDirection;
delete entry.scoreNotes;
});
return {
date: Number(entries[entries.length - 1].date), // keep last value
value: entries[entries.length - 1].value, // keep last value,
scoredUp,
scoredDown,
};
})
.value();
return dbTasks.update({_id: habit._id}, {
$set: {history: habit.history},
});
}
}
function updateUser (user) {
count++;
const timezoneOffset = user.preferences.timezoneOffset;
const dayStart = user.preferences.dayStart;
if (count % progressCount === 0) console.warn(`${count } ${ user._id}`);
if (user._id === authorUuid) console.warn(`${authorName } being processed`);
return dbTasks.find({
type: 'habit',
userId: user._id,
})
.then(habits => {
return Promise.all(habits.map(habit => updateHabit(habit, timezoneOffset, dayStart)));
})
.catch((err) => {
console.log(err);
return exiting(1, `ERROR! ${ err}`);
});
}
function displayData () {
console.warn(`\n${ count } tasks processed\n`);
return exiting(0);
}
function exiting (code, msg) {
code = code || 0; // 0 = success
if (code && !msg) {
msg = 'ERROR!';
}
if (msg) {
if (code) {
console.error(msg);
} else {
console.log(msg);
}
}
process.exit(code);
}
module.exports = processUsers;

View File

@@ -406,7 +406,8 @@ describe('POST /tasks/:id/score/:direction', () => {
expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp);
});
it('adds score notes to task', async () => {
// not supported anymore
it('does not add score notes to task', async () => {
let scoreNotesString = 'test-notes';
await user.post(`/tasks/${habit._id}/score/up`, {
@@ -414,20 +415,24 @@ describe('POST /tasks/:id/score/:direction', () => {
});
let updatedTask = await user.get(`/tasks/${habit._id}`);
expect(updatedTask.history[0].scoreNotes).to.eql(scoreNotesString);
expect(updatedTask.history[0].scoreNotes).to.eql(undefined);
});
it('errors when score notes are too large', async () => {
let scoreNotesString = new Array(258).join('a');
it('records only one history entry per day', async () => {
const initialHistoryLength = habit.history.length;
await expect(user.post(`/tasks/${habit._id}/score/up`, {
scoreNotes: scoreNotesString,
}))
.to.eventually.be.rejected.and.eql({
code: 401,
error: 'NotAuthorized',
message: t('taskScoreNotesTooLong'),
});
await user.post(`/tasks/${habit._id}/score/up`);
await user.post(`/tasks/${habit._id}/score/up`);
await user.post(`/tasks/${habit._id}/score/down`);
await user.post(`/tasks/${habit._id}/score/up`);
const updatedTask = await user.get(`/tasks/${habit._id}`);
expect(updatedTask.history.length).to.eql(initialHistoryLength + 1);
const lastHistoryEntry = updatedTask.history[updatedTask.history.length - 1];
expect(lastHistoryEntry.scoredUp).to.equal(3);
expect(lastHistoryEntry.scoredDown).to.equal(1);
});
});

View File

@@ -254,13 +254,14 @@ describe('shared.ops.scoreTask', () => {
expect(ref.afterUser.stats.gp).to.be.greaterThan(ref.beforeUser.stats.gp);
});
it('adds score notes', () => {
// not supported anymore
it('does not add score notes to task', () => {
let scoreNotesString = 'scoreNotes';
habit.scoreNotes = scoreNotesString;
options = { user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false };
scoreTask(options);
expect(habit.history[0].scoreNotes).to.eql(scoreNotesString);
expect(habit.history[0].scoreNotes).to.eql(undefined);
});
it('down', () => {

View File

@@ -194,8 +194,6 @@
"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",
"taskNotes": "Task Notes",
"monthlyRepeatHelpContent": "This task will be due every X months",

View File

@@ -1,5 +1,6 @@
import timesLodash from 'lodash/times';
import reduce from 'lodash/reduce';
import moment from 'moment';
import max from 'lodash/max';
import {
NotAuthorized,
@@ -212,16 +213,47 @@ module.exports = function scoreTask (options = {}, req = {}) {
}
_gainMP(user, max([0.25, 0.0025 * statsComputed(user).maxMP]) * (direction === 'down' ? -1 : 1));
// Save history entry for habit
task.history = task.history || [];
const timezoneOffset = user.preferences.timezoneOffset;
const dayStart = user.preferences.dayStart;
const historyLength = task.history.length;
const lastHistoryEntry = task.history[historyLength - 1];
// Add history entry, even more than 1 per day
let historyEntry = {
date: Number(new Date()),
value: task.value,
};
if (task.scoreNotes) historyEntry.scoreNotes = task.scoreNotes;
// Adjust the last entry date according to the user's timezone and CDS
let lastHistoryEntryDate;
task.history.push(historyEntry);
if (lastHistoryEntry && lastHistoryEntry.date) {
lastHistoryEntryDate = moment(lastHistoryEntry.date).zone(timezoneOffset);
if (lastHistoryEntryDate.hour() < dayStart) lastHistoryEntryDate.subtract(1, 'day');
}
if (
lastHistoryEntryDate &&
moment().zone(timezoneOffset).isSame(lastHistoryEntryDate, 'day')
) {
lastHistoryEntry.value = task.value;
lastHistoryEntry.date = Number(new Date());
// @TODO remove this extra check after migration has run to set scoredUp and scoredDown in every task
lastHistoryEntry.scoredUp = lastHistoryEntry.scoredUp || 0;
lastHistoryEntry.scoredDown = lastHistoryEntry.scoredDown || 0;
if (direction === 'up') {
lastHistoryEntry.scoredUp += times;
} else {
lastHistoryEntry.scoredDown += times;
}
if (task.markModified) task.markModified(`history.${historyLength - 1}`);
} else {
task.history.push({
date: Number(new Date()),
value: task.value,
scoredUp: direction === 'up' ? 1 : 0,
scoredDown: direction === 'down' ? 1 : 0,
});
}
_updateCounter(task, direction, times);
} else if (task.type === 'daily') {

View File

@@ -25,8 +25,6 @@ import logger from '../../libs/logger';
import moment from 'moment';
import apiError from '../../libs/apiError';
const MAX_SCORE_NOTES_LENGTH = 256;
function canNotEditTasks (group, user, assignedUserId) {
let isNotGroupLeader = group.leader !== user._id;
let isManager = Boolean(group.managers[user._id]);
@@ -530,7 +528,6 @@ api.updateTask = {
*
* @apiParam (Path) {String} taskId The task _id or alias
* @apiParam (Path) {String="up","down"} direction The direction for scoring the task
* @apiParam (Body) {String} scoreNotes Notes explaining the scoring
*
* @apiExample {json} Example call:
* curl -X "POST" https://habitica.com/api/v3/tasks/test-api-params/score/up
@@ -556,18 +553,14 @@ api.scoreTask = {
async handler (req, res) {
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
let validationErrors = req.validationErrors();
const validationErrors = req.validationErrors();
if (validationErrors) throw validationErrors;
let user = res.locals.user;
let scoreNotes = req.body.scoreNotes;
if (scoreNotes && scoreNotes.length > MAX_SCORE_NOTES_LENGTH) throw new NotAuthorized(res.t('taskScoreNotesTooLong'));
let {taskId} = req.params;
const user = res.locals.user;
const {taskId} = req.params;
let task = await Tasks.Task.findByIdOrAlias(taskId, user._id, {userId: user._id});
let direction = req.params.direction;
if (scoreNotes) task.scoreNotes = scoreNotes;
const task = await Tasks.Task.findByIdOrAlias(taskId, user._id, {userId: user._id});
const direction = req.params.direction;
if (!task) throw new NotFound(res.t('taskNotFound'));
@@ -679,13 +672,13 @@ api.scoreTask = {
if (task.challenge && task.challenge.id && task.challenge.taskId && !task.challenge.broken && task.type !== 'reward') {
// Wrapping everything in a try/catch block because if an error occurs using `await` it MUST NOT bubble up because the request has already been handled
try {
let chalTask = await Tasks.Task.findOne({
const chalTask = await Tasks.Task.findOne({
_id: task.challenge.taskId,
}).exec();
if (!chalTask) return;
await chalTask.scoreChallengeTask(delta);
await chalTask.scoreChallengeTask(delta, direction);
} catch (e) {
logger.error(e);
}

View File

@@ -2,10 +2,12 @@ import _ from 'lodash';
import moment from 'moment';
// Aggregate entries
function _aggregate (history, aggregateBy) {
function _aggregate (history, aggregateBy, timezoneOffset, dayStart) {
return _.chain(history)
.groupBy(entry => { // group entries by aggregateBy
return moment(entry.date).format(aggregateBy);
const entryDate = moment(entry.date).zone(timezoneOffset);
if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day');
return entryDate.format(aggregateBy);
})
.toPairs() // [key, entry]
.sortBy(([key]) => key) // sort by date
@@ -31,27 +33,29 @@ Subscribers and challenges:
- 1 value each month for the previous 12 months
- 1 value each year for the previous years
*/
export function preenHistory (history, isSubscribed, timezoneOffset) {
export function preenHistory (history, isSubscribed, timezoneOffset = 0, dayStart = 0) {
// history = _.filter(history, historyEntry => Boolean(historyEntry)); // Filter missing entries
let now = timezoneOffset ? moment().zone(timezoneOffset) : moment();
const now = moment().zone(timezoneOffset);
// Date after which to begin compressing data
let cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day');
const cutOff = now.subtract(isSubscribed ? 365 : 60, 'days').startOf('day');
// Keep uncompressed entries (modifies history and returns removed items)
let newHistory = _.remove(history, entry => {
let date = moment(entry.date);
return date.isSame(cutOff) || date.isAfter(cutOff);
const entryDate = moment(entry.date).zone(timezoneOffset);
if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day');
return entryDate.isSame(cutOff) || entryDate.isAfter(cutOff);
});
// Date after which to begin compressing data by year
let monthsCutOff = cutOff.subtract(isSubscribed ? 12 : 10, 'months').startOf('day');
let aggregateByMonth = _.remove(history, entry => {
let date = moment(entry.date);
return date.isSame(monthsCutOff) || date.isAfter(monthsCutOff);
const entryDate = moment(entry.date).zone(timezoneOffset);
if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day');
return entryDate.isSame(monthsCutOff) || entryDate.isAfter(monthsCutOff);
});
// Aggregate remaining entries by month and year
if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM'));
if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY'));
if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM', timezoneOffset, dayStart));
if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY', timezoneOffset, dayStart));
return newHistory;
}
@@ -60,11 +64,12 @@ export function preenHistory (history, isSubscribed, timezoneOffset) {
export function preenUserHistory (user, tasksByType) {
let isSubscribed = user.isSubscribed();
let timezoneOffset = user.preferences.timezoneOffset;
let dayStart = user.preferences.dayStart;
let minHistoryLength = isSubscribed ? 365 : 60;
function _processTask (task) {
if (task.history && task.history.length > minHistoryLength) {
task.history = preenHistory(task.history, isSubscribed, timezoneOffset);
task.history = preenHistory(task.history, isSubscribed, timezoneOffset, dayStart);
task.markModified('history');
}
}
@@ -73,12 +78,12 @@ export function preenUserHistory (user, tasksByType) {
tasksByType.dailys.forEach(_processTask);
if (user.history.exp.length > minHistoryLength) {
user.history.exp = preenHistory(user.history.exp, isSubscribed, timezoneOffset);
user.history.exp = preenHistory(user.history.exp, isSubscribed, timezoneOffset, dayStart);
user.markModified('history.exp');
}
if (user.history.todos.length > minHistoryLength) {
user.history.todos = preenHistory(user.history.todos, isSubscribed, timezoneOffset);
user.history.todos = preenHistory(user.history.todos, isSubscribed, timezoneOffset, dayStart);
user.markModified('history.todos');
}
}

View File

@@ -184,31 +184,53 @@ TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) {
return reminderObj;
};
TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta) {
TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta, direction) {
let chalTask = this;
chalTask.value += delta;
if (chalTask.type === 'habit' || chalTask.type === 'daily') {
// Add only one history entry per day
let lastChallengHistoryIndex = chalTask.history.length - 1;
const history = chalTask.history;
const lastChallengHistoryIndex = history.length - 1;
const lastHistoryEntry = history[lastChallengHistoryIndex];
if (chalTask.history[lastChallengHistoryIndex] &&
moment(chalTask.history[lastChallengHistoryIndex].date).isSame(new Date(), 'day')) {
chalTask.history[lastChallengHistoryIndex] = {
if (
lastHistoryEntry && lastHistoryEntry.date &&
moment().isSame(lastHistoryEntry.date, 'day')
) {
lastHistoryEntry.value = chalTask.value;
lastHistoryEntry.date = Number(new Date());
if (chalTask.type === 'habit') {
// @TODO remove this extra check after migration has run to set scoredUp and scoredDown in every task
lastHistoryEntry.scoredUp = lastHistoryEntry.scoredUp || 0;
lastHistoryEntry.scoredDown = lastHistoryEntry.scoredDown || 0;
if (direction === 'up') {
lastHistoryEntry.scoredUp += 1;
} else {
lastHistoryEntry.scoredDown += 1;
}
}
chalTask.markModified(`history.${lastChallengHistoryIndex}`);
} else {
const historyEntry = {
date: Number(new Date()),
value: chalTask.value,
};
chalTask.markModified(`history.${lastChallengHistoryIndex}`);
} else {
chalTask.history.push({
date: Number(new Date()),
value: chalTask.value,
});
if (chalTask.type === 'habit') {
historyEntry.scoredUp = direction === 'up' ? 1 : 0;
historyEntry.scoredDown = direction === 'down' ? 1 : 0;
}
history.push(historyEntry);
// Only preen task history once a day when the task is scored first
if (chalTask.history.length > 365) {
chalTask.history = preenHistory(chalTask.history, true); // true means the challenge will retain as much entries as a subscribed user
chalTask.history = preenHistory(chalTask.history, true); // true means the challenge will retain as many entries as a subscribed user
}
}
}
@@ -220,7 +242,11 @@ export let Task = mongoose.model('Task', TaskSchema);
// habits and dailies shared fields
let habitDailySchema = () => {
return {history: Array}; // [{date:Date, value:Number}], // this causes major performance problems
// Schema not defined because it causes serious perf problems
// date is a date stored as a Number value
// value is a Number
// scoredUp and scoredDown only exist for habits and are numbers
return {history: Array};
};
// dailys and todos shared fields

View File

@@ -499,8 +499,8 @@ let schema = new Schema({
streak: {type: Boolean, default: false},
},
tasks: {
groupByChallenge: {type: Boolean, default: false},
confirmScoreNotes: {type: Boolean, default: false},
groupByChallenge: {type: Boolean, default: false}, // @TODO remove? not used
confirmScoreNotes: {type: Boolean, default: false}, // @TODO remove? not used
},
improvementCategories: {
type: Array,