diff --git a/assets/js/dashboard/api.ts b/assets/js/dashboard/api.ts index 482af38483cb..1eb66db7f9fd 100644 --- a/assets/js/dashboard/api.ts +++ b/assets/js/dashboard/api.ts @@ -5,6 +5,7 @@ import { StatsQuery } from './stats-query' import { formatISO } from './util/date' import { serializeApiFilters } from './util/filters' import * as url from './util/url' +import { MainGraphResponse } from './stats/graph/fetch-main-graph' let abortController = new AbortController() let SHARED_LINK_AUTH: null | string = null @@ -37,10 +38,21 @@ export type QueryResultRow = { comparison?: { metrics: Array; change: Array } } +// Added client-side in the queryFn before storing to TanStack cache. +// Needed to make sure that the time/metric labels we're constructing +// in stats reports are in sync with the dashboardState that was used +// to make that query. Otherwise, relying on current dashboardState +// while rendering previous (placeholder) data, it'd be out of sync. +export type ExtraContext = { + isRealtime: boolean + hasConversionGoalFilter: boolean +} + export type QueryApiResponse = { query: QueryResultQuery meta: QueryResultMeta results: QueryResultRow[] + extraContext: ExtraContext } export class ApiError extends Error { @@ -141,7 +153,9 @@ function getSharedLinkSearchParams(): Record { return SHARED_LINK_AUTH ? { auth: SHARED_LINK_AUTH } : {} } -export async function stats(site: PlausibleSite, statsQuery: StatsQuery) { +export async function stats< + TResponse extends QueryApiResponse | MainGraphResponse +>(site: PlausibleSite, statsQuery: StatsQuery) { const sharedLinkParams = getSharedLinkSearchParams() const queryString = sharedLinkParams.auth ? new URLSearchParams(sharedLinkParams).toString() @@ -158,7 +172,7 @@ export async function stats(site: PlausibleSite, statsQuery: StatsQuery) { body: JSON.stringify(statsQuery) }) - return handleApiResponse(response) + return (await handleApiResponse(response)) as TResponse } export async function get( diff --git a/assets/js/dashboard/components/sort-button.tsx b/assets/js/dashboard/components/sort-button.tsx index 3c1e2c4952e7..d184cd9ce177 100644 --- a/assets/js/dashboard/components/sort-button.tsx +++ b/assets/js/dashboard/components/sort-button.tsx @@ -1,5 +1,6 @@ import React, { ReactNode } from 'react' -import { cycleSortDirection, SortDirection } from '../hooks/use-order-by-legacy' +import { cycleSortDirection } from '../hooks/use-order-by-legacy' +import { SortDirection } from '../../types/query-api' import classNames from 'classnames' export const SortButton = ({ @@ -29,8 +30,8 @@ export const SortButton = ({ 'rounded inline-block size-4', 'ml-1', { - [SortDirection.asc]: 'rotate-180', - [SortDirection.desc]: 'rotate-0' + asc: 'rotate-180', + desc: 'rotate-0' }[sortDirection ?? next.direction], !sortDirection && 'opacity-0', !sortDirection && 'group-hover:opacity-100', diff --git a/assets/js/dashboard/components/table-legacy.tsx b/assets/js/dashboard/components/table-legacy.tsx index 7460fb26a2d1..c4c2712ea97b 100644 --- a/assets/js/dashboard/components/table-legacy.tsx +++ b/assets/js/dashboard/components/table-legacy.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames' import React, { ReactNode } from 'react' -import { SortDirection } from '../hooks/use-order-by-legacy' +import { SortDirection } from '../../types/query-api' import { SortButton } from './sort-button' import { Tooltip } from '../util/tooltip' diff --git a/assets/js/dashboard/current-visitors-context.tsx b/assets/js/dashboard/current-visitors-context.tsx new file mode 100644 index 000000000000..f93d6f1093a9 --- /dev/null +++ b/assets/js/dashboard/current-visitors-context.tsx @@ -0,0 +1,47 @@ +import React, { createContext, useContext, useEffect } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { get } from './api' +import { useSiteContext } from './site-context' +import { useDashboardStateContext } from './dashboard-state-context' +import { CACHE_TTL_REALTIME } from './hooks/api-client' +import { isRealTimeDashboard } from './util/filters' + +const CurrentVisitorsContext = createContext(null) + +export function CurrentVisitorsProvider({ + children +}: { + children: React.ReactNode +}) { + const site = useSiteContext() + const { dashboardState } = useDashboardStateContext() + const queryClient = useQueryClient() + + const isEnabled = + isRealTimeDashboard(dashboardState) || dashboardState.filters.length === 0 + + const { data } = useQuery({ + queryKey: ['current-visitors'], + queryFn: () => + get(`/api/stats/${encodeURIComponent(site.domain)}/current-visitors`), + staleTime: CACHE_TTL_REALTIME, + enabled: isEnabled + }) + + useEffect(() => { + const onTick = () => { + queryClient.invalidateQueries({ queryKey: ['current-visitors'] }) + } + document.addEventListener('tick', onTick) + return () => document.removeEventListener('tick', onTick) + }, [queryClient]) + + return ( + + {children} + + ) +} + +export const useCurrentVisitorsContext = () => + useContext(CurrentVisitorsContext) diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index 25660ad587e8..ff8347833500 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -13,8 +13,6 @@ import { } from '../dashboard-time-periods' import { REALTIME_UPDATE_TIME_MS } from '../util/realtime-update-timer' import { Interval, validIntervals } from '../stats/graph/intervals' -import { PlausibleSite } from '../site-context' -import { StatsQuery } from '../stats-query' // define (in ms) when query API responses should become stale export const CACHE_TTL_REALTIME = REALTIME_UPDATE_TIME_MS @@ -23,7 +21,7 @@ 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 +export const PAGINATION_LIMIT = 100 /** full endpoint URL */ type Endpoint = string @@ -34,54 +32,6 @@ type GetRequestParams = ( k: TKey ) => [DashboardState, Record] -/** - * Hook for paginated POST /api/stats/:domain/query requests (i.e. Details views). - */ -export function usePaginatedQueryAPI({ - site, - dashboardState, - statsQuery -}: { - site: PlausibleSite - dashboardState: DashboardState - statsQuery: StatsQuery -}) { - const queryClient = useQueryClient() - const dimensionKey = statsQuery.dimensions.join(',') - - useEffect(() => { - return () => { - const tanstackQueryFilters: QueryFilters = { - predicate: ({ queryKey }) => queryKey[0] === dimensionKey - } - queryClient.setQueriesData(tanstackQueryFilters, cleanToPageOne) - } - }, [queryClient, dimensionKey]) - - return useInfiniteQuery({ - queryKey: [dimensionKey, statsQuery], - queryFn: async ({ pageParam }): Promise => { - return api.stats(site, { - ...statsQuery, - pagination: { limit: PAGINATION_LIMIT, offset: pageParam as number } - }) - }, - getNextPageParam: (lastPage, _, lastPageParam) => { - return lastPage.results.length === PAGINATION_LIMIT - ? (lastPageParam as number) + PAGINATION_LIMIT - : null - }, - staleTime: () => - getStaleTime({ - siteTimezoneOffset: site.offset, - siteStatsBegin: site.statsBegin, - ...dashboardState - }), - initialPageParam: 0, - placeholderData: (previousData) => previousData - }) -} - /** * Hook that fetches the first page from the defined GET endpoint on mount, * then subsequent pages when component calls fetchNextPage. diff --git a/assets/js/dashboard/hooks/use-order-by.test.ts b/assets/js/dashboard/hooks/use-metric-order-by.test.ts similarity index 73% rename from assets/js/dashboard/hooks/use-order-by.test.ts rename to assets/js/dashboard/hooks/use-metric-order-by.test.ts index 044003756738..29b5ef3221fc 100644 --- a/assets/js/dashboard/hooks/use-order-by.test.ts +++ b/assets/js/dashboard/hooks/use-metric-order-by.test.ts @@ -1,39 +1,38 @@ import { Metric } from '../stats/metrics' import { - OrderBy, - SortDirection, + MetricOrderBy, cycleSortDirection, getOrderByStorageKey, getStoredOrderBy, maybeStoreOrderBy, rearrangeOrderBy, validateOrderBy -} from './use-order-by' +} from './use-metric-order-by' describe(`${cycleSortDirection.name}`, () => { test.each([ [ null, { - direction: SortDirection.desc, + direction: 'desc', hint: 'Press to sort column in descending order' } ], [ - SortDirection.desc, + 'desc', { - direction: SortDirection.asc, + direction: 'asc', hint: 'Press to sort column in ascending order' } ], [ - SortDirection.asc, + 'asc', { - direction: SortDirection.desc, + direction: 'desc', hint: 'Press to sort column in descending order' } ] - ])( + ] as const)( 'for current direction %p returns %p', (currentDirection, expectedOutput) => { expect(cycleSortDirection(currentDirection)).toEqual(expectedOutput) @@ -42,22 +41,10 @@ describe(`${cycleSortDirection.name}`, () => { }) describe(`${rearrangeOrderBy.name}`, () => { - const cases: [Metric, OrderBy, OrderBy][] = [ - [ - 'visitors', - [['visitors', SortDirection.asc]], - [['visitors', SortDirection.desc]] - ], - [ - 'visitors', - [['visitors', SortDirection.desc]], - [['visitors', SortDirection.asc]] - ], - [ - 'visit_duration', - [['visitors', SortDirection.asc]], - [['visit_duration', SortDirection.desc]] - ] + const cases: [Metric, MetricOrderBy, MetricOrderBy][] = [ + ['visitors', [['visitors', 'asc']], [['visitors', 'desc']]], + ['visitors', [['visitors', 'desc']], [['visitors', 'asc']]], + ['visit_duration', [['visitors', 'asc']], [['visit_duration', 'desc']]] ] it.each(cases)( `[%#] clicking on %p yields expected order`, @@ -96,7 +83,7 @@ describe(`storing detailed report preferred order`, () => { it('does not store invalid value', () => { maybeStoreOrderBy({ - orderBy: [['total_visitors', SortDirection.desc]], + orderBy: [['total_visitors', 'desc']], domain, dimensionLabel, metrics: ['total_visitors'] @@ -108,7 +95,7 @@ describe(`storing detailed report preferred order`, () => { it('falls back to fallbackValue if metric has become unsortable between storing and retrieving', () => { maybeStoreOrderBy({ - orderBy: [['visitors', SortDirection.desc]], + orderBy: [['visitors', 'desc']], domain, dimensionLabel, metrics: ['visitors'] @@ -122,13 +109,13 @@ describe(`storing detailed report preferred order`, () => { domain, dimensionLabel, metrics: ['total_visitors'], - fallbackValue: [['visitors', SortDirection.desc]] + fallbackValue: [['visitors', 'desc']] }) - ).toEqual([['visitors', SortDirection.desc]]) + ).toEqual([['visitors', 'desc']]) }) it('retrieves stored value correctly', () => { - const input: OrderBy = [['visitors', SortDirection.asc]] + const input: MetricOrderBy = [['visitors', 'asc']] localStorage.setItem( getOrderByStorageKey(domain, dimensionLabel), JSON.stringify(input) @@ -138,7 +125,7 @@ describe(`storing detailed report preferred order`, () => { domain, dimensionLabel, metrics: ['visitors'], - fallbackValue: [['visitors', SortDirection.desc]] + fallbackValue: [['visitors', 'desc']] }) ).toEqual(input) }) diff --git a/assets/js/dashboard/hooks/use-order-by.ts b/assets/js/dashboard/hooks/use-metric-order-by.ts similarity index 82% rename from assets/js/dashboard/hooks/use-order-by.ts rename to assets/js/dashboard/hooks/use-metric-order-by.ts index 01b5de2a2366..652d305f0537 100644 --- a/assets/js/dashboard/hooks/use-order-by.ts +++ b/assets/js/dashboard/hooks/use-metric-order-by.ts @@ -2,30 +2,26 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { isSortable, Metric } from '../stats/metrics' import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage' import { useSiteContext } from '../site-context' +import { SortDirection } from '../../types/query-api' -export enum SortDirection { - asc = 'asc', - desc = 'desc' -} - -export type Order = [string, SortDirection] +export type MetricOrderByEntry = [Metric, SortDirection] -export type OrderBy = Order[] +export type MetricOrderBy = MetricOrderByEntry[] export const getSortDirectionLabel = (sortDirection: SortDirection): string => ({ - [SortDirection.asc]: 'Sorted in ascending order', - [SortDirection.desc]: 'Sorted in descending order' + asc: 'Sorted in ascending order', + desc: 'Sorted in descending order' })[sortDirection] -export function useOrderBy({ +export function useMetricOrderBy({ metrics, defaultOrderBy }: { metrics: Metric[] - defaultOrderBy: OrderBy + defaultOrderBy: MetricOrderBy }) { - const [orderBy, setOrderBy] = useState([]) + const [orderBy, setOrderBy] = useState([]) const orderByDictionary = useMemo( () => orderBy.length @@ -59,26 +55,26 @@ export function useOrderBy({ export function cycleSortDirection( currentSortDirection: SortDirection | null ): { direction: SortDirection; hint: string } { - if (currentSortDirection === SortDirection.desc) { + if (currentSortDirection === 'desc') { return { - direction: SortDirection.asc, + direction: 'asc', hint: 'Press to sort column in ascending order' } } return { - direction: SortDirection.desc, + direction: 'desc', hint: 'Press to sort column in descending order' } } export function rearrangeOrderBy( - currentOrderBy: OrderBy, + currentOrderBy: MetricOrderBy, metric: Metric -): OrderBy { +): MetricOrderBy { const orderIndex = currentOrderBy.findIndex(([m]) => m === metric) if (orderIndex < 0) { - const sortDirection = cycleSortDirection(null).direction as SortDirection + const sortDirection = cycleSortDirection(null).direction return [[metric, sortDirection]] } const previousOrder = currentOrderBy[orderIndex] @@ -100,7 +96,7 @@ export function getOrderByStorageKey(domain: string, dimensionLabel: string) { export function validateOrderBy( orderBy: unknown, metrics: Metric[] -): orderBy is OrderBy { +): orderBy is MetricOrderBy { if (!Array.isArray(orderBy)) { return false } @@ -113,7 +109,7 @@ export function validateOrderBy( if ( orderBy[0].length === 2 && metrics.findIndex((m) => m === orderBy[0][0]) > -1 && - [SortDirection.asc, SortDirection.desc].includes(orderBy[0][1]) + ['asc', 'desc'].includes(orderBy[0][1]) ) { return true } @@ -129,8 +125,8 @@ export function getStoredOrderBy({ domain: string dimensionLabel: string metrics: Metric[] - fallbackValue: OrderBy -}): OrderBy { + fallbackValue: MetricOrderBy +}): MetricOrderBy { try { const storedItem = getItem(getOrderByStorageKey(domain, dimensionLabel)) const parsed = JSON.parse(storedItem) @@ -153,7 +149,7 @@ export function maybeStoreOrderBy({ domain: string dimensionLabel: string metrics: Metric[] - orderBy: OrderBy + orderBy: MetricOrderBy }) { if ( validateOrderBy( @@ -173,7 +169,7 @@ export function useRememberOrderBy({ metrics, dimensionLabel }: { - effectiveOrderBy: OrderBy + effectiveOrderBy: MetricOrderBy metrics: Metric[] dimensionLabel: string }) { diff --git a/assets/js/dashboard/hooks/use-order-by-legacy.test.ts b/assets/js/dashboard/hooks/use-order-by-legacy.test.ts index 8e5a1a8cbfc1..9b663b5392bd 100644 --- a/assets/js/dashboard/hooks/use-order-by-legacy.test.ts +++ b/assets/js/dashboard/hooks/use-order-by-legacy.test.ts @@ -1,7 +1,6 @@ import { Metric } from '../stats/reports/metrics' import { OrderBy, - SortDirection, cycleSortDirection, findOrderIndex, getOrderByStorageKey, @@ -15,9 +14,9 @@ describe(`${findOrderIndex.name}`, () => { /* prettier-ignore */ const cases: [OrderBy, Pick, number][] = [ [[], { key: 'anything' }, -1], - [[['visitors', SortDirection.asc]], { key: 'anything' }, -1], - [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], {key: 'bounce_rate'}, 0], - [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], {key: 'visitors'}, 1] + [[['visitors', 'asc']], { key: 'anything' }, -1], + [[['bounce_rate', 'desc'], ['visitors', 'asc']], {key: 'bounce_rate'}, 0], + [[['bounce_rate', 'desc'], ['visitors', 'asc']], {key: 'visitors'}, 1] ] test.each(cases)( @@ -33,25 +32,25 @@ describe(`${cycleSortDirection.name}`, () => { [ null, { - direction: SortDirection.desc, + direction: 'desc', hint: 'Press to sort column in descending order' } ], [ - SortDirection.desc, + 'desc', { - direction: SortDirection.asc, + direction: 'asc', hint: 'Press to sort column in ascending order' } ], [ - SortDirection.asc, + 'asc', { - direction: SortDirection.desc, + direction: 'desc', hint: 'Press to sort column in descending order' } ] - ])( + ] as const)( 'for current direction %p returns %p', (currentDirection, expectedOutput) => { expect(cycleSortDirection(currentDirection)).toEqual(expectedOutput) @@ -61,20 +60,12 @@ describe(`${cycleSortDirection.name}`, () => { describe(`${rearrangeOrderBy.name}`, () => { const cases: [Pick, OrderBy, OrderBy][] = [ - [ - { key: 'visitors' }, - [['visitors', SortDirection.asc]], - [['visitors', SortDirection.desc]] - ], - [ - { key: 'visitors' }, - [['visitors', SortDirection.desc]], - [['visitors', SortDirection.asc]] - ], + [{ key: 'visitors' }, [['visitors', 'asc']], [['visitors', 'desc']]], + [{ key: 'visitors' }, [['visitors', 'desc']], [['visitors', 'asc']]], [ { key: 'visit_duration' }, - [['visitors', SortDirection.asc]], - [['visit_duration', SortDirection.desc]] + [['visitors', 'asc']], + [['visit_duration', 'desc']] ] ] it.each(cases)( @@ -114,7 +105,7 @@ describe(`storing detailed report preferred order`, () => { it('does not store invalid value', () => { maybeStoreOrderBy({ - orderBy: [['foo', SortDirection.desc]], + orderBy: [['foo', 'desc']], domain, reportInfo, metrics: [{ key: 'foo', sortable: false }] @@ -126,7 +117,7 @@ describe(`storing detailed report preferred order`, () => { it('falls back to fallbackValue if metric has become unsortable between storing and retrieving', () => { maybeStoreOrderBy({ - orderBy: [['c', SortDirection.desc]], + orderBy: [['c', 'desc']], domain, reportInfo, metrics: [{ key: 'c', sortable: true }] @@ -140,13 +131,13 @@ describe(`storing detailed report preferred order`, () => { domain, reportInfo, metrics: [{ key: 'c', sortable: false }], - fallbackValue: [['visitors', SortDirection.desc]] + fallbackValue: [['visitors', 'desc']] }) - ).toEqual([['visitors', SortDirection.desc]]) + ).toEqual([['visitors', 'desc']]) }) it('retrieves stored value correctly', () => { - const input = [['any-column', SortDirection.asc]] + const input = [['any-column', 'asc']] localStorage.setItem( getOrderByStorageKey(domain, reportInfo), JSON.stringify(input) @@ -156,7 +147,7 @@ describe(`storing detailed report preferred order`, () => { domain, reportInfo, metrics: [{ key: 'any-column', sortable: true }], - fallbackValue: [['visitors', SortDirection.desc]] + fallbackValue: [['visitors', 'desc']] }) ).toEqual(input) }) diff --git a/assets/js/dashboard/hooks/use-order-by-legacy.ts b/assets/js/dashboard/hooks/use-order-by-legacy.ts index a8908f4fca9d..f5d22365a189 100644 --- a/assets/js/dashboard/hooks/use-order-by-legacy.ts +++ b/assets/js/dashboard/hooks/use-order-by-legacy.ts @@ -3,11 +3,7 @@ import { Metric } from '../stats/reports/metrics' import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage' import { useSiteContext } from '../site-context' import { ReportInfo } from '../stats/modals/breakdown-modal-legacy' - -export enum SortDirection { - asc = 'asc', - desc = 'desc' -} +import { SortDirection } from '../../types/query-api' export type Order = [Metric['key'], SortDirection] @@ -15,8 +11,8 @@ export type OrderBy = Order[] export const getSortDirectionLabel = (sortDirection: SortDirection): string => ({ - [SortDirection.asc]: 'Sorted in ascending order', - [SortDirection.desc]: 'Sorted in descending order' + asc: 'Sorted in ascending order', + desc: 'Sorted in descending order' })[sortDirection] export function useOrderBy({ @@ -60,15 +56,15 @@ export function useOrderBy({ export function cycleSortDirection( currentSortDirection: SortDirection | null ): { direction: SortDirection; hint: string } { - if (currentSortDirection === SortDirection.desc) { + if (currentSortDirection === 'desc') { return { - direction: SortDirection.asc, + direction: 'asc', hint: 'Press to sort column in ascending order' } } return { - direction: SortDirection.desc, + direction: 'desc', hint: 'Press to sort column in descending order' } } @@ -83,7 +79,7 @@ export function rearrangeOrderBy( ): OrderBy { const orderIndex = findOrderIndex(currentOrderBy, metric) if (orderIndex < 0) { - const sortDirection = cycleSortDirection(null).direction as SortDirection + const sortDirection = cycleSortDirection(null).direction return [[metric.key, sortDirection]] } const previousOrder = currentOrderBy[orderIndex] @@ -121,7 +117,7 @@ export function validateOrderBy( if ( orderBy[0].length === 2 && metrics.findIndex((m) => m.key === orderBy[0][0]) > -1 && - [SortDirection.asc, SortDirection.desc].includes(orderBy[0][1]) + ['asc', 'desc'].includes(orderBy[0][1]) ) { return true } diff --git a/assets/js/dashboard/hooks/use-query-api.ts b/assets/js/dashboard/hooks/use-query-api.ts new file mode 100644 index 000000000000..0d65ff588388 --- /dev/null +++ b/assets/js/dashboard/hooks/use-query-api.ts @@ -0,0 +1,200 @@ +import { + QueryFilters, + useInfiniteQuery, + useQuery, + useQueryClient, + UseQueryResult +} from '@tanstack/react-query' +import { DashboardState } from '../dashboard-state' +import { PlausibleSite } from '../site-context' +import { + createStatsQuery, + NonTimeDimension, + ReportParams, + StatsQuery +} from '../stats-query' +import { useEffect, useState } from 'react' +import { cleanToPageOne, getStaleTime, PAGINATION_LIMIT } from './api-client' +import { ExtraContext, QueryApiResponse, stats } from '../api' +import { addDimensionSearchFilter } from '../stats/breakdowns' +import { DashboardPeriod } from '../dashboard-time-periods' +import { hasConversionGoalFilter, isRealTimeDashboard } from '../util/filters' +import { MainGraphResponse } from '../stats/graph/fetch-main-graph' + +export type StatsReportId = + | 'top-stats' + | 'main-graph' + | NonTimeDimension + | `${NonTimeDimension},${NonTimeDimension}` + +export type StatsReportQueryKey = [ + StatsReportId, + { + dashboardState: DashboardState + reportParams: ReportParams + search?: string + } +] + +type ReportOpts = { + enabled?: boolean + getStatsQuery?: (queryKey: StatsReportQueryKey) => StatsQuery +} + +function withExtraContext( + response: T, + dashboardState: DashboardState +): T { + const extraContext: ExtraContext = { + isRealtime: isRealTimeDashboard(dashboardState), + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState) + } + return { ...response, extraContext } as T +} + +const defaultGetStatsQuery = (queryKey: StatsReportQueryKey): StatsQuery => { + const [_, keyOpts] = queryKey + return createStatsQuery(keyOpts.dashboardState, keyOpts.reportParams) +} + +/** + * Hook for POST /api/stats/:domain/query requests, calling TanStack useQuery + * under the hood. Also sets up automatic realtime updates and allows passing + * `opts = {enabled: false}` to prevent fetching anything, e.g. for until the + * report is visible. + */ +export function useQueryApi< + TResponse extends QueryApiResponse | MainGraphResponse = QueryApiResponse +>( + site: PlausibleSite, + statsReportQueryKey: StatsReportQueryKey, + opts?: ReportOpts +): { + apiState: UseQueryResult + isRealtimeSilentUpdate: boolean +} { + const statsReportId = statsReportQueryKey[0] + const isRealtime = + statsReportQueryKey[1].dashboardState.period === DashboardPeriod.realtime + const [isRealtimeSilentUpdate, setIsRealtimeSilentUpdate] = useState(false) + + const enabled = opts?.enabled ?? true + const getStatsQuery = opts?.getStatsQuery ?? defaultGetStatsQuery + + const queryClient = useQueryClient() + + const apiState = useQuery({ + queryKey: statsReportQueryKey, + enabled, + queryFn: async ({ queryKey }) => { + const [_, keyOpts] = queryKey + const response = await stats(site, getStatsQuery(queryKey)) + return withExtraContext(response, keyOpts.dashboardState) + }, + placeholderData: (previousData) => previousData, + staleTime: ({ queryKey }) => { + const [_, keyOpts] = queryKey + return getStaleTime({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, + ...keyOpts.dashboardState + }) + } + }) + + useEffect(() => { + if (!enabled || !isRealtime) return + + const onTick = () => { + setIsRealtimeSilentUpdate(true) + queryClient.invalidateQueries({ + predicate: ({ queryKey }) => { + const [id, keyOpts] = queryKey as StatsReportQueryKey + return ( + id === statsReportId && + keyOpts.dashboardState.period === DashboardPeriod.realtime + ) + } + }) + } + + document.addEventListener('tick', onTick) + + return () => { + document.removeEventListener('tick', onTick) + } + }, [queryClient, isRealtime, statsReportId, enabled]) + + useEffect(() => { + if (!apiState.isRefetching) { + setIsRealtimeSilentUpdate(false) + } + }, [apiState.isRefetching]) + + useEffect(() => { + if (!isRealtime) { + setIsRealtimeSilentUpdate(false) + } + }, [isRealtime]) + + return { apiState, isRealtimeSilentUpdate } +} + +/** + * Hook for paginated POST /api/stats/:domain/query requests (i.e. Details views). + * Optionally supports search, appending a `['contains', dimensions[0], search]` + * filter to the query filters. + */ +export function useSearchAndPaginateQueryAPI({ + site, + statsReportQueryKey +}: { + site: PlausibleSite + statsReportQueryKey: StatsReportQueryKey +}) { + const queryClient = useQueryClient() + const key = statsReportQueryKey[0] + const { dashboardState } = statsReportQueryKey[1] + + useEffect(() => { + return () => { + const tanstackQueryFilters: QueryFilters = { + predicate: ({ queryKey }) => queryKey[0] === key + } + queryClient.setQueriesData(tanstackQueryFilters, cleanToPageOne) + } + }, [queryClient, key]) + + return useInfiniteQuery({ + queryKey: statsReportQueryKey, + queryFn: async ({ pageParam, queryKey }): Promise => { + const { dashboardState, reportParams, search } = queryKey[1] + + let statsQuery = createStatsQuery(dashboardState, reportParams) + + if (search && search !== '') { + const searchBy = reportParams.dimensions[0] + statsQuery = addDimensionSearchFilter(statsQuery, searchBy, search) + } + + const response = await stats(site, { + ...statsQuery, + pagination: { limit: PAGINATION_LIMIT, offset: pageParam as number } + }) + return withExtraContext(response, dashboardState) + }, + getNextPageParam: (lastPage, _, lastPageParam) => { + return lastPage.results.length === PAGINATION_LIMIT + ? (lastPageParam as number) + PAGINATION_LIMIT + : null + }, + staleTime: () => + getStaleTime({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, + ...dashboardState + }), + initialPageParam: 0, + placeholderData: (previousData) => previousData + }) +} diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index a0e514c4e5d5..38a31ecd5537 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -10,6 +10,7 @@ import { useDashboardStateContext } from './dashboard-state-context' import { isRealTimeDashboard } from './util/filters' import { GraphIntervalProvider } from './stats/graph/graph-interval-context' import { ImportsIncludedProvider } from './stats/graph/imports-included-context' +import { CurrentVisitorsProvider } from './current-visitors-context' function DashboardStats({ importedDataInView, @@ -42,21 +43,23 @@ function Dashboard() { const [importedDataInView, setImportedDataInView] = useState(false) return ( - - -
- - -
-
-
+ + + +
+ + +
+
+
+
) } diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 46107fd013f9..1a0592bda5a0 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -5,9 +5,15 @@ import { FilterKey, FilterClause } from './dashboard-state' -import { OrderBy } from './hooks/use-order-by' import { ComparisonMode, DashboardPeriod } from './dashboard-time-periods' import { formatISO } from './util/date' +import { + Pagination, + SimpleFilterDimensions, + CustomPropertyFilterDimensions, + TimeDimensions, + SortDirection +} from '../types/query-api' import { remapToApiFilters } from './util/filters' export type FilterModifiers = { case_sensitive?: boolean } @@ -16,7 +22,14 @@ export type ApiFilter = | [FilterOperator, FilterKey, FilterClause[]] | [FilterOperator, FilterKey, FilterClause[], FilterModifiers] -type Pagination = { limit: number; offset: number } +export type NonTimeDimension = + | SimpleFilterDimensions + | CustomPropertyFilterDimensions + +export type Dimension = NonTimeDimension | TimeDimensions | 'time:minute' + +export type OrderByEntry = [Metric | NonTimeDimension, SortDirection] +export type OrderBy = OrderByEntry[] type DateRange = DashboardPeriod | [string, string] type IncludeCompare = @@ -38,7 +51,7 @@ type QueryInclude = { export type ReportParams = { metrics: Metric[] - dimensions?: string[] + dimensions: Dimension[] include?: Partial order_by?: OrderBy pagination?: Pagination @@ -48,7 +61,7 @@ export type StatsQuery = { date_range: DateRange relative_date: string | null filters: ApiFilter[] - dimensions: string[] + dimensions: Dimension[] metrics: Metric[] include: QueryInclude order_by?: OrderBy | null diff --git a/assets/js/dashboard/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx index 166f21aa6fcc..7a68aadd499c 100644 --- a/assets/js/dashboard/stats/breakdowns.tsx +++ b/assets/js/dashboard/stats/breakdowns.tsx @@ -1,16 +1,21 @@ import React, { ReactNode, useEffect, useRef } from 'react' -import { SortDirection } from '../hooks/use-order-by' +import { SortDirection } from '../../types/query-api' import type { QueryResultRow, QueryResultQuery } from '../api' import { Metric } from './metrics' import { FilterInfo } from '../components/drilldown-link' import { ChangeArrow } from './reports/change-arrow' import { MetricFormatterLong, ValueType } from './reports/metric-formatter' import dayjs from 'dayjs' -import { addFilter, ApiFilter, StatsQuery } from '../stats-query' +import { + addFilter, + ApiFilter, + NonTimeDimension, + StatsQuery +} from '../stats-query' export type SharedBreakdownReportProps = { dimensionLabel: string - dimensions: string[] + dimensions: NonTimeDimension[] metrics: Metric[] getFilterInfo: (row: QueryResultRow) => FilterInfo | null getExternalLinkUrl?: (row: QueryResultRow) => string | null diff --git a/assets/js/dashboard/stats/current-visitors.js b/assets/js/dashboard/stats/current-visitors.js index c2723068e1ea..007726e554c7 100644 --- a/assets/js/dashboard/stats/current-visitors.js +++ b/assets/js/dashboard/stats/current-visitors.js @@ -1,39 +1,17 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React from 'react' import { AppNavigationLink } from '../navigation/use-app-navigate' -import * as api from '../api' import { Tooltip } from '../util/tooltip' import { SecondsSinceLastLoad } from '../util/seconds-since-last-load' -import { useDashboardStateContext } from '../dashboard-state-context' -import { useSiteContext } from '../site-context' import { useLastLoadContext } from '../last-load-context' +import { useCurrentVisitorsContext } from '../current-visitors-context' import classNames from 'classnames' import { popover } from '../components/popover' export default function CurrentVisitors({ className = '' }) { - const { dashboardState } = useDashboardStateContext() const lastLoadTimestamp = useLastLoadContext() - const site = useSiteContext() - const [currentVisitors, setCurrentVisitors] = useState(null) + const currentVisitors = useCurrentVisitorsContext() - const updateCount = useCallback(() => { - api - .get(`/api/stats/${encodeURIComponent(site.domain)}/current-visitors`) - .then((res) => setCurrentVisitors(res)) - }, [site.domain]) - - useEffect(() => { - document.addEventListener('tick', updateCount) - - return () => { - document.removeEventListener('tick', updateCount) - } - }, [updateCount]) - - useEffect(() => { - updateCount() - }, [dashboardState, updateCount]) - - if (currentVisitors !== null && dashboardState.filters.length === 0) { + if (currentVisitors !== null) { return ( + isRealtimeSilentUpdate: boolean +} { + const site = useSiteContext() + const { dashboardState } = useDashboardStateContext() + + const mainGraphQueryKey: StatsReportQueryKey = [ + 'main-graph', + { + dashboardState, + reportParams: { + metrics: [metric!], + dimensions: [`time:${interval}`], + include: { + time_labels: true, + partial_time_labels: true, + empty_metrics: true, + present_index: true + } + } + } + ] + + const { apiState, isRealtimeSilentUpdate } = useQueryApi( + site, + mainGraphQueryKey, + { + getStatsQuery: getMainGraphQuery, + enabled: !!metric + } + ) + + return { apiState, isRealtimeSilentUpdate } +} + +function getMainGraphQuery(queryKey: StatsReportQueryKey): StatsQuery { + const [_reportId, keyOpts] = queryKey + const { dashboardState, reportParams } = keyOpts + const statsQuery = createStatsQuery(dashboardState, reportParams) + + if (isRealTimeDashboard(dashboardState)) { + return { ...statsQuery, date_range: DashboardPeriod.realtime_30m } + } + + return statsQuery +} export function fetchMainGraph( site: PlausibleSite, dashboardState: DashboardState, metric: Metric, - interval: string + interval: Interval ): Promise { const metricToQuery = metric === 'conversion_rate' ? 'group_conversion_rate' : metric @@ -43,7 +98,10 @@ export type ResultItem = { export type MetricValues = [MetricValue] // one item -export type MainGraphResponse = { +export type MainGraphResponse = Pick< + api.QueryApiResponse, + 'query' | 'extraContext' +> & { results: Array comparison_results: Array< (ResultItem & { change: [number | null] | null }) | null @@ -58,5 +116,4 @@ export type MainGraphResponse = { empty_metrics: MetricValues present_index: number } - query: api.QueryResultQuery } diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts index 9d12a4663ffb..291a556a2195 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts @@ -8,9 +8,10 @@ import { ComparisonMode, DashboardPeriod } from '../../dashboard-time-periods' import { PlausibleSite, siteContextDefaultValue } from '../../site-context' import { StatsQuery } from '../../stats-query' import { remapToApiFilters } from '../../util/filters' +import { StatsReportQueryKey } from '../../hooks/use-query-api' import { chooseMetrics, - topStatsQueries, + getTopStatsQuery, getPartialDayTimeRange, formatTopStatsData } from './fetch-top-stats' @@ -39,17 +40,6 @@ const expectedBaseQuery = { pagination: null } -const expectedRealtimeVisitorsQuery: StatsQuery = { - ...expectedBaseQuery, - date_range: DashboardPeriod.realtime, - include: { - ...expectedBaseInclude, - compare: null, - imports_meta: false - }, - metrics: ['visitors'] -} - type TestCase = [ /** situation */ string, @@ -58,8 +48,8 @@ type TestCase = [ Partial<{ site?: Pick }>, /** expected metrics */ Metric[], - /** expected queries */ - [StatsQuery, null | StatsQuery] + /** expected top stats query */ + StatsQuery ] const cases: TestCase[] = [ @@ -67,34 +57,28 @@ const cases: TestCase[] = [ 'realtime and goal filter', { period: DashboardPeriod.realtime, filters: [aGoalFilter] }, ['visitors', 'events'], - [ - { - ...expectedBaseQuery, - date_range: DashboardPeriod.realtime_30m, - filters: remapToApiFilters([aGoalFilter]), - include: { ...expectedBaseInclude, compare: null }, - metrics: ['visitors', 'events'] - }, - expectedRealtimeVisitorsQuery - ] + { + ...expectedBaseQuery, + date_range: DashboardPeriod.realtime_30m, + filters: remapToApiFilters([aGoalFilter]), + include: { ...expectedBaseInclude, compare: null }, + metrics: ['visitors', 'events'] + } ], [ 'realtime', { period: DashboardPeriod.realtime, filters: [] }, ['visitors', 'pageviews'], - [ - { - ...expectedBaseQuery, - date_range: DashboardPeriod.realtime_30m, - include: { - ...expectedBaseInclude, - compare: null - }, - metrics: ['visitors', 'pageviews'] + { + ...expectedBaseQuery, + date_range: DashboardPeriod.realtime_30m, + include: { + ...expectedBaseInclude, + compare: null }, - expectedRealtimeVisitorsQuery - ] + metrics: ['visitors', 'pageviews'] + } ], [ @@ -113,36 +97,30 @@ const cases: TestCase[] = [ 'average_revenue', 'conversion_rate' ], - [ - { - ...expectedBaseQuery, - date_range: aPeriodNotRealtime, - filters: remapToApiFilters([aGoalFilter]), - metrics: [ - 'visitors', - 'events', - 'total_revenue', - 'average_revenue', - 'conversion_rate' - ] - }, - null - ] + { + ...expectedBaseQuery, + date_range: aPeriodNotRealtime, + filters: remapToApiFilters([aGoalFilter]), + metrics: [ + 'visitors', + 'events', + 'total_revenue', + 'average_revenue', + 'conversion_rate' + ] + } ], [ 'goal filter', { period: aPeriodNotRealtime, filters: [aGoalFilter] }, ['visitors', 'events', 'conversion_rate'], - [ - { - ...expectedBaseQuery, - date_range: aPeriodNotRealtime, - filters: remapToApiFilters([aGoalFilter]), - metrics: ['visitors', 'events', 'conversion_rate'] - }, - null - ] + { + ...expectedBaseQuery, + date_range: aPeriodNotRealtime, + filters: remapToApiFilters([aGoalFilter]), + metrics: ['visitors', 'events', 'conversion_rate'] + } ], [ @@ -159,23 +137,19 @@ const cases: TestCase[] = [ 'scroll_depth', 'time_on_page' ], - - [ - { - ...expectedBaseQuery, - date_range: aPeriodNotRealtime, - filters: remapToApiFilters([aPageFilter]), - metrics: [ - 'visitors', - 'visits', - 'pageviews', - 'bounce_rate', - 'scroll_depth', - 'time_on_page' - ] - }, - null - ] + { + ...expectedBaseQuery, + date_range: aPeriodNotRealtime, + filters: remapToApiFilters([aPageFilter]), + metrics: [ + 'visitors', + 'visits', + 'pageviews', + 'bounce_rate', + 'scroll_depth', + 'time_on_page' + ] + } ], [ @@ -189,21 +163,18 @@ const cases: TestCase[] = [ 'bounce_rate', 'visit_duration' ], - [ - { - ...expectedBaseQuery, - date_range: aPeriodNotRealtime, - metrics: [ - 'visitors', - 'visits', - 'pageviews', - 'views_per_visit', - 'bounce_rate', - 'visit_duration' - ] - }, - null - ] + { + ...expectedBaseQuery, + date_range: aPeriodNotRealtime, + metrics: [ + 'visitors', + 'visits', + 'pageviews', + 'views_per_visit', + 'bounce_rate', + 'visit_duration' + ] + } ] ] @@ -280,23 +251,18 @@ function makeTopStatsResponse( dimensions: [], comparison: { metrics: [80], change: [25] } } - ] + ], + extraContext: { isRealtime: false, hasConversionGoalFilter: false } } } describe(`${formatTopStatsData.name}`, () => { - const metrics = [{ key: 'visitors' as const, label: 'Visitors' }] - it('sets comparisonTimeRange to "until HH:MM" when comparison period is also a partial day (Today vs Previous period)', () => { const response = makeTopStatsResponse( ['2026-04-21T00:00:00', '2026-04-21T10:25:00'], ['2026-04-20T00:00:00', '2026-04-20T10:25:00'] ) - const { timeRange, comparisonTimeRange } = formatTopStatsData( - response, - null, - metrics - ) + const { timeRange, comparisonTimeRange } = formatTopStatsData(response) expect(timeRange).toBe('until 10:25') expect(comparisonTimeRange).toBe('until 10:25') }) @@ -306,27 +272,33 @@ describe(`${formatTopStatsData.name}`, () => { ['2026-04-21T00:00:00', '2026-04-21T10:25:00'], ['2026-04-20T00:00:00', '2026-04-20T23:59:59'] ) - const { timeRange, comparisonTimeRange } = formatTopStatsData( - response, - null, - metrics - ) + const { timeRange, comparisonTimeRange } = formatTopStatsData(response) expect(timeRange).toBe('until 10:25') expect(comparisonTimeRange).toBeNull() }) }) -describe(`${topStatsQueries.name}`, () => { +describe(`${getTopStatsQuery.name}`, () => { test.each(cases)( - 'for %s dashboard, queries are as expected', - (_, { site: _site, ...inputDashboardState }, metrics, expectedQueries) => { + 'for %s dashboard, top stats query is as expected', + (_, { site: _site, ...inputDashboardState }, metrics, expectedQuery) => { const dashboardState = { ...dashboardStateDefaultValue, resolvedFilters: inputDashboardState.filters, ...inputDashboardState } - const queries = topStatsQueries(dashboardState, metrics) - expect(queries).toEqual(expectedQueries) + const queryKey: StatsReportQueryKey = [ + 'top-stats', + { + dashboardState, + reportParams: { + metrics, + dimensions: [], + include: { imports_meta: true } + } + } + ] + expect(getTopStatsQuery(queryKey)).toEqual(expectedQuery) } ) }) diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.ts index d33b2331ba42..0794baa5f0a2 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.ts @@ -1,77 +1,72 @@ import * as api from '../../api' import { DashboardState } from '../../dashboard-state' -import { Metric, getMetricLabel } from '../metrics' +import { getMetricLabel, Metric } from '../metrics' import { ComparisonMode, DashboardPeriod, isComparisonEnabled, isComparisonForbidden } from '../../dashboard-time-periods' -import { PlausibleSite } from '../../site-context' -import { createStatsQuery, ReportParams, StatsQuery } from '../../stats-query' +import { PlausibleSite, useSiteContext } from '../../site-context' +import { createStatsQuery, StatsQuery } from '../../stats-query' import { hasConversionGoalFilter, hasPageFilter, isRealTimeDashboard } from '../../util/filters' +import { StatsReportQueryKey, useQueryApi } from '../../hooks/use-query-api' +import { useMemo } from 'react' +import { useDashboardStateContext } from '../../dashboard-state-context' -export function topStatsQueries( - dashboardState: DashboardState, - metrics: Metric[] -): [StatsQuery, StatsQuery | null] { - let currentVisitorsQuery = null +export function useTopStatsQuery() { + const site = useSiteContext() + const { dashboardState } = useDashboardStateContext() - if (isRealTimeDashboard(dashboardState)) { - currentVisitorsQuery = createStatsQuery(dashboardState, { - metrics: ['visitors'] - }) + const topStatsQueryKey = useMemo((): StatsReportQueryKey => { + return [ + 'top-stats', + { + dashboardState, + reportParams: { + metrics: chooseMetrics(site, dashboardState), + dimensions: [], + include: { imports_meta: true } + } + } + ] + }, [site, dashboardState]) - currentVisitorsQuery.filters = [] - } - const topStatsQuery = constructTopStatsQuery(dashboardState, metrics) + const { apiState, isRealtimeSilentUpdate } = useQueryApi( + site, + topStatsQueryKey, + { getStatsQuery: getTopStatsQuery } + ) - return [topStatsQuery, currentVisitorsQuery] + return { apiState, isRealtimeSilentUpdate } } -export async function fetchTopStats( - site: PlausibleSite, - dashboardState: DashboardState -) { - const metrics = chooseMetrics(site, dashboardState) - const [topStatsQuery, currentVisitorsQuery] = topStatsQueries( - dashboardState, - metrics - ) - const topStatsPromise = api.stats(site, topStatsQuery) +export function getTopStatsQuery(queryKey: StatsReportQueryKey): StatsQuery { + const [_reportId, keyOpts] = queryKey + const { dashboardState, reportParams } = keyOpts - const currentVisitorsPromise = currentVisitorsQuery - ? api.stats(site, currentVisitorsQuery) - : null + const statsQuery = createStatsQuery(dashboardState, reportParams) - const [topStatsResponse, currentVisitorsResponse] = await Promise.all([ - topStatsPromise, - currentVisitorsPromise - ]) - - const metricLabelSuffix = isRealTimeDashboard(dashboardState) - ? ' (last 30 min)' - : '' - - const formattedMetrics = metrics.map((key) => ({ - key, - label: `${getMetricLabel(key, { - hasConversionGoalFilter: hasConversionGoalFilter(dashboardState) - })}${metricLabelSuffix}` - })) - - return formatTopStatsData( - topStatsResponse, - currentVisitorsResponse, - formattedMetrics - ) -} + if ( + !isComparisonEnabled(dashboardState.comparison) && + !isComparisonForbidden({ + period: dashboardState.period, + segmentIsExpanded: false + }) + ) { + statsQuery.include.compare = ComparisonMode.previous_period + } + + if (isRealTimeDashboard(dashboardState)) { + statsQuery.date_range = DashboardPeriod.realtime_30m + } -export type MetricDef = { key: Metric; label: string } + return statsQuery +} export function chooseMetrics( site: Pick, @@ -110,32 +105,13 @@ export function chooseMetrics( } } -function constructTopStatsQuery( - dashboardState: DashboardState, - metrics: Metric[] -): StatsQuery { - const reportParams: ReportParams = { - metrics, - include: { imports_meta: true } - } - - const statsQuery = createStatsQuery(dashboardState, reportParams) - - if ( - !isComparisonEnabled(dashboardState.comparison) && - !isComparisonForbidden({ - period: dashboardState.period, - segmentIsExpanded: false - }) - ) { - statsQuery.include.compare = ComparisonMode.previous_period - } - - if (isRealTimeDashboard(dashboardState)) { - statsQuery.date_range = DashboardPeriod.realtime_30m - } +function getTopStatMetricLabel( + metricKey: Metric, + { isRealtime, hasConversionGoalFilter }: api.ExtraContext +) { + const metricLabelSuffix = isRealtime ? ' (last 30 min)' : '' - return statsQuery + return `${getMetricLabel(metricKey, { hasConversionGoalFilter })}${metricLabelSuffix}` } type TopStatItem = { @@ -147,36 +123,17 @@ type TopStatItem = { comparisonValue?: number } -export function formatTopStatsData( - topStatsResponse: api.QueryApiResponse, - currentVisitorsResponse: api.QueryApiResponse | null, - metrics: MetricDef[] -) { - const { query, meta, results } = topStatsResponse +export function formatTopStatsData(topStatsResponse: api.QueryApiResponse) { + const { query, meta, results, extraContext } = topStatsResponse const topStats: TopStatItem[] = [] - if (currentVisitorsResponse) { - topStats.push({ - metric: currentVisitorsResponse.query.metrics[0], - value: currentVisitorsResponse.results[0].metrics[0], - name: 'Current visitors', - graphable: false - }) - } - for (let i = 0; i < query.metrics.length; i++) { const metricKey = query.metrics[i] - const metricDef = metrics.find((m) => m.key === metricKey) - - if (!metricDef) { - throw new Error('API response returned a metric that was not asked for') - } - topStats.push({ metric: metricKey, value: results[0].metrics[i], - name: metricDef.label, + name: getTopStatMetricLabel(metricKey, extraContext), graphable: true, change: results[0].comparison?.change[i], comparisonValue: results[0].comparison?.metrics[i] diff --git a/assets/js/dashboard/stats/graph/intervals.ts b/assets/js/dashboard/stats/graph/intervals.ts index 2eb22dd6433b..3f276b488445 100644 --- a/assets/js/dashboard/stats/graph/intervals.ts +++ b/assets/js/dashboard/stats/graph/intervals.ts @@ -43,6 +43,10 @@ const INTERVAL_COARSENESS: Record = { [Interval.month]: 4 } +export function extractIntervalFromDimensions(dimensions: string[]): Interval { + return dimensions[0].split(':')[1] as Interval +} + /** * Returns the intervals available for the current dashboard state. * diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index 4c48f1c23bdd..aeb5142ceecd 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -38,9 +38,8 @@ import { MainGraphSeriesName } from './main-graph-data' import { Metric, getMetricLabel } from '../metrics' -import { useDashboardStateContext } from '../../dashboard-state-context' -import { hasConversionGoalFilter } from '../../util/filters' -import { Interval } from './intervals' + +import { extractIntervalFromDimensions, Interval } from './intervals' const height = 368 const marginTop = 16 @@ -50,11 +49,6 @@ const defaultMarginLeft = 16 // this is adjusted by the Graph component based on const hoverBuffer = 4 const HORIZONTAL_PAN_DELAY_MS = 100 -type MainGraphData = MainGraphResponse & { - period: DashboardPeriod - interval: Interval -} - type MainGraphYValues = Readonly< [ // first element is comparison series @@ -80,7 +74,7 @@ export const MainGraph = ({ data }: { width: number - data: MainGraphData + data: MainGraphResponse }) => { const site = useSiteContext() const { mode } = useTheme() @@ -91,8 +85,8 @@ export const MainGraph = ({ const { selectedIndex } = tooltip const panGestureStartTimeRef = useRef(null) const metric = data.query.metrics[0] as Metric - const interval = data.interval - const period = data.period + const interval = extractIntervalFromDimensions(data.query.dimensions) + const isRealtime = data.extraContext.isRealtime useEffect(() => { setTooltip(initialTooltipState) @@ -160,7 +154,7 @@ export const MainGraph = ({ startEndLabels: mainSeriesStartEndLabels }), interval, - period, + isRealtime, bucketIndex, totalBuckets: remappedData.length }) @@ -244,7 +238,15 @@ export const MainGraph = ({ settings, gradients } - }, [site, data, interval, period, primaryGradient, secondaryGradient, metric]) + }, [ + site, + data, + interval, + isRealtime, + primaryGradient, + secondaryGradient, + metric + ]) const getFormattedValue = useCallback( (value: MetricValue) => MetricFormatterShort[metric](value), @@ -391,7 +393,8 @@ export const MainGraph = ({ showZoomToPeriod={!!zoomDate} shouldShowYear={!yearIsUnambiguous} shouldShowDate={!dateIsUnambiguous} - period={period} + isRealtime={isRealtime} + hasConversionGoalFilter={data.extraContext.hasConversionGoalFilter} interval={interval} metric={metric} x={tooltip.x} @@ -416,7 +419,8 @@ const MainGraphTooltip = ({ metric, getFormattedValue, interval, - period, + isRealtime, + hasConversionGoalFilter, shouldShowDate, shouldShowYear, maxX, @@ -432,7 +436,8 @@ const MainGraphTooltip = ({ metric: Metric getFormattedValue: (value: MetricValue) => string interval: Interval - period: DashboardPeriod + isRealtime: boolean + hasConversionGoalFilter: boolean shouldShowYear: boolean shouldShowDate: boolean x: number @@ -445,10 +450,7 @@ const MainGraphTooltip = ({ persistent: boolean onClick?: () => void }) => { - const { dashboardState } = useDashboardStateContext() - const metricLabel = getMetricLabel(metric, { - hasConversionGoalFilter: hasConversionGoalFilter(dashboardState) - }) + const metricLabel = getMetricLabel(metric, { hasConversionGoalFilter }) const { main, comparison, change } = datum return (
{getFullBucketLabel(main.timeLabel, { - period, + isRealtime, interval, shouldShowYear, shouldShowDate, @@ -503,7 +505,7 @@ const MainGraphTooltip = ({
{getFullBucketLabel(comparison.timeLabel, { - period, + isRealtime, interval, shouldShowYear, shouldShowDate, @@ -561,7 +563,7 @@ type BucketLabelParams = { shouldShowYear: boolean shouldShowDate: boolean interval: Interval - period: DashboardPeriod + isRealtime: boolean bucketIndex: number totalBuckets: number } @@ -572,7 +574,7 @@ const getBucketLabel = ( { shouldShowYear, shouldShowDate, - period, + isRealtime, interval, bucketIndex, totalBuckets @@ -596,7 +598,7 @@ const getBucketLabel = ( return time } case Interval.minute: { - if (period === DashboardPeriod.realtime) { + if (isRealtime) { const minutesAgo = totalBuckets - bucketIndex return `-${minutesAgo}m` } @@ -618,7 +620,7 @@ const getFullBucketLabel = ( { shouldShowYear, shouldShowDate, - period, + isRealtime, interval, bucketIndex, totalBuckets, @@ -632,7 +634,7 @@ const getFullBucketLabel = ( shouldShowYear, shouldShowDate, interval, - period, + isRealtime, bucketIndex, totalBuckets }) @@ -643,7 +645,7 @@ const getFullBucketLabel = ( shouldShowYear, shouldShowDate, interval, - period, + isRealtime, bucketIndex, totalBuckets }) @@ -662,7 +664,7 @@ const getFullBucketLabel = ( return time } case Interval.minute: { - if (period === DashboardPeriod.realtime) { + if (isRealtime) { const minutesAgo = totalBuckets - bucketIndex return minutesAgo === 1 ? `1 minute ago` : `${minutesAgo} minutes ago` } diff --git a/assets/js/dashboard/stats/graph/top-stats.js b/assets/js/dashboard/stats/graph/top-stats.js index b3995404f8bb..1e5b51c38150 100644 --- a/assets/js/dashboard/stats/graph/top-stats.js +++ b/assets/js/dashboard/stats/graph/top-stats.js @@ -1,4 +1,5 @@ import React from 'react' +import { formatTopStatsData } from './fetch-top-stats' import { Tooltip } from '../../util/tooltip' import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load' import classNames from 'classnames' @@ -6,6 +7,7 @@ import { formatDateRange, formatDayShort, parseUTCDate } from '../../util/date' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { useLastLoadContext } from '../../last-load-context' +import { useCurrentVisitorsContext } from '../../current-visitors-context' import { ChangeArrow } from '../reports/change-arrow' import { MetricFormatterShort, @@ -32,8 +34,19 @@ export default function TopStats({ const lastLoadTimestamp = useLastLoadContext() const site = useSiteContext() + const { + topStats, + meta, + from, + to, + comparingFrom, + comparingTo, + timeRange, + comparisonTimeRange + } = formatTopStatsData(data) + const isComparison = - (dashboardState.comparison && data && data.comparingFrom !== null) || false + (dashboardState.comparison && comparingFrom !== null) || false function tooltip(stat) { let statName = stat.name.toLowerCase() @@ -70,7 +83,7 @@ export default function TopStats({ } function warningText(metric) { - const warning = data.meta.metric_warnings?.[metric] + const warning = meta.metric_warnings?.[metric] if (!warning) { return null } @@ -182,9 +195,9 @@ export default function TopStats({ {isComparison ? (

- {data.timeRange - ? `${formatDayShort(parseUTCDate(data.from))}, ${data.timeRange}` - : formatDateRange(site, data.from, data.to)} + {timeRange + ? `${formatDayShort(parseUTCDate(from))}, ${timeRange}` + : formatDateRange(site, from, to)}

) : null}
@@ -198,9 +211,9 @@ export default function TopStats({ {topStatNumberShort(stat.metric, stat.comparisonValue)}

- {data.comparisonTimeRange - ? `${formatDayShort(parseUTCDate(data.comparingFrom))}, ${data.comparisonTimeRange}` - : formatDateRange(site, data.comparingFrom, data.comparingTo)} + {comparisonTimeRange + ? `${formatDayShort(parseUTCDate(comparingFrom))}, ${comparisonTimeRange}` + : formatDateRange(site, comparingFrom, comparingTo)}

) : null} @@ -209,8 +222,26 @@ export default function TopStats({ ) } - const stats = - data && data.topStats.filter((stat) => stat.value !== null).map(renderStat) + const currentVisitors = useCurrentVisitorsContext() + + const currentVisitorsStat = + dashboardState.period === 'realtime' && currentVisitors !== null + ? { + metric: 'visitors', + value: currentVisitors, + name: 'Current visitors', + graphable: false + } + : null + + const allTopStats = [ + ...(currentVisitorsStat ? [currentVisitorsStat] : []), + ...topStats + ] + + const stats = allTopStats + .filter((stat) => stat.value !== null) + .map(renderStat) if (stats && dashboardState.period === 'realtime') { stats.push(blinkingDot()) diff --git a/assets/js/dashboard/stats/graph/visitor-graph.tsx b/assets/js/dashboard/stats/graph/visitor-graph.tsx index 97fa477dfa9c..0c866547b5b4 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.tsx +++ b/assets/js/dashboard/stats/graph/visitor-graph.tsx @@ -1,15 +1,10 @@ import React, { useState, useEffect, useRef, useCallback } from 'react' import * as storage from '../../util/storage' import TopStats from './top-stats' -import { fetchTopStats } from './fetch-top-stats' -import { fetchMainGraph } from './fetch-main-graph' -import { useDashboardStateContext } from '../../dashboard-state-context' +import { useTopStatsQuery } from './fetch-top-stats' +import { useMainGraphQuery } from './fetch-main-graph' import { PlausibleSite, useSiteContext } from '../../site-context' -import { useQuery, useQueryClient } from '@tanstack/react-query' import { Metric } from '../metrics' -import { DashboardPeriod } from '../../dashboard-time-periods' -import { DashboardState } from '../../dashboard-state' -import { getStaleTime } from '../../hooks/api-client' import { MainGraph, MainGraphContainer, useMainGraphWidth } from './main-graph' import { useGraphIntervalContext } from './graph-interval-context' import { useSetImportsIncluded } from './imports-included-context' @@ -26,12 +21,15 @@ export default function VisitorGraph({ const mainGraphContainer = useRef(null) const { width } = useMainGraphWidth(mainGraphContainer) const site = useSiteContext() - const { dashboardState } = useDashboardStateContext() - const isRealtime = dashboardState.period === DashboardPeriod.realtime - const queryClient = useQueryClient() const { selectedInterval } = useGraphIntervalContext() + // Possible future improvement -- currently, if there's no stored metric, + // the graph fetch doesn't run until Top Stats are loaded. That's because + // Top Stats tell us which metrics are available for the graph. However, + // as things stand today, the `visitors` metric is always available and + // could become the default selectedMetric, making it possible to fetch + // the graph instantly. const [selectedMetric, setSelectedMetric] = useState( getStoredMetric(site) ) @@ -43,63 +41,20 @@ export default function VisitorGraph({ [site] ) - const topStatsQuery = useQuery({ - queryKey: ['top-stats', { dashboardState }] as const, - queryFn: async ({ queryKey }) => { - const [_, opts] = queryKey - return await fetchTopStats(site, opts.dashboardState) - }, - placeholderData: (previousData) => previousData, - staleTime: ({ queryKey }) => { - const [_, opts] = queryKey - return getStaleTime({ - siteTimezoneOffset: site.offset, - siteStatsBegin: site.statsBegin, - ...opts.dashboardState - }) - } - }) - - const mainGraphQuery = useQuery({ - enabled: !!selectedMetric, - queryKey: [ - 'main-graph', - { dashboardState, metric: selectedMetric!, interval: selectedInterval } - ] as const, - queryFn: async ({ queryKey }) => { - const [_, opts] = queryKey - const data = await fetchMainGraph( - site, - opts.dashboardState, - opts.metric, - opts.interval - ) + const { + apiState: topStatsApiState, + isRealtimeSilentUpdate: isTopStatsRealtimeSilentUpdate + } = useTopStatsQuery() - // pack dashboard period and interval used for the request next to data - // so they'd never be out of sync with each other - return { - ...data, - period: opts.dashboardState.period, - interval: opts.interval - } - }, - placeholderData: (previousData) => previousData, - staleTime: ({ queryKey }) => { - const [_, opts] = queryKey - return getStaleTime({ - siteTimezoneOffset: site.offset, - siteStatsBegin: site.statsBegin, - ...opts.dashboardState - }) - } - }) + const { + apiState: mainGraphApiState, + isRealtimeSilentUpdate: isMainGraphRealtimeSilentUpdate + } = useMainGraphQuery(selectedMetric, selectedInterval) // update metric to one that exists useEffect(() => { - if (topStatsQuery.data) { - const availableMetrics = topStatsQuery.data.topStats - .filter((stat) => stat.graphable) - .map((stat) => stat.metric) + if (topStatsApiState.data) { + const availableMetrics = topStatsApiState.data.query.metrics setSelectedMetric((currentlySelectedMetric) => { if ( @@ -112,86 +67,35 @@ export default function VisitorGraph({ } }) } - }, [topStatsQuery.data]) - - const [isRealtimeSilentUpdate, setIsRealtimeSilentUpdate] = useState({ - topStats: false, - mainGraph: false - }) - useEffect(() => { - setIsRealtimeSilentUpdate((current) => ({ ...current, mainGraph: false })) - }, [selectedMetric]) - - useEffect(() => { - if (!mainGraphQuery.isRefetching) { - setIsRealtimeSilentUpdate((current) => ({ ...current, mainGraph: false })) - } - }, [mainGraphQuery.isRefetching]) - - useEffect(() => { - if (!topStatsQuery.isRefetching) { - setIsRealtimeSilentUpdate((current) => ({ ...current, topStats: false })) - } - }, [topStatsQuery.isRefetching]) - - useEffect(() => { - if (!isRealtime) { - setIsRealtimeSilentUpdate({ - topStats: false, - mainGraph: false - }) - } - }, [isRealtime]) + }, [topStatsApiState.data]) // sync import related info useEffect(() => { - if (topStatsQuery.data && typeof updateImportedDataInView === 'function') { + if ( + topStatsApiState.data && + typeof updateImportedDataInView === 'function' + ) { updateImportedDataInView( - topStatsQuery.data.meta.imports_included as boolean + topStatsApiState.data.meta.imports_included as boolean ) } - }, [topStatsQuery.data, updateImportedDataInView]) - - useEffect(() => { - const onTick = () => { - setIsRealtimeSilentUpdate({ topStats: true, mainGraph: true }) - queryClient.invalidateQueries({ - predicate: ({ queryKey }) => { - const realtimeTopStatsOrMainGraphQuery = - ['top-stats', 'main-graph'].includes(queryKey[0] as string) && - typeof queryKey[1] === 'object' && - (queryKey[1] as { dashboardState?: DashboardState })?.dashboardState - ?.period === DashboardPeriod.realtime - - return realtimeTopStatsOrMainGraphQuery - } - }) - } - - if (isRealtime) { - document.addEventListener('tick', onTick) - } - - return () => { - document.removeEventListener('tick', onTick) - } - }, [queryClient, isRealtime]) + }, [topStatsApiState.data, updateImportedDataInView]) const switchVisible = !['no_imported_data', 'out_of_range'].includes( - topStatsQuery.data?.meta.imports_skip_reason as string + topStatsApiState.data?.meta.imports_skip_reason as string ) const switchDisabled = - topStatsQuery.data?.meta.imports_skip_reason === 'unsupported_query' + topStatsApiState.data?.meta.imports_skip_reason === 'unsupported_query' const setImportsIncluded = useSetImportsIncluded() useEffect(() => { - if (topStatsQuery.data) { + if (topStatsApiState.data) { setImportsIncluded({ switchVisible, switchDisabled }) } else { setImportsIncluded(null) } - }, [topStatsQuery.data, switchVisible, switchDisabled, setImportsIncluded]) + }, [topStatsApiState.data, switchVisible, switchDisabled, setImportsIncluded]) useEffect(() => { return () => setImportsIncluded(null) @@ -200,14 +104,14 @@ export default function VisitorGraph({ const { heightPx } = useGuessTopStatsHeight(site, topStatsBoundary) const showFullLoader = - topStatsQuery.isFetching && - topStatsQuery.isStale && - !isRealtimeSilentUpdate.topStats + topStatsApiState.isFetching && + topStatsApiState.isStale && + !isTopStatsRealtimeSilentUpdate const showGraphLoader = - mainGraphQuery.isFetching && - mainGraphQuery.isStale && - !isRealtimeSilentUpdate.mainGraph && + mainGraphApiState.isFetching && + mainGraphApiState.isStale && + !isMainGraphRealtimeSilentUpdate && !showFullLoader return ( @@ -218,9 +122,9 @@ export default function VisitorGraph({ className="flex flex-wrap relative" ref={topStatsBoundary} > - {topStatsQuery.data ? ( + {topStatsApiState.data ? (
- {!!mainGraphQuery.data && !!width && ( + {!!mainGraphApiState.data && !!width && ( <> {!showGraphLoader && ( - + )} {showGraphLoader && } @@ -247,9 +151,8 @@ export default function VisitorGraph({
- {(!(topStatsQuery.data && mainGraphQuery.data) || showFullLoader) && ( - - )} + {(!(topStatsApiState.data && mainGraphApiState.data) || + showFullLoader) && } ) } diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 47b7b26480bf..f1b49c3f5cb7 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -6,19 +6,22 @@ import React, { useState } from 'react' import { useDashboardStateContext } from '../../dashboard-state-context' -import { usePaginatedQueryAPI } from '../../hooks/api-client' +import { + StatsReportId, + StatsReportQueryKey, + useSearchAndPaginateQueryAPI +} from '../../hooks/use-query-api' import { rootRoute } from '../../router' import { getStoredOrderBy, - Order, - OrderBy, - SortDirection, - useOrderBy, + MetricOrderBy, + useMetricOrderBy, useRememberOrderBy -} from '../../hooks/use-order-by' +} from '../../hooks/use-metric-order-by' +import { SortDirection } from '../../../types/query-api' import { Metric, getBreakdownMetricLabel, isSortable } from '../metrics' import { BreakdownTable } from './breakdown-table' -import { createStatsQuery, StatsQuery } from '../../stats-query' +import { OrderByEntry } from '../../stats-query' import { useSiteContext } from '../../site-context' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' import { @@ -53,8 +56,8 @@ type PaginatedData = { pages: QueryApiResponse[] } type DetailsBreakdownProps = SharedBreakdownReportProps & { title: ReactNode - defaultOrderBy?: OrderBy - addSearchFilter?: (statsQuery: StatsQuery, search: string) => StatsQuery + defaultOrderBy?: MetricOrderBy + searchEnabled?: boolean onDataReady?: (data: PaginatedData) => void } @@ -82,10 +85,10 @@ export function DetailsBreakdown({ dimensionLabel, dimensions, metrics, - defaultOrderBy = [] as OrderBy, + defaultOrderBy = [] as MetricOrderBy, getFilterInfo, getExternalLinkUrl, - addSearchFilter, + searchEnabled = true, onDataReady }: DetailsBreakdownProps) { const site = useSiteContext() @@ -99,7 +102,7 @@ export function DetailsBreakdown({ fallbackValue: defaultOrderBy }) - const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({ + const { orderBy, orderByDictionary, toggleSortByMetric } = useMetricOrderBy({ metrics, defaultOrderBy: storedOrderBy }) @@ -110,32 +113,25 @@ export function DetailsBreakdown({ dimensionLabel }) - const effectiveOrderBy = (orderBy.length ? orderBy : storedOrderBy).concat( - dimensions.map((dim) => [dim, SortDirection.asc]) - ) - - const baseStatsQuery: StatsQuery = useMemo( - () => - createStatsQuery(dashboardState, { - metrics: metrics, - dimensions, - order_by: effectiveOrderBy as Order[] - }), - [dashboardState, metrics, dimensions, effectiveOrderBy] - ) - - const statsQuery: StatsQuery = useMemo(() => { - if (search && addSearchFilter) { - return addSearchFilter(baseStatsQuery, search) - } - return baseStatsQuery - }, [baseStatsQuery, search, addSearchFilter]) + const statsReportQueryKey: StatsReportQueryKey = useMemo(() => { + return [ + dimensions.join(',') as StatsReportId, + { + dashboardState, + reportParams: { + metrics, + dimensions, + order_by: [ + ...(orderBy.length ? orderBy : storedOrderBy), + ...dimensions.map((dim): OrderByEntry => [dim, 'asc']) + ] + }, + search + } + ] + }, [dashboardState, metrics, dimensions, orderBy, storedOrderBy, search]) - const apiState = usePaginatedQueryAPI({ - site, - dashboardState, - statsQuery - }) + const apiState = useSearchAndPaginateQueryAPI({ site, statsReportQueryKey }) useEffect(() => { const pages = apiState.data?.pages @@ -251,7 +247,7 @@ export function DetailsBreakdown({ {...apiState} data={tableData} columns={columns} - onSearch={addSearchFilter ? setSearch : undefined} + onSearch={searchEnabled ? setSearch : undefined} getRowKey={(row) => row.dimensions[0]} /> ) diff --git a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js index 2197ca03be0d..a24a06754a3b 100644 --- a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js @@ -7,7 +7,6 @@ import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { browserIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by-legacy' function BrowserVersionsModal() { const { dashboardState } = useDashboardStateContext() @@ -18,7 +17,7 @@ function BrowserVersionsModal() { dimension: 'browser_version', endpoint: url.apiPath(site, '/browser-versions'), dimensionLabel: 'Browser version', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } const getFilterInfo = useCallback( diff --git a/assets/js/dashboard/stats/modals/devices/browsers-modal.js b/assets/js/dashboard/stats/modals/devices/browsers-modal.js index f6a14ce1bbf7..09ce75ae16aa 100644 --- a/assets/js/dashboard/stats/modals/devices/browsers-modal.js +++ b/assets/js/dashboard/stats/modals/devices/browsers-modal.js @@ -7,7 +7,6 @@ import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { browserIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by-legacy' function BrowsersModal() { const { dashboardState } = useDashboardStateContext() @@ -18,7 +17,7 @@ function BrowsersModal() { dimension: 'browser', endpoint: url.apiPath(site, '/browsers'), dimensionLabel: 'Browser', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } const getFilterInfo = useCallback( diff --git a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js index 50b20d45679c..fd53af221a5d 100644 --- a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js @@ -7,7 +7,6 @@ import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { osIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by-legacy' function OperatingSystemVersionsModal() { const { dashboardState } = useDashboardStateContext() @@ -18,7 +17,7 @@ function OperatingSystemVersionsModal() { dimension: 'os_version', endpoint: url.apiPath(site, '/operating-system-versions'), dimensionLabel: 'Operating system version', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } const getFilterInfo = useCallback( diff --git a/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js b/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js index 8685c2158abe..1e5000b5a390 100644 --- a/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js +++ b/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js @@ -7,7 +7,6 @@ import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { osIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by-legacy' function OperatingSystemsModal() { const { dashboardState } = useDashboardStateContext() @@ -18,7 +17,7 @@ function OperatingSystemsModal() { dimension: 'os', endpoint: url.apiPath(site, '/operating-systems'), dimensionLabel: 'Operating system', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } const getFilterInfo = useCallback( diff --git a/assets/js/dashboard/stats/modals/devices/screen-sizes.js b/assets/js/dashboard/stats/modals/devices/screen-sizes.js index 15828f1fc013..499c880d4888 100644 --- a/assets/js/dashboard/stats/modals/devices/screen-sizes.js +++ b/assets/js/dashboard/stats/modals/devices/screen-sizes.js @@ -6,7 +6,6 @@ import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { screenSizeIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by-legacy' function ScreenSizesModal() { const { dashboardState } = useDashboardStateContext() @@ -17,7 +16,7 @@ function ScreenSizesModal() { dimension: 'screen', endpoint: url.apiPath(site, '/screen-sizes'), dimensionLabel: 'Device', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } const getFilterInfo = useCallback( diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js index fbfdd97c9203..739a0cd084f9 100644 --- a/assets/js/dashboard/stats/modals/locations-modal.js +++ b/assets/js/dashboard/stats/modals/locations-modal.js @@ -11,7 +11,6 @@ import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { addFilter, revenueAvailable } from '../../dashboard-state' -import { SortDirection } from '../../hooks/use-order-by-legacy' const VIEWS = { countries: { @@ -19,21 +18,21 @@ const VIEWS = { dimension: 'country', endpoint: '/countries', dimensionLabel: 'Country', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] }, regions: { title: 'Top regions', dimension: 'region', endpoint: '/regions', dimensionLabel: 'Region', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] }, cities: { title: 'Top cities', dimension: 'city', endpoint: '/cities', dimensionLabel: 'City', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } } diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index eae9c6344c2d..173e23b53d29 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -14,7 +14,6 @@ import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by-legacy' function PropsModal() { const { dashboardState } = useDashboardStateContext() @@ -36,7 +35,7 @@ function PropsModal() { `/custom-prop-values/${url.maybeEncodeRouteParam(propKey)}` ), dimensionLabel: propKey, - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } const getFilterInfo = useCallback( diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index 6ee599a4ac29..991ea296077c 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -12,7 +12,6 @@ import * as url from '../../util/url' import { addFilter, revenueAvailable } from '../../dashboard-state' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by-legacy' import { SourceFavicon } from '../sources/source-favicon' function ReferrerDrilldownModal() { @@ -32,7 +31,7 @@ function ReferrerDrilldownModal() { `/referrers/${url.maybeEncodeRouteParam(referrer)}` ), dimensionLabel: 'Referrer', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } const getFilterInfo = useCallback( diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index 7d06fb8cde3e..5322870c1362 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -10,7 +10,6 @@ import * as url from '../../util/url' import { addFilter, revenueAvailable } from '../../dashboard-state' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by-legacy' import { SourceFavicon } from '../sources/source-favicon' const VIEWS = { @@ -20,7 +19,7 @@ const VIEWS = { dimension: 'source', endpoint: '/sources', dimensionLabel: 'Source', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] }, renderIcon: (listItem) => { return ( @@ -37,7 +36,7 @@ const VIEWS = { dimension: 'channel', endpoint: '/channels', dimensionLabel: 'Channel', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } }, utm_mediums: { @@ -46,7 +45,7 @@ const VIEWS = { dimension: 'utm_medium', endpoint: '/utm_mediums', dimensionLabel: 'UTM medium', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } }, utm_sources: { @@ -55,7 +54,7 @@ const VIEWS = { dimension: 'utm_source', endpoint: '/utm_sources', dimensionLabel: 'UTM source', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } }, utm_campaigns: { @@ -64,7 +63,7 @@ const VIEWS = { dimension: 'utm_campaign', endpoint: '/utm_campaigns', dimensionLabel: 'UTM campaign', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } }, utm_contents: { @@ -73,7 +72,7 @@ const VIEWS = { dimension: 'utm_content', endpoint: '/utm_contents', dimensionLabel: 'UTM content', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } }, utm_terms: { @@ -82,7 +81,7 @@ const VIEWS = { dimension: 'utm_term', endpoint: '/utm_terms', dimensionLabel: 'UTM term', - defaultOrder: ['visitors', SortDirection.desc] + defaultOrder: ['visitors', 'desc'] } } } diff --git a/assets/js/dashboard/stats/pages/entry-pages.tsx b/assets/js/dashboard/stats/pages/entry-pages.tsx index 35fa97194439..a5821daffe78 100644 --- a/assets/js/dashboard/stats/pages/entry-pages.tsx +++ b/assets/js/dashboard/stats/pages/entry-pages.tsx @@ -2,7 +2,6 @@ import React, { useCallback } from 'react' import Modal from '../modals/modal' import { Metric } from '../metrics' import * as url from '../../util/url' -import { StatsQuery } from '../../stats-query' import { IndexBreakdown } from '../reports/index-breakdown' import { DetailsBreakdown } from '../modals/details-breakdown' import { useDashboardStateContext } from '../../dashboard-state-context' @@ -13,8 +12,7 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { SortDirection } from '../../hooks/use-order-by' -import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' +import { getBreakdownMetrics } from '../breakdowns' import { PAGES_BAR_COLOR } from './pages' const DIMENSION = 'visit:entry_page' @@ -33,10 +31,6 @@ function getFilterInfo(row: QueryResultRow) { } } -function addSearchFilter(statsQuery: StatsQuery, search: string) { - return addDimensionSearchFilter(statsQuery, DIMENSION, search) -} - export function EntryPagesIndex({ onDataReady }: { @@ -96,10 +90,9 @@ export function EntryPagesDetails() { dimensionLabel="Entry page" dimensions={[DIMENSION]} metrics={metrics} - defaultOrderBy={[['visitors', SortDirection.desc]]} + defaultOrderBy={[['visitors', 'desc']]} getFilterInfo={getFilterInfo} getExternalLinkUrl={getExternalLinkUrl} - addSearchFilter={addSearchFilter} /> ) diff --git a/assets/js/dashboard/stats/pages/exit-pages.tsx b/assets/js/dashboard/stats/pages/exit-pages.tsx index 71625afbaca5..83452afe5682 100644 --- a/assets/js/dashboard/stats/pages/exit-pages.tsx +++ b/assets/js/dashboard/stats/pages/exit-pages.tsx @@ -2,7 +2,6 @@ import React, { useCallback } from 'react' import Modal from '../modals/modal' import { Metric } from '../metrics' import * as url from '../../util/url' -import { StatsQuery } from '../../stats-query' import { IndexBreakdown } from '../reports/index-breakdown' import { DetailsBreakdown } from '../modals/details-breakdown' import { useDashboardStateContext } from '../../dashboard-state-context' @@ -13,8 +12,7 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { SortDirection } from '../../hooks/use-order-by' -import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' +import { getBreakdownMetrics } from '../breakdowns' import { PAGES_BAR_COLOR } from './pages' const DIMENSION = 'visit:exit_page' @@ -32,10 +30,6 @@ function getFilterInfo(row: QueryResultRow) { } } -function addSearchFilter(statsQuery: StatsQuery, search: string) { - return addDimensionSearchFilter(statsQuery, DIMENSION, search) -} - export function ExitPagesIndex({ onDataReady }: { @@ -95,10 +89,9 @@ export function ExitPagesDetails() { dimensionLabel="Exit page" dimensions={[DIMENSION]} metrics={metrics} - defaultOrderBy={[['visitors', SortDirection.desc]]} + defaultOrderBy={[['visitors', 'desc']]} getFilterInfo={getFilterInfo} getExternalLinkUrl={getExternalLinkUrl} - addSearchFilter={addSearchFilter} /> ) diff --git a/assets/js/dashboard/stats/pages/pages.tsx b/assets/js/dashboard/stats/pages/pages.tsx index 6eefc47943c1..43a7d135cfb2 100644 --- a/assets/js/dashboard/stats/pages/pages.tsx +++ b/assets/js/dashboard/stats/pages/pages.tsx @@ -2,7 +2,6 @@ import React, { useCallback } from 'react' import Modal from '../modals/modal' import { Metric } from '../metrics' import * as url from '../../util/url' -import { StatsQuery } from '../../stats-query' import { IndexBreakdown } from '../reports/index-breakdown' import { DetailsBreakdown } from '../modals/details-breakdown' import { useDashboardStateContext } from '../../dashboard-state-context' @@ -13,8 +12,7 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { SortDirection } from '../../hooks/use-order-by' -import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' +import { getBreakdownMetrics } from '../breakdowns' export const PAGES_BAR_COLOR = 'bg-orange-50 group-hover/row:bg-orange-100' @@ -35,10 +33,6 @@ function getFilterInfo(row: QueryResultRow) { } } -function addSearchFilter(statsQuery: StatsQuery, search: string) { - return addDimensionSearchFilter(statsQuery, DIMENSION, search) -} - export function PagesIndex({ onDataReady }: { @@ -98,10 +92,9 @@ export function PagesDetails() { dimensionLabel="Page url" dimensions={[DIMENSION]} metrics={metrics} - defaultOrderBy={[['visitors', SortDirection.desc]]} + defaultOrderBy={[['visitors', 'desc']]} getFilterInfo={getFilterInfo} getExternalLinkUrl={getExternalLinkUrl} - addSearchFilter={addSearchFilter} /> ) diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index 28a6d68fa3e3..5548a267481c 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -4,9 +4,7 @@ import LazyLoader from '../../components/lazy-loader' import { trimURL } from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { getStaleTime } from '../../hooks/api-client' -import { createStatsQuery } from '../../stats-query' -import type { StatsQuery } from '../../stats-query' +import { OrderByEntry } from '../../stats-query' import { Metric, getBreakdownMetricLabel } from '../metrics' import { ColumnConfiguration, @@ -18,12 +16,7 @@ import { extractMetricValue } from '../breakdowns' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' -import { - QueryResultRow, - QueryResultQuery, - QueryApiResponse, - stats -} from '../../api' +import { QueryResultRow, QueryResultQuery, QueryApiResponse } from '../../api' import classNames from 'classnames' import { Tooltip } from '../../util/tooltip' import { ChangeArrow } from './change-arrow' @@ -32,11 +25,11 @@ import { hasConversionGoalFilter, isRealTimeDashboard } from '../../util/filters' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { DashboardPeriod } from '../../dashboard-time-periods' -import { DashboardState } from '../../dashboard-state' -import { SortDirection } from '../../hooks/use-order-by-legacy' -import { OrderBy } from '../../hooks/use-order-by' +import { + StatsReportId, + StatsReportQueryKey, + useQueryApi +} from '../../hooks/use-query-api' const MAX_ITEMS = 9 export const MIN_HEIGHT = 356 @@ -71,33 +64,30 @@ export function IndexBreakdown({ const site = useSiteContext() const { dashboardState } = useDashboardStateContext() const [visible, setVisible] = useState(false) - const isRealtime = dashboardState.period === DashboardPeriod.realtime - const [isRealtimeSilentUpdate, setIsRealtimeSilentUpdate] = useState(false) - const queryClient = useQueryClient() - - const statsQuery: StatsQuery = useMemo(() => { - return createStatsQuery(dashboardState, { - metrics, - dimensions, - order_by: [['visitors', SortDirection.desc]].concat( - dimensions.map((dim) => [dim, SortDirection.asc]) - ) as OrderBy, - pagination: { limit: MAX_ITEMS, offset: 0 } - }) - }, [dashboardState, metrics, dimensions]) - const dimensionKey = statsQuery.dimensions.join(',') + const statsReportQueryKey: StatsReportQueryKey = useMemo(() => { + return [ + dimensions.join(',') as StatsReportId, + { + dashboardState, + reportParams: { + metrics, + dimensions, + order_by: [ + ['visitors', 'desc'], + ...dimensions.map((dim): OrderByEntry => [dim, 'asc']) + ], + pagination: { limit: MAX_ITEMS, offset: 0 } + } + } + ] + }, [dashboardState, metrics, dimensions]) - const apiState = useQuery({ - queryKey: [dimensionKey, dashboardState], - enabled: visible, - queryFn: () => stats(site, statsQuery), - staleTime: getStaleTime({ - siteTimezoneOffset: site.offset, - siteStatsBegin: site.statsBegin, - ...dashboardState - }) - }) + const { apiState, isRealtimeSilentUpdate } = useQueryApi( + site, + statsReportQueryKey, + { enabled: visible } + ) useEffect(() => { if (apiState.data && typeof onDataReady === 'function') { @@ -105,38 +95,6 @@ export function IndexBreakdown({ } }, [apiState.data, onDataReady]) - useEffect(() => { - if (!apiState.isRefetching) { - setIsRealtimeSilentUpdate(false) - } - }, [apiState.isRefetching]) - - useEffect(() => { - if (!isRealtime) { - setIsRealtimeSilentUpdate(false) - } - }, [isRealtime]) - - useEffect(() => { - const onTick = () => { - setIsRealtimeSilentUpdate(true) - queryClient.invalidateQueries({ - predicate: ({ queryKey }) => - queryKey[0] === dimensionKey && - typeof queryKey[1] === 'object' && - (queryKey[1] as DashboardState)?.period === DashboardPeriod.realtime - }) - } - - if (isRealtime) { - document.addEventListener('tick', onTick) - } - - return () => { - document.removeEventListener('tick', onTick) - } - }, [queryClient, isRealtime, dimensionKey]) - const query: QueryResultQuery | null = apiState.data?.query ?? null const barMetricIndex = query diff --git a/assets/js/types/query-api.d.ts b/assets/js/types/query-api.d.ts index a6607a9fca09..78a5aec67d24 100644 --- a/assets/js/types/query-api.d.ts +++ b/assets/js/types/query-api.d.ts @@ -67,7 +67,7 @@ export type SimpleFilterDimensions = | "visit:exit_page" | "visit:entry_page_hostname" | "visit:exit_page_hostname"; -export type CustomPropertyFilterDimensions = string; +export type CustomPropertyFilterDimensions = `event:props:${string}`; export type GoalDimension = "event:goal"; export type TimeDimensions = "time" | "time:month" | "time:week" | "time:day" | "time:hour"; export type FilterTree = FilterEntry | FilterAndOr | FilterNot | FilterHasDone; @@ -156,8 +156,12 @@ export type FilterHasDone = ["has_done" | "has_not_done", FilterTree]; */ export type OrderByEntry = [ Metric | SimpleFilterDimensions | CustomPropertyFilterDimensions | TimeDimensions, - "asc" | "desc" + SortDirection ]; +/** + * Sorting order + */ +export type SortDirection = "asc" | "desc"; export interface QueryApiSchema { /** @@ -198,14 +202,15 @@ export interface QueryApiSchema { */ trim_relative_date_range?: boolean; }; - pagination?: { - /** - * Number of rows to limit result to. - */ - limit?: number; - /** - * Pagination offset. - */ - offset?: number; - }; + pagination?: Pagination; +} +export interface Pagination { + /** + * Number of rows to limit result to. + */ + limit?: number; + /** + * Pagination offset. + */ + offset?: number; } diff --git a/assets/test-utils/app-context-providers.tsx b/assets/test-utils/app-context-providers.tsx index 1e40a191d8c2..6f7c64e36d20 100644 --- a/assets/test-utils/app-context-providers.tsx +++ b/assets/test-utils/app-context-providers.tsx @@ -15,6 +15,7 @@ import { SegmentsContextProvider } from '../js/dashboard/filtering/segments-cont import { SavedSegment, SavedSegments } from '../js/dashboard/filtering/segments' import { GraphIntervalProvider } from '../js/dashboard/stats/graph/graph-interval-context' import { ImportsIncludedProvider } from '../js/dashboard/stats/graph/imports-included-context' +import { CurrentVisitorsProvider } from '../js/dashboard/current-visitors-context' type TestContextProvidersProps = { children: ReactNode @@ -94,11 +95,13 @@ export const TestContextProviders = ({ - - - {children} - - + + + + {children} + + + diff --git a/priv/json-schemas/query-api-schema.json b/priv/json-schemas/query-api-schema.json index 1f692ef8682e..c96ab95536b6 100644 --- a/priv/json-schemas/query-api-schema.json +++ b/priv/json-schemas/query-api-schema.json @@ -67,22 +67,7 @@ } }, "pagination": { - "type": "object", - "additionalProperties": false, - "properties": { - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 10000, - "description": "Number of rows to limit result to." - }, - "offset": { - "type": "integer", - "minimum": 0, - "default": 0, - "description": "Pagination offset." - } - } + "$ref": "#/definitions/pagination" } }, "required": ["site_id", "metrics", "date_range"], @@ -290,6 +275,7 @@ }, "custom_property_filter_dimensions": { "type": "string", + "tsType": "`event:props:${string}`", "pattern": "^event:props:.+", "markdownDescription": "Custom property. See [documentation](https://plausible.io/docs/custom-props/introduction) for more information", "examples": ["event:props:url", "event:props:path"] @@ -502,6 +488,29 @@ { "$ref": "#/definitions/filter_tree" } ] }, + "pagination": { + "type": "object", + "additionalProperties": false, + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Number of rows to limit result to." + }, + "offset": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "Pagination offset." + } + } + }, + "sort_direction": { + "type": "string", + "enum": ["asc", "desc"], + "description": "Sorting order" + }, "order_by_entry": { "type": "array", "additionalItems": false, @@ -517,11 +526,7 @@ ], "markdownDescription": "Metric or dimension to order by. Must be listed under `metrics` or `dimensions`" }, - { - "type": "string", - "enum": ["asc", "desc"], - "description": "Sorting order" - } + { "$ref": "#/definitions/sort_direction" } ] } }