mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 06:37:23 +01:00
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:
@@ -17,5 +17,5 @@ function setUpServer () {
|
|||||||
setUpServer();
|
setUpServer();
|
||||||
|
|
||||||
// Replace this with your migration
|
// 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();
|
processUsers();
|
||||||
|
|||||||
138
migrations/tasks/habits-one-history-entry-per-day-challenges.js
Normal file
138
migrations/tasks/habits-one-history-entry-per-day-challenges.js
Normal 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;
|
||||||
156
migrations/tasks/habits-one-history-entry-per-day-users.js
Normal file
156
migrations/tasks/habits-one-history-entry-per-day-users.js
Normal 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;
|
||||||
@@ -406,7 +406,8 @@ describe('POST /tasks/:id/score/:direction', () => {
|
|||||||
expect(updatedUser.stats.gp).to.be.greaterThan(user.stats.gp);
|
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';
|
let scoreNotesString = 'test-notes';
|
||||||
|
|
||||||
await user.post(`/tasks/${habit._id}/score/up`, {
|
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}`);
|
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 () => {
|
it('records only one history entry per day', async () => {
|
||||||
let scoreNotesString = new Array(258).join('a');
|
const initialHistoryLength = habit.history.length;
|
||||||
|
|
||||||
await expect(user.post(`/tasks/${habit._id}/score/up`, {
|
await user.post(`/tasks/${habit._id}/score/up`);
|
||||||
scoreNotes: scoreNotesString,
|
await user.post(`/tasks/${habit._id}/score/up`);
|
||||||
}))
|
await user.post(`/tasks/${habit._id}/score/down`);
|
||||||
.to.eventually.be.rejected.and.eql({
|
await user.post(`/tasks/${habit._id}/score/up`);
|
||||||
code: 401,
|
|
||||||
error: 'NotAuthorized',
|
const updatedTask = await user.get(`/tasks/${habit._id}`);
|
||||||
message: t('taskScoreNotesTooLong'),
|
|
||||||
});
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -254,13 +254,14 @@ describe('shared.ops.scoreTask', () => {
|
|||||||
expect(ref.afterUser.stats.gp).to.be.greaterThan(ref.beforeUser.stats.gp);
|
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';
|
let scoreNotesString = 'scoreNotes';
|
||||||
habit.scoreNotes = scoreNotesString;
|
habit.scoreNotes = scoreNotesString;
|
||||||
options = { user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false };
|
options = { user: ref.afterUser, task: habit, direction: 'up', times: 5, cron: false };
|
||||||
scoreTask(options);
|
scoreTask(options);
|
||||||
|
|
||||||
expect(habit.history[0].scoreNotes).to.eql(scoreNotesString);
|
expect(habit.history[0].scoreNotes).to.eql(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('down', () => {
|
it('down', () => {
|
||||||
|
|||||||
@@ -194,8 +194,6 @@
|
|||||||
"weeks": "Weeks",
|
"weeks": "Weeks",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
"years": "Years",
|
"years": "Years",
|
||||||
"confirmScoreNotes": "Confirm task scoring with notes",
|
|
||||||
"taskScoreNotesTooLong": "Task score notes must be less than 256 characters",
|
|
||||||
"groupTasksByChallenge": "Group tasks by challenge title",
|
"groupTasksByChallenge": "Group tasks by challenge title",
|
||||||
"taskNotes": "Task Notes",
|
"taskNotes": "Task Notes",
|
||||||
"monthlyRepeatHelpContent": "This task will be due every X months",
|
"monthlyRepeatHelpContent": "This task will be due every X months",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import timesLodash from 'lodash/times';
|
import timesLodash from 'lodash/times';
|
||||||
import reduce from 'lodash/reduce';
|
import reduce from 'lodash/reduce';
|
||||||
|
import moment from 'moment';
|
||||||
import max from 'lodash/max';
|
import max from 'lodash/max';
|
||||||
import {
|
import {
|
||||||
NotAuthorized,
|
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));
|
_gainMP(user, max([0.25, 0.0025 * statsComputed(user).maxMP]) * (direction === 'down' ? -1 : 1));
|
||||||
|
|
||||||
|
// Save history entry for habit
|
||||||
task.history = task.history || [];
|
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
|
// Adjust the last entry date according to the user's timezone and CDS
|
||||||
let historyEntry = {
|
let lastHistoryEntryDate;
|
||||||
date: Number(new Date()),
|
|
||||||
value: task.value,
|
|
||||||
};
|
|
||||||
if (task.scoreNotes) historyEntry.scoreNotes = task.scoreNotes;
|
|
||||||
|
|
||||||
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);
|
_updateCounter(task, direction, times);
|
||||||
} else if (task.type === 'daily') {
|
} else if (task.type === 'daily') {
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ import logger from '../../libs/logger';
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import apiError from '../../libs/apiError';
|
import apiError from '../../libs/apiError';
|
||||||
|
|
||||||
const MAX_SCORE_NOTES_LENGTH = 256;
|
|
||||||
|
|
||||||
function canNotEditTasks (group, user, assignedUserId) {
|
function canNotEditTasks (group, user, assignedUserId) {
|
||||||
let isNotGroupLeader = group.leader !== user._id;
|
let isNotGroupLeader = group.leader !== user._id;
|
||||||
let isManager = Boolean(group.managers[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} taskId The task _id or alias
|
||||||
* @apiParam (Path) {String="up","down"} direction The direction for scoring the task
|
* @apiParam (Path) {String="up","down"} direction The direction for scoring the task
|
||||||
* @apiParam (Body) {String} scoreNotes Notes explaining the scoring
|
|
||||||
*
|
*
|
||||||
* @apiExample {json} Example call:
|
* @apiExample {json} Example call:
|
||||||
* curl -X "POST" https://habitica.com/api/v3/tasks/test-api-params/score/up
|
* curl -X "POST" https://habitica.com/api/v3/tasks/test-api-params/score/up
|
||||||
@@ -556,18 +553,14 @@ api.scoreTask = {
|
|||||||
async handler (req, res) {
|
async handler (req, res) {
|
||||||
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
|
req.checkParams('direction', res.t('directionUpDown')).notEmpty().isIn(['up', 'down']);
|
||||||
|
|
||||||
let validationErrors = req.validationErrors();
|
const validationErrors = req.validationErrors();
|
||||||
if (validationErrors) throw validationErrors;
|
if (validationErrors) throw validationErrors;
|
||||||
|
|
||||||
let user = res.locals.user;
|
const user = res.locals.user;
|
||||||
let scoreNotes = req.body.scoreNotes;
|
const {taskId} = req.params;
|
||||||
if (scoreNotes && scoreNotes.length > MAX_SCORE_NOTES_LENGTH) throw new NotAuthorized(res.t('taskScoreNotesTooLong'));
|
|
||||||
let {taskId} = req.params;
|
|
||||||
|
|
||||||
let task = await Tasks.Task.findByIdOrAlias(taskId, user._id, {userId: user._id});
|
const task = await Tasks.Task.findByIdOrAlias(taskId, user._id, {userId: user._id});
|
||||||
let direction = req.params.direction;
|
const direction = req.params.direction;
|
||||||
|
|
||||||
if (scoreNotes) task.scoreNotes = scoreNotes;
|
|
||||||
|
|
||||||
if (!task) throw new NotFound(res.t('taskNotFound'));
|
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') {
|
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
|
// 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 {
|
try {
|
||||||
let chalTask = await Tasks.Task.findOne({
|
const chalTask = await Tasks.Task.findOne({
|
||||||
_id: task.challenge.taskId,
|
_id: task.challenge.taskId,
|
||||||
}).exec();
|
}).exec();
|
||||||
|
|
||||||
if (!chalTask) return;
|
if (!chalTask) return;
|
||||||
|
|
||||||
await chalTask.scoreChallengeTask(delta);
|
await chalTask.scoreChallengeTask(delta, direction);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import _ from 'lodash';
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
// Aggregate entries
|
// Aggregate entries
|
||||||
function _aggregate (history, aggregateBy) {
|
function _aggregate (history, aggregateBy, timezoneOffset, dayStart) {
|
||||||
return _.chain(history)
|
return _.chain(history)
|
||||||
.groupBy(entry => { // group entries by aggregateBy
|
.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]
|
.toPairs() // [key, entry]
|
||||||
.sortBy(([key]) => key) // sort by date
|
.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 month for the previous 12 months
|
||||||
- 1 value each year for the previous years
|
- 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
|
// 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
|
// 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)
|
// Keep uncompressed entries (modifies history and returns removed items)
|
||||||
let newHistory = _.remove(history, entry => {
|
let newHistory = _.remove(history, entry => {
|
||||||
let date = moment(entry.date);
|
const entryDate = moment(entry.date).zone(timezoneOffset);
|
||||||
return date.isSame(cutOff) || date.isAfter(cutOff);
|
if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day');
|
||||||
|
return entryDate.isSame(cutOff) || entryDate.isAfter(cutOff);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Date after which to begin compressing data by year
|
// Date after which to begin compressing data by year
|
||||||
let monthsCutOff = cutOff.subtract(isSubscribed ? 12 : 10, 'months').startOf('day');
|
let monthsCutOff = cutOff.subtract(isSubscribed ? 12 : 10, 'months').startOf('day');
|
||||||
let aggregateByMonth = _.remove(history, entry => {
|
let aggregateByMonth = _.remove(history, entry => {
|
||||||
let date = moment(entry.date);
|
const entryDate = moment(entry.date).zone(timezoneOffset);
|
||||||
return date.isSame(monthsCutOff) || date.isAfter(monthsCutOff);
|
if (entryDate.hour() < dayStart) entryDate.subtract(1, 'day');
|
||||||
|
return entryDate.isSame(monthsCutOff) || entryDate.isAfter(monthsCutOff);
|
||||||
});
|
});
|
||||||
// Aggregate remaining entries by month and year
|
// Aggregate remaining entries by month and year
|
||||||
if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM'));
|
if (aggregateByMonth.length > 0) newHistory.unshift(..._aggregate(aggregateByMonth, 'YYYYMM', timezoneOffset, dayStart));
|
||||||
if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY'));
|
if (history.length > 0) newHistory.unshift(..._aggregate(history, 'YYYY', timezoneOffset, dayStart));
|
||||||
|
|
||||||
return newHistory;
|
return newHistory;
|
||||||
}
|
}
|
||||||
@@ -60,11 +64,12 @@ export function preenHistory (history, isSubscribed, timezoneOffset) {
|
|||||||
export function preenUserHistory (user, tasksByType) {
|
export function preenUserHistory (user, tasksByType) {
|
||||||
let isSubscribed = user.isSubscribed();
|
let isSubscribed = user.isSubscribed();
|
||||||
let timezoneOffset = user.preferences.timezoneOffset;
|
let timezoneOffset = user.preferences.timezoneOffset;
|
||||||
|
let dayStart = user.preferences.dayStart;
|
||||||
let minHistoryLength = isSubscribed ? 365 : 60;
|
let minHistoryLength = isSubscribed ? 365 : 60;
|
||||||
|
|
||||||
function _processTask (task) {
|
function _processTask (task) {
|
||||||
if (task.history && task.history.length > minHistoryLength) {
|
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');
|
task.markModified('history');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,12 +78,12 @@ export function preenUserHistory (user, tasksByType) {
|
|||||||
tasksByType.dailys.forEach(_processTask);
|
tasksByType.dailys.forEach(_processTask);
|
||||||
|
|
||||||
if (user.history.exp.length > minHistoryLength) {
|
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');
|
user.markModified('history.exp');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.history.todos.length > minHistoryLength) {
|
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');
|
user.markModified('history.todos');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,31 +184,53 @@ TaskSchema.statics.sanitizeReminder = function sanitizeReminder (reminderObj) {
|
|||||||
return reminderObj;
|
return reminderObj;
|
||||||
};
|
};
|
||||||
|
|
||||||
TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta) {
|
TaskSchema.methods.scoreChallengeTask = async function scoreChallengeTask (delta, direction) {
|
||||||
let chalTask = this;
|
let chalTask = this;
|
||||||
|
|
||||||
chalTask.value += delta;
|
chalTask.value += delta;
|
||||||
|
|
||||||
if (chalTask.type === 'habit' || chalTask.type === 'daily') {
|
if (chalTask.type === 'habit' || chalTask.type === 'daily') {
|
||||||
// Add only one history entry per day
|
// 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] &&
|
if (
|
||||||
moment(chalTask.history[lastChallengHistoryIndex].date).isSame(new Date(), 'day')) {
|
lastHistoryEntry && lastHistoryEntry.date &&
|
||||||
chalTask.history[lastChallengHistoryIndex] = {
|
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()),
|
date: Number(new Date()),
|
||||||
value: chalTask.value,
|
value: chalTask.value,
|
||||||
};
|
};
|
||||||
chalTask.markModified(`history.${lastChallengHistoryIndex}`);
|
|
||||||
} else {
|
if (chalTask.type === 'habit') {
|
||||||
chalTask.history.push({
|
historyEntry.scoredUp = direction === 'up' ? 1 : 0;
|
||||||
date: Number(new Date()),
|
historyEntry.scoredDown = direction === 'down' ? 1 : 0;
|
||||||
value: chalTask.value,
|
}
|
||||||
});
|
|
||||||
|
history.push(historyEntry);
|
||||||
|
|
||||||
// Only preen task history once a day when the task is scored first
|
// Only preen task history once a day when the task is scored first
|
||||||
if (chalTask.history.length > 365) {
|
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
|
// habits and dailies shared fields
|
||||||
let habitDailySchema = () => {
|
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
|
// dailys and todos shared fields
|
||||||
|
|||||||
@@ -499,8 +499,8 @@ let schema = new Schema({
|
|||||||
streak: {type: Boolean, default: false},
|
streak: {type: Boolean, default: false},
|
||||||
},
|
},
|
||||||
tasks: {
|
tasks: {
|
||||||
groupByChallenge: {type: Boolean, default: false},
|
groupByChallenge: {type: Boolean, default: false}, // @TODO remove? not used
|
||||||
confirmScoreNotes: {type: Boolean, default: false},
|
confirmScoreNotes: {type: Boolean, default: false}, // @TODO remove? not used
|
||||||
},
|
},
|
||||||
improvementCategories: {
|
improvementCategories: {
|
||||||
type: Array,
|
type: Array,
|
||||||
|
|||||||
Reference in New Issue
Block a user