mirror of
https://github.com/sudoxnym/habitica.git
synced 2026-05-25 23:25:51 +00:00
Merge remote-tracking branch 'TheHollidayInn/yesterdailies-2' into release
This commit is contained in:
commit
d9e50e7632
28 changed files with 321 additions and 109 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import moment from 'moment';
|
||||
import {
|
||||
generateUser,
|
||||
} from '../../../../helpers/api-integration/v3';
|
||||
|
|
@ -127,4 +128,26 @@ describe('GET /tasks/user', () => {
|
|||
let allCompletedTodos = await user.get('/tasks/user?type=_allCompletedTodos');
|
||||
expect(allCompletedTodos.length).to.equal(numberOfTodos);
|
||||
});
|
||||
|
||||
it('returns dailies with isDue for the date specified', async () => {
|
||||
let startDate = moment().subtract('1', 'days').toDate();
|
||||
let createdTasks = await user.post('/tasks/user', [
|
||||
{
|
||||
text: 'test daily',
|
||||
type: 'daily',
|
||||
startDate,
|
||||
frequency: 'daily',
|
||||
everyX: 2,
|
||||
},
|
||||
]);
|
||||
let dailys = await user.get('/tasks/user?type=dailys');
|
||||
|
||||
expect(dailys.length).to.be.at.least(1);
|
||||
expect(dailys[0]._id).to.equal(createdTasks._id);
|
||||
expect(dailys[0].isDue).to.be.false;
|
||||
|
||||
let dailys2 = await user.get(`/tasks/user?type=dailys&dueDate=${startDate}`);
|
||||
expect(dailys2[0]._id).to.equal(createdTasks._id);
|
||||
expect(dailys2[0].isDue).to.be.true;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ describe('POST /tasks/user', () => {
|
|||
expect(task.completed).to.equal(false);
|
||||
expect(task.streak).not.to.equal('never');
|
||||
expect(task.value).not.to.equal(324);
|
||||
expect(task.yesterDaily).to.equal(true);
|
||||
});
|
||||
|
||||
it('ignores invalid fields', async () => {
|
||||
|
|
|
|||
|
|
@ -396,6 +396,7 @@ describe('PUT /tasks/:id', () => {
|
|||
notes: 'some new notes',
|
||||
frequency: 'daily',
|
||||
everyX: 5,
|
||||
yesterDaily: false,
|
||||
startDate: moment().add(1, 'days').toDate(),
|
||||
});
|
||||
|
||||
|
|
@ -405,6 +406,7 @@ describe('PUT /tasks/:id', () => {
|
|||
expect(savedDaily.everyX).to.eql(5);
|
||||
expect(savedDaily.isDue).to.be.false;
|
||||
expect(savedDaily.nextDue.length).to.eql(6);
|
||||
expect(savedDaily.yesterDaily).to.be.false;
|
||||
});
|
||||
|
||||
it('can update checklists (replace it)', async () => {
|
||||
|
|
|
|||
|
|
@ -483,6 +483,25 @@ describe('cron', () => {
|
|||
|
||||
expect(progress.down).to.equal(-1);
|
||||
});
|
||||
|
||||
it('should do damage for only yesterday\'s dailies', () => {
|
||||
daysMissed = 3;
|
||||
tasksByType.dailys[0].startDate = moment(new Date()).subtract({days: 1});
|
||||
|
||||
let daily = {
|
||||
text: 'test daily',
|
||||
type: 'daily',
|
||||
};
|
||||
let task = new Tasks.daily(Tasks.Task.sanitize(daily)); // eslint-disable-line new-cap
|
||||
tasksByType.dailys.push(task);
|
||||
tasksByType.dailys[1].startDate = moment(new Date()).subtract({days: 2});
|
||||
tasksByType.dailys[1].everyX = 2;
|
||||
tasksByType.dailys[1].frequency = 'daily';
|
||||
|
||||
cron({user, tasksByType, daysMissed, analytics});
|
||||
|
||||
expect(user.stats.hp).to.equal(48);
|
||||
});
|
||||
});
|
||||
|
||||
describe('habits', () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,69 @@
|
|||
habitrpg.controller('NotificationCtrl',
|
||||
['$scope', '$rootScope', 'Shared', 'Content', 'User', 'Guide', 'Notification', 'Analytics', 'Achievement', 'Social', 'Tasks',
|
||||
function ($scope, $rootScope, Shared, Content, User, Guide, Notification, Analytics, Achievement, Social, Tasks) {
|
||||
$rootScope.$watch('user', function (after, before) {
|
||||
runYesterDailies();
|
||||
});
|
||||
|
||||
$rootScope.$on('userUpdated', function (after, before) {
|
||||
runYesterDailies();
|
||||
});
|
||||
|
||||
function runYesterDailies() {
|
||||
var userLastCron = moment(User.user.lastCron).local();
|
||||
var userDayStart = moment().startOf('day').add({ hours: User.user.preferences.dayStart });
|
||||
|
||||
if (!User.user.needsCron) return;
|
||||
var dailys = User.user.dailys;
|
||||
|
||||
if (!Boolean(dailys) || dailys.length === 0) return;
|
||||
|
||||
var yesterDay = moment().subtract('1', 'day').startOf('day').add({ hours: User.user.preferences.dayStart });
|
||||
var yesterDailies = [];
|
||||
dailys.forEach(function (task) {
|
||||
if (task && task.group.approval && task.group.approval.requested) return;
|
||||
if (task.completed) return;
|
||||
var shouldDo = Shared.shouldDo(yesterDay, task);
|
||||
|
||||
if (task.yesterDaily && shouldDo) yesterDailies.push(task);
|
||||
});
|
||||
|
||||
if (yesterDailies.length === 0) {
|
||||
User.runCron();
|
||||
return;
|
||||
};
|
||||
|
||||
var modalScope = $rootScope.$new();
|
||||
modalScope.obj = User.user;
|
||||
modalScope.taskList = yesterDailies;
|
||||
modalScope.list = {
|
||||
showCompleted: false,
|
||||
type: 'daily',
|
||||
};
|
||||
modalScope.processingYesterdailies = true;
|
||||
|
||||
$scope.yesterDailiesModalOpen = true;
|
||||
$rootScope.openModal('yesterDailies', {
|
||||
scope: modalScope,
|
||||
backdrop: 'static',
|
||||
controller: ['$scope', 'Tasks', 'User', '$rootScope', function ($scope, Tasks, User, $rootScope) {
|
||||
$rootScope.$on('task:scored', function (event, data) {
|
||||
var task = data.task;
|
||||
var indexOfTask = _.findIndex($scope.taskList, function (taskInList) {
|
||||
return taskInList._id === task._id;
|
||||
});
|
||||
if (!$scope.taskList[indexOfTask]) return;
|
||||
$scope.taskList[indexOfTask].group.approval.requested = task.group.approval.requested;
|
||||
if ($scope.taskList[indexOfTask].group.approval.requested) return;
|
||||
$scope.taskList[indexOfTask].completed = task.completed;
|
||||
});
|
||||
|
||||
$scope.ageDailies = function () {
|
||||
User.runCron();
|
||||
};
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
$rootScope.$watch('user.stats.hp', function (after, before) {
|
||||
if (after <= 0){
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ habitrpg.controller("TasksCtrl", ['$scope', '$rootScope', '$location', 'User','N
|
|||
},
|
||||
});
|
||||
Analytics.updateUser();
|
||||
|
||||
if (task.group.approval.required) task.group.approval.requested = true;
|
||||
$rootScope.$broadcast('task:scored', {task: task});
|
||||
}
|
||||
|
||||
$scope.score = function(task, direction) {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@
|
|||
'$state',
|
||||
'User',
|
||||
'$rootScope',
|
||||
'Tasks',
|
||||
];
|
||||
|
||||
function taskList($state, User, $rootScope) {
|
||||
function taskList($state, User, $rootScope, Tasks) {
|
||||
return {
|
||||
restrict: 'EA',
|
||||
templateUrl: 'templates/task-list.html',
|
||||
|
|
@ -24,6 +25,11 @@
|
|||
// user: "=",
|
||||
// },
|
||||
link: function($scope, element, attrs) {
|
||||
$scope.checklistCompletion = Tasks.checklistCompletion;
|
||||
|
||||
$scope.completeChecklistItem = function completeChecklistItem(task) {
|
||||
User.updateTask(task, {body: task});
|
||||
};
|
||||
// @TODO: The use of scope with tasks is incorrect. We need to fix all task ctrls to use directives/services
|
||||
// $scope.obj = {};
|
||||
function setObj (obj, force) {
|
||||
|
|
@ -95,4 +101,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}());
|
||||
}());
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
scope: true,
|
||||
link: function($scope, element, attrs) {
|
||||
$scope.getClasses = function (task, user, list, main) {
|
||||
return Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main);
|
||||
return Shared.taskClasses(task, user.filters, user.preferences.dayStart, user.lastCron, list.showCompleted, main, $scope.processingYesterdailies);
|
||||
}
|
||||
|
||||
$scope.showNoteDetails = function (task) {
|
||||
|
|
|
|||
|
|
@ -417,6 +417,16 @@ angular.module('habitrpg')
|
|||
});
|
||||
},
|
||||
|
||||
runCron: function () {
|
||||
$http({
|
||||
method: "POST",
|
||||
url: 'api/v3/cron',
|
||||
})
|
||||
.then(function (response) {
|
||||
sync();
|
||||
})
|
||||
},
|
||||
|
||||
setCustomDayStart: function (dayStart) {
|
||||
$http({
|
||||
method: "POST",
|
||||
|
|
|
|||
20
website/client/components/guilds/newPartyModal.jade
Normal file
20
website/client/components/guilds/newPartyModal.jade
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<template lang="pug">
|
||||
div
|
||||
button.btn.btn-primary(b-btn, @click="$root.$emit('show::modal','new-party-modal')") {{ $t('viewMembers') }}
|
||||
|
||||
b-modal#new-party-modal(:title="$t('createGuild')", size='lg')
|
||||
.header-wrap(slot="modal-header")
|
||||
.row
|
||||
.col-6
|
||||
h1(v-once) {{$t('members')}}
|
||||
.col-6
|
||||
button(type="button" aria-label="Close" class="close")
|
||||
span(aria-hidden="true") ×
|
||||
.row
|
||||
.form-group.col-6
|
||||
input.form-control.search(type="text", :placeholder="$t('search')", v-model='searchTerm')
|
||||
.col-4.offset-2
|
||||
span.dropdown-label {{ $t('sortBy') }}
|
||||
b-dropdown(:text="$t('sort')", right=true)
|
||||
b-dropdown-item(v-for='sortOption in sortOptions', @click='sort(sortOption.value)', :key='sortOption.value') {{sortOption.text}}
|
||||
</template>
|
||||
|
|
@ -170,5 +170,9 @@
|
|||
"resets": "Resets",
|
||||
"summaryStart": "Repeats <%= frequency %> every <%= everyX %> <%= frequencyPlural %> ",
|
||||
"nextDue": "Next Due Dates",
|
||||
"yesterDailiesTitle": "You left these Dailies unchecked yesterday! Do you want to check off any of them now?",
|
||||
"yesterDailiesCallToAction": "Start My New Day!",
|
||||
"yesterDailiesOptionTitle": "Confirm that this Daily wasn't done before applying damage",
|
||||
"yesterDailiesDescription": "If this setting is applied, Habitica will ask you if you meant to leave the Daily undone before calculating and applying damage to your avatar. This can protect you against unintentional damage.",
|
||||
"repeatDayError": "Please ensure that you have at least one day of the week selected."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,8 +85,10 @@ export function startOfDay (options = {}) {
|
|||
|
||||
export function daysSince (yesterday, options = {}) {
|
||||
let o = sanitizeOptions(options);
|
||||
let startOfNow = startOfDay(defaults({ now: o.now }, o));
|
||||
let startOfYesterday = startOfDay(defaults({ now: yesterday }, o));
|
||||
|
||||
return startOfDay(defaults({ now: o.now }, o)).diff(startOfDay(defaults({ now: yesterday }, o)), 'days');
|
||||
return startOfNow.diff(startOfYesterday, 'days');
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
shouldDo,
|
||||
} from '../cron';
|
||||
import moment from 'moment';
|
||||
|
||||
/*
|
||||
Task classes given everything about the class
|
||||
|
|
@ -8,7 +9,7 @@ Task classes given everything about the class
|
|||
|
||||
// TODO move to the client
|
||||
|
||||
module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCron = Number(new Date()), showCompleted = false, main = false) {
|
||||
module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCron = Number(new Date()), showCompleted = false, main = false, processingYesterdailies = false) {
|
||||
if (!task) {
|
||||
return '';
|
||||
}
|
||||
|
|
@ -34,7 +35,9 @@ module.exports = function taskClasses (task, filters = [], dayStart = 0, lastCro
|
|||
}
|
||||
|
||||
if (type === 'todo' || type === 'daily') {
|
||||
let notDue = !shouldDo(Number(new Date()), task, { dayStart });
|
||||
let dayShouldDo = moment();
|
||||
if (processingYesterdailies) dayShouldDo.subtract(1, 'days');
|
||||
let notDue = !shouldDo(Number(dayShouldDo), task, { dayStart });
|
||||
let isNotDueDaily = type === 'daily' && notDue;
|
||||
|
||||
if (completed || isNotDueDaily) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ module.exports = function taskDefaults (task = {}) {
|
|||
challenge: {
|
||||
shortName: 'None',
|
||||
},
|
||||
yesterDaily: true,
|
||||
reminders: [],
|
||||
attribute: 'str',
|
||||
createdAt: new Date(), // TODO these are going to be overwritten by the server...
|
||||
|
|
|
|||
22
website/server/controllers/api-v3/cron.js
Normal file
22
website/server/controllers/api-v3/cron.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { authWithHeaders } from '../../middlewares/auth';
|
||||
import cron from '../../middlewares/cron';
|
||||
|
||||
let api = {};
|
||||
|
||||
/**
|
||||
* @api {post} /api/v3/cron Runs cron
|
||||
* @apiName Cron
|
||||
* @apiGroup Cron
|
||||
*
|
||||
* @apiSuccess {Object} data An empty Object
|
||||
*/
|
||||
api.cron = {
|
||||
method: 'POST',
|
||||
url: '/cron',
|
||||
middlewares: [authWithHeaders(), cron],
|
||||
async handler (req, res) {
|
||||
res.respond(200, {});
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = api;
|
||||
|
|
@ -343,7 +343,6 @@ api.getGroups = {
|
|||
api.getGroup = {
|
||||
method: 'GET',
|
||||
url: '/groups/:groupId',
|
||||
runCron: false, // Do not run cron to avoid double cronning because it's called in parallel to GET /user when the site loads
|
||||
middlewares: [authWithHeaders()],
|
||||
async handler (req, res) {
|
||||
let user = res.locals.user;
|
||||
|
|
|
|||
|
|
@ -272,8 +272,9 @@ api.getUserTasks = {
|
|||
if (validationErrors) throw validationErrors;
|
||||
|
||||
let user = res.locals.user;
|
||||
let dueDate = req.query.dueDate;
|
||||
|
||||
let tasks = await getTasks(req, res, {user});
|
||||
let tasks = await getTasks(req, res, {user, dueDate});
|
||||
return res.respond(200, tasks);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ api.getUser = {
|
|||
// Remove apiToken from response TODO make it private at the user level? returned in signup/login
|
||||
delete userToJSON.apiToken;
|
||||
|
||||
let {daysMissed} = user.daysUserHasMissed(new Date(), req);
|
||||
userToJSON.needsCron = false;
|
||||
if (daysMissed > 0) userToJSON.needsCron = true;
|
||||
|
||||
user.addComputedStatsToJSONObj(userToJSON.stats);
|
||||
return res.respond(200, userToJSON);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -264,6 +264,10 @@ export function cron (options = {}) {
|
|||
let EvadeTask = 0;
|
||||
let scheduleMisses = daysMissed;
|
||||
|
||||
// Only check one day back
|
||||
let dailiesDaysMissed = daysMissed;
|
||||
if (dailiesDaysMissed > 1) dailiesDaysMissed = 1;
|
||||
|
||||
if (completed) {
|
||||
dailyChecked += 1;
|
||||
if (!atLeastOneDailyDue) { // only bother checking until the first thing is found
|
||||
|
|
@ -274,7 +278,7 @@ export function cron (options = {}) {
|
|||
// dailys repeat, so need to calculate how many they've missed according to their own schedule
|
||||
scheduleMisses = 0;
|
||||
|
||||
for (let i = 0; i < daysMissed; i++) {
|
||||
for (let i = 0; i < dailiesDaysMissed; i++) {
|
||||
let thatDay = moment(now).subtract({days: i + 1});
|
||||
|
||||
if (shouldDo(thatDay.toDate(), task, user.preferences)) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import _ from 'lodash';
|
|||
import {
|
||||
getUserLanguage,
|
||||
} from '../middlewares/language';
|
||||
import cron from '../middlewares/cron';
|
||||
|
||||
// Wrapper function to handler `async` route handlers that return promises
|
||||
// It takes the async function, execute it and pass any error to next (args[2])
|
||||
|
|
@ -12,7 +11,7 @@ let noop = (req, res, next) => next();
|
|||
|
||||
module.exports.readController = function readController (router, controller) {
|
||||
_.each(controller, (action) => {
|
||||
let {method, url, middlewares = [], handler, runCron} = action;
|
||||
let {method, url, middlewares = [], handler} = action;
|
||||
|
||||
// If an authentication middleware is used run getUserLanguage after it, otherwise before
|
||||
// for cron instead use it only if an authentication middleware is present
|
||||
|
|
@ -27,10 +26,6 @@ module.exports.readController = function readController (router, controller) {
|
|||
let middlewaresToAdd = [getUserLanguage];
|
||||
|
||||
if (authMiddlewareIndex !== -1) { // the user will be authenticated, getUserLanguage and cron after authentication
|
||||
if (!(runCron === false)) { // eslint-disable-line no-extra-parens
|
||||
middlewaresToAdd.push(cron);
|
||||
}
|
||||
|
||||
if (authMiddlewareIndex === middlewares.length - 1) {
|
||||
middlewares.push(...middlewaresToAdd);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import moment from 'moment';
|
||||
import * as Tasks from '../models/task';
|
||||
import {
|
||||
BadRequest,
|
||||
|
|
@ -22,13 +23,16 @@ async function _validateTaskAlias (tasks, res) {
|
|||
});
|
||||
}
|
||||
|
||||
export function setNextDue (task, user) {
|
||||
export function setNextDue (task, user, dueDateOption) {
|
||||
if (task.type !== 'daily') return;
|
||||
|
||||
let dateTaskIsDue = Date.now();
|
||||
if (dueDateOption) dateTaskIsDue = moment(dueDateOption);
|
||||
|
||||
let optionsForShouldDo = user.preferences.toObject();
|
||||
task.isDue = shared.shouldDo(Date.now(), task, optionsForShouldDo);
|
||||
task.isDue = shared.shouldDo(dateTaskIsDue, task, optionsForShouldDo);
|
||||
optionsForShouldDo.nextDue = true;
|
||||
let nextDue = shared.shouldDo(Date.now(), task, optionsForShouldDo);
|
||||
let nextDue = shared.shouldDo(dateTaskIsDue, task, optionsForShouldDo);
|
||||
if (nextDue && nextDue.length > 0) {
|
||||
task.nextDue = nextDue.map((dueDate) => {
|
||||
return dueDate.toISOString();
|
||||
|
|
@ -120,6 +124,7 @@ export async function getTasks (req, res, options = {}) {
|
|||
user,
|
||||
challenge,
|
||||
group,
|
||||
dueDate,
|
||||
} = options;
|
||||
|
||||
let query = {userId: user._id};
|
||||
|
|
@ -185,6 +190,8 @@ export async function getTasks (req, res, options = {}) {
|
|||
} else {
|
||||
orderedTasks[i] = task;
|
||||
}
|
||||
|
||||
if (dueDate) setNextDue(task, user, dueDate);
|
||||
});
|
||||
|
||||
// Remove empty values from the array and add any unordered task
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import common from '../../common';
|
||||
import * as Tasks from '../models/task';
|
||||
import Bluebird from 'bluebird';
|
||||
import { model as Group } from '../models/group';
|
||||
|
|
@ -8,8 +6,6 @@ import { model as User } from '../models/user';
|
|||
import { recoverCron, cron } from '../libs/cron';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
const daysSince = common.daysSince;
|
||||
|
||||
async function cronAsync (req, res) {
|
||||
let user = res.locals.user;
|
||||
if (!user) return null; // User might not be available when authentication is not mandatory
|
||||
|
|
@ -18,83 +14,7 @@ async function cronAsync (req, res) {
|
|||
let now = new Date();
|
||||
|
||||
try {
|
||||
// If the user's timezone has changed (due to travel or daylight savings),
|
||||
// cron can be triggered twice in one day, so we check for that and use
|
||||
// both timezones to work out if cron should run.
|
||||
// CDS = Custom Day Start time.
|
||||
let timezoneOffsetFromUserPrefs = user.preferences.timezoneOffset;
|
||||
let timezoneOffsetAtLastCron = _.isFinite(user.preferences.timezoneOffsetAtLastCron) ? user.preferences.timezoneOffsetAtLastCron : timezoneOffsetFromUserPrefs;
|
||||
let timezoneOffsetFromBrowser = Number(req.header('x-user-timezoneoffset'));
|
||||
timezoneOffsetFromBrowser = _.isFinite(timezoneOffsetFromBrowser) ? timezoneOffsetFromBrowser : timezoneOffsetFromUserPrefs;
|
||||
// NB: All timezone offsets can be 0, so can't use `... || ...` to apply non-zero defaults
|
||||
|
||||
if (timezoneOffsetFromBrowser !== timezoneOffsetFromUserPrefs) {
|
||||
// The user's browser has just told Habitica that the user's timezone has
|
||||
// changed so store and use the new zone.
|
||||
user.preferences.timezoneOffset = timezoneOffsetFromBrowser;
|
||||
timezoneOffsetFromUserPrefs = timezoneOffsetFromBrowser;
|
||||
}
|
||||
|
||||
// How many days have we missed using the user's current timezone:
|
||||
let daysMissed = daysSince(user.lastCron, _.defaults({now}, user.preferences));
|
||||
|
||||
if (timezoneOffsetAtLastCron !== timezoneOffsetFromUserPrefs) {
|
||||
// Since cron last ran, the user's timezone has changed.
|
||||
// How many days have we missed using the old timezone:
|
||||
let daysMissedNewZone = daysMissed;
|
||||
let daysMissedOldZone = daysSince(user.lastCron, _.defaults({
|
||||
now,
|
||||
timezoneOffsetOverride: timezoneOffsetAtLastCron,
|
||||
}, user.preferences));
|
||||
|
||||
if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) {
|
||||
// The timezone change was in the unsafe direction.
|
||||
// E.g., timezone changes from UTC+1 (offset -60) to UTC+0 (offset 0).
|
||||
// or timezone changes from UTC-4 (offset 240) to UTC-5 (offset 300).
|
||||
// Local time changed from, for example, 03:00 to 02:00.
|
||||
|
||||
if (daysMissedOldZone > 0 && daysMissedNewZone > 0) {
|
||||
// Both old and new timezones indicate that we SHOULD run cron, so
|
||||
// it is safe to do so immediately.
|
||||
daysMissed = Math.min(daysMissedOldZone, daysMissedNewZone);
|
||||
// use minimum value to be nice to user
|
||||
} else if (daysMissedOldZone > 0) {
|
||||
// The old timezone says that cron should run; the new timezone does not.
|
||||
// This should be impossible for this direction of timezone change, but
|
||||
// just in case I'm wrong...
|
||||
// TODO
|
||||
// console.log("zone has changed - old zone says run cron, NEW zone says no - stop cron now only -- SHOULD NOT HAVE GOT TO HERE", timezoneOffsetAtLastCron, timezoneOffsetFromUserPrefs, now); // used in production for confirming this never happens
|
||||
} else if (daysMissedNewZone > 0) {
|
||||
// The old timezone says that cron should NOT run -- i.e., cron has
|
||||
// already run today, from the old timezone's point of view.
|
||||
// The new timezone says that cron SHOULD run, but this is almost
|
||||
// certainly incorrect.
|
||||
// This happens when cron occurred at a time soon after the CDS. When
|
||||
// you reinterpret that time in the new timezone, it looks like it
|
||||
// was before the CDS, because local time has stepped backwards.
|
||||
// To fix this, rewrite the cron time to a time that the new
|
||||
// timezone interprets as being in today.
|
||||
|
||||
daysMissed = 0; // prevent cron running now
|
||||
let timezoneOffsetDiff = timezoneOffsetAtLastCron - timezoneOffsetFromUserPrefs;
|
||||
// e.g., for dangerous zone change: 240 - 300 = -60 or -660 - -600 = -60
|
||||
|
||||
user.lastCron = moment(user.lastCron).subtract(timezoneOffsetDiff, 'minutes');
|
||||
// NB: We don't change user.auth.timestamps.loggedin so that will still record the time that the previous cron actually ran.
|
||||
// From now on we can ignore the old timezone:
|
||||
user.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs;
|
||||
} else {
|
||||
// Both old and new timezones indicate that cron should
|
||||
// NOT run.
|
||||
daysMissed = 0; // prevent cron running now
|
||||
}
|
||||
} else if (timezoneOffsetAtLastCron > timezoneOffsetFromUserPrefs) {
|
||||
daysMissed = daysMissedNewZone;
|
||||
// TODO: Either confirm that there is nothing that could possibly go wrong here and remove the need for this else branch, or fix stuff.
|
||||
// There are probably situations where the Dailies do not reset early enough for a user who was expecting the zone change and wants to use all their Dailies immediately in the new zone;
|
||||
// if so, we should provide an option for easy reset of Dailies (can't be automatic because there will be other situations where the user was not prepared).
|
||||
}
|
||||
}
|
||||
let {daysMissed, timezoneOffsetFromUserPrefs} = user.daysUserHasMissed(now, req);
|
||||
|
||||
if (daysMissed <= 0) {
|
||||
if (user.isModified()) await user.save();
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ TaskSchema.statics.findByIdOrAlias = async function findByIdOrAlias (identifier,
|
|||
TaskSchema.statics.sanitizeUserChallengeTask = function sanitizeUserChallengeTask (taskObj) {
|
||||
let initialSanitization = this.sanitize(taskObj);
|
||||
|
||||
return _.pick(initialSanitization, ['streak', 'checklist', 'attribute', 'reminders', 'tags', 'notes', 'collapseChecklist', 'alias']);
|
||||
return _.pick(initialSanitization, ['streak', 'checklist', 'attribute', 'reminders', 'tags', 'notes', 'collapseChecklist', 'alias', 'yesterDaily']);
|
||||
};
|
||||
|
||||
// Sanitize checklist objects (disallowing id)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import amazonPayments from '../../libs/amazonPayments';
|
|||
import stripePayments from '../../libs/stripePayments';
|
||||
import paypalPayments from '../../libs/paypalPayments';
|
||||
|
||||
const daysSince = common.daysSince;
|
||||
|
||||
|
||||
schema.methods.isSubscribed = function isSubscribed () {
|
||||
let now = new Date();
|
||||
let plan = this.purchased.plan;
|
||||
|
|
@ -27,7 +30,7 @@ schema.methods.hasNotCancelled = function hasNotCancelled () {
|
|||
|
||||
// Get an array of groups ids the user is member of
|
||||
schema.methods.getGroups = function getUserGroups () {
|
||||
let userGroups = this.guilds.slice(0); // clone user.guilds so we don't modify the original
|
||||
let userGroups = this.guilds.slice(0); // clone this.guilds so we don't modify the original
|
||||
if (this.party._id) userGroups.push(this.party._id);
|
||||
userGroups.push(TAVERN_ID);
|
||||
return userGroups;
|
||||
|
|
@ -85,7 +88,7 @@ schema.methods.getObjectionsToInteraction = function getObjectionsToInteraction
|
|||
|
||||
|
||||
/**
|
||||
* Sends a message to a user. Archives a copy in sender's inbox.
|
||||
* Sends a message to a this. Archives a copy in sender's inbox.
|
||||
*
|
||||
* @param userToReceiveMessage The receiver
|
||||
* @param options
|
||||
|
|
@ -113,7 +116,7 @@ schema.methods.sendMessage = async function sendMessage (userToReceiveMessage, o
|
|||
* Creates a notification based on the input parameters and adds it to the local user notifications array.
|
||||
* This does not save the notification to the database or interact with the database in any way.
|
||||
*
|
||||
* @param type The type of notification to add to the user. Possible values are defined in the UserNotificaiton Schema
|
||||
* @param type The type of notification to add to the this. Possible values are defined in the UserNotificaiton Schema
|
||||
* @param data The data to add to the notification
|
||||
*/
|
||||
schema.methods.addNotification = function addUserNotification (type, data = {}) {
|
||||
|
|
@ -130,7 +133,7 @@ schema.methods.addNotification = function addUserNotification (type, data = {})
|
|||
* the user document(s) opened.
|
||||
*
|
||||
* @param query A Mongoose query defining the users to add the notification to.
|
||||
* @param type The type of notification to add to the user. Possible values are defined in the UserNotificaiton Schema
|
||||
* @param type The type of notification to add to the this. Possible values are defined in the UserNotificaiton Schema
|
||||
* @param data The data to add to the notification
|
||||
*/
|
||||
schema.statics.pushNotification = async function pushNotification (query, type, data = {}) {
|
||||
|
|
@ -145,7 +148,7 @@ schema.statics.pushNotification = async function pushNotification (query, type,
|
|||
// Add stats.toNextLevel, stats.maxMP and stats.maxHealth
|
||||
// to a JSONified User stats object
|
||||
schema.methods.addComputedStatsToJSONObj = function addComputedStatsToUserJSONObj (statsObject) {
|
||||
// NOTE: if an item is manually added to user.stats then
|
||||
// NOTE: if an item is manually added to this.stats then
|
||||
// common/fns/predictableRandom must be tweaked so the new item is not considered.
|
||||
// Otherwise the client will have it while the server won't and the results will be different.
|
||||
statsObject.toNextLevel = common.tnl(this.stats.lvl);
|
||||
|
|
@ -186,3 +189,85 @@ schema.methods.cancelSubscription = async function cancelSubscription (options =
|
|||
|
||||
return await payments.cancelSubscription(options);
|
||||
};
|
||||
|
||||
schema.methods.daysUserHasMissed = function daysUserHasMissed (now, req = {}) {
|
||||
// If the user's timezone has changed (due to travel or daylight savings),
|
||||
// cron can be triggered twice in one day, so we check for that and use
|
||||
// both timezones to work out if cron should run.
|
||||
// CDS = Custom Day Start time.
|
||||
let timezoneOffsetFromUserPrefs = this.preferences.timezoneOffset;
|
||||
let timezoneOffsetAtLastCron = isFinite(this.preferences.timezoneOffsetAtLastCron) ? this.preferences.timezoneOffsetAtLastCron : timezoneOffsetFromUserPrefs;
|
||||
let timezoneOffsetFromBrowser = typeof req.header === 'function' && Number(req.header('x-user-timezoneoffset'));
|
||||
timezoneOffsetFromBrowser = isFinite(timezoneOffsetFromBrowser) ? timezoneOffsetFromBrowser : timezoneOffsetFromUserPrefs;
|
||||
// NB: All timezone offsets can be 0, so can't use `... || ...` to apply non-zero defaults
|
||||
|
||||
if (timezoneOffsetFromBrowser !== timezoneOffsetFromUserPrefs) {
|
||||
// The user's browser has just told Habitica that the user's timezone has
|
||||
// changed so store and use the new zone.
|
||||
this.preferences.timezoneOffset = timezoneOffsetFromBrowser;
|
||||
timezoneOffsetFromUserPrefs = timezoneOffsetFromBrowser;
|
||||
}
|
||||
|
||||
// How many days have we missed using the user's current timezone:
|
||||
let daysMissed = daysSince(this.lastCron, defaults({now}, this.preferences));
|
||||
|
||||
if (timezoneOffsetAtLastCron !== timezoneOffsetFromUserPrefs) {
|
||||
// Since cron last ran, the user's timezone has changed.
|
||||
// How many days have we missed using the old timezone:
|
||||
let daysMissedNewZone = daysMissed;
|
||||
let daysMissedOldZone = daysSince(this.lastCron, defaults({
|
||||
now,
|
||||
timezoneOffsetOverride: timezoneOffsetAtLastCron,
|
||||
}, this.preferences));
|
||||
|
||||
if (timezoneOffsetAtLastCron < timezoneOffsetFromUserPrefs) {
|
||||
// The timezone change was in the unsafe direction.
|
||||
// E.g., timezone changes from UTC+1 (offset -60) to UTC+0 (offset 0).
|
||||
// or timezone changes from UTC-4 (offset 240) to UTC-5 (offset 300).
|
||||
// Local time changed from, for example, 03:00 to 02:00.
|
||||
|
||||
if (daysMissedOldZone > 0 && daysMissedNewZone > 0) {
|
||||
// Both old and new timezones indicate that we SHOULD run cron, so
|
||||
// it is safe to do so immediately.
|
||||
daysMissed = Math.min(daysMissedOldZone, daysMissedNewZone);
|
||||
// use minimum value to be nice to user
|
||||
} else if (daysMissedOldZone > 0) {
|
||||
// The old timezone says that cron should run; the new timezone does not.
|
||||
// This should be impossible for this direction of timezone change, but
|
||||
// just in case I'm wrong...
|
||||
// TODO
|
||||
// console.log("zone has changed - old zone says run cron, NEW zone says no - stop cron now only -- SHOULD NOT HAVE GOT TO HERE", timezoneOffsetAtLastCron, timezoneOffsetFromUserPrefs, now); // used in production for confirming this never happens
|
||||
} else if (daysMissedNewZone > 0) {
|
||||
// The old timezone says that cron should NOT run -- i.e., cron has
|
||||
// already run today, from the old timezone's point of view.
|
||||
// The new timezone says that cron SHOULD run, but this is almost
|
||||
// certainly incorrect.
|
||||
// This happens when cron occurred at a time soon after the CDS. When
|
||||
// you reinterpret that time in the new timezone, it looks like it
|
||||
// was before the CDS, because local time has stepped backwards.
|
||||
// To fix this, rewrite the cron time to a time that the new
|
||||
// timezone interprets as being in today.
|
||||
|
||||
daysMissed = 0; // prevent cron running now
|
||||
let timezoneOffsetDiff = timezoneOffsetAtLastCron - timezoneOffsetFromUserPrefs;
|
||||
// e.g., for dangerous zone change: 240 - 300 = -60 or -660 - -600 = -60
|
||||
|
||||
this.lastCron = moment(this.lastCron).subtract(timezoneOffsetDiff, 'minutes');
|
||||
// NB: We don't change this.auth.timestamps.loggedin so that will still record the time that the previous cron actually ran.
|
||||
// From now on we can ignore the old timezone:
|
||||
this.preferences.timezoneOffsetAtLastCron = timezoneOffsetFromUserPrefs;
|
||||
} else {
|
||||
// Both old and new timezones indicate that cron should
|
||||
// NOT run.
|
||||
daysMissed = 0; // prevent cron running now
|
||||
}
|
||||
} else if (timezoneOffsetAtLastCron > timezoneOffsetFromUserPrefs) {
|
||||
daysMissed = daysMissedNewZone;
|
||||
// TODO: Either confirm that there is nothing that could possibly go wrong here and remove the need for this else branch, or fix stuff.
|
||||
// There are probably situations where the Dailies do not reset early enough for a user who was expecting the zone change and wants to use all their Dailies immediately in the new zone;
|
||||
// if so, we should provide an option for easy reset of Dailies (can't be automatic because there will be other situations where the user was not prepared).
|
||||
}
|
||||
}
|
||||
|
||||
return {daysMissed, timezoneOffsetFromUserPrefs};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ include ./tasks-edit.jade
|
|||
include ./task-notes.jade
|
||||
include ./task-extra-notes.jade
|
||||
include ./testing.jade
|
||||
include ./yester-dailies.jade
|
||||
|
||||
//- Settings
|
||||
script(type='text/ng-template', id='modals/change-day-start.html')
|
||||
|
|
|
|||
11
website/views/shared/modals/yester-dailies.jade
Normal file
11
website/views/shared/modals/yester-dailies.jade
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
script(type='text/ng-template', id='modals/yesterDailies.html')
|
||||
.modal-header
|
||||
h3.text-center=env.t('yesterDailiesTitle')
|
||||
.modal-body
|
||||
.container-fluid
|
||||
.row
|
||||
.col-md-8.col-md-offset-2
|
||||
.task-column.dailys
|
||||
task-list
|
||||
.modal-footer
|
||||
a.btn.btn-info.btn-lg.flex-column.btn-wrap(ng-click='ageDailies();$close()')=env.t('yesterDailiesCallToAction')
|
||||
|
|
@ -10,6 +10,12 @@ div(ng-if='(task.type !== "reward") || task.userId || (!obj.auth && obj.purchase
|
|||
input.form-control(ng-model='task._edit.alias' type='text' placeholder=env.t('taskAliasPlaceholder'))
|
||||
|
||||
include ./habits/frequency
|
||||
|
||||
fieldset.option-group.advanced-option(ng-if='task.type === "daily" && task._edit._advanced')
|
||||
.form-group
|
||||
input(type='checkbox', ng-model='task._edit.yesterDaily', ng-disabled='$state.includes("options.social.challenges") || $state.includes("options.social.groups")')
|
||||
legend.option-title
|
||||
span.hint(popover-trigger='mouseenter', popover="{{::env.t('yesterDailiesDescription')}}")=env.t('yesterDailiesOptionTitle')
|
||||
|
||||
fieldset.option-group.advanced-option(ng-show="task._edit._advanced", ng-if="!obj.auth && obj.purchased && obj.purchased.active")
|
||||
group-tasks-actions(task='task', group='obj')
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
script(id='templates/task.html', type="text/ng-template")
|
||||
li(id='task-{{::task._id}}',
|
||||
ng-repeat='task in getTaskList(list, taskList, obj) | filterByTaskInfo: obj.filterQuery | conditionalOrderBy: list.view=="dated":"date"',
|
||||
ng-repeat='task in getTaskList(list, taskList, obj) | filterByTaskInfo: obj.filterQuery | conditionalOrderBy: list.view=="dated":"date"',
|
||||
class='task {{getClasses(task, user, list, main)}}',
|
||||
ng-class='{"cast-target":spell && (list.type != "reward"), "locked-task":obj._locked === true}',
|
||||
ng-click='spell && (list.type != "reward") && castEnd(task, "task", $event)',
|
||||
ng-show='shouldShow(task, list, user.preferences)',
|
||||
ng-show='!shouldShow || shouldShow(task, list, user.preferences)',
|
||||
popover-trigger='mouseenter', popover-placement="top", popover-append-to-body='{{::modal ? "false":"true"}}',
|
||||
data-popover-html="{{taskPopover(task) | markdown}}")
|
||||
|
||||
data-popover-html="{{taskPopover(task) | markdown}}"
|
||||
)
|
||||
ng-form(name='taskForm')
|
||||
include ./meta_controls
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue