mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-14 21:27:23 +01:00
* Issue 10209 - Remove read usages of zone * Issue 10209 - Add coverage on daysSince and startOfDay cron utility functions * Issue 10209 - Add unit test for daysUserHasMissed method * Issue 10209 - Remove usages of deprecated `moment.js#zone` method. * Issue 10209 - Add helper function to centralise logic Also simplify timezoneOffsetToUtc function in site.vue * Issue 10209 - Also add getUtcOffset as method on user Co-authored-by: Matteo Pagliazzi <matteopagliazzi@gmail.com>
253 lines
9.1 KiB
JavaScript
253 lines
9.1 KiB
JavaScript
// TODO what can be moved to /website/server?
|
|
/*
|
|
------------------------------------------------------
|
|
Cron and time / day functions
|
|
------------------------------------------------------
|
|
*/
|
|
import defaults from 'lodash/defaults';
|
|
import invert from 'lodash/invert';
|
|
import moment from 'moment';
|
|
import 'moment-recur';
|
|
|
|
export const DAY_MAPPING = {
|
|
0: 'su',
|
|
1: 'm',
|
|
2: 't',
|
|
3: 'w',
|
|
4: 'th',
|
|
5: 'f',
|
|
6: 's',
|
|
};
|
|
|
|
export const DAY_MAPPING_STRING_TO_NUMBER = invert(DAY_MAPPING);
|
|
|
|
/*
|
|
Each time we perform date maths (cron, task-due-days, etc), we need to consider user preferences.
|
|
Specifically {dayStart} (custom day start) and {timezoneOffset}.
|
|
This function sanitizes / defaults those values.
|
|
{now} is also passed in for various purposes,
|
|
one example being the test scripts scripts testing different "now" times.
|
|
*/
|
|
|
|
function sanitizeOptions (o) {
|
|
const ref = Number(o.dayStart || 0);
|
|
const dayStart = !Number.isNaN(ref) && ref >= 0 && ref <= 24 ? ref : 0;
|
|
|
|
let timezoneUtcOffset;
|
|
const timezoneUtcOffsetDefault = moment().utcOffset();
|
|
|
|
if (Number.isFinite(o.timezoneUtcOffset)) {
|
|
// Options were already sanitized
|
|
timezoneUtcOffset = o.timezoneUtcOffset;
|
|
} else if (Number.isFinite(o.timezoneUtcOffsetOverride)) {
|
|
timezoneUtcOffset = o.timezoneUtcOffsetOverride;
|
|
} else if (Number.isFinite(o.timezoneOffset)) {
|
|
timezoneUtcOffset = -o.timezoneOffset;
|
|
} else {
|
|
timezoneUtcOffset = timezoneUtcOffsetDefault;
|
|
}
|
|
if (timezoneUtcOffset < -720 || timezoneUtcOffset > 840) {
|
|
// timezones range from -12 (offset -720) to +14 (offset 840)
|
|
timezoneUtcOffset = timezoneUtcOffsetDefault;
|
|
}
|
|
|
|
const now = moment(o.now).utcOffset(timezoneUtcOffset);
|
|
// return a new object, we don't want to add "now" to user object
|
|
return {
|
|
dayStart,
|
|
timezoneUtcOffset,
|
|
now,
|
|
};
|
|
}
|
|
|
|
export function startOfWeek (options = {}) {
|
|
const o = sanitizeOptions(options);
|
|
|
|
return moment(o.now).startOf('week');
|
|
}
|
|
|
|
/*
|
|
This is designed for use with any date that has an important time portion
|
|
(e.g., when comparing the current date-time with the previous cron's date-time
|
|
for determining if cron should run now).
|
|
It changes the time portion of the date-time to be the Custom Day Start hour,
|
|
so that the date-time is now the user's correct start of day.
|
|
It SUBTRACTS a day if the date-time's original hour is before CDS
|
|
(e.g., if your CDS is 5am and it's currently 4am, it's still the previous day).
|
|
This is NOT suitable for manipulating any dates that are displayed to the user
|
|
as a date with no time portion, such as a Daily's Start Dates
|
|
(e.g., a Start Date of today shows only the date,
|
|
so it should be considered to be today even if the hidden time portion is before CDS).
|
|
*/
|
|
|
|
export function startOfDay (options = {}) {
|
|
const o = sanitizeOptions(options);
|
|
const dayStart = moment(o.now).startOf('day').add({ hours: o.dayStart });
|
|
|
|
if (o.now.hour() < o.dayStart) {
|
|
dayStart.subtract({ days: 1 });
|
|
}
|
|
|
|
return dayStart;
|
|
}
|
|
|
|
/*
|
|
Absolute diff from "yesterday" till now
|
|
*/
|
|
|
|
export function daysSince (yesterday, options = {}) {
|
|
const o = sanitizeOptions(options);
|
|
const startOfNow = startOfDay(defaults({ now: o.now }, o));
|
|
const startOfYesterday = startOfDay(defaults({ now: yesterday }, o));
|
|
|
|
return startOfNow.diff(startOfYesterday, 'days');
|
|
}
|
|
|
|
/*
|
|
Should the user do this task on this date,
|
|
given the task's repeat options and user.preferences.dayStart?
|
|
*/
|
|
|
|
export function shouldDo (day, dailyTask, options = {}) {
|
|
if (dailyTask.type !== 'daily' || dailyTask.startDate === null || dailyTask.everyX < 1 || dailyTask.everyX > 9999) {
|
|
return false;
|
|
}
|
|
const o = sanitizeOptions(options);
|
|
const startOfDayWithCDSTime = startOfDay(defaults({ now: day }, o));
|
|
|
|
// The time portion of the Start Date is never visible to
|
|
// or modifiable by the user so we must ignore it.
|
|
// Therefore, we must also ignore the time portion of the user's day start
|
|
// (startOfDayWithCDSTime), otherwise the date comparison will be wrong for some times.
|
|
// NB: The user's day start date has already been converted to the PREVIOUS
|
|
// day's date if the time portion was before CDS.
|
|
|
|
const startDate = moment(dailyTask.startDate).utcOffset(o.timezoneUtcOffset).startOf('day');
|
|
|
|
if (startDate > startOfDayWithCDSTime.startOf('day') && !options.nextDue) {
|
|
return false; // Daily starts in the future
|
|
}
|
|
|
|
const daysOfTheWeek = [];
|
|
if (dailyTask.repeat) {
|
|
for (const [repeatDay, active] of Object.entries(dailyTask.repeat)) {
|
|
if (!Number.isFinite(parseInt(DAY_MAPPING_STRING_TO_NUMBER[repeatDay], 10))) continue; // eslint-disable-line no-continue, max-len
|
|
if (active) daysOfTheWeek.push(parseInt(DAY_MAPPING_STRING_TO_NUMBER[repeatDay], 10));
|
|
}
|
|
}
|
|
|
|
if (dailyTask.frequency === 'daily') {
|
|
if (!dailyTask.everyX) return false; // error condition
|
|
const schedule = moment(startDate).recur()
|
|
.every(dailyTask.everyX).days();
|
|
|
|
if (options.nextDue) {
|
|
const filteredDates = [];
|
|
for (let i = 1; filteredDates.length < 6; i += 1) {
|
|
const calcDate = moment(startDate).add(dailyTask.everyX * i, 'days');
|
|
if (calcDate > startOfDayWithCDSTime) filteredDates.push(calcDate);
|
|
}
|
|
return filteredDates;
|
|
}
|
|
|
|
return schedule.matches(startOfDayWithCDSTime);
|
|
} if (dailyTask.frequency === 'weekly') {
|
|
let schedule = moment(startDate).recur();
|
|
|
|
const differenceInWeeks = moment(startOfDayWithCDSTime).diff(moment(startDate), 'week');
|
|
const matchEveryX = differenceInWeeks % dailyTask.everyX === 0;
|
|
|
|
if (daysOfTheWeek.length === 0) return false;
|
|
schedule = schedule.every(daysOfTheWeek).daysOfWeek();
|
|
if (options.nextDue) {
|
|
const filteredDates = [];
|
|
for (let i = 0; filteredDates.length < 6; i += 1) {
|
|
for (let j = 0; j < daysOfTheWeek.length && filteredDates.length < 6; j += 1) {
|
|
const calcDate = moment(startDate).day(daysOfTheWeek[j]).add(dailyTask.everyX * i, 'weeks');
|
|
if (calcDate > startOfDayWithCDSTime) filteredDates.push(calcDate);
|
|
}
|
|
}
|
|
const sortedDates = filteredDates.sort((date1, date2) => {
|
|
if (date1.toDate() > date2.toDate()) return 1;
|
|
if (date2.toDate() > date1.toDate()) return -1;
|
|
return 0;
|
|
});
|
|
return sortedDates;
|
|
}
|
|
|
|
return schedule.matches(startOfDayWithCDSTime) && matchEveryX;
|
|
} if (dailyTask.frequency === 'monthly') {
|
|
let schedule = moment(startDate).recur();
|
|
|
|
// Use startOf to ensure that we are always comparing month
|
|
// to the next rather than a month from the day
|
|
const differenceInMonths = moment(startOfDayWithCDSTime).startOf('month')
|
|
.diff(moment(startDate).startOf('month'), 'month', true);
|
|
|
|
const matchEveryX = differenceInMonths % dailyTask.everyX === 0;
|
|
|
|
if (dailyTask.weeksOfMonth && dailyTask.weeksOfMonth.length > 0) {
|
|
if (daysOfTheWeek.length === 0) return false;
|
|
schedule = schedule.every(daysOfTheWeek).daysOfWeek()
|
|
.every(dailyTask.weeksOfMonth).weeksOfMonthByDay();
|
|
|
|
if (options.nextDue) {
|
|
const filteredDates = [];
|
|
for (let i = 1; filteredDates.length < 6; i += 1) {
|
|
const recurDate = moment(startDate).add(dailyTask.everyX * i, 'months');
|
|
const calcDate = recurDate.clone();
|
|
calcDate.day(daysOfTheWeek[0]);
|
|
|
|
const startDateWeek = Math.ceil(moment(startDate).date() / 7);
|
|
let calcDateWeek = Math.ceil(calcDate.date() / 7);
|
|
|
|
// adjust week since weeks will rollover to other months
|
|
if (calcDate.month() < recurDate.month()) calcDate.add(1, 'weeks');
|
|
else if (calcDate.month() > recurDate.month()) calcDate.subtract(1, 'weeks');
|
|
else if (calcDateWeek > startDateWeek) calcDate.subtract(1, 'weeks');
|
|
else if (calcDateWeek < startDateWeek) calcDate.add(1, 'weeks');
|
|
|
|
calcDateWeek = Math.ceil(calcDate.date() / 7);
|
|
|
|
if (
|
|
calcDate >= startOfDayWithCDSTime
|
|
&& calcDateWeek === startDateWeek
|
|
&& calcDate.month() === recurDate.month()
|
|
) filteredDates.push(calcDate);
|
|
}
|
|
return filteredDates;
|
|
}
|
|
|
|
return schedule.matches(startOfDayWithCDSTime) && matchEveryX;
|
|
} if (dailyTask.daysOfMonth && dailyTask.daysOfMonth.length > 0) {
|
|
schedule = schedule.every(dailyTask.daysOfMonth).daysOfMonth();
|
|
if (options.nextDue) {
|
|
const filteredDates = [];
|
|
for (let i = 1; filteredDates.length < 6; i += 1) {
|
|
const calcDate = moment(startDate).add(dailyTask.everyX * i, 'months');
|
|
if (calcDate >= startOfDayWithCDSTime) filteredDates.push(calcDate);
|
|
}
|
|
return filteredDates;
|
|
}
|
|
}
|
|
|
|
return schedule.matches(startOfDayWithCDSTime) && matchEveryX;
|
|
} if (dailyTask.frequency === 'yearly') {
|
|
let schedule = moment(startDate).recur();
|
|
|
|
schedule = schedule.every(dailyTask.everyX).years();
|
|
|
|
if (options.nextDue) {
|
|
const filteredDates = [];
|
|
for (let i = 1; filteredDates.length < 6; i += 1) {
|
|
const calcDate = moment(startDate).add(dailyTask.everyX * i, 'years');
|
|
if (calcDate > startOfDayWithCDSTime) filteredDates.push(calcDate);
|
|
}
|
|
return filteredDates;
|
|
}
|
|
|
|
return schedule.matches(startOfDayWithCDSTime);
|
|
}
|
|
return false;
|
|
}
|