// Relative date spec describes a date that is distant to the current date by a series of jumps in // time defined as a series of periods. Hence, starting from the current date, each one of the // periods gets applied successively which eventually yields to the final date. Typical relative import getCurrentTime from "app/common/getCurrentTime"; import isEqual from "lodash/isEqual"; import isNumber from "lodash/isNumber"; import isUndefined from "lodash/isUndefined"; import omitBy from "lodash/omitBy"; import moment from "moment-timezone"; // Relative date uses one or two periods. When relative dates are defined by two periods, they are // applied successively to the start date to resolve the target date. In practice in grist, as of // the time of writing, relative date never uses more than 2 periods and the second period's unit is // always day. export type IRelativeDateSpec = IPeriod[]; // IPeriod describes a period of time: when used along with a start date, it allows to target a new // date. It allows to encode simple periods such as `30 days ago` as `{quantity: -30, unit: // 'day'}`. Or `The last day of last week` as `{quantity: -1, unit: 'week', endOf: true}`. Not that // .endOf flag is only relevant when the unit is one of 'week', 'month' or 'year'. When `endOf` is // false or missing then it will target the first day (of the week, month or year). export interface IPeriod { quantity: number; unit: 'day'|'week'|'month'|'year'; endOf?: boolean; } export const CURRENT_DATE: IRelativeDateSpec = [{quantity: 0, unit: 'day'}]; export function isRelativeBound(bound?: number|IRelativeDateSpec): bound is IRelativeDateSpec { return !isUndefined(bound) && !isNumber(bound); } // Returns the number of seconds between 1 January 1970 00:00:00 UTC and the given bound, may it be // a relative date. export function relativeDateToUnixTimestamp(bound: IRelativeDateSpec): number { const localDate = getCurrentTime().startOf('day'); const date = moment.utc(localDate.toObject()); const periods = Array.isArray(bound) ? bound : [bound]; for (const period of periods) { const {quantity, unit, endOf} = period; date.add(quantity, unit); if (endOf) { date.endOf(unit); // date must have "hh:mm:ss" set to "00:00:00" date.startOf('day'); } else { date.startOf(unit); } } return Math.floor(date.valueOf() / 1000); } // Format a relative date. export function formatRelBounds(periods: IPeriod[]): string { // if 2nd period is moot revert to one single period periods = periods[1]?.quantity ? periods : [periods[0]]; if (periods.length === 1) { const {quantity, unit, endOf} = periods[0]; if (unit === 'day') { if (quantity === 0) { return 'Today'; } if (quantity === -1) { return 'Yesterday'; } if (quantity === 1) { return 'Tomorrow'; } return formatReference(periods[0]); } if (endOf) { return `Last day of ${formatReference(periods[0])}`; } else { return `1st day of ${formatReference(periods[0])}`; } } if (periods.length === 2) { let dayQuantity = periods[1].quantity; // If the 1st period has the endOf flag, we're already 1 day back. if (periods[0].endOf) { dayQuantity -= 1; } let startOrEnd = ''; if (periods[0].unit === 'week') { if (periods[1].quantity === 0) { startOrEnd = 'start '; } else if (periods[1].quantity === 6) { startOrEnd = 'end '; } } return `${formatDay(dayQuantity, periods[0].unit)} ${startOrEnd}of ${formatReference(periods[0])}`; } throw new Error( `Relative date spec does not support more that 2 periods: ${periods.length}` ); } /** * Returns a new timestamp that is the UTC equivalent of the original local `timestamp`, offset * according to the delta between`timezone` and UTC. */ export function localTimestampToUTC(timestamp: number, timezone: string): number { return moment.unix(timestamp).utc().tz(timezone, true).unix(); } function formatDay(quantity: number, refUnit: IPeriod['unit']): string { if (refUnit === 'week') { const n = (quantity + 7) % 7; return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][n]; } const ord = (n: number) => moment.localeData().ordinal(n); if (quantity < 0) { if (quantity === -1) { return 'Last day'; } return `${ord(-quantity)} to last day`; } else { return `${ord(quantity + 1)} day`; } } function formatReference(period: IPeriod): string { const {quantity, unit} = period; if (quantity === 0) { return `this ${unit}`; } if (quantity === -1) { return `last ${unit}`; } if (quantity === 1) { return `next ${unit}`; } const n = Math.abs(quantity); const plurals = n > 1 ? 's' : ''; return `${n} ${unit}${plurals} ${quantity < 1 ? 'ago' : 'from now'}`; } export function isEquivalentRelativeDate(a: IPeriod|IPeriod[], b: IPeriod|IPeriod[]) { a = Array.isArray(a) ? a : [a]; b = Array.isArray(b) ? b : [b]; if (a.length === 2 && a[1].quantity === 0) { a = [a[0]]; } if (b.length === 2 && b[1].quantity === 0) { b = [b[0]]; } const compactA = a.map(period => omitBy(period, isUndefined)); const compactB = b.map(period => omitBy(period, isUndefined)); return isEqual(compactA, compactB); } // Get the difference in unit of measurement. If unit is week, makes sure that two dates that are in // two different weeks are always at least 1 number apart. Same for month and year. export function diffUnit(a: moment.Moment, b: moment.Moment, unit: 'day'|'week'|'month'|'year') { return a.clone().startOf(unit).diff(b.clone().startOf(unit), unit); }