From 0810b9b91599488b098069d7eddfba9a194891c7 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 22 Apr 2026 17:34:22 +0100 Subject: [PATCH 01/15] change staleTime logic --- assets/js/dashboard/dashboard-time-periods.ts | 46 ++++++++ assets/js/dashboard/hooks/api-client.ts | 100 ++++++++---------- .../stats/graph/graph-interval-context.tsx | 3 +- .../dashboard/stats/graph/interval-picker.tsx | 23 ++-- assets/js/dashboard/stats/graph/intervals.ts | 21 ++-- .../dashboard/stats/graph/visitor-graph.tsx | 22 ++-- 6 files changed, 122 insertions(+), 93 deletions(-) diff --git a/assets/js/dashboard/dashboard-time-periods.ts b/assets/js/dashboard/dashboard-time-periods.ts index 308329931303..ddbc9ece7ae3 100644 --- a/assets/js/dashboard/dashboard-time-periods.ts +++ b/assets/js/dashboard/dashboard-time-periods.ts @@ -49,6 +49,17 @@ export enum ComparisonMode { custom = 'custom' } +export type DashboardTimeSettings = { + site: Pick + 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 +84,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({ + site, + date, + period, + from, + to, + comparison, + compare_from, + compare_to +}: DashboardTimeSettings) { + const startOfDay = nowForSite(site).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 diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index 3d01462ddbd5..0f56a72b0714 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -6,12 +6,23 @@ 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 { + GetIntervalProps, + 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 +const CACHE_TTL_REALTIME = REALTIME_UPDATE_TIME_MS +const CACHE_TTL_SHORT_ONGOING = 5 * 60 * 1000 // 5 minutes +const CACHE_TTL_LONG_ONGOING = 60 * 60 * 1000 // 1 hour +const CACHE_TTL_HISTORICAL = 12 * 60 * 60 * 1000 // 12 hours // how many items per page for breakdown modals const PAGINATION_LIMIT = 100 @@ -108,59 +119,40 @@ 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( + Object.fromEntries( + Object.entries(props).filter(([k, _v]) => k !== 'date') + ) as GetIntervalProps + ) + + 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/stats/graph/graph-interval-context.tsx b/assets/js/dashboard/stats/graph/graph-interval-context.tsx index d96ec4b9d7fd..a0a5f5b5d5f7 100644 --- a/assets/js/dashboard/stats/graph/graph-interval-context.tsx +++ b/assets/js/dashboard/stats/graph/graph-interval-context.tsx @@ -21,9 +21,10 @@ export function GraphIntervalProvider({ }) { const site = useSiteContext() const { dashboardState } = useDashboardStateContext() + const intervalStorageKey = `interval__${dashboardState.period}__${site.domain}` const { selectedInterval, onIntervalClick, availableIntervals } = - useStoredInterval({ + useStoredInterval(intervalStorageKey, { site, to: dashboardState.to, from: dashboardState.from, diff --git a/assets/js/dashboard/stats/graph/interval-picker.tsx b/assets/js/dashboard/stats/graph/interval-picker.tsx index 0fe1b4979140..be89d84e20b6 100644 --- a/assets/js/dashboard/stats/graph/interval-picker.tsx +++ b/assets/js/dashboard/stats/graph/interval-picker.tsx @@ -16,8 +16,8 @@ const INTERVAL_LABELS: Record = { [Interval.month]: 'Months' } -function getStoredInterval(period: string, domain: string): string | null { - const stored = storage.getItem(`interval__${period}__${domain}`) +function getStoredInterval(storageKey: string): string | null { + const stored = storage.getItem(storageKey) if (stored === 'date') { return 'day' @@ -26,15 +26,14 @@ function getStoredInterval(period: string, domain: string): string | null { } } -function storeInterval( - period: string, - domain: string, - interval: Interval -): void { - storage.setItem(`interval__${period}__${domain}`, interval) +function storeInterval(storageKey: string, interval: Interval): void { + storage.setItem(storageKey, interval) } -export const useStoredInterval = (props: GetIntervalProps) => { +export const useStoredInterval = ( + storageKey: string, + props: GetIntervalProps +) => { const { period, from, to, site, comparison, compare_from, compare_to } = props // Dayjs objects are new references on every render, so we @@ -77,7 +76,7 @@ export const useStoredInterval = (props: GetIntervalProps) => { [storableIntervals] ) - const storedInterval = getStoredInterval(period, site.domain) + const storedInterval = getStoredInterval(storageKey) const [selectedInterval, setSelectedInterval] = useState(null) @@ -88,11 +87,11 @@ export const useStoredInterval = (props: GetIntervalProps) => { const onIntervalClick = useCallback( (interval: Interval) => { if (isStorable(interval)) { - storeInterval(period, site.domain, interval) + storeInterval(storageKey, interval) } setSelectedInterval(interval) }, - [period, site, isStorable] + [storageKey, isStorable] ) return { diff --git a/assets/js/dashboard/stats/graph/intervals.ts b/assets/js/dashboard/stats/graph/intervals.ts index 2c0405882086..ef6cc092f098 100644 --- a/assets/js/dashboard/stats/graph/intervals.ts +++ b/assets/js/dashboard/stats/graph/intervals.ts @@ -1,7 +1,9 @@ -import { PlausibleSite } from '../../site-context' -import { DashboardState } from '../../dashboard-state' import { Dayjs } from 'dayjs' -import { ComparisonMode, DashboardPeriod } from '../../dashboard-time-periods' +import { + ComparisonMode, + DashboardPeriod, + DashboardTimeSettings +} from '../../dashboard-time-periods' import { dateForSite, nowForSite } from '../../util/date' export enum Interval { @@ -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 } @@ -117,7 +116,7 @@ function coarser(a: Interval[], b: Interval[]): Interval[] { } function validIntervalsForMainPeriod( - site: PlausibleSite, + site: DashboardTimeSettings['site'], period: DashboardPeriod, from: Dayjs | null, to: Dayjs | null @@ -143,7 +142,7 @@ function validIntervalsForCustomComparison( } function defaultForMainPeriod( - site: PlausibleSite, + site: DashboardTimeSettings['site'], period: DashboardPeriod, from: Dayjs | null, to: Dayjs | null @@ -191,7 +190,9 @@ function validIntervalsForCustomPeriod({ to, from }: DayjsRange): Interval[] { return [Interval.week, Interval.month] } -function validIntervalsForAllTimePeriod(site: PlausibleSite): Interval[] { +function validIntervalsForAllTimePeriod( + site: DashboardTimeSettings['site'] +): Interval[] { const to = nowForSite(site) const from = site.statsBegin ? dateForSite(site.statsBegin, site) : to diff --git a/assets/js/dashboard/stats/graph/visitor-graph.tsx b/assets/js/dashboard/stats/graph/visitor-graph.tsx index d6047ae36f4a..2dbfbc63e5f8 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,10 @@ 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({ site, ...opts.dashboardState }) + } }) const mainGraphQuery = useQuery({ @@ -86,14 +80,10 @@ 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({ site, ...opts.dashboardState }) + } }) // update metric to one that exists From ca87cf79f4c04b1a4dac001aa6bf8a122388e22d Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 22 Apr 2026 18:35:34 +0100 Subject: [PATCH 02/15] add tests for cache ttl --- assets/js/dashboard/hooks/api-client.test.ts | 153 +++++++++++++++++++ assets/js/dashboard/hooks/api-client.ts | 8 +- 2 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 assets/js/dashboard/hooks/api-client.test.ts 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..1850f6a39c76 --- /dev/null +++ b/assets/js/dashboard/hooks/api-client.test.ts @@ -0,0 +1,153 @@ +import { DEFAULT_SITE } from '../../../test-utils/app-context-providers' +import { ComparisonMode, DashboardPeriod } from '../dashboard-time-periods' +import { formatISO, nowForSite } from '../util/date' +import { + CACHE_TTL_HISTORICAL, + CACHE_TTL_LONG_ONGOING, + CACHE_TTL_REALTIME, + CACHE_TTL_SHORT_ONGOING, + getStaleTime +} from './api-client' + +const site = DEFAULT_SITE +const today = nowForSite(site) +const yesterday = nowForSite(site).subtract(1, 'day') + +const noComparison = { comparison: null, compare_from: null, compare_to: null } +const base = { site, date: null, from: null, to: null, ...noComparison } + +describe(`${getStaleTime.name}`, () => { + describe('realtime periods', () => { + it('for realtime', () => { + expect(getStaleTime({ ...base, period: DashboardPeriod.realtime })).toBe( + CACHE_TTL_REALTIME + ) + }) + + it('for realtime_30m', () => { + expect( + getStaleTime({ ...base, period: DashboardPeriod.realtime_30m }) + ).toBe(CACHE_TTL_REALTIME) + }) + }) + + describe('historical periods (does not include today)', () => { + it('for 28d', () => { + expect(getStaleTime({ ...base, period: DashboardPeriod['28d'] })).toBe( + CACHE_TTL_HISTORICAL + ) + }) + + it('for 6mo', () => { + expect(getStaleTime({ ...base, period: DashboardPeriod['6mo'] })).toBe( + CACHE_TTL_HISTORICAL + ) + }) + + it('for period=day and date=yesterday', () => { + expect( + getStaleTime({ ...base, period: DashboardPeriod.day, date: yesterday }) + ).toBe(CACHE_TTL_HISTORICAL) + }) + + it('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)', () => { + it('for today period', () => { + expect(getStaleTime({ ...base, period: DashboardPeriod.day })).toBe( + CACHE_TTL_SHORT_ONGOING + ) + }) + + it('for 24h period', () => { + expect(getStaleTime({ ...base, period: DashboardPeriod['24h'] })).toBe( + CACHE_TTL_SHORT_ONGOING + ) + }) + + it('for period=month and date=today', () => { + expect( + getStaleTime({ ...base, period: DashboardPeriod.month, date: today }) + ).toBe(CACHE_TTL_SHORT_ONGOING) + }) + + it('for period=year and date=yesterday', () => { + expect( + getStaleTime({ ...base, period: DashboardPeriod.year, date: today }) + ).toBe(CACHE_TTL_SHORT_ONGOING) + }) + + it('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) + }) + + it('for all time period when stats begin recently', () => { + const siteWithRecentStats = { + ...DEFAULT_SITE, + statsBegin: formatISO(yesterday) + } + expect( + getStaleTime({ + ...base, + site: siteWithRecentStats, + period: DashboardPeriod.all + }) + ).toBe(CACHE_TTL_SHORT_ONGOING) + }) + + it('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)', () => { + it('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) + }) + + it('for all time period when stats begin over 12 months ago', () => { + const siteWithOldStats = { ...DEFAULT_SITE, statsBegin: '2020-01-01' } + expect( + getStaleTime({ + ...base, + site: siteWithOldStats, + 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 0f56a72b0714..adaf0a583b14 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -19,10 +19,10 @@ import { } from '../stats/graph/intervals' // define (in ms) when query API responses should become stale -const CACHE_TTL_REALTIME = REALTIME_UPDATE_TIME_MS -const CACHE_TTL_SHORT_ONGOING = 5 * 60 * 1000 // 5 minutes -const CACHE_TTL_LONG_ONGOING = 60 * 60 * 1000 // 1 hour -const CACHE_TTL_HISTORICAL = 12 * 60 * 60 * 1000 // 12 hours +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 From 6c98f8f375c3cf9e296a5668d60b60c3846b7321 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 22 Apr 2026 18:48:21 +0100 Subject: [PATCH 03/15] plug in staleTime logic to breakdown modals --- assets/js/dashboard/hooks/api-client.ts | 6 ++++++ assets/js/dashboard/stats/modals/breakdown-modal.tsx | 1 + assets/js/dashboard/stats/modals/google-keywords.tsx | 1 + 3 files changed, 8 insertions(+) diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index adaf0a583b14..141cbf0e118a 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -45,12 +45,14 @@ export function usePaginatedGetAPI< TResponse extends { results: unknown[] }, TKey extends PaginatedQueryKeyBase = PaginatedQueryKeyBase >({ + site, key, getRequestParams, afterFetchData, afterFetchNextPage, initialPageParam = 1 }: { + site: DashboardTimeSettings['site'] key: TKey getRequestParams: GetRequestParams afterFetchData?: (response: TResponse) => void @@ -100,6 +102,10 @@ export function usePaginatedGetAPI< ? lastPageIndex + 1 : null }, + staleTime: ({ queryKey }) => { + const [_, opts] = queryKey + return getStaleTime({ site, ...opts.dashboardState }) + }, initialPageParam, placeholderData: (previousData) => previousData }) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.tsx index b2ab4f6cae15..25c01c043264 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal.tsx @@ -107,6 +107,7 @@ export default function BreakdownModal({ { dashboardState: DashboardState; search: string; orderBy: OrderBy } ] >({ + site, key: [reportInfo.endpoint, { dashboardState, search, orderBy }], getRequestParams: (key) => { const [_endpoint, { dashboardState, search }] = key diff --git a/assets/js/dashboard/stats/modals/google-keywords.tsx b/assets/js/dashboard/stats/modals/google-keywords.tsx index d2e89378932f..c65f1eff28a5 100644 --- a/assets/js/dashboard/stats/modals/google-keywords.tsx +++ b/assets/js/dashboard/stats/modals/google-keywords.tsx @@ -58,6 +58,7 @@ function GoogleKeywordsModal() { { results: GoogleKeywordItem[] }, [string, { dashboardState: DashboardState; search: string }] >({ + site, key: [endpoint, { dashboardState, search }], getRequestParams: (key) => { const [_endpoint, { dashboardState, search }] = key From c5e1b609431580e169d922941794f886eced8953 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 23 Apr 2026 09:38:43 +0100 Subject: [PATCH 04/15] test cache works in modals --- .../stats/modals/breakdown-modal.test.tsx | 70 +++++++++++++++++++ .../stats/modals/google-keywords.test.tsx | 67 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 assets/js/dashboard/stats/modals/breakdown-modal.test.tsx create mode 100644 assets/js/dashboard/stats/modals/google-keywords.test.tsx 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..9f5ec6d946a9 --- /dev/null +++ b/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx @@ -0,0 +1,70 @@ +import React, { useState, Dispatch, SetStateAction } from 'react' +import { act, render, waitFor } from '@testing-library/react' +import { + mockAnimationsApi, + mockResizeObserver, + mockIntersectionObserver +} from 'jsdom-testing-mocks' +import { TestContextProviders } from '../../../../test-utils/app-context-providers' +import PagesModal from './pages' + +mockAnimationsApi() +mockResizeObserver() +mockIntersectionObserver() + +const fetchMock = jest.fn() + +beforeAll(() => { + globalThis.fetch = fetchMock +}) + +beforeEach(() => { + const modalRoot = document.createElement('div') + modalRoot.setAttribute('id', 'modal_root') + document.body.appendChild(modalRoot) + + fetchMock.mockImplementation((url: string) => { + if (url.includes('/pages/')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + results: [], + meta: { date_range_label: 'Last 30 days', metric_warnings: undefined } + }) + }) + } + throw new Error(`Unmocked request: ${url}`) + }) +}) + +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 () => { + let setOpen: Dispatch> + + function ToggleableModal() { + const [open, s] = useState(true) + setOpen = s + return open ? : null + } + + render( + + + + ) + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + act(() => setOpen(false)) + act(() => setOpen(true)) + + await act(async () => {}) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) 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..d16bb4049911 --- /dev/null +++ b/assets/js/dashboard/stats/modals/google-keywords.test.tsx @@ -0,0 +1,67 @@ +import React, { useState, Dispatch, SetStateAction } from 'react' +import { act, render, waitFor } from '@testing-library/react' +import { + mockAnimationsApi, + mockResizeObserver, + mockIntersectionObserver +} from 'jsdom-testing-mocks' +import { TestContextProviders } from '../../../../test-utils/app-context-providers' +import GoogleKeywordsModal from './google-keywords' + +mockAnimationsApi() +mockResizeObserver() +mockIntersectionObserver() + +const fetchMock = jest.fn() + +beforeAll(() => { + globalThis.fetch = fetchMock +}) + +beforeEach(() => { + const modalRoot = document.createElement('div') + modalRoot.setAttribute('id', 'modal_root') + document.body.appendChild(modalRoot) + + fetchMock.mockImplementation((url: string) => { + if (url.includes('/referrers/Google/')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ results: [] }) + }) + } + throw new Error(`Unmocked request: ${url}`) + }) +}) + +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 () => { + let setOpen: Dispatch> + + function ToggleableModal() { + const [open, s] = useState(true) + setOpen = s + return open ? : null + } + + render( + + + + ) + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + + act(() => setOpen(false)) + act(() => setOpen(true)) + + await act(async () => {}) + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) From b1e207bf672bb32bd84d5243319246498691aab2 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 23 Apr 2026 10:10:09 +0100 Subject: [PATCH 05/15] fix test description --- assets/js/dashboard/hooks/api-client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/hooks/api-client.test.ts b/assets/js/dashboard/hooks/api-client.test.ts index 1850f6a39c76..51d89af747a7 100644 --- a/assets/js/dashboard/hooks/api-client.test.ts +++ b/assets/js/dashboard/hooks/api-client.test.ts @@ -82,7 +82,7 @@ describe(`${getStaleTime.name}`, () => { ).toBe(CACHE_TTL_SHORT_ONGOING) }) - it('for period=year and date=yesterday', () => { + it('for period=year and date=today', () => { expect( getStaleTime({ ...base, period: DashboardPeriod.year, date: today }) ).toBe(CACHE_TTL_SHORT_ONGOING) From 6102dfb9c981fcb7cf7c1055f87b78336ab4e15e Mon Sep 17 00:00:00 2001 From: Artur Pata Date: Thu, 23 Apr 2026 16:17:20 +0300 Subject: [PATCH 06/15] Refactor mock API to ignore query params, use in breakdown modal test --- .../stats/modals/breakdown-modal.test.tsx | 65 ++++++++++--------- assets/test-utils/mock-api.ts | 44 ++++++++----- 2 files changed, 61 insertions(+), 48 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx index 9f5ec6d946a9..3f3e579581a5 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx @@ -1,41 +1,29 @@ import React, { useState, Dispatch, SetStateAction } from 'react' -import { act, render, waitFor } from '@testing-library/react' -import { - mockAnimationsApi, - mockResizeObserver, - mockIntersectionObserver -} from 'jsdom-testing-mocks' +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' -mockAnimationsApi() -mockResizeObserver() -mockIntersectionObserver() +const domain = 'dummy.site' -const fetchMock = jest.fn() +let mockAPI: MockAPI beforeAll(() => { - globalThis.fetch = fetchMock + 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) - - fetchMock.mockImplementation((url: string) => { - if (url.includes('/pages/')) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - results: [], - meta: { date_range_label: 'Last 30 days', metric_warnings: undefined } - }) - }) - } - throw new Error(`Unmocked request: ${url}`) - }) }) afterEach(() => { @@ -44,27 +32,42 @@ afterEach(() => { 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(true) + const [open, s] = useState(false) setOpen = s return open ? : null } render( - + ) - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + 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() - await act(async () => {}) - - expect(fetchMock).toHaveBeenCalledTimes(1) + expect(pagesHandler).toHaveBeenCalledTimes(1) }) }) diff --git a/assets/test-utils/mock-api.ts b/assets/test-utils/mock-api.ts index cdbbdd7fc4e4..31dc308b2219 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 + ): 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,16 @@ 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) { + console.log(input, 'no handler') + throw new Error( `Unmocked request ${method.toString()} ${input.toString()}` ) } - return handler() + return handler(input, init) } global.fetch = this.fetch.mockImplementation(mockFetch) From a53c1bda717272426ab2ee5948519f434cfe8d89 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 24 Apr 2026 12:07:17 +0100 Subject: [PATCH 07/15] fix misleading comment about segment timestamps --- assets/js/dashboard/filtering/segments.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From efc0da0322af7ab815a2aacc509281d944fd2d25 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 24 Apr 2026 12:17:44 +0100 Subject: [PATCH 08/15] flatten site props in DashboardTimeSettings - rename some date utils to not have to pass site - fix parsing siteStatsBegin in validIntervalsForAllTimePeriod and add missing tests - instead of passing intervalStorageKey, pass domain to useStoredInterval instead --- .../js/dashboard/dashboard-state-context.tsx | 8 +- assets/js/dashboard/dashboard-state.ts | 4 +- assets/js/dashboard/dashboard-time-periods.ts | 19 +- assets/js/dashboard/hooks/api-client.test.ts | 26 +-- assets/js/dashboard/hooks/api-client.ts | 12 +- .../query-periods/comparison-period-menu.tsx | 4 +- .../query-periods/dashboard-period-menu.tsx | 4 +- .../stats/graph/graph-interval-context.tsx | 7 +- .../dashboard/stats/graph/interval-picker.tsx | 37 ++-- .../dashboard/stats/graph/intervals.test.ts | 175 ++++++++++++++---- assets/js/dashboard/stats/graph/intervals.ts | 46 +++-- .../dashboard/stats/graph/visitor-graph.tsx | 12 +- .../stats/modals/breakdown-modal.tsx | 3 +- .../stats/modals/google-keywords.tsx | 3 +- assets/js/dashboard/util/date.js | 24 +-- assets/js/dashboard/util/date.test.ts | 16 +- 16 files changed, 283 insertions(+), 117 deletions(-) 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 ddbc9ece7ae3..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' @@ -50,7 +50,8 @@ export enum ComparisonMode { } export type DashboardTimeSettings = { - site: Pick + siteTimezoneOffset: PlausibleSite['offset'] + siteStatsBegin: PlausibleSite['statsBegin'] date: DashboardState['date'] period: DashboardState['period'] from: DashboardState['from'] @@ -94,7 +95,7 @@ const PERIODS_EXCLUDING_NOW = [ ] export function isHistoricalPeriod({ - site, + siteTimezoneOffset, date, period, from, @@ -103,7 +104,7 @@ export function isHistoricalPeriod({ compare_from, compare_to }: DashboardTimeSettings) { - const startOfDay = nowForSite(site).startOf('day') + const startOfDay = now(siteTimezoneOffset).startOf('day') const mainPeriodIncludesToday = period === DashboardPeriod.custom && to && from @@ -331,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 } ], @@ -347,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 } ], @@ -456,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/hooks/api-client.test.ts b/assets/js/dashboard/hooks/api-client.test.ts index 51d89af747a7..5b92d9060dfa 100644 --- a/assets/js/dashboard/hooks/api-client.test.ts +++ b/assets/js/dashboard/hooks/api-client.test.ts @@ -1,6 +1,5 @@ -import { DEFAULT_SITE } from '../../../test-utils/app-context-providers' import { ComparisonMode, DashboardPeriod } from '../dashboard-time-periods' -import { formatISO, nowForSite } from '../util/date' +import { formatISO, utcNow } from '../util/date' import { CACHE_TTL_HISTORICAL, CACHE_TTL_LONG_ONGOING, @@ -9,12 +8,18 @@ import { getStaleTime } from './api-client' -const site = DEFAULT_SITE -const today = nowForSite(site) -const yesterday = nowForSite(site).subtract(1, 'day') +const today = utcNow() +const yesterday = utcNow().subtract(1, 'day') const noComparison = { comparison: null, compare_from: null, compare_to: null } -const base = { site, date: null, from: null, to: null, ...noComparison } +const base = { + siteStatsBegin: '', + siteTimezoneOffset: 0, + date: null, + from: null, + to: null, + ...noComparison +} describe(`${getStaleTime.name}`, () => { describe('realtime periods', () => { @@ -100,14 +105,10 @@ describe(`${getStaleTime.name}`, () => { }) it('for all time period when stats begin recently', () => { - const siteWithRecentStats = { - ...DEFAULT_SITE, - statsBegin: formatISO(yesterday) - } expect( getStaleTime({ ...base, - site: siteWithRecentStats, + siteStatsBegin: formatISO(yesterday), period: DashboardPeriod.all }) ).toBe(CACHE_TTL_SHORT_ONGOING) @@ -140,11 +141,10 @@ describe(`${getStaleTime.name}`, () => { }) it('for all time period when stats begin over 12 months ago', () => { - const siteWithOldStats = { ...DEFAULT_SITE, statsBegin: '2020-01-01' } expect( getStaleTime({ ...base, - site: siteWithOldStats, + 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 141cbf0e118a..eeffd5642f80 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -45,14 +45,16 @@ export function usePaginatedGetAPI< TResponse extends { results: unknown[] }, TKey extends PaginatedQueryKeyBase = PaginatedQueryKeyBase >({ - site, + siteTimezoneOffset, + siteStatsBegin, key, getRequestParams, afterFetchData, afterFetchNextPage, initialPageParam = 1 }: { - site: DashboardTimeSettings['site'] + siteTimezoneOffset: DashboardTimeSettings['siteTimezoneOffset'] + siteStatsBegin: DashboardTimeSettings['siteStatsBegin'] key: TKey getRequestParams: GetRequestParams afterFetchData?: (response: TResponse) => void @@ -104,7 +106,11 @@ export function usePaginatedGetAPI< }, staleTime: ({ queryKey }) => { const [_, opts] = queryKey - return getStaleTime({ site, ...opts.dashboardState }) + return getStaleTime({ + siteTimezoneOffset: siteTimezoneOffset, + siteStatsBegin: siteStatsBegin, + ...opts.dashboardState + }) }, initialPageParam, placeholderData: (previousData) => previousData 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 a0a5f5b5d5f7..b6e430f4f7bb 100644 --- a/assets/js/dashboard/stats/graph/graph-interval-context.tsx +++ b/assets/js/dashboard/stats/graph/graph-interval-context.tsx @@ -21,11 +21,12 @@ export function GraphIntervalProvider({ }) { const site = useSiteContext() const { dashboardState } = useDashboardStateContext() - const intervalStorageKey = `interval__${dashboardState.period}__${site.domain}` const { selectedInterval, onIntervalClick, availableIntervals } = - useStoredInterval(intervalStorageKey, { - site, + useStoredInterval({ + 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 be89d84e20b6..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(storageKey: string): string | null { - const stored = storage.getItem(storageKey) +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' @@ -26,15 +38,19 @@ function getStoredInterval(storageKey: string): string | null { } } -function storeInterval(storageKey: string, interval: Interval): void { - storage.setItem(storageKey, interval) +function storeInterval( + domain: PlausibleSite['domain'], + period: DashboardPeriod, + interval: Interval +): void { + storage.setItem(getIntervalStorageKey(domain, period), interval) } export const useStoredInterval = ( - storageKey: string, - props: GetIntervalProps + props: Pick & GetIntervalProps ) => { - const { period, from, to, site, comparison, compare_from, compare_to } = props + 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 @@ -51,7 +67,6 @@ export const useStoredInterval = ( } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - site, period, customFrom, customTo, @@ -76,7 +91,7 @@ export const useStoredInterval = ( [storableIntervals] ) - const storedInterval = getStoredInterval(storageKey) + const storedInterval = getStoredInterval(domain, period) const [selectedInterval, setSelectedInterval] = useState(null) @@ -87,11 +102,11 @@ export const useStoredInterval = ( const onIntervalClick = useCallback( (interval: Interval) => { if (isStorable(interval)) { - storeInterval(storageKey, interval) + storeInterval(domain, period, interval) } setSelectedInterval(interval) }, - [storageKey, 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 ef6cc092f098..2eb22dd6433b 100644 --- a/assets/js/dashboard/stats/graph/intervals.ts +++ b/assets/js/dashboard/stats/graph/intervals.ts @@ -4,7 +4,7 @@ import { DashboardPeriod, DashboardTimeSettings } from '../../dashboard-time-periods' -import { dateForSite, nowForSite } from '../../util/date' +import { now, parseUTCDate } from '../../util/date' export enum Interval { minute = 'minute', @@ -53,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, @@ -61,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, @@ -81,7 +88,8 @@ export function validIntervals({ * appropriate for the comparison date range. */ export function getDefaultInterval({ - site, + siteTimezoneOffset, + siteStatsBegin, period, to, from, @@ -89,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, @@ -116,7 +130,8 @@ function coarser(a: Interval[], b: Interval[]): Interval[] { } function validIntervalsForMainPeriod( - site: DashboardTimeSettings['site'], + siteTimezoneOffset: DashboardTimeSettings['siteTimezoneOffset'], + siteStatsBegin: DashboardTimeSettings['siteStatsBegin'], period: DashboardPeriod, from: Dayjs | null, to: Dayjs | null @@ -125,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] } @@ -142,7 +157,8 @@ function validIntervalsForCustomComparison( } function defaultForMainPeriod( - site: DashboardTimeSettings['site'], + siteTimezoneOffset: DashboardTimeSettings['siteTimezoneOffset'], + siteStatsBegin: DashboardTimeSettings['siteStatsBegin'], period: DashboardPeriod, from: Dayjs | null, to: Dayjs | null @@ -151,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,10 +210,13 @@ function validIntervalsForCustomPeriod({ to, from }: DayjsRange): Interval[] { } function validIntervalsForAllTimePeriod( - site: DashboardTimeSettings['site'] + siteTimezoneOffset: DashboardTimeSettings['siteTimezoneOffset'], + siteStatsBegin: DashboardTimeSettings['siteStatsBegin'] ): Interval[] { - const to = nowForSite(site) - const from = site.statsBegin ? dateForSite(site.statsBegin, site) : to + 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 2dbfbc63e5f8..2128ee71f5a0 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.tsx +++ b/assets/js/dashboard/stats/graph/visitor-graph.tsx @@ -52,7 +52,11 @@ export default function VisitorGraph({ placeholderData: (previousData) => previousData, staleTime: ({ queryKey }) => { const [_, opts] = queryKey - return getStaleTime({ site, ...opts.dashboardState }) + return getStaleTime({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, + ...opts.dashboardState + }) } }) @@ -82,7 +86,11 @@ export default function VisitorGraph({ placeholderData: (previousData) => previousData, staleTime: ({ queryKey }) => { const [_, opts] = queryKey - return getStaleTime({ site, ...opts.dashboardState }) + return getStaleTime({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, + ...opts.dashboardState + }) } }) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.tsx index 25c01c043264..04d3e4f65adf 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal.tsx @@ -107,7 +107,8 @@ export default function BreakdownModal({ { dashboardState: DashboardState; search: string; orderBy: OrderBy } ] >({ - site, + 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.tsx b/assets/js/dashboard/stats/modals/google-keywords.tsx index c65f1eff28a5..cf8ce484de4a 100644 --- a/assets/js/dashboard/stats/modals/google-keywords.tsx +++ b/assets/js/dashboard/stats/modals/google-keywords.tsx @@ -58,7 +58,8 @@ function GoogleKeywordsModal() { { results: GoogleKeywordItem[] }, [string, { dashboardState: DashboardState; search: string }] >({ - site, + 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, From 03d94668ea38677fc2d2f6988b0602f22e5dff16 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 24 Apr 2026 12:25:08 +0100 Subject: [PATCH 09/15] remove console log --- assets/test-utils/mock-api.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/assets/test-utils/mock-api.ts b/assets/test-utils/mock-api.ts index 31dc308b2219..e172cf2fb360 100644 --- a/assets/test-utils/mock-api.ts +++ b/assets/test-utils/mock-api.ts @@ -59,8 +59,6 @@ export class MockAPI { const urlWithoutQueryString = input.split('?')[0] const handler = this.getHandler(method, urlWithoutQueryString) if (!handler) { - console.log(input, 'no handler') - throw new Error( `Unmocked request ${method.toString()} ${input.toString()}` ) From ac26609b008fd0bb3d362323cd336b3cad2ef328 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 24 Apr 2026 12:34:40 +0100 Subject: [PATCH 10/15] use MockAPI in google-keywords.test.tsx too --- .../stats/modals/breakdown-modal.test.tsx | 4 +- .../stats/modals/google-keywords.test.tsx | 60 ++++++++++--------- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx index 3f3e579581a5..55e56447b2e0 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx @@ -59,7 +59,9 @@ describe('BreakdownModal', () => { 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.stringContaining( + 'order_by=%5B%5B%22visitors%22%2C%22desc%22%5D%5D&limit=100&page=1' + ), expect.anything() ) diff --git a/assets/js/dashboard/stats/modals/google-keywords.test.tsx b/assets/js/dashboard/stats/modals/google-keywords.test.tsx index d16bb4049911..146689b52a0a 100644 --- a/assets/js/dashboard/stats/modals/google-keywords.test.tsx +++ b/assets/js/dashboard/stats/modals/google-keywords.test.tsx @@ -1,38 +1,29 @@ import React, { useState, Dispatch, SetStateAction } from 'react' -import { act, render, waitFor } from '@testing-library/react' -import { - mockAnimationsApi, - mockResizeObserver, - mockIntersectionObserver -} from 'jsdom-testing-mocks' +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' -mockAnimationsApi() -mockResizeObserver() -mockIntersectionObserver() +const domain = 'dummy.site' -const fetchMock = jest.fn() +let mockAPI: MockAPI beforeAll(() => { - globalThis.fetch = fetchMock + 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) - - fetchMock.mockImplementation((url: string) => { - if (url.includes('/referrers/Google/')) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ results: [] }) - }) - } - throw new Error(`Unmocked request: ${url}`) - }) }) afterEach(() => { @@ -41,27 +32,40 @@ afterEach(() => { 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(true) + const [open, s] = useState(false) setOpen = s return open ? : null } render( - + ) - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + 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() - await act(async () => {}) - - expect(fetchMock).toHaveBeenCalledTimes(1) + expect(googleKeywordsHandler).toHaveBeenCalledTimes(1) }) }) From bb48cee20c6bcdb7166f6e3b1ec6e338244981e4 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 24 Apr 2026 12:36:08 +0100 Subject: [PATCH 11/15] it() -> test() --- assets/js/dashboard/hooks/api-client.test.ts | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/assets/js/dashboard/hooks/api-client.test.ts b/assets/js/dashboard/hooks/api-client.test.ts index 5b92d9060dfa..cd7f4498973b 100644 --- a/assets/js/dashboard/hooks/api-client.test.ts +++ b/assets/js/dashboard/hooks/api-client.test.ts @@ -23,13 +23,13 @@ const base = { describe(`${getStaleTime.name}`, () => { describe('realtime periods', () => { - it('for realtime', () => { + test('for realtime', () => { expect(getStaleTime({ ...base, period: DashboardPeriod.realtime })).toBe( CACHE_TTL_REALTIME ) }) - it('for realtime_30m', () => { + test('for realtime_30m', () => { expect( getStaleTime({ ...base, period: DashboardPeriod.realtime_30m }) ).toBe(CACHE_TTL_REALTIME) @@ -37,25 +37,25 @@ describe(`${getStaleTime.name}`, () => { }) describe('historical periods (does not include today)', () => { - it('for 28d', () => { + test('for 28d', () => { expect(getStaleTime({ ...base, period: DashboardPeriod['28d'] })).toBe( CACHE_TTL_HISTORICAL ) }) - it('for 6mo', () => { + test('for 6mo', () => { expect(getStaleTime({ ...base, period: DashboardPeriod['6mo'] })).toBe( CACHE_TTL_HISTORICAL ) }) - it('for period=day and date=yesterday', () => { + test('for period=day and date=yesterday', () => { expect( getStaleTime({ ...base, period: DashboardPeriod.day, date: yesterday }) ).toBe(CACHE_TTL_HISTORICAL) }) - it('for custom period ending yesterday', () => { + test('for custom period ending yesterday', () => { expect( getStaleTime({ ...base, @@ -69,31 +69,31 @@ describe(`${getStaleTime.name}`, () => { }) describe('ongoing periods with short TTL (supports day or shorter interval)', () => { - it('for today period', () => { + test('for today period', () => { expect(getStaleTime({ ...base, period: DashboardPeriod.day })).toBe( CACHE_TTL_SHORT_ONGOING ) }) - it('for 24h period', () => { + test('for 24h period', () => { expect(getStaleTime({ ...base, period: DashboardPeriod['24h'] })).toBe( CACHE_TTL_SHORT_ONGOING ) }) - it('for period=month and date=today', () => { + test('for period=month and date=today', () => { expect( getStaleTime({ ...base, period: DashboardPeriod.month, date: today }) ).toBe(CACHE_TTL_SHORT_ONGOING) }) - it('for period=year and date=today', () => { + test('for period=year and date=today', () => { expect( getStaleTime({ ...base, period: DashboardPeriod.year, date: today }) ).toBe(CACHE_TTL_SHORT_ONGOING) }) - it('for custom period under 12 months ending today', () => { + test('for custom period under 12 months ending today', () => { expect( getStaleTime({ ...base, @@ -104,7 +104,7 @@ describe(`${getStaleTime.name}`, () => { ).toBe(CACHE_TTL_SHORT_ONGOING) }) - it('for all time period when stats begin recently', () => { + test('for all time period when stats begin recently', () => { expect( getStaleTime({ ...base, @@ -114,7 +114,7 @@ describe(`${getStaleTime.name}`, () => { ).toBe(CACHE_TTL_SHORT_ONGOING) }) - it('for a historical period when comparison includes today', () => { + test('for a historical period when comparison includes today', () => { expect( getStaleTime({ ...base, @@ -129,7 +129,7 @@ describe(`${getStaleTime.name}`, () => { }) describe('ongoing periods with long TTL (only week or month interval available)', () => { - it('for custom period over 12 months ending today', () => { + test('for custom period over 12 months ending today', () => { expect( getStaleTime({ ...base, @@ -140,7 +140,7 @@ describe(`${getStaleTime.name}`, () => { ).toBe(CACHE_TTL_LONG_ONGOING) }) - it('for all time period when stats begin over 12 months ago', () => { + test('for all time period when stats begin over 12 months ago', () => { expect( getStaleTime({ ...base, From 1308651a67dc005a605a3f4a167d22aba4082b7f Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 24 Apr 2026 12:38:32 +0100 Subject: [PATCH 12/15] getStaleTime: pass props directly to validIntervals --- assets/js/dashboard/hooks/api-client.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index eeffd5642f80..1632b1521446 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -152,11 +152,7 @@ export const getStaleTime = (props: DashboardTimeSettings): number => { return CACHE_TTL_HISTORICAL } - const availableIntervals = validIntervals( - Object.fromEntries( - Object.entries(props).filter(([k, _v]) => k !== 'date') - ) as GetIntervalProps - ) + const availableIntervals = validIntervals(props) if ( availableIntervals.includes(Interval.day) || From 9d4755e13096b795bd6b8f1480943f87abee24e8 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 24 Apr 2026 12:40:48 +0100 Subject: [PATCH 13/15] MockAPI: extend responseHandler type with number | null (for current visitors query) --- assets/test-utils/mock-api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/test-utils/mock-api.ts b/assets/test-utils/mock-api.ts index e172cf2fb360..3856162181b8 100644 --- a/assets/test-utils/mock-api.ts +++ b/assets/test-utils/mock-api.ts @@ -22,7 +22,7 @@ export class MockAPI { // sets get handler public get( urlWithoutQueryString: string, - responseHandler: typeof fetch | Record + responseHandler: typeof fetch | Record | number | null ): jest.Mock { const handler: typeof fetch = typeof responseHandler === 'function' From d615a752aa27ee5fe3c4170edb6c76aaa46f664a Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 24 Apr 2026 13:00:40 +0100 Subject: [PATCH 14/15] remove unused import --- assets/js/dashboard/hooks/api-client.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index 1632b1521446..363efae57954 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -13,7 +13,6 @@ import { } from '../dashboard-time-periods' import { REALTIME_UPDATE_TIME_MS } from '../util/realtime-update-timer' import { - GetIntervalProps, Interval, validIntervals } from '../stats/graph/intervals' From 8ebbed950f5e0f3a39de8465c5c94a36a0c47c30 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 24 Apr 2026 13:07:18 +0100 Subject: [PATCH 15/15] format --- assets/js/dashboard/hooks/api-client.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index 363efae57954..5b944c511761 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -12,10 +12,7 @@ import { isHistoricalPeriod } from '../dashboard-time-periods' import { REALTIME_UPDATE_TIME_MS } from '../util/realtime-update-timer' -import { - Interval, - validIntervals -} from '../stats/graph/intervals' +import { Interval, validIntervals } from '../stats/graph/intervals' // define (in ms) when query API responses should become stale export const CACHE_TTL_REALTIME = REALTIME_UPDATE_TIME_MS