mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-16 06:07:21 +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();
|
||||
|
||||
// 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();
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user