diff --git a/assets/js/dashboard/dashboard-state-context.tsx b/assets/js/dashboard/dashboard-state-context.tsx index 46641ec5ddd3..5c021b4f7501 100644 --- a/assets/js/dashboard/dashboard-state-context.tsx +++ b/assets/js/dashboard/dashboard-state-context.tsx @@ -5,7 +5,7 @@ import * as api from './api' import { useSiteContext } from './site-context' import { parseSearch } from './util/url-search-params' import dayjs from 'dayjs' -import { nowForSite, yesterday } from './util/date' +import { now, yesterday } from './util/date' import { getDashboardTimeSettings, getSavedTimePreferencesFromStorage, @@ -95,18 +95,18 @@ export default function DashboardStateContextProvider({ date: typeof date === 'string' && date.length ? dayjs.utc(date) - : nowForSite(site).startOf('day'), + : now(site.offset).startOf('day'), from: typeof from === 'string' && from.length ? dayjs.utc(from) : timeSettings.period === DashboardPeriod.custom - ? yesterday(site) + ? yesterday(site.offset) : defaultValues.from, to: typeof to === 'string' && to.length ? dayjs.utc(to) : timeSettings.period === DashboardPeriod.custom - ? nowForSite(site) + ? now(site.offset) : defaultValues.to, with_imported: [true, false].includes(with_imported as boolean) ? (with_imported as boolean) diff --git a/assets/js/dashboard/dashboard-state.ts b/assets/js/dashboard/dashboard-state.ts index 3e5175c9f321..81aba87e1e84 100644 --- a/assets/js/dashboard/dashboard-state.ts +++ b/assets/js/dashboard/dashboard-state.ts @@ -1,5 +1,5 @@ import { - nowForSite, + now, formatISO, shiftDays, shiftMonths, @@ -152,7 +152,7 @@ export function isDateBeforeOrOnCurrentDate({ date: string period: DashboardPeriod }) { - const currentDate = nowForSite(site) + const currentDate = now(site.offset) return !isAfter(parseUTCDate(date), currentDate, period) } diff --git a/assets/js/dashboard/dashboard-time-periods.ts b/assets/js/dashboard/dashboard-time-periods.ts index 308329931303..780c94439cfc 100644 --- a/assets/js/dashboard/dashboard-time-periods.ts +++ b/assets/js/dashboard/dashboard-time-periods.ts @@ -18,7 +18,7 @@ import { isToday, isTodayOrYesterday, lastMonth, - nowForSite, + now, parseNaiveDate, yesterday } from './util/date' @@ -49,6 +49,18 @@ export enum ComparisonMode { custom = 'custom' } +export type DashboardTimeSettings = { + siteTimezoneOffset: PlausibleSite['offset'] + siteStatsBegin: PlausibleSite['statsBegin'] + date: DashboardState['date'] + period: DashboardState['period'] + from: DashboardState['from'] + to: DashboardState['to'] + comparison: DashboardState['comparison'] + compare_from: DashboardState['compare_from'] + compare_to: DashboardState['compare_to'] +} + export const COMPARISON_MODES = { [ComparisonMode.off]: 'Disable comparison', [ComparisonMode.previous_period]: 'Previous period', @@ -73,6 +85,41 @@ const COMPARISON_DISABLED_PERIODS = [ DashboardPeriod.all ] +const PERIODS_EXCLUDING_NOW = [ + DashboardPeriod['7d'], + DashboardPeriod['28d'], + DashboardPeriod['30d'], + DashboardPeriod['91d'], + DashboardPeriod['6mo'], + DashboardPeriod['12mo'] +] + +export function isHistoricalPeriod({ + siteTimezoneOffset, + date, + period, + from, + to, + comparison, + compare_from, + compare_to +}: DashboardTimeSettings) { + const startOfDay = now(siteTimezoneOffset).startOf('day') + + const mainPeriodIncludesToday = + period === DashboardPeriod.custom && to && from + ? !to.isBefore(startOfDay) + : !(date?.isBefore(startOfDay) || PERIODS_EXCLUDING_NOW.includes(period)) + + const comparisonPeriodIncludesToday = + comparison === ComparisonMode.custom && + compare_to && + compare_from && + !compare_to.isBefore(startOfDay) + + return !(mainPeriodIncludesToday || comparisonPeriodIncludesToday) +} + export const isComparisonForbidden = ({ period, segmentIsExpanded @@ -285,12 +332,12 @@ export const getDatePeriodGroups = ({ ...s, ...clearedDateSearch, period: DashboardPeriod.day, - date: formatISO(nowForSite(site)), + date: formatISO(now(site.offset)), keybindHint: 'D' }), isActive: ({ dashboardState }) => dashboardState.period === DashboardPeriod.day && - isSameDate(dashboardState.date, nowForSite(site)), + isSameDate(dashboardState.date, now(site.offset)), onEvent } ], @@ -301,12 +348,12 @@ export const getDatePeriodGroups = ({ ...s, ...clearedDateSearch, period: DashboardPeriod.day, - date: formatISO(yesterday(site)), + date: formatISO(yesterday(site.offset)), keybindHint: 'E' }), isActive: ({ dashboardState }) => dashboardState.period === DashboardPeriod.day && - isSameDate(dashboardState.date, yesterday(site)), + isSameDate(dashboardState.date, yesterday(site.offset)), onEvent } ], @@ -410,7 +457,7 @@ export const getDatePeriodGroups = ({ }), isActive: ({ dashboardState }) => dashboardState.period === DashboardPeriod.month && - isSameMonth(dashboardState.date, nowForSite(site)), + isSameMonth(dashboardState.date, now(site.offset)), onEvent } ], diff --git a/assets/js/dashboard/filtering/segments.ts b/assets/js/dashboard/filtering/segments.ts index 705f73bc9593..bbe41e580f6e 100644 --- a/assets/js/dashboard/filtering/segments.ts +++ b/assets/js/dashboard/filtering/segments.ts @@ -34,9 +34,9 @@ export type SavedSegment = { id: number name: string type: SegmentType - /** datetime in site timezone, example 2025-02-26 10:00:00 */ + /** naive UTC timestamp, example 2025-02-26 10:00:00 */ inserted_at: string - /** datetime in site timezone, example 2025-02-26 10:00:00 */ + /** naive UTC timestamp, example 2025-02-26 10:00:00 */ updated_at: string } & SegmentOwnership diff --git a/assets/js/dashboard/hooks/api-client.test.ts b/assets/js/dashboard/hooks/api-client.test.ts new file mode 100644 index 000000000000..cd7f4498973b --- /dev/null +++ b/assets/js/dashboard/hooks/api-client.test.ts @@ -0,0 +1,153 @@ +import { ComparisonMode, DashboardPeriod } from '../dashboard-time-periods' +import { formatISO, utcNow } from '../util/date' +import { + CACHE_TTL_HISTORICAL, + CACHE_TTL_LONG_ONGOING, + CACHE_TTL_REALTIME, + CACHE_TTL_SHORT_ONGOING, + getStaleTime +} from './api-client' + +const today = utcNow() +const yesterday = utcNow().subtract(1, 'day') + +const noComparison = { comparison: null, compare_from: null, compare_to: null } +const base = { + siteStatsBegin: '', + siteTimezoneOffset: 0, + date: null, + from: null, + to: null, + ...noComparison +} + +describe(`${getStaleTime.name}`, () => { + describe('realtime periods', () => { + test('for realtime', () => { + expect(getStaleTime({ ...base, period: DashboardPeriod.realtime })).toBe( + CACHE_TTL_REALTIME + ) + }) + + test('for realtime_30m', () => { + expect( + getStaleTime({ ...base, period: DashboardPeriod.realtime_30m }) + ).toBe(CACHE_TTL_REALTIME) + }) + }) + + describe('historical periods (does not include today)', () => { + test('for 28d', () => { + expect(getStaleTime({ ...base, period: DashboardPeriod['28d'] })).toBe( + CACHE_TTL_HISTORICAL + ) + }) + + test('for 6mo', () => { + expect(getStaleTime({ ...base, period: DashboardPeriod['6mo'] })).toBe( + CACHE_TTL_HISTORICAL + ) + }) + + test('for period=day and date=yesterday', () => { + expect( + getStaleTime({ ...base, period: DashboardPeriod.day, date: yesterday }) + ).toBe(CACHE_TTL_HISTORICAL) + }) + + test('for custom period ending yesterday', () => { + expect( + getStaleTime({ + ...base, + period: DashboardPeriod.custom, + from: yesterday.subtract(7, 'day'), + to: yesterday, + ...noComparison + }) + ).toBe(CACHE_TTL_HISTORICAL) + }) + }) + + describe('ongoing periods with short TTL (supports day or shorter interval)', () => { + test('for today period', () => { + expect(getStaleTime({ ...base, period: DashboardPeriod.day })).toBe( + CACHE_TTL_SHORT_ONGOING + ) + }) + + test('for 24h period', () => { + expect(getStaleTime({ ...base, period: DashboardPeriod['24h'] })).toBe( + CACHE_TTL_SHORT_ONGOING + ) + }) + + test('for period=month and date=today', () => { + expect( + getStaleTime({ ...base, period: DashboardPeriod.month, date: today }) + ).toBe(CACHE_TTL_SHORT_ONGOING) + }) + + test('for period=year and date=today', () => { + expect( + getStaleTime({ ...base, period: DashboardPeriod.year, date: today }) + ).toBe(CACHE_TTL_SHORT_ONGOING) + }) + + test('for custom period under 12 months ending today', () => { + expect( + getStaleTime({ + ...base, + period: DashboardPeriod.custom, + from: today.subtract(6, 'month'), + to: today + }) + ).toBe(CACHE_TTL_SHORT_ONGOING) + }) + + test('for all time period when stats begin recently', () => { + expect( + getStaleTime({ + ...base, + siteStatsBegin: formatISO(yesterday), + period: DashboardPeriod.all + }) + ).toBe(CACHE_TTL_SHORT_ONGOING) + }) + + test('for a historical period when comparison includes today', () => { + expect( + getStaleTime({ + ...base, + period: DashboardPeriod['28d'], + date: today, + comparison: ComparisonMode.custom, + compare_from: yesterday.subtract(28, 'day'), + compare_to: today + }) + ).toBe(CACHE_TTL_SHORT_ONGOING) + }) + }) + + describe('ongoing periods with long TTL (only week or month interval available)', () => { + test('for custom period over 12 months ending today', () => { + expect( + getStaleTime({ + ...base, + period: DashboardPeriod.custom, + from: today.subtract(13, 'month'), + to: today + }) + ).toBe(CACHE_TTL_LONG_ONGOING) + }) + + test('for all time period when stats begin over 12 months ago', () => { + expect( + getStaleTime({ + ...base, + siteStatsBegin: '2020-01-01', + period: DashboardPeriod.all + }) + ).toBe(CACHE_TTL_LONG_ONGOING) + }) + }) +}) diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index 3d01462ddbd5..5b944c511761 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -6,12 +6,19 @@ import { } from '@tanstack/react-query' import * as api from '../api' import { DashboardState } from '../dashboard-state' -import { DashboardPeriod } from '../dashboard-time-periods' -import { Dayjs } from 'dayjs' +import { + DashboardPeriod, + DashboardTimeSettings, + isHistoricalPeriod +} from '../dashboard-time-periods' import { REALTIME_UPDATE_TIME_MS } from '../util/realtime-update-timer' +import { Interval, validIntervals } from '../stats/graph/intervals' -// defines when queries that don't include the current time should be refetched -const HISTORICAL_RESPONSES_STALE_TIME_MS = 12 * 60 * 60 * 1000 +// define (in ms) when query API responses should become stale +export const CACHE_TTL_REALTIME = REALTIME_UPDATE_TIME_MS +export const CACHE_TTL_SHORT_ONGOING = 5 * 60 * 1000 // 5 minutes +export const CACHE_TTL_LONG_ONGOING = 60 * 60 * 1000 // 1 hour +export const CACHE_TTL_HISTORICAL = 12 * 60 * 60 * 1000 // 12 hours // how many items per page for breakdown modals const PAGINATION_LIMIT = 100 @@ -34,12 +41,16 @@ export function usePaginatedGetAPI< TResponse extends { results: unknown[] }, TKey extends PaginatedQueryKeyBase = PaginatedQueryKeyBase >({ + siteTimezoneOffset, + siteStatsBegin, key, getRequestParams, afterFetchData, afterFetchNextPage, initialPageParam = 1 }: { + siteTimezoneOffset: DashboardTimeSettings['siteTimezoneOffset'] + siteStatsBegin: DashboardTimeSettings['siteStatsBegin'] key: TKey getRequestParams: GetRequestParams afterFetchData?: (response: TResponse) => void @@ -89,6 +100,14 @@ export function usePaginatedGetAPI< ? lastPageIndex + 1 : null }, + staleTime: ({ queryKey }) => { + const [_, opts] = queryKey + return getStaleTime({ + siteTimezoneOffset: siteTimezoneOffset, + siteStatsBegin: siteStatsBegin, + ...opts.dashboardState + }) + }, initialPageParam, placeholderData: (previousData) => previousData }) @@ -108,59 +127,36 @@ export const cleanToPageOne = < return data } -export const getStaleTime = ( - /** the start of the current day */ - startOfDay: Dayjs, - { - period, - from, - to, - date - }: Pick -): number => { - if (DashboardPeriod.custom && to && from) { - // historical - if (from.isBefore(startOfDay) && to.isBefore(startOfDay)) { - return HISTORICAL_RESPONSES_STALE_TIME_MS - } - // period includes now - if (to.diff(from, 'days') < 7) { - return 5 * 60 * 1000 - } - if (to.diff(from, 'months') < 1) { - return 15 * 60 * 1000 - } - if (to.diff(from, 'months') < 12) { - return 60 * 60 * 1000 - } - return 3 * 60 * 60 * 1000 +/** + * Returns the time-to-live for cached query API responses based on the given DashboardTimeSettings. + * + * - For a realtime dashboard: {@link CACHE_TTL_REALTIME} + * - For any historical period (i.e. does not include today): {@link CACHE_TTL_HISTORICAL} + * - For a period that includes today, supporting 'day' or shorter interval: {@link CACHE_TTL_SHORT_ONGOING} + * - For a period that includes today, too long to support 'day' interval: {@link CACHE_TTL_LONG_ONGOING} + */ +export const getStaleTime = (props: DashboardTimeSettings): number => { + if ( + [DashboardPeriod.realtime, DashboardPeriod.realtime_30m].includes( + props.period + ) + ) { + return CACHE_TTL_REALTIME } - const historical = date?.isBefore(startOfDay) - if (historical) { - return HISTORICAL_RESPONSES_STALE_TIME_MS + if (isHistoricalPeriod(props)) { + return CACHE_TTL_HISTORICAL } - switch (period) { - case DashboardPeriod.realtime: - return REALTIME_UPDATE_TIME_MS - case DashboardPeriod['24h']: - case DashboardPeriod.day: - return 5 * 60 * 1000 - case DashboardPeriod['7d']: - return 15 * 60 * 1000 - case DashboardPeriod['28d']: - case DashboardPeriod['30d']: - case DashboardPeriod['91d']: - case DashboardPeriod['6mo']: - return 60 * 60 * 1000 - case DashboardPeriod['12mo']: - case DashboardPeriod.year: - return 3 * 60 * 60 * 1000 - case DashboardPeriod.all: - default: - // err on the side of less caching, - // to avoid the user refresheshing - return 15 * 60 * 1000 + const availableIntervals = validIntervals(props) + + if ( + availableIntervals.includes(Interval.day) || + availableIntervals.includes(Interval.hour) || + availableIntervals.includes(Interval.minute) + ) { + return CACHE_TTL_SHORT_ONGOING + } else { + return CACHE_TTL_LONG_ONGOING } } diff --git a/assets/js/dashboard/nav-menu/query-periods/comparison-period-menu.tsx b/assets/js/dashboard/nav-menu/query-periods/comparison-period-menu.tsx index ad723cec9a4d..cace8bd9bc0c 100644 --- a/assets/js/dashboard/nav-menu/query-periods/comparison-period-menu.tsx +++ b/assets/js/dashboard/nav-menu/query-periods/comparison-period-menu.tsx @@ -26,7 +26,7 @@ import { hiddenCalendarButtonClassName } from './shared-menu-items' import { DateRangeCalendar } from './date-range-calendar' -import { formatISO, nowForSite } from '../../util/date' +import { formatISO, now } from '../../util/date' import { MenuSeparator } from '../nav-menu-components' export const ComparisonPeriodMenuItems = ({ @@ -169,7 +169,7 @@ export const ComparisonCalendarMenu = ({ closeDropdown() }} minDate={site.statsBegin} - maxDate={formatISO(nowForSite(site))} + maxDate={formatISO(now(site.offset))} defaultDates={ dashboardState.compare_from && dashboardState.compare_to ? [ diff --git a/assets/js/dashboard/nav-menu/query-periods/dashboard-period-menu.tsx b/assets/js/dashboard/nav-menu/query-periods/dashboard-period-menu.tsx index bc26d2581c9e..204d48a5ee80 100644 --- a/assets/js/dashboard/nav-menu/query-periods/dashboard-period-menu.tsx +++ b/assets/js/dashboard/nav-menu/query-periods/dashboard-period-menu.tsx @@ -34,7 +34,7 @@ import { hiddenCalendarButtonClassName } from './shared-menu-items' import { DateRangeCalendar } from './date-range-calendar' -import { formatISO, nowForSite } from '../../util/date' +import { formatISO, now } from '../../util/date' import { MenuSeparator } from '../nav-menu-components' import { MovePeriodArrows, periodsWithArrows } from './move-period-arrows' @@ -250,7 +250,7 @@ export const MainCalendar = ({ closeDropdown() }} minDate={site.statsBegin} - maxDate={formatISO(nowForSite(site))} + maxDate={formatISO(now(site.offset))} defaultDates={ dashboardState.from && dashboardState.to ? [formatISO(dashboardState.from), formatISO(dashboardState.to)] diff --git a/assets/js/dashboard/stats/graph/graph-interval-context.tsx b/assets/js/dashboard/stats/graph/graph-interval-context.tsx index d96ec4b9d7fd..b6e430f4f7bb 100644 --- a/assets/js/dashboard/stats/graph/graph-interval-context.tsx +++ b/assets/js/dashboard/stats/graph/graph-interval-context.tsx @@ -24,7 +24,9 @@ export function GraphIntervalProvider({ const { selectedInterval, onIntervalClick, availableIntervals } = useStoredInterval({ - site, + domain: site.domain, + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, to: dashboardState.to, from: dashboardState.from, period: dashboardState.period, diff --git a/assets/js/dashboard/stats/graph/interval-picker.tsx b/assets/js/dashboard/stats/graph/interval-picker.tsx index 0fe1b4979140..dd0a103e9e64 100644 --- a/assets/js/dashboard/stats/graph/interval-picker.tsx +++ b/assets/js/dashboard/stats/graph/interval-picker.tsx @@ -7,6 +7,8 @@ import { validIntervals, getDefaultInterval } from './intervals' +import { PlausibleSite } from '../../site-context' +import { DashboardPeriod } from '../../dashboard-time-periods' const INTERVAL_LABELS: Record = { [Interval.minute]: 'Min', @@ -16,8 +18,18 @@ const INTERVAL_LABELS: Record = { [Interval.month]: 'Months' } -function getStoredInterval(period: string, domain: string): string | null { - const stored = storage.getItem(`interval__${period}__${domain}`) +function getIntervalStorageKey( + domain: PlausibleSite['domain'], + period: DashboardPeriod +) { + return `interval__${period}__${domain}` +} + +function getStoredInterval( + domain: PlausibleSite['domain'], + period: DashboardPeriod +): string | null { + const stored = storage.getItem(getIntervalStorageKey(domain, period)) if (stored === 'date') { return 'day' @@ -27,15 +39,18 @@ function getStoredInterval(period: string, domain: string): string | null { } function storeInterval( - period: string, - domain: string, + domain: PlausibleSite['domain'], + period: DashboardPeriod, interval: Interval ): void { - storage.setItem(`interval__${period}__${domain}`, interval) + storage.setItem(getIntervalStorageKey(domain, period), interval) } -export const useStoredInterval = (props: GetIntervalProps) => { - const { period, from, to, site, comparison, compare_from, compare_to } = props +export const useStoredInterval = ( + props: Pick & GetIntervalProps +) => { + const { domain, period, from, to, comparison, compare_from, compare_to } = + props // Dayjs objects are new references on every render, so we // use valueOf() (ms since epoch) to get stable primitive @@ -52,7 +67,6 @@ export const useStoredInterval = (props: GetIntervalProps) => { } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - site, period, customFrom, customTo, @@ -77,7 +91,7 @@ export const useStoredInterval = (props: GetIntervalProps) => { [storableIntervals] ) - const storedInterval = getStoredInterval(period, site.domain) + const storedInterval = getStoredInterval(domain, period) const [selectedInterval, setSelectedInterval] = useState(null) @@ -88,11 +102,11 @@ export const useStoredInterval = (props: GetIntervalProps) => { const onIntervalClick = useCallback( (interval: Interval) => { if (isStorable(interval)) { - storeInterval(period, site.domain, interval) + storeInterval(domain, period, interval) } setSelectedInterval(interval) }, - [period, site, isStorable] + [domain, period, isStorable] ) return { diff --git a/assets/js/dashboard/stats/graph/intervals.test.ts b/assets/js/dashboard/stats/graph/intervals.test.ts index 4003e30af2c1..ad68ad5c8143 100644 --- a/assets/js/dashboard/stats/graph/intervals.test.ts +++ b/assets/js/dashboard/stats/graph/intervals.test.ts @@ -1,9 +1,11 @@ -import { DEFAULT_SITE } from '../../../../test-utils/app-context-providers' import { ComparisonMode, DashboardPeriod } from '../../dashboard-time-periods' import { getDefaultInterval, validIntervals } from './intervals' import dayjs from 'dayjs' -const site = DEFAULT_SITE +const siteProps = { + siteTimezoneOffset: 0, + siteStatsBegin: '' +} const noComparison = { comparison: null, @@ -21,43 +23,140 @@ describe(`${validIntervals.name}`, () => { describe('fixed periods', () => { it('returns [minute] for realtime', () => { expect( - validIntervals({ site, period: DashboardPeriod.realtime, ...noCustom }) + validIntervals({ + ...siteProps, + period: DashboardPeriod.realtime, + ...noCustom + }) ).toEqual(['minute']) }) it('returns [minute, hour] for day', () => { expect( - validIntervals({ site, period: DashboardPeriod.day, ...noCustom }) + validIntervals({ + ...siteProps, + period: DashboardPeriod.day, + ...noCustom + }) ).toEqual(['minute', 'hour']) }) it('returns [minute, hour] for 24h', () => { expect( - validIntervals({ site, period: DashboardPeriod['24h'], ...noCustom }) + validIntervals({ + ...siteProps, + period: DashboardPeriod['24h'], + ...noCustom + }) ).toEqual(['minute', 'hour']) }) it('returns [hour, day] for 7d', () => { expect( - validIntervals({ site, period: DashboardPeriod['7d'], ...noCustom }) + validIntervals({ + ...siteProps, + period: DashboardPeriod['7d'], + ...noCustom + }) ).toEqual(['hour', 'day']) }) it('returns [day, week, month] for 6mo', () => { expect( - validIntervals({ site, period: DashboardPeriod['6mo'], ...noCustom }) + validIntervals({ + ...siteProps, + period: DashboardPeriod['6mo'], + ...noCustom + }) ).toEqual(['day', 'week', 'month']) }) it('returns [day, week, month] for 12mo', () => { expect( - validIntervals({ site, period: DashboardPeriod['12mo'], ...noCustom }) + validIntervals({ + ...siteProps, + period: DashboardPeriod['12mo'], + ...noCustom + }) ).toEqual(['day', 'week', 'month']) }) it('returns [day, week, month] for year', () => { expect( - validIntervals({ site, period: DashboardPeriod.year, ...noCustom }) + validIntervals({ + ...siteProps, + period: DashboardPeriod.year, + ...noCustom + }) + ).toEqual(['day', 'week', 'month']) + }) + }) + + describe('all time period', () => { + afterEach(() => jest.useRealTimers()) + + it('returns [minute, hour] siteStatsBegin is empty string', () => { + expect( + validIntervals({ + ...siteProps, + period: DashboardPeriod.all, + ...noCustom + }) + ).toEqual(['minute', 'hour']) + }) + + it('returns [minute, hour] when all time is 23h', () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-01-01T23:00:00Z')) + expect( + validIntervals({ + siteTimezoneOffset: 0, + siteStatsBegin: '2026-01-01', + period: DashboardPeriod.all, + ...noCustom + }) + ).toEqual(['minute', 'hour']) + }) + + it('returns [minute, hour] when all time is 23h for a siteTimezoneOffset of UTC-05:00', () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-01-02T04:00:00Z')) + + expect( + validIntervals({ + siteTimezoneOffset: -300, + siteStatsBegin: '2026-01-01', + period: DashboardPeriod.all, + ...noCustom + }) + ).toEqual(['minute', 'hour']) + }) + + it('returns [hour, day] when all time is 25h for a siteTimezoneOffset of UTC+05:00', () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-01-01T23:00:00Z')) + + expect( + validIntervals({ + siteTimezoneOffset: 300, + siteStatsBegin: '2026-01-01', + period: DashboardPeriod.all, + ...noCustom + }) + ).toEqual(['hour', 'day']) + }) + + it('returns [day, week, month] when all time is 3 months', () => { + jest.useFakeTimers() + jest.setSystemTime(new Date('2026-03-31T10:00:00Z')) + + expect( + validIntervals({ + siteTimezoneOffset: 300, + siteStatsBegin: '2026-01-01', + period: DashboardPeriod.all, + ...noCustom + }) ).toEqual(['day', 'week', 'month']) }) }) @@ -66,7 +165,7 @@ describe(`${validIntervals.name}`, () => { it('returns [minute, hour] for one day', () => { expect( validIntervals({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2024-01-01'), to: dayjs('2024-01-01'), @@ -78,7 +177,7 @@ describe(`${validIntervals.name}`, () => { it('returns [hour, day] for a range of 7 days', () => { expect( validIntervals({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2024-01-01'), to: dayjs('2024-01-07'), @@ -90,7 +189,7 @@ describe(`${validIntervals.name}`, () => { it('returns [day, week] for a range of 8 days', () => { expect( validIntervals({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2024-01-01'), to: dayjs('2024-01-08'), @@ -102,7 +201,7 @@ describe(`${validIntervals.name}`, () => { it('returns [day, week] for a range of exactly one month', () => { expect( validIntervals({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2024-01-01'), to: dayjs('2024-01-31'), @@ -114,7 +213,7 @@ describe(`${validIntervals.name}`, () => { it('returns [day, week, month] for a range that barely spans two months', () => { expect( validIntervals({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2024-01-01'), to: dayjs('2024-02-01'), @@ -126,7 +225,7 @@ describe(`${validIntervals.name}`, () => { it('returns [day, week, month] for a range of exactly one year', () => { expect( validIntervals({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2024-01-01'), to: dayjs('2024-12-31'), @@ -138,7 +237,7 @@ describe(`${validIntervals.name}`, () => { it('returns [week, month] for a range that exceeds 12 months', () => { expect( validIntervals({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2024-01-01'), to: dayjs('2025-01-01'), @@ -152,7 +251,7 @@ describe(`${validIntervals.name}`, () => { it('uses custom comparison range when it is coarser than the custom main range', () => { expect( validIntervals({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2024-06-01'), to: dayjs('2024-06-02'), @@ -166,7 +265,7 @@ describe(`${validIntervals.name}`, () => { it('uses custom main range when it is coarser than the custom comparison range', () => { expect( validIntervals({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2023-01-01'), to: dayjs('2024-01-01'), @@ -180,7 +279,7 @@ describe(`${validIntervals.name}`, () => { it('uses custom comparison range when it is coarser than the fixed main period', () => { expect( validIntervals({ - site, + ...siteProps, period: DashboardPeriod.day, from: null, to: null, @@ -194,7 +293,7 @@ describe(`${validIntervals.name}`, () => { it('uses fixed main period when it is coarser than the custom comparison period', () => { expect( validIntervals({ - site, + ...siteProps, period: DashboardPeriod['12mo'], from: null, to: null, @@ -211,14 +310,18 @@ describe(`${getDefaultInterval.name}`, () => { describe('fixed periods', () => { it('returns hour for day', () => { expect( - getDefaultInterval({ site, period: DashboardPeriod.day, ...noCustom }) + getDefaultInterval({ + ...siteProps, + period: DashboardPeriod.day, + ...noCustom + }) ).toBe('hour') }) it('returns hour for 24h', () => { expect( getDefaultInterval({ - site, + ...siteProps, period: DashboardPeriod['24h'], ...noCustom }) @@ -227,14 +330,18 @@ describe(`${getDefaultInterval.name}`, () => { it('returns day for 7d', () => { expect( - getDefaultInterval({ site, period: DashboardPeriod['7d'], ...noCustom }) + getDefaultInterval({ + ...siteProps, + period: DashboardPeriod['7d'], + ...noCustom + }) ).toBe('day') }) it('returns month for 6mo', () => { expect( getDefaultInterval({ - site, + ...siteProps, period: DashboardPeriod['6mo'], ...noCustom }) @@ -244,7 +351,7 @@ describe(`${getDefaultInterval.name}`, () => { it('returns month for 12mo', () => { expect( getDefaultInterval({ - site, + ...siteProps, period: DashboardPeriod['12mo'], ...noCustom }) @@ -253,7 +360,11 @@ describe(`${getDefaultInterval.name}`, () => { it('returns month for year', () => { expect( - getDefaultInterval({ site, period: DashboardPeriod.year, ...noCustom }) + getDefaultInterval({ + ...siteProps, + period: DashboardPeriod.year, + ...noCustom + }) ).toBe('month') }) }) @@ -262,7 +373,7 @@ describe(`${getDefaultInterval.name}`, () => { it('returns hour for a single date', () => { expect( getDefaultInterval({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2024-01-01'), to: dayjs('2024-01-01'), @@ -274,7 +385,7 @@ describe(`${getDefaultInterval.name}`, () => { it('returns day for a range under 30 days', () => { expect( getDefaultInterval({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2024-01-01'), to: dayjs('2024-01-20'), @@ -286,7 +397,7 @@ describe(`${getDefaultInterval.name}`, () => { it('returns week for a range below 6 months', () => { expect( getDefaultInterval({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2024-01-01'), to: dayjs('2024-05-31'), @@ -298,7 +409,7 @@ describe(`${getDefaultInterval.name}`, () => { it('returns month for a range that barely spans 7 months', () => { expect( getDefaultInterval({ - site, + ...siteProps, period: DashboardPeriod.custom, from: dayjs('2023-01-01'), to: dayjs('2023-07-01'), @@ -310,7 +421,7 @@ describe(`${getDefaultInterval.name}`, () => { it('returns day for a fixed 7d period even when comparing with a whole year', () => { expect( getDefaultInterval({ - site, + ...siteProps, period: DashboardPeriod['7d'], from: null, to: null, @@ -324,7 +435,7 @@ describe(`${getDefaultInterval.name}`, () => { it('returns default for comparison range instead when default for main is not appropriate', () => { expect( getDefaultInterval({ - site, + ...siteProps, period: DashboardPeriod.day, from: null, to: null, diff --git a/assets/js/dashboard/stats/graph/intervals.ts b/assets/js/dashboard/stats/graph/intervals.ts index 2c0405882086..2eb22dd6433b 100644 --- a/assets/js/dashboard/stats/graph/intervals.ts +++ b/assets/js/dashboard/stats/graph/intervals.ts @@ -1,8 +1,10 @@ -import { PlausibleSite } from '../../site-context' -import { DashboardState } from '../../dashboard-state' import { Dayjs } from 'dayjs' -import { ComparisonMode, DashboardPeriod } from '../../dashboard-time-periods' -import { dateForSite, nowForSite } from '../../util/date' +import { + ComparisonMode, + DashboardPeriod, + DashboardTimeSettings +} from '../../dashboard-time-periods' +import { now, parseUTCDate } from '../../util/date' export enum Interval { minute = 'minute', @@ -12,10 +14,7 @@ export enum Interval { month = 'month' } -export type GetIntervalProps = { site: PlausibleSite } & Pick< - DashboardState, - 'period' | 'to' | 'from' | 'comparison' | 'compare_to' | 'compare_from' -> +export type GetIntervalProps = Omit type DayjsRange = { from: Dayjs; to: Dayjs } @@ -54,7 +53,8 @@ const INTERVAL_COARSENESS: Record = { * modes the valid intervals are determined solely by the main period. */ export function validIntervals({ - site, + siteTimezoneOffset, + siteStatsBegin, period, to, from, @@ -62,7 +62,13 @@ export function validIntervals({ compare_to, compare_from }: GetIntervalProps): Interval[] { - const mainIntervals = validIntervalsForMainPeriod(site, period, from, to) + const mainIntervals = validIntervalsForMainPeriod( + siteTimezoneOffset, + siteStatsBegin, + period, + from, + to + ) const comparisonIntervals = validIntervalsForCustomComparison( comparison, compare_from, @@ -82,7 +88,8 @@ export function validIntervals({ * appropriate for the comparison date range. */ export function getDefaultInterval({ - site, + siteTimezoneOffset, + siteStatsBegin, period, to, from, @@ -90,7 +97,13 @@ export function getDefaultInterval({ compare_to, compare_from }: GetIntervalProps): Interval { - const defaultForMain = defaultForMainPeriod(site, period, from, to) + const defaultForMain = defaultForMainPeriod( + siteTimezoneOffset, + siteStatsBegin, + period, + from, + to + ) const validComparisonIntervals = validIntervalsForCustomComparison( comparison, @@ -117,7 +130,8 @@ function coarser(a: Interval[], b: Interval[]): Interval[] { } function validIntervalsForMainPeriod( - site: PlausibleSite, + siteTimezoneOffset: DashboardTimeSettings['siteTimezoneOffset'], + siteStatsBegin: DashboardTimeSettings['siteStatsBegin'], period: DashboardPeriod, from: Dayjs | null, to: Dayjs | null @@ -126,7 +140,7 @@ function validIntervalsForMainPeriod( return validIntervalsForCustomPeriod({ from, to }) } if (period === 'all') { - return validIntervalsForAllTimePeriod(site) + return validIntervalsForAllTimePeriod(siteTimezoneOffset, siteStatsBegin) } return VALID_INTERVALS_BY_FIXED_PERIOD[period as FixedPeriod] } @@ -143,7 +157,8 @@ function validIntervalsForCustomComparison( } function defaultForMainPeriod( - site: PlausibleSite, + siteTimezoneOffset: DashboardTimeSettings['siteTimezoneOffset'], + siteStatsBegin: DashboardTimeSettings['siteStatsBegin'], period: DashboardPeriod, from: Dayjs | null, to: Dayjs | null @@ -152,7 +167,10 @@ function defaultForMainPeriod( return defaultForCustomPeriod({ from, to }) } if (period === 'all') { - return validIntervalsForAllTimePeriod(site).includes(Interval.day) + return validIntervalsForAllTimePeriod( + siteTimezoneOffset, + siteStatsBegin + ).includes(Interval.day) ? Interval.day : Interval.month } @@ -191,9 +209,14 @@ function validIntervalsForCustomPeriod({ to, from }: DayjsRange): Interval[] { return [Interval.week, Interval.month] } -function validIntervalsForAllTimePeriod(site: PlausibleSite): Interval[] { - const to = nowForSite(site) - const from = site.statsBegin ? dateForSite(site.statsBegin, site) : to +function validIntervalsForAllTimePeriod( + siteTimezoneOffset: DashboardTimeSettings['siteTimezoneOffset'], + siteStatsBegin: DashboardTimeSettings['siteStatsBegin'] +): Interval[] { + const to = now(siteTimezoneOffset) + const from = siteStatsBegin + ? parseUTCDate(siteStatsBegin).utcOffset(siteTimezoneOffset / 60, true) + : to return validIntervalsForCustomPeriod({ from, to }) } diff --git a/assets/js/dashboard/stats/graph/visitor-graph.tsx b/assets/js/dashboard/stats/graph/visitor-graph.tsx index d6047ae36f4a..2128ee71f5a0 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.tsx +++ b/assets/js/dashboard/stats/graph/visitor-graph.tsx @@ -9,7 +9,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' import { Metric } from '../../../types/query-api' import { DashboardPeriod } from '../../dashboard-time-periods' import { DashboardState } from '../../dashboard-state' -import { nowForSite } from '../../util/date' import { getStaleTime } from '../../hooks/api-client' import { MainGraph, MainGraphContainer, useMainGraphWidth } from './main-graph' import { useGraphIntervalContext } from './graph-interval-context' @@ -30,7 +29,6 @@ export default function VisitorGraph({ const { dashboardState } = useDashboardStateContext() const isRealtime = dashboardState.period === DashboardPeriod.realtime const queryClient = useQueryClient() - const startOfDay = nowForSite(site).startOf('day') const { selectedInterval } = useGraphIntervalContext() @@ -52,14 +50,14 @@ export default function VisitorGraph({ return await fetchTopStats(site, opts.dashboardState) }, placeholderData: (previousData) => previousData, - staleTime: ({ queryKey, meta }) => { + staleTime: ({ queryKey }) => { const [_, opts] = queryKey - return getStaleTime( - meta!.startOfDay as typeof startOfDay, - opts.dashboardState - ) - }, - meta: { startOfDay } + return getStaleTime({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, + ...opts.dashboardState + }) + } }) const mainGraphQuery = useQuery({ @@ -86,14 +84,14 @@ export default function VisitorGraph({ } }, placeholderData: (previousData) => previousData, - staleTime: ({ queryKey, meta }) => { + staleTime: ({ queryKey }) => { const [_, opts] = queryKey - return getStaleTime( - meta!.startOfDay as typeof startOfDay, - opts.dashboardState - ) - }, - meta: { startOfDay } + return getStaleTime({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, + ...opts.dashboardState + }) + } }) // update metric to one that exists diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx new file mode 100644 index 000000000000..55e56447b2e0 --- /dev/null +++ b/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx @@ -0,0 +1,75 @@ +import React, { useState, Dispatch, SetStateAction } from 'react' +import { act, render, screen } from '@testing-library/react' +import { TestContextProviders } from '../../../../test-utils/app-context-providers' +import PagesModal from './pages' +import { MockAPI } from '../../../../test-utils/mock-api' + +const domain = 'dummy.site' + +let mockAPI: MockAPI + +beforeAll(() => { + mockAPI = new MockAPI().start() +}) + +afterAll(() => { + mockAPI.stop() +}) + +beforeEach(() => { + mockAPI.clear() +}) + +beforeEach(() => { + const modalRoot = document.createElement('div') + modalRoot.setAttribute('id', 'modal_root') + document.body.appendChild(modalRoot) +}) + +afterEach(() => { + document.getElementById('modal_root')?.remove() +}) + +describe('BreakdownModal', () => { + test('opening the modal for a second time with the same dashboardState gets response from cache', async () => { + const response = { + results: [], + meta: { date_range_label: 'Last 30 days', metric_warnings: undefined } + } + + const pagesHandler = mockAPI.get(`/api/stats/${domain}/pages/`, response) + + let setOpen: Dispatch> + + function ToggleableModal() { + const [open, s] = useState(false) + setOpen = s + return open ? : null + } + + render( + + + + ) + + expect(pagesHandler).toHaveBeenCalledTimes(0) + act(() => setOpen(true)) + expect(screen.getByText('Top pages')).toBeVisible() + expect(pagesHandler).toHaveBeenCalledTimes(1) + expect(pagesHandler).toHaveBeenNthCalledWith( + 1, + expect.stringContaining( + 'order_by=%5B%5B%22visitors%22%2C%22desc%22%5D%5D&limit=100&page=1' + ), + expect.anything() + ) + + act(() => setOpen(false)) + expect(screen.queryByText('Top pages')).not.toBeInTheDocument() + act(() => setOpen(true)) + expect(screen.getByText('Top pages')).toBeVisible() + + expect(pagesHandler).toHaveBeenCalledTimes(1) + }) +}) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.tsx index b2ab4f6cae15..04d3e4f65adf 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal.tsx @@ -107,6 +107,8 @@ export default function BreakdownModal({ { dashboardState: DashboardState; search: string; orderBy: OrderBy } ] >({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, key: [reportInfo.endpoint, { dashboardState, search, orderBy }], getRequestParams: (key) => { const [_endpoint, { dashboardState, search }] = key diff --git a/assets/js/dashboard/stats/modals/google-keywords.test.tsx b/assets/js/dashboard/stats/modals/google-keywords.test.tsx new file mode 100644 index 000000000000..146689b52a0a --- /dev/null +++ b/assets/js/dashboard/stats/modals/google-keywords.test.tsx @@ -0,0 +1,71 @@ +import React, { useState, Dispatch, SetStateAction } from 'react' +import { act, render, screen } from '@testing-library/react' +import { TestContextProviders } from '../../../../test-utils/app-context-providers' +import GoogleKeywordsModal from './google-keywords' +import { MockAPI } from '../../../../test-utils/mock-api' + +const domain = 'dummy.site' + +let mockAPI: MockAPI + +beforeAll(() => { + mockAPI = new MockAPI().start() +}) + +afterAll(() => { + mockAPI.stop() +}) + +beforeEach(() => { + mockAPI.clear() +}) + +beforeEach(() => { + const modalRoot = document.createElement('div') + modalRoot.setAttribute('id', 'modal_root') + document.body.appendChild(modalRoot) +}) + +afterEach(() => { + document.getElementById('modal_root')?.remove() +}) + +describe('GoogleKeywordsModal', () => { + test('opening the modal for a second time with the same dashboardState gets response from cache', async () => { + const googleKeywordsHandler = mockAPI.get( + `/api/stats/${domain}/referrers/Google/`, + { results: [] } + ) + + let setOpen: Dispatch> + + function ToggleableModal() { + const [open, s] = useState(false) + setOpen = s + return open ? : null + } + + render( + + + + ) + + expect(googleKeywordsHandler).toHaveBeenCalledTimes(0) + act(() => setOpen(true)) + expect(screen.getByText('Google search terms')).toBeVisible() + expect(googleKeywordsHandler).toHaveBeenCalledTimes(1) + expect(googleKeywordsHandler).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('limit=100'), + expect.anything() + ) + + act(() => setOpen(false)) + expect(screen.queryByText('Google search terms')).not.toBeInTheDocument() + act(() => setOpen(true)) + expect(screen.getByText('Google search terms')).toBeVisible() + + expect(googleKeywordsHandler).toHaveBeenCalledTimes(1) + }) +}) diff --git a/assets/js/dashboard/stats/modals/google-keywords.tsx b/assets/js/dashboard/stats/modals/google-keywords.tsx index d2e89378932f..cf8ce484de4a 100644 --- a/assets/js/dashboard/stats/modals/google-keywords.tsx +++ b/assets/js/dashboard/stats/modals/google-keywords.tsx @@ -58,6 +58,8 @@ function GoogleKeywordsModal() { { results: GoogleKeywordItem[] }, [string, { dashboardState: DashboardState; search: string }] >({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, key: [endpoint, { dashboardState, search }], getRequestParams: (key) => { const [_endpoint, { dashboardState, search }] = key diff --git a/assets/js/dashboard/util/date.js b/assets/js/dashboard/util/date.js index e8541b17aaeb..e32e9a8fe60a 100644 --- a/assets/js/dashboard/util/date.js +++ b/assets/js/dashboard/util/date.js @@ -11,10 +11,6 @@ export function is12HourClock() { return browserDateFormat.resolvedOptions().hour12 } -export function utcNow() { - return dayjs() -} - // https://stackoverflow.com/a/50130338 export function formatISO(date) { return date.format('YYYY-MM-DD') @@ -90,16 +86,20 @@ export function dateForSite(utcDateString, site) { return dayjs.utc(utcDateString).utcOffset(site.offset / 60) } -export function nowForSite(site) { - return dayjs.utc().utcOffset(site.offset / 60) +export function utcNow() { + return dayjs.utc() +} + +export function now(offset) { + return utcNow().utcOffset(offset / 60) } -export function yesterday(site) { - return shiftDays(nowForSite(site), -1) +export function yesterday(offset) { + return shiftDays(now(offset), -1) } export function lastMonth(site) { - return shiftMonths(nowForSite(site), -1) + return shiftMonths(now(site.offset), -1) } export function isSameDate(date1, date2) { @@ -111,7 +111,7 @@ export function isSameMonth(date1, date2) { } export function isToday(site, date) { - return isSameDate(date, nowForSite(site)) + return isSameDate(date, now(site.offset)) } export function isTodayOrYesterday(isoDate) { @@ -121,11 +121,11 @@ export function isTodayOrYesterday(isoDate) { } export function isThisMonth(site, date) { - return formatMonthYYYY(date) === formatMonthYYYY(nowForSite(site)) + return formatMonthYYYY(date) === formatMonthYYYY(now(site.offset)) } export function isThisYear(site, date) { - return date.year() === nowForSite(site).year() + return date.year() === now(site.offset).year() } export function isBefore(date1, date2, period) { diff --git a/assets/js/dashboard/util/date.test.ts b/assets/js/dashboard/util/date.test.ts index 215e059ced03..961bcd1495a8 100644 --- a/assets/js/dashboard/util/date.test.ts +++ b/assets/js/dashboard/util/date.test.ts @@ -4,7 +4,7 @@ import { formatTime, formatMonthYYYY, formatISO, - nowForSite, + now, parseNaiveDate, shiftMonths, yesterday @@ -12,7 +12,7 @@ import { jest.useFakeTimers() -describe(`${nowForSite.name} and ${formatISO.name}`, () => { +describe(`${now.name} and ${formatISO.name}`, () => { /* prettier-ignore */ const cases = [ [ 'Los Angeles/America', -3600 * 6, '2024-11-01T20:00:00.000Z', '2024-11-01' ], @@ -22,7 +22,7 @@ describe(`${nowForSite.name} and ${formatISO.name}`, () => { 'in timezone of %s (offset %p) at %s, today is %s', (_tz, offset, utcTime, expectedToday) => { jest.setSystemTime(new Date(utcTime)) - expect(formatISO(nowForSite({ offset }))).toEqual(expectedToday) + expect(formatISO(now(offset))).toEqual(expectedToday) } ) }) @@ -94,11 +94,11 @@ for (const [timezone, suite] of sets) { ) => { jest.setSystemTime(new Date(utcTime)) expect({ - today: formatISO(nowForSite({ offset })), - yesterday: formatISO(yesterday({ offset })), - twoMonthsBack: formatISO(shiftMonths(nowForSite({ offset }), -2)), - twoMonthsAhead: formatISO(shiftMonths(nowForSite({ offset }), 2)), - oneYearBack: formatISO(shiftMonths(nowForSite({ offset }), -12)) + today: formatISO(now(offset)), + yesterday: formatISO(yesterday(offset)), + twoMonthsBack: formatISO(shiftMonths(now(offset), -2)), + twoMonthsAhead: formatISO(shiftMonths(now(offset), 2)), + oneYearBack: formatISO(shiftMonths(now(offset), -12)) }).toEqual({ today: expectedToday, yesterday: expectedYesterday, diff --git a/assets/test-utils/mock-api.ts b/assets/test-utils/mock-api.ts index cdbbdd7fc4e4..3856162181b8 100644 --- a/assets/test-utils/mock-api.ts +++ b/assets/test-utils/mock-api.ts @@ -1,5 +1,5 @@ export class MockAPI { - private mocked: Map Promise> + private mocked: Map private fetch: jest.Mock private originalFetch: null | unknown = null @@ -10,30 +10,38 @@ export class MockAPI { private setHandler( method: string, - url: string, - handler: () => Promise + urlWithoutQueryString: string, + handler: jest.Mock ) { - this.mocked.set([method.toLowerCase(), url].join(' '), handler) + this.mocked.set( + [method.toLowerCase(), urlWithoutQueryString].join(' '), + handler + ) } - public get(url: string, response: (() => Promise) | unknown) { - const handler = - typeof response === 'function' - ? (response as () => Promise) + // sets get handler + public get( + urlWithoutQueryString: string, + responseHandler: typeof fetch | Record | number | null + ): jest.Mock { + const handler: typeof fetch = + typeof responseHandler === 'function' + ? responseHandler : () => - new Promise((resolve) => + new Promise((resolve) => resolve({ status: 200, ok: true, - json: async () => response + json: async () => responseHandler } as Response) ) - this.setHandler('get', url, handler) - return this + const jestWrappedHandler = jest.fn(handler) + this.setHandler('get', urlWithoutQueryString, jestWrappedHandler) + return jestWrappedHandler } - private getHandler(method: string, url: string) { - return this.mocked.get([method, url].join(' ')) + private getHandler(method: string, urlWithoutQueryString: string) { + return this.mocked.get([method, urlWithoutQueryString].join(' ')) } public clear() { @@ -48,14 +56,14 @@ export class MockAPI { throw new Error(`Unmocked request ${input.toString()}`) } const method = init?.method ?? 'get' - - const handler = this.getHandler(method, input) + const urlWithoutQueryString = input.split('?')[0] + const handler = this.getHandler(method, urlWithoutQueryString) if (!handler) { throw new Error( `Unmocked request ${method.toString()} ${input.toString()}` ) } - return handler() + return handler(input, init) } global.fetch = this.fetch.mockImplementation(mockFetch)