// @flow strict // $FlowFixMe - strict types for date-fns import parseISO from 'date-fns/parseISO'; import invariant from 'invariant'; import {chunk, isEmpty, range} from 'lodash'; // $FlowFixMe[untyped-import] import moment from 'moment'; import type { DateRange, DateRangePickerError, DateRangePickerErrorTypes, DateRangeWithTimezone, TimeUnit, } from 'src/types/date-range-picker'; import type {MenuOption} from '../../components/Menu'; import {TIMEZONES} from './timezones'; export const makeKey = (value: string): string => { try { if (value && typeof value === 'string') { return value .replace(/\(s\)/gi, '_s') // Replace (s) with _s (case-insensitive) .replace(/[^\w\s']/g, '') // Remove special chars except apostrophes .replace(/\s+/g, '_') // Spaces → underscores .replace(/_+/g, '_') // Collapse multiple ____ into _ .replace(/^_|_$/g, ''); // Trim leading/trailing _ } return value; } catch { return value; } }; export const NAVIGATION_ACTION = Object.freeze({ NEXT: 'next', PREV: 'prev', }); export const MARKERS = Object.freeze({ DATE_RANGE_START: 'FIRST', DATE_RANGE_END: 'SECOND', }); export const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; export const getMonths = ( t: ?(key: string, fallback: string) => string, ): Array => [ {key: '0', label: getTranslation(t, 'Jan')}, {key: '1', label: getTranslation(t, 'Feb')}, {key: '2', label: getTranslation(t, 'Mar')}, {key: '3', label: getTranslation(t, 'Apr')}, {key: '4', label: getTranslation(t, 'May')}, {key: '5', label: getTranslation(t, 'Jun')}, {key: '6', label: getTranslation(t, 'Jul')}, {key: '7', label: getTranslation(t, 'Aug')}, {key: '8', label: getTranslation(t, 'Sep')}, {key: '9', label: getTranslation(t, 'Oct')}, {key: '10', label: getTranslation(t, 'Nov')}, {key: '11', label: getTranslation(t, 'Dec')}, ]; export const getDateRangePickerErrors = ( t: ?(key: string, fallback: string) => string, ): DateRangePickerErrorTypes => ({ MIN_MAX_INVALID: { type: 'MIN_MAX_INVALID', description: getTranslation(t, 'Given minDate and maxDate are invalid.'), }, START_DATE_EARLY: { type: 'START_DATE_EARLY', description: getTranslation( t, 'Given startDate can not come before minDate.', ), }, START_DATE_LATE: { type: 'START_DATE_LATE', description: getTranslation( t, 'Given startDate can not come after endDate.', ), }, END_DATE_LATE: { type: 'END_DATE_LATE', description: getTranslation(t, 'Given endDate can not come after maxDate.'), }, }); export const checkRangeValidity = ( rangeStart?: ?string, rangeEnd?: ?string, errorBody: DateRangePickerError, onError?: (DateRangePickerError) => void, ): boolean => { const isRangeStartValid = isValid(rangeStart); const isRangeEndValid = isRangeStartValid && isValid(rangeEnd); const isRangeValid = isRangeEndValid && isSameOrBefore(rangeStart, rangeEnd); invariant(isRangeValid, JSON.stringify(errorBody)); if (!isRangeValid) { onError?.(errorBody); } return isRangeValid; }; export const wrangleMoment = (date?: string | Date): Date => { if (date instanceof Date) { return date; } else if (!date) { return new Date(); } return date instanceof moment ? date.toDate() : parseISO(date); }; export const formatIsoDate = ( date?: string | Date, format?: string = 'YYYY-MM-DD', ): string => moment.utc(date).format(format); export const isStartOfRange = ({startDate}: DateRange, date: string): boolean => Boolean(startDate) && moment.utc(date).isSame(moment.utc(startDate), 'd'); export const isEndOfRange = ({endDate}: DateRange, date: string): boolean => Boolean(endDate) && moment.utc(date).isSame(moment.utc(endDate), 'd'); export const inDateRange = ( {startDate, endDate}: DateRange, date: string, ): boolean => { if (startDate && endDate) { const momentDay = moment.utc(date); const momentStartDate = moment.utc(startDate); const momentEndDate = moment.utc(endDate); return isBetween(momentDay, momentStartDate, momentEndDate); } return false; }; export const isStartDateEndDateSame = ({ startDate, endDate, }: DateRange): boolean => { if (startDate && endDate) { return moment.utc(startDate).isSame(moment.utc(endDate), 'd'); } return false; }; const getMonthAndYear = (date) => { const momentDate = date ? moment.utc(date) : moment.utc(); return [momentDate.month(), momentDate.year()]; }; export const getDaysInMonth = (date: string): Array> => { const startWeek = moment.utc(date).startOf('month').startOf('week'); const endWeek = moment.utc(date).endOf('month').endOf('week'); const days = [], current = startWeek; while (isBefore(current, endWeek)) { days.push(current.clone().format('YYYY-MM-DD')); current.add(1, 'd'); } const daysInChunks = chunk(days, 7); // if total rows in calendar are 5 add one more week to the calendar if (daysInChunks.length === 5) { const nextWeek = getAddedDate(endWeek, WEEKDAYS.length, 'd'); const extraDays = []; while (isSameOrBefore(current, nextWeek)) { extraDays.push(current.clone().format('YYYY-MM-DD')); current.add(1, 'd'); } daysInChunks.push(extraDays); } return daysInChunks; }; export const getAddedDate = ( date: string, addCount: number, timeUnit: TimeUnit, ): string => formatIsoDate(moment.utc(date).add(addCount, timeUnit)); export const getSubtractedDate = ( date: string, subtractCount: number, timeUnit: TimeUnit, ): string => formatIsoDate(moment.utc(date).subtract(subtractCount, timeUnit)); export const getMonthEndDate = (date: string): string => formatIsoDate(moment.utc(date).endOf('M')); export const getTimezones = ( t: ?(key: string, fallback: string) => string, ): Array => Object.keys(TIMEZONES).reduce((menuOptions, key) => { menuOptions.push({ key, label: getTranslation(t, TIMEZONES[key]), }); return menuOptions; }, []); export const generateAvailableYears = ({ marker, minDate, maxDate, rangeStartMonth, rangeEndMonth, }: { marker: $Values, minDate: string, maxDate: string, rangeStartMonth: string, rangeEndMonth: string, }): Array => { const rangeStartYear = moment.utc(rangeStartMonth).year(); const rangeEndYear = moment.utc(rangeEndMonth).year(); const isWithinRange = (year: number) => marker === MARKERS.DATE_RANGE_START ? year <= rangeEndYear : year >= rangeStartYear; return range(moment.utc(minDate).year(), moment.utc(maxDate).year() + 1) .filter((year) => isWithinRange(year)) .map((year) => ({ key: year.toString(), label: year.toString(), })); }; export const getAvailableMonths = ({ marker, minDate, maxDate, rangeStartMonth, rangeEndMonth, t, }: { marker: $Values, minDate: string, maxDate: string, rangeStartMonth: string, rangeEndMonth: string, t: ?(key: string, fallback: string) => string, }): Array => { const [rangeStartMonthKey, rangeStartYear] = getMonthAndYear(rangeStartMonth); const [rangeEndMonthKey, rangeEndYear] = getMonthAndYear(rangeEndMonth); const [minDateMonth, minDateYear] = getMonthAndYear(minDate); const [maxDateMonth, maxDateYear] = getMonthAndYear(maxDate); const MONTHS = getMonths(t); return MONTHS.filter((month: MenuOption) => { const isSameYear = rangeStartYear === rangeEndYear; const isFirstAndMinDateYearSame = rangeStartYear === minDateYear; const isSecondAndMaxDateYearSame = rangeEndYear === maxDateYear; if (marker === MARKERS.DATE_RANGE_START) { if (isSameYear && month.key >= rangeEndMonthKey) { return false; } else if (isFirstAndMinDateYearSame && month.key < minDateMonth) { return false; } else { return true; } } else { if (isSameYear && month.key <= rangeStartMonthKey) { return false; } else if (isSecondAndMaxDateYearSame && month.key > maxDateMonth) { return false; } else { return true; } } }); }; export const getValidDates = ({ selectedDateRange, minDate, maxDate, today, onError, t, }: { selectedDateRange: DateRangeWithTimezone, minDate?: ?string, maxDate?: ?string, today: string, onError?: (DateRangePickerError) => void, t: ?(key: string, fallback: string) => string, }): { validMinDate: string, validMaxDate: string, validDateRange: DateRange, } => { const {startDate, endDate} = selectedDateRange; const validMaxDate = maxDate && !isEmpty(maxDate) ? maxDate : today; const validMinDate = minDate && !isEmpty(minDate) ? minDate : getSubtractedDate(today, 8, 'y'); const isRangeValid = (min, max, errorMessage) => checkRangeValidity(min, max, errorMessage, onError); // minDate should be after maxDate const isMinMaxRangeInvalid = !isRangeValid( validMinDate, validMaxDate, getDateRangePickerErrors(t).MIN_MAX_INVALID, ); // if startDate is defined and then it should be after minDate const isStartDateInvalid = isMinMaxRangeInvalid || isEmpty(startDate) || !isRangeValid( validMinDate, startDate, getDateRangePickerErrors(t).START_DATE_EARLY, ); if (isMinMaxRangeInvalid || isStartDateInvalid) { return { validDateRange: {startDate: null, endDate: null}, validMinDate, validMaxDate, }; } // if endDate is defined then it should be before maxDate const isEndDateInvalid = isEmpty(endDate) || !isRangeValid( endDate, validMaxDate, getDateRangePickerErrors(t).END_DATE_LATE, ); // startDate should be before endDate const isStartEndRangeInvalid = isEndDateInvalid || !isRangeValid( startDate, endDate, getDateRangePickerErrors(t).START_DATE_LATE, ); if (isEndDateInvalid || isStartEndRangeInvalid) { return { validDateRange: {startDate, endDate: null}, validMinDate, validMaxDate, }; } return { validDateRange: { startDate: isEmpty(startDate) ? null : startDate, endDate: isEmpty(endDate) ? null : endDate, }, validMinDate, validMaxDate, }; }; // If date1 is same as date2 w.r.t the unit passed export const isSame = ( date1: string, date2: string, unit: 'd' | 'month', ): boolean => moment.utc(date1).isSame(moment.utc(date2), unit); // If date1 is before date2 export const isBefore = (date1: string, date2: string): boolean => moment.utc(date1).isBefore(moment.utc(date2)); // If date1 is after date2 w.r.t the unit passed export const isAfter = (date1: string, date2: string): boolean => moment.utc(date1).isAfter(moment.utc(date2)); // If date1 is same or before date2 w.r.t the unit passed export const isSameOrBefore = ( date1?: ?string, date2?: ?string, unit?: 'd' | 'month', ): boolean => moment.utc(date1).isSameOrBefore(moment.utc(date2), unit); // If date1 is same or after date2 w.r.t the unit passed export const isSameOrAfter = ( date1?: ?string, date2?: ?string, unit?: 'd' | 'month', ): boolean => moment.utc(date1).isSameOrAfter(moment.utc(date2), unit); // If date is between startRange and endRange export const isBetween = ( date: string, startRange: string, endRange: string, ): boolean => moment .utc(date) .isBetween(moment.utc(startRange), moment.utc(endRange), null, '[]'); // If the date results in a date that exists in the calendar export const isValid = (date?: ?string): boolean => moment.utc(date).isValid(); export const getFormattedDate = ( marker: string, dateRange: DateRange, locale?: string, ): string => { const {startDate, endDate} = dateRange; // set locale if provided if (locale) { moment.locale(locale); } switch (marker) { case MARKERS.DATE_RANGE_START: return startDate ? moment.utc(startDate).format('MMM DD, YYYY') : 'MMM DD, YYYY'; default: return endDate ? moment.utc(endDate).format('MMM DD, YYYY') : 'MMM DD, YYYY'; } }; export const getTranslation = ( t: ?(key: string, fallback: string) => string, labelToTranslate: string, ): string => t ? t(makeKey(labelToTranslate), labelToTranslate) : labelToTranslate;