diff --git a/src/utils/dates/types/index.ts b/src/utils/dates/types/index.ts index 467a0e8f..6041a68f 100644 --- a/src/utils/dates/types/index.ts +++ b/src/utils/dates/types/index.ts @@ -1,13 +1,13 @@ import { subDays, startOfDay, endOfDay } from 'date-fns'; -import { filter, isEmpty } from 'ramda'; -import { formatInternational } from '../../helpers/date'; +import { cond, filter, isEmpty, T } from 'ramda'; +import { DateOrString, formatInternational, isBeforeOrEqual, parseISO } from '../../helpers/date'; export interface DateRange { startDate?: Date | null; endDate?: Date | null; } -export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days'; +export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180Days' | 'last365Days'; export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined || isEmpty(filter(Boolean, dateRange as any)); @@ -21,7 +21,7 @@ const INTERVAL_TO_STRING_MAP: Record = { last7Days: 'Last 7 days', last30Days: 'Last 30 days', last90Days: 'Last 90 days', - last180days: 'Last 180 days', + last180Days: 'Last 180 days', last365Days: 'Last 365 days', all: undefined, }; @@ -75,7 +75,7 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => { return endingToday(startOfDaysAgo(30)); case 'last90Days': return endingToday(startOfDaysAgo(90)); - case 'last180days': + case 'last180Days': return endingToday(startOfDaysAgo(180)); case 'last365Days': return endingToday(startOfDaysAgo(365)); @@ -83,3 +83,18 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => { return {}; }; + +export const dateToMatchingInterval = (date: DateOrString): DateInterval => { + const theDate: Date = parseISO(date); + + return cond([ + [ () => isBeforeOrEqual(startOfDay(new Date()), theDate), () => 'today' ], + [ () => isBeforeOrEqual(startOfDaysAgo(1), theDate), () => 'yesterday' ], + [ () => isBeforeOrEqual(startOfDaysAgo(7), theDate), () => 'last7Days' ], + [ () => isBeforeOrEqual(startOfDaysAgo(30), theDate), () => 'last30Days' ], + [ () => isBeforeOrEqual(startOfDaysAgo(90), theDate), () => 'last90Days' ], + [ () => isBeforeOrEqual(startOfDaysAgo(180), theDate), () => 'last180Days' ], + [ () => isBeforeOrEqual(startOfDaysAgo(365), theDate), () => 'last365Days' ], + [ T, () => 'all' ], + ])(); +}; diff --git a/src/utils/helpers/date.ts b/src/utils/helpers/date.ts index 5ce27ae3..98aa1d4d 100644 --- a/src/utils/helpers/date.ts +++ b/src/utils/helpers/date.ts @@ -1,7 +1,8 @@ -import { format, formatISO, isAfter, isBefore, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns'; +import { format, formatISO, isBefore, isEqual, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns'; import { OptionalString } from '../utils'; -type DateOrString = Date | string; +export type DateOrString = Date | string; + type NullableDate = DateOrString | null; export const isDateObject = (date: DateOrString): date is Date => typeof date !== 'string'; @@ -22,20 +23,15 @@ export const formatInternational = formatDate(); export const parseDate = (date: string, format: string) => parse(date, format, new Date()); -const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date); +export const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date); export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => { - if (!start && end) { - return isBefore(parseISO(date), parseISO(end)); + try { + return isWithinInterval(parseISO(date), { start: parseISO(start ?? date), end: parseISO(end ?? date) }); + } catch (e) { + return false; } - - if (start && !end) { - return isAfter(parseISO(date), parseISO(start)); - } - - if (start && end) { - return isWithinInterval(parseISO(date), { start: parseISO(start), end: parseISO(end) }); - } - - return true; }; + +export const isBeforeOrEqual = (date: Date | number, dateToCompare: Date | number) => + isEqual(date, dateToCompare) || isBefore(date, dateToCompare); diff --git a/src/utils/helpers/hooks.ts b/src/utils/helpers/hooks.ts index 6e9548ad..7d908852 100644 --- a/src/utils/helpers/hooks.ts +++ b/src/utils/helpers/hooks.ts @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, EffectCallback, DependencyList, useEffect } from 'react'; import { useSwipeable as useReactSwipeable } from 'react-swipeable'; import { parseQuery, stringifyQuery } from './query'; @@ -66,3 +66,12 @@ export const useQueryState = (paramName: string, initialState: T): [ T, (newV return [ value, setValueWithLocation ]; }; + +export const useEffectExceptFirstTime = (callback: EffectCallback, deps: DependencyList): void => { + const isFirstLoad = useRef(true); + + useEffect(() => { + !isFirstLoad.current && callback(); + isFirstLoad.current = false; + }, deps); +}; diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 05f8226b..03789a28 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -1,7 +1,7 @@ import { Action } from 'redux'; import { ShortUrl } from '../../short-urls/data'; import { ProblemDetailsError, ShlinkVisitsParams } from '../../api/types'; -import { DateRange } from '../../utils/dates/types'; +import { DateInterval, DateRange } from '../../utils/dates/types'; export interface VisitsInfo { visits: Visit[]; @@ -12,12 +12,17 @@ export interface VisitsInfo { progress: number; cancelLoad: boolean; query?: ShlinkVisitsParams; + fallbackInterval?: DateInterval; } export interface VisitsLoadProgressChangedAction extends Action { progress: number; } +export interface VisitsFallbackIntervalAction extends Action { + fallbackInterval: DateInterval; +} + export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; interface VisitLocation { diff --git a/test/settings/Visits.test.tsx b/test/settings/Visits.test.tsx index cfbaf833..89a73546 100644 --- a/test/settings/Visits.test.tsx +++ b/test/settings/Visits.test.tsx @@ -55,12 +55,12 @@ describe('', () => { const selector = wrapper.find(DateIntervalSelector); selector.simulate('change', 'last7Days'); - selector.simulate('change', 'last180days'); + selector.simulate('change', 'last180Days'); selector.simulate('change', 'yesterday'); expect(setVisitsSettings).toHaveBeenCalledTimes(3); expect(setVisitsSettings).toHaveBeenNthCalledWith(1, { defaultInterval: 'last7Days' }); - expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180days' }); + expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180Days' }); expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' }); }); }); diff --git a/test/settings/reducers/settings.test.ts b/test/settings/reducers/settings.test.ts index 9699f57a..70c5d4b7 100644 --- a/test/settings/reducers/settings.test.ts +++ b/test/settings/reducers/settings.test.ts @@ -54,9 +54,9 @@ describe('settingsReducer', () => { describe('setVisitsSettings', () => { it('creates action to set visits settings', () => { - const result = setVisitsSettings({ defaultInterval: 'last180days' }); + const result = setVisitsSettings({ defaultInterval: 'last180Days' }); - expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180days' } }); + expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180Days' } }); }); }); }); diff --git a/test/utils/dates/DateIntervalDropdownItems.test.tsx b/test/utils/dates/DateIntervalDropdownItems.test.tsx index d77fb1e4..8587e5e9 100644 --- a/test/utils/dates/DateIntervalDropdownItems.test.tsx +++ b/test/utils/dates/DateIntervalDropdownItems.test.tsx @@ -8,7 +8,7 @@ describe('', () => { const onChange = jest.fn(); beforeEach(() => { - wrapper = shallow(); + wrapper = shallow(); }); afterEach(jest.clearAllMocks); diff --git a/test/utils/dates/types/index.test.ts b/test/utils/dates/types/index.test.ts index fa7dc1d4..21cec68f 100644 --- a/test/utils/dates/types/index.test.ts +++ b/test/utils/dates/types/index.test.ts @@ -1,7 +1,8 @@ -import { format, subDays } from 'date-fns'; +import { endOfDay, format, formatISO, startOfDay, subDays } from 'date-fns'; import { DateInterval, dateRangeIsEmpty, + dateToMatchingInterval, intervalToDateRange, rangeIsInterval, rangeOrIntervalToString, @@ -9,6 +10,9 @@ import { import { parseDate } from '../../../../src/utils/helpers/date'; describe('date-types', () => { + const now = () => new Date(); + const daysBack = (days: number) => subDays(new Date(), days); + describe('dateRangeIsEmpty', () => { it.each([ [ undefined, true ], @@ -48,7 +52,7 @@ describe('date-types', () => { [ 'last7Days' as DateInterval, 'Last 7 days' ], [ 'last30Days' as DateInterval, 'Last 30 days' ], [ 'last90Days' as DateInterval, 'Last 90 days' ], - [ 'last180days' as DateInterval, 'Last 180 days' ], + [ 'last180Days' as DateInterval, 'Last 180 days' ], [ 'last365Days' as DateInterval, 'Last 365 days' ], [{}, undefined ], [{ startDate: null }, undefined ], @@ -71,8 +75,6 @@ describe('date-types', () => { }); describe('intervalToDateRange', () => { - const now = () => new Date(); - const daysBack = (days: number) => subDays(new Date(), days); const formatted = (date?: Date | null): string | undefined => !date ? undefined : format(date, 'yyyy-MM-dd'); it.each([ @@ -82,7 +84,7 @@ describe('date-types', () => { [ 'last7Days' as DateInterval, daysBack(7), now() ], [ 'last30Days' as DateInterval, daysBack(30), now() ], [ 'last90Days' as DateInterval, daysBack(90), now() ], - [ 'last180days' as DateInterval, daysBack(180), now() ], + [ 'last180Days' as DateInterval, daysBack(180), now() ], [ 'last365Days' as DateInterval, daysBack(365), now() ], ])('returns proper result', (interval, expectedStartDate, expectedEndDate) => { const { startDate, endDate } = intervalToDateRange(interval); @@ -91,4 +93,27 @@ describe('date-types', () => { expect(formatted(expectedEndDate)).toEqual(formatted(endDate)); }); }); + + describe('dateToMatchingInterval', () => { + it.each([ + [ startOfDay(now()), 'today' ], + [ now(), 'today' ], + [ formatISO(now()), 'today' ], + [ daysBack(1), 'yesterday' ], + [ endOfDay(daysBack(1)), 'yesterday' ], + [ daysBack(2), 'last7Days' ], + [ daysBack(7), 'last7Days' ], + [ startOfDay(daysBack(7)), 'last7Days' ], + [ daysBack(18), 'last30Days' ], + [ daysBack(29), 'last30Days' ], + [ daysBack(58), 'last90Days' ], + [ startOfDay(daysBack(90)), 'last90Days' ], + [ daysBack(120), 'last180Days' ], + [ daysBack(250), 'last365Days' ], + [ daysBack(366), 'all' ], + [ formatISO(daysBack(500)), 'all' ], + ])('returns the first interval which contains provided date', (date, expectedInterval) => { + expect(dateToMatchingInterval(date)).toEqual(expectedInterval); + }); + }); }); diff --git a/test/utils/helpers/date.test.ts b/test/utils/helpers/date.test.ts index ac0444a7..349fae49 100644 --- a/test/utils/helpers/date.test.ts +++ b/test/utils/helpers/date.test.ts @@ -1,7 +1,9 @@ -import { formatISO } from 'date-fns'; -import { formatDate, formatIsoDate, parseDate } from '../../../src/utils/helpers/date'; +import { addDays, formatISO, subDays } from 'date-fns'; +import { formatDate, formatIsoDate, isBeforeOrEqual, isBetween, parseDate } from '../../../src/utils/helpers/date'; describe('date', () => { + const now = new Date(); + describe('formatDate', () => { it.each([ [ parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'), 'dd/MM/yyyy', '05/03/2020' ], @@ -30,4 +32,32 @@ describe('date', () => { expect(formatIsoDate(date)).toEqual(expected); }); }); + + describe('isBetween', () => { + test.each([ + [ now, undefined, undefined, true ], + [ now, subDays(now, 1), undefined, true ], + [ now, now, undefined, true ], + [ now, undefined, addDays(now, 1), true ], + [ now, undefined, now, true ], + [ now, subDays(now, 1), addDays(now, 1), true ], + [ now, now, now, true ], + [ now, addDays(now, 1), undefined, false ], + [ now, undefined, subDays(now, 1), false ], + [ now, subDays(now, 3), subDays(now, 1), false ], + [ now, addDays(now, 1), addDays(now, 3), false ], + ])('returns true when a date is between provided range', (date, start, end, expectedResult) => { + expect(isBetween(date, start, end)).toEqual(expectedResult); + }); + }); + + describe('isBeforeOrEqual', () => { + test.each([ + [ now, now, true ], + [ now, addDays(now, 1), true ], + [ now, subDays(now, 1), false ], + ])('returns true when the date before or equal to provided one', (date, dateToCompare, expectedResult) => { + expect(isBeforeOrEqual(date, dateToCompare)).toEqual(expectedResult); + }); + }); });