627 lines
18 KiB
JavaScript
627 lines
18 KiB
JavaScript
/**
|
|
* External dependencies
|
|
*/
|
|
import moment from 'moment';
|
|
import { find } from 'lodash';
|
|
import { __ } from '@wordpress/i18n';
|
|
import { parse } from 'qs';
|
|
|
|
export const isoDateFormat = 'YYYY-MM-DD';
|
|
|
|
/**
|
|
* @typedef {Object} Moment - An instance of moment
|
|
* @typedef {Object} DateParams
|
|
*/
|
|
|
|
/**
|
|
* DateValue Object
|
|
*
|
|
* @typedef {Object} DateValue - Describes the date range supplied by the date picker.
|
|
* @property {string} label - The translated value of the period.
|
|
* @property {string} range - The human readable value of a date range.
|
|
* @property {moment.Moment} after - Start of the date range.
|
|
* @property {moment.Moment} before - End of the date range.
|
|
*/
|
|
|
|
/**
|
|
* DateParams Object
|
|
*
|
|
* @typedef {Object} dateParams - date parameters derived from query parameters.
|
|
* @property {string} period - period value, ie `last_week`
|
|
* @property {string} compare - compare valuer, ie previous_year
|
|
* @param {moment.Moment|null} after - If the period supplied is "custom", this is the after date
|
|
* @param {moment.Moment|null} before - If the period supplied is "custom", this is the before date
|
|
*/
|
|
|
|
export const presetValues = [
|
|
{ value: 'today', label: __( 'Today', 'woocommerce-admin' ) },
|
|
{ value: 'yesterday', label: __( 'Yesterday', 'woocommerce-admin' ) },
|
|
{ value: 'week', label: __( 'Week to Date', 'woocommerce-admin' ) },
|
|
{ value: 'last_week', label: __( 'Last Week', 'woocommerce-admin' ) },
|
|
{ value: 'month', label: __( 'Month to Date', 'woocommerce-admin' ) },
|
|
{ value: 'last_month', label: __( 'Last Month', 'woocommerce-admin' ) },
|
|
{ value: 'quarter', label: __( 'Quarter to Date', 'woocommerce-admin' ) },
|
|
{ value: 'last_quarter', label: __( 'Last Quarter', 'woocommerce-admin' ) },
|
|
{ value: 'year', label: __( 'Year to Date', 'woocommerce-admin' ) },
|
|
{ value: 'last_year', label: __( 'Last Year', 'woocommerce-admin' ) },
|
|
{ value: 'custom', label: __( 'Custom', 'woocommerce-admin' ) },
|
|
];
|
|
|
|
export const periods = [
|
|
{
|
|
value: 'previous_period',
|
|
label: __( 'Previous Period', 'woocommerce-admin' ),
|
|
},
|
|
{
|
|
value: 'previous_year',
|
|
label: __( 'Previous Year', 'woocommerce-admin' ),
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Adds timestamp to a string date.
|
|
*
|
|
* @param {moment.Moment} date - Date as a moment object.
|
|
* @param {string} timeOfDay - Either `start`, `now` or `end` of the day.
|
|
* @return {string} - String date with timestamp attached.
|
|
*/
|
|
export const appendTimestamp = ( date, timeOfDay ) => {
|
|
date = date.format( isoDateFormat );
|
|
if ( timeOfDay === 'start' ) {
|
|
return date + 'T00:00:00';
|
|
}
|
|
if ( timeOfDay === 'now' ) {
|
|
// Set seconds to 00 to avoid consecutives calls happening before the previous
|
|
// one finished.
|
|
return date + 'T' + moment().format( 'HH:mm:00' );
|
|
}
|
|
if ( timeOfDay === 'end' ) {
|
|
return date + 'T23:59:59';
|
|
}
|
|
throw new Error(
|
|
'appendTimestamp requires second parameter to be either `start`, `now` or `end`'
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Convert a string to Moment object
|
|
*
|
|
* @param {string} format - localized date string format
|
|
* @param {string} str - date string
|
|
* @return {Moment|null} - Moment object representing given string
|
|
*/
|
|
export function toMoment( format, str ) {
|
|
if ( moment.isMoment( str ) ) {
|
|
return str.isValid() ? str : null;
|
|
}
|
|
if ( typeof str === 'string' ) {
|
|
const date = moment( str, [ isoDateFormat, format ], true );
|
|
return date.isValid() ? date : null;
|
|
}
|
|
throw new Error( 'toMoment requires a string to be passed as an argument' );
|
|
}
|
|
|
|
/**
|
|
* Given two dates, derive a string representation
|
|
*
|
|
* @param {Moment} after - start date
|
|
* @param {Moment} before - end date
|
|
* @return {string} - text value for the supplied date range
|
|
*/
|
|
export function getRangeLabel( after, before ) {
|
|
const isSameYear = after.year() === before.year();
|
|
const isSameMonth = isSameYear && after.month() === before.month();
|
|
const isSameDay =
|
|
isSameYear && isSameMonth && after.isSame( before, 'day' );
|
|
const fullDateFormat = __( 'MMM D, YYYY', 'woocommerce-admin' );
|
|
|
|
if ( isSameDay ) {
|
|
return after.format( fullDateFormat );
|
|
} else if ( isSameMonth ) {
|
|
const afterDate = after.date();
|
|
return after
|
|
.format( fullDateFormat )
|
|
.replace( afterDate, `${ afterDate } - ${ before.date() }` );
|
|
} else if ( isSameYear ) {
|
|
const monthDayFormat = __( 'MMM D', 'woocommerce-admin' );
|
|
return `${ after.format( monthDayFormat ) } - ${ before.format(
|
|
fullDateFormat
|
|
) }`;
|
|
}
|
|
return `${ after.format( fullDateFormat ) } - ${ before.format(
|
|
fullDateFormat
|
|
) }`;
|
|
}
|
|
|
|
/**
|
|
* Get a DateValue object for a period prior to the current period.
|
|
*
|
|
* @param {string} period - the chosen period
|
|
* @param {string} compare - `previous_period` or `previous_year`
|
|
* @return {DateValue} - DateValue data about the selected period
|
|
*/
|
|
export function getLastPeriod( period, compare ) {
|
|
const primaryStart = moment()
|
|
.startOf( period )
|
|
.subtract( 1, period );
|
|
const primaryEnd = primaryStart.clone().endOf( period );
|
|
let secondaryStart;
|
|
let secondaryEnd;
|
|
|
|
if ( compare === 'previous_period' ) {
|
|
if ( period === 'year' ) {
|
|
// Subtract two entire periods for years to take into account leap year
|
|
secondaryStart = moment()
|
|
.startOf( period )
|
|
.subtract( 2, period );
|
|
secondaryEnd = secondaryStart.clone().endOf( period );
|
|
} else {
|
|
// Otherwise, use days in primary period to figure out how far to go back
|
|
const daysDiff = primaryEnd.diff( primaryStart, 'days' );
|
|
secondaryEnd = primaryStart.clone().subtract( 1, 'days' );
|
|
secondaryStart = secondaryEnd.clone().subtract( daysDiff, 'days' );
|
|
}
|
|
} else {
|
|
secondaryStart = primaryStart.clone().subtract( 1, 'years' );
|
|
secondaryEnd = primaryEnd.clone().subtract( 1, 'years' );
|
|
}
|
|
return {
|
|
primaryStart,
|
|
primaryEnd,
|
|
secondaryStart,
|
|
secondaryEnd,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a DateValue object for a curent period. The period begins on the first day of the period,
|
|
* and ends on the current day.
|
|
*
|
|
* @param {string} period - the chosen period
|
|
* @param {string} compare - `previous_period` or `previous_year`
|
|
* @return {DateValue} - DateValue data about the selected period
|
|
*/
|
|
export function getCurrentPeriod( period, compare ) {
|
|
const primaryStart = moment().startOf( period );
|
|
const primaryEnd = moment();
|
|
const daysSoFar = primaryEnd.diff( primaryStart, 'days' );
|
|
let secondaryStart;
|
|
let secondaryEnd;
|
|
|
|
if ( compare === 'previous_period' ) {
|
|
secondaryStart = primaryStart.clone().subtract( 1, period );
|
|
secondaryEnd = primaryEnd.clone().subtract( 1, period );
|
|
} else {
|
|
secondaryStart = primaryStart.clone().subtract( 1, 'years' );
|
|
// Set the end time to 23:59:59.
|
|
secondaryEnd = secondaryStart
|
|
.clone()
|
|
.add( daysSoFar + 1, 'days' )
|
|
.subtract( 1, 'seconds' );
|
|
}
|
|
return {
|
|
primaryStart,
|
|
primaryEnd,
|
|
secondaryStart,
|
|
secondaryEnd,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a DateValue object for a period described by a period, compare value, and start/end
|
|
* dates, for custom dates.
|
|
*
|
|
* @param {string} period - the chosen period
|
|
* @param {string} compare - `previous_period` or `previous_year`
|
|
* @param {Moment} [after] - after date if custom period
|
|
* @param {Moment} [before] - before date if custom period
|
|
* @return {DateValue} - DateValue data about the selected period
|
|
*/
|
|
function getDateValue( period, compare, after, before ) {
|
|
switch ( period ) {
|
|
case 'today':
|
|
return getCurrentPeriod( 'day', compare );
|
|
case 'yesterday':
|
|
return getLastPeriod( 'day', compare );
|
|
case 'week':
|
|
return getCurrentPeriod( 'week', compare );
|
|
case 'last_week':
|
|
return getLastPeriod( 'week', compare );
|
|
case 'month':
|
|
return getCurrentPeriod( 'month', compare );
|
|
case 'last_month':
|
|
return getLastPeriod( 'month', compare );
|
|
case 'quarter':
|
|
return getCurrentPeriod( 'quarter', compare );
|
|
case 'last_quarter':
|
|
return getLastPeriod( 'quarter', compare );
|
|
case 'year':
|
|
return getCurrentPeriod( 'year', compare );
|
|
case 'last_year':
|
|
return getLastPeriod( 'year', compare );
|
|
case 'custom':
|
|
const difference = before.diff( after, 'days' );
|
|
if ( compare === 'previous_period' ) {
|
|
const secondaryEnd = after.clone().subtract( 1, 'days' );
|
|
const secondaryStart = secondaryEnd
|
|
.clone()
|
|
.subtract( difference, 'days' );
|
|
return {
|
|
primaryStart: after,
|
|
primaryEnd: before,
|
|
secondaryStart,
|
|
secondaryEnd,
|
|
};
|
|
}
|
|
return {
|
|
primaryStart: after,
|
|
primaryEnd: before,
|
|
secondaryStart: after.clone().subtract( 1, 'years' ),
|
|
secondaryEnd: before.clone().subtract( 1, 'years' ),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add default date-related parameters to a query object
|
|
*
|
|
* @param {Object} query - query object
|
|
* @property {string} query.period - period value, ie `last_week`
|
|
* @property {string} query.compare - compare value, ie `previous_year`
|
|
* @property {string} query.after - date in iso date format, ie `2018-07-03`
|
|
* @property {string} query.before - date in iso date format, ie `2018-07-03`
|
|
* @param {string} defaultDateRange - the store's default date range
|
|
* @return {DateParams} - date parameters derived from query parameters with added defaults
|
|
*/
|
|
export const getDateParamsFromQuery = (
|
|
query,
|
|
defaultDateRange = 'period=month&compare=previous_year'
|
|
) => {
|
|
const { period, compare, after, before } = query;
|
|
if ( period && compare ) {
|
|
return {
|
|
period,
|
|
compare,
|
|
after: after ? moment( after ) : null,
|
|
before: before ? moment( before ) : null,
|
|
};
|
|
}
|
|
const queryDefaults = parse( defaultDateRange.replace( /&/g, '&' ) );
|
|
|
|
return {
|
|
period: queryDefaults.period,
|
|
compare: queryDefaults.compare,
|
|
after: queryDefaults.after ? moment( queryDefaults.after ) : null,
|
|
before: queryDefaults.before ? moment( queryDefaults.before ) : null,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Get Date Value Objects for a primary and secondary date range
|
|
*
|
|
* @param {Object} query - query object
|
|
* @property {string} query.period - period value, ie `last_week`
|
|
* @property {string} query.compare - compare value, ie `previous_year`
|
|
* @property {string} query.after - date in iso date format, ie `2018-07-03`
|
|
* @property {string} query.before - date in iso date format, ie `2018-07-03`
|
|
* @param {string} defaultDateRange - the store's default date range
|
|
* @return {{primary: DateValue, secondary: DateValue}} - Primary and secondary DateValue objects
|
|
*/
|
|
export const getCurrentDates = (
|
|
query,
|
|
defaultDateRange = 'period=month&compare=previous_year'
|
|
) => {
|
|
const { period, compare, after, before } = getDateParamsFromQuery(
|
|
query,
|
|
defaultDateRange
|
|
);
|
|
const {
|
|
primaryStart,
|
|
primaryEnd,
|
|
secondaryStart,
|
|
secondaryEnd,
|
|
} = getDateValue( period, compare, after, before );
|
|
|
|
return {
|
|
primary: {
|
|
label: find( presetValues, ( item ) => item.value === period )
|
|
.label,
|
|
range: getRangeLabel( primaryStart, primaryEnd ),
|
|
after: primaryStart,
|
|
before: primaryEnd,
|
|
},
|
|
secondary: {
|
|
label: find( periods, ( item ) => item.value === compare ).label,
|
|
range: getRangeLabel( secondaryStart, secondaryEnd ),
|
|
after: secondaryStart,
|
|
before: secondaryEnd,
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Calculates the date difference between two dates. Used in calculating a matching date for previous period.
|
|
*
|
|
* @param {string} date - Date to compare
|
|
* @param {string} date2 - Seconary date to compare
|
|
* @return {number} - Difference in days.
|
|
*/
|
|
export const getDateDifferenceInDays = ( date, date2 ) => {
|
|
const _date = moment( date );
|
|
const _date2 = moment( date2 );
|
|
return _date.diff( _date2, 'days' );
|
|
};
|
|
|
|
/**
|
|
* Get the previous date for either the previous period of year.
|
|
*
|
|
* @param {string} date - Base date
|
|
* @param {string|Moment.moment} date1 - primary start
|
|
* @param {string|Moment.moment} date2 - secondary start
|
|
* @param {string} compare - `previous_period` or `previous_year`
|
|
* @param {string} interval - interval
|
|
* @return {Moment.moment} - Calculated date
|
|
*/
|
|
export const getPreviousDate = ( date, date1, date2, compare, interval ) => {
|
|
const dateMoment = moment( date );
|
|
|
|
if ( compare === 'previous_year' ) {
|
|
return dateMoment.clone().subtract( 1, 'years' );
|
|
}
|
|
|
|
const _date1 = moment( date1 );
|
|
const _date2 = moment( date2 );
|
|
const difference = _date1.diff( _date2, interval );
|
|
|
|
return dateMoment.clone().subtract( difference, interval );
|
|
};
|
|
|
|
/**
|
|
* Returns the allowed selectable intervals for a specific query.
|
|
*
|
|
* @param {Object} query Current query
|
|
* @return {Array} Array containing allowed intervals.
|
|
*/
|
|
export function getAllowedIntervalsForQuery( query ) {
|
|
let allowed = [];
|
|
if ( query.period === 'custom' ) {
|
|
const { primary } = getCurrentDates( query );
|
|
const differenceInDays = getDateDifferenceInDays(
|
|
primary.before,
|
|
primary.after
|
|
);
|
|
if ( differenceInDays >= 365 ) {
|
|
allowed = [ 'day', 'week', 'month', 'quarter', 'year' ];
|
|
} else if ( differenceInDays >= 90 ) {
|
|
allowed = [ 'day', 'week', 'month', 'quarter' ];
|
|
} else if ( differenceInDays >= 28 ) {
|
|
allowed = [ 'day', 'week', 'month' ];
|
|
} else if ( differenceInDays >= 7 ) {
|
|
allowed = [ 'day', 'week' ];
|
|
} else if ( differenceInDays > 1 && differenceInDays < 7 ) {
|
|
allowed = [ 'day' ];
|
|
} else {
|
|
allowed = [ 'hour', 'day' ];
|
|
}
|
|
} else {
|
|
switch ( query.period ) {
|
|
case 'today':
|
|
case 'yesterday':
|
|
allowed = [ 'hour', 'day' ];
|
|
break;
|
|
case 'week':
|
|
case 'last_week':
|
|
allowed = [ 'day' ];
|
|
break;
|
|
case 'month':
|
|
case 'last_month':
|
|
allowed = [ 'day', 'week' ];
|
|
break;
|
|
case 'quarter':
|
|
case 'last_quarter':
|
|
allowed = [ 'day', 'week', 'month' ];
|
|
break;
|
|
case 'year':
|
|
case 'last_year':
|
|
allowed = [ 'day', 'week', 'month', 'quarter' ];
|
|
break;
|
|
default:
|
|
allowed = [ 'day' ];
|
|
break;
|
|
}
|
|
}
|
|
return allowed;
|
|
}
|
|
|
|
/**
|
|
* Returns the current interval to use.
|
|
*
|
|
* @param {Object} query Current query
|
|
* @return {string} Current interval.
|
|
*/
|
|
export function getIntervalForQuery( query ) {
|
|
const allowed = getAllowedIntervalsForQuery( query );
|
|
const defaultInterval = allowed[ 0 ];
|
|
let current = query.interval || defaultInterval;
|
|
if ( query.interval && ! allowed.includes( query.interval ) ) {
|
|
current = defaultInterval;
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* Returns the current chart type to use.
|
|
*
|
|
* @param {Object} query Current query
|
|
* @return {string} Current chart type.
|
|
*/
|
|
export function getChartTypeForQuery( { chartType } ) {
|
|
if ( [ 'line', 'bar' ].includes( chartType ) ) {
|
|
return chartType;
|
|
}
|
|
return 'line';
|
|
}
|
|
|
|
export const dayTicksThreshold = 63;
|
|
export const weekTicksThreshold = 9;
|
|
export const defaultTableDateFormat = 'm/d/Y';
|
|
|
|
/**
|
|
* Returns date formats for the current interval.
|
|
* See https://github.com/d3/d3-time-format for chart formats.
|
|
*
|
|
* @param {string} interval Interval to get date formats for.
|
|
* @param {number} [ticks] Number of ticks the axis will have.
|
|
* @return {string} Current interval.
|
|
*/
|
|
export function getDateFormatsForInterval( interval, ticks = 0 ) {
|
|
let screenReaderFormat = '%B %-d, %Y';
|
|
let tooltipLabelFormat = '%B %-d, %Y';
|
|
let xFormat = '%Y-%m-%d';
|
|
let x2Format = '%b %Y';
|
|
let tableFormat = defaultTableDateFormat;
|
|
|
|
switch ( interval ) {
|
|
case 'hour':
|
|
screenReaderFormat = '%_I%p %B %-d, %Y';
|
|
tooltipLabelFormat = '%_I%p %b %-d, %Y';
|
|
xFormat = '%_I%p';
|
|
x2Format = '%b %-d, %Y';
|
|
tableFormat = 'h A';
|
|
break;
|
|
case 'day':
|
|
if ( ticks < dayTicksThreshold ) {
|
|
xFormat = '%-d';
|
|
} else {
|
|
xFormat = '%b';
|
|
x2Format = '%Y';
|
|
}
|
|
break;
|
|
case 'week':
|
|
if ( ticks < weekTicksThreshold ) {
|
|
xFormat = '%-d';
|
|
x2Format = '%b %Y';
|
|
} else {
|
|
xFormat = '%b';
|
|
x2Format = '%Y';
|
|
}
|
|
screenReaderFormat = __(
|
|
'Week of %B %-d, %Y',
|
|
'woocommerce-admin'
|
|
);
|
|
tooltipLabelFormat = __(
|
|
'Week of %B %-d, %Y',
|
|
'woocommerce-admin'
|
|
);
|
|
break;
|
|
case 'quarter':
|
|
case 'month':
|
|
screenReaderFormat = '%B %Y';
|
|
tooltipLabelFormat = '%B %Y';
|
|
xFormat = '%b';
|
|
x2Format = '%Y';
|
|
break;
|
|
case 'year':
|
|
screenReaderFormat = '%Y';
|
|
tooltipLabelFormat = '%Y';
|
|
xFormat = '%Y';
|
|
break;
|
|
}
|
|
|
|
return {
|
|
screenReaderFormat,
|
|
tooltipLabelFormat,
|
|
xFormat,
|
|
x2Format,
|
|
tableFormat,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Gutenberg's moment instance is loaded with i18n values, which are
|
|
* PHP date formats, ie 'LLL: "F j, Y g:i a"'. Override those with translations
|
|
* of moment style js formats.
|
|
*
|
|
* @param {Object} config Locale config object, from store settings.
|
|
*/
|
|
export function loadLocaleData( { userLocale, weekdaysShort } ) {
|
|
// Don't update if the wp locale hasn't been set yet, like in unit tests, for instance.
|
|
if ( moment.locale() !== 'en' ) {
|
|
moment.updateLocale( userLocale, {
|
|
longDateFormat: {
|
|
L: __( 'MM/DD/YYYY', 'woocommerce-admin' ),
|
|
LL: __( 'MMMM D, YYYY', 'woocommerce-admin' ),
|
|
LLL: __( 'D MMMM YYYY LT', 'woocommerce-admin' ),
|
|
LLLL: __( 'dddd, D MMMM YYYY LT', 'woocommerce-admin' ),
|
|
LT: __( 'HH:mm', 'woocommerce-admin' ),
|
|
},
|
|
weekdaysMin: weekdaysShort,
|
|
} );
|
|
}
|
|
}
|
|
|
|
export const dateValidationMessages = {
|
|
invalid: __( 'Invalid date', 'woocommerce-admin' ),
|
|
future: __( 'Select a date in the past', 'woocommerce-admin' ),
|
|
startAfterEnd: __(
|
|
'Start date must be before end date',
|
|
'woocommerce-admin'
|
|
),
|
|
endBeforeStart: __(
|
|
'Start date must be before end date',
|
|
'woocommerce-admin'
|
|
),
|
|
};
|
|
|
|
/**
|
|
* @typedef {Object} validatedDate
|
|
* @property {Moment|null} validatedDate.date - A resulting Moment date object or null, if invalid
|
|
* @property {string} validatedDate.error - An optional error message if date is invalid
|
|
*/
|
|
|
|
/**
|
|
* Validate text input supplied for a date range.
|
|
*
|
|
* @param {string} type - Designate beginning or end of range, eg `before` or `after`.
|
|
* @param {string} value - User input value
|
|
* @param {Moment|null} [before] - If already designated, the before date parameter
|
|
* @param {Moment|null} [after] - If already designated, the after date parameter
|
|
* @param {string} format - The expected date format in a user's locale
|
|
* @return {Object} validatedDate - validated date object
|
|
*/
|
|
export function validateDateInputForRange(
|
|
type,
|
|
value,
|
|
before,
|
|
after,
|
|
format
|
|
) {
|
|
const date = toMoment( format, value );
|
|
if ( ! date ) {
|
|
return {
|
|
date: null,
|
|
error: dateValidationMessages.invalid,
|
|
};
|
|
}
|
|
if ( moment().isBefore( date, 'day' ) ) {
|
|
return {
|
|
date: null,
|
|
error: dateValidationMessages.future,
|
|
};
|
|
}
|
|
if ( type === 'after' && before && date.isAfter( before, 'day' ) ) {
|
|
return {
|
|
date: null,
|
|
error: dateValidationMessages.startAfterEnd,
|
|
};
|
|
}
|
|
if ( type === 'before' && after && date.isBefore( after, 'day' ) ) {
|
|
return {
|
|
date: null,
|
|
error: dateValidationMessages.endBeforeStart,
|
|
};
|
|
}
|
|
return { date };
|
|
}
|