From c1bd7f5dc5c162d5a7916c4564be2f419e9319eb Mon Sep 17 00:00:00 2001 From: Matteo Pagliazzi Date: Thu, 21 Jun 2018 21:25:19 +0200 Subject: [PATCH] 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 --- migrations/migration-runner.js | 2 +- ...ts-one-history-entry-per-day-challenges.js | 138 ++++++++++++++++ .../habits-one-history-entry-per-day-users.js | 156 ++++++++++++++++++ .../POST-tasks_id_score_direction.test.js | 29 ++-- test/common/ops/scoreTask.test.js | 5 +- website/common/locales/en/tasks.json | 2 - website/common/script/ops/scoreTask.js | 46 +++++- website/server/controllers/api-v3/tasks.js | 21 +-- website/server/libs/preening.js | 33 ++-- website/server/models/task.js | 52 ++++-- website/server/models/user/schema.js | 4 +- 11 files changed, 421 insertions(+), 67 deletions(-) create mode 100644 migrations/tasks/habits-one-history-entry-per-day-challenges.js create mode 100644 migrations/tasks/habits-one-history-entry-per-day-users.js diff --git a/migrations/migration-runner.js b/migrations/migration-runner.js index c5faeb2df8..e758e741cb 100644 --- a/migrations/migration-runner.js +++ b/migrations/migration-runner.js @@ -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(); diff --git a/migrations/tasks/habits-one-history-entry-per-day-challenges.js b/migrations/tasks/habits-one-history-entry-per-day-challenges.js new file mode 100644 index 0000000000..dcb05f48bf --- /dev/null +++ b/migrations/tasks/habits-one-history-entry-per-day-challenges.js @@ -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; diff --git a/migrations/tasks/habits-one-history-entry-per-day-users.js b/migrations/tasks/habits-one-history-entry-per-day-users.js new file mode 100644 index 0000000000..8eb52e8f90 --- /dev/null +++ b/migrations/tasks/habits-one-history-entry-per-day-users.js @@ -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; diff --git a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js index 0ee5f710b6..c34b94ed05 100644 --- a/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js +++ b/test/api/v3/integration/tasks/POST-tasks_id_score_direction.test.js @@ -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); }); }); diff --git a/test/common/ops/scoreTask.test.js b/test/common/ops/scoreTask.test.js index bdfa5b91ee..3e78c943c6 100644 --- a/test/common/ops/scoreTask.test.js +++ b/test/common/ops/scoreTask.test.js @@ -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', () => { diff --git a/website/common/locales/en/tasks.json b/website/common/locales/en/tasks.json index 50ba001de6..ea20df33b1 100644 --- a/website/common/locales/en/tasks.json +++ b/website/common/locales/en/tasks.json @@ -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", diff --git a/website/common/script/ops/scoreTask.js b/website/common/script/ops/scoreTask.js index 78e5e39451..58e594754d 100644 --- a/website/common/script/ops/scoreTask.js +++ b/website/common/script/ops/scoreTask.js @@ -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') { diff --git a/website/server/controllers/api-v3/tasks.js b/website/server/controllers/api-v3/tasks.js index dd12bc70c6..5cdb4618b8 100644 --- a/website/server/controllers/api-v3/tasks.js +++ b/website/server/controllers/api-v3/tasks.js @@ -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); } diff --git a/website/server/libs/preening.js b/website/server/libs/preening.js index 9cb9a3d3e5..527cca6dff 100644 --- a/website/server/libs/preening.js +++ b/website/server/libs/preening.js @@ -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'); } } diff --git a/website/server/models/task.js b/website/server/models/task.js index 026d922714..2b3d8e1c8f 100644 --- a/website/server/models/task.js +++ b/website/server/models/task.js @@ -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 diff --git a/website/server/models/user/schema.js b/website/server/models/user/schema.js index c459d9d79c..81d7386d5c 100644 --- a/website/server/models/user/schema.js +++ b/website/server/models/user/schema.js @@ -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,