From 224bbdcb7fca1137aed1cd4544269f2a28fce805 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 8 May 2026 18:26:23 +0300 Subject: [PATCH 01/17] more specific type to event:props:* dimension --- assets/js/types/query-api.d.ts | 2 +- priv/json-schemas/query-api-schema.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/js/types/query-api.d.ts b/assets/js/types/query-api.d.ts index a6607a9fca09..718dd6fc4be2 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; diff --git a/priv/json-schemas/query-api-schema.json b/priv/json-schemas/query-api-schema.json index 1f692ef8682e..96db944340de 100644 --- a/priv/json-schemas/query-api-schema.json +++ b/priv/json-schemas/query-api-schema.json @@ -290,6 +290,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"] From 643b53f8a3c2dcb933c995ecebe9e1e5bce367b9 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 8 May 2026 18:28:29 +0300 Subject: [PATCH 02/17] rename use-order-by.ts -> use-metric-order-by.ts --- .../hooks/{use-order-by.test.ts => use-metric-order-by.test.ts} | 2 +- .../dashboard/hooks/{use-order-by.ts => use-metric-order-by.ts} | 0 assets/js/dashboard/stats-query.ts | 2 +- assets/js/dashboard/stats/breakdowns.tsx | 2 +- assets/js/dashboard/stats/modals/details-breakdown.tsx | 2 +- assets/js/dashboard/stats/pages/entry-pages.tsx | 2 +- assets/js/dashboard/stats/pages/exit-pages.tsx | 2 +- assets/js/dashboard/stats/pages/pages.tsx | 2 +- assets/js/dashboard/stats/reports/index-breakdown.tsx | 2 +- 9 files changed, 8 insertions(+), 8 deletions(-) rename assets/js/dashboard/hooks/{use-order-by.test.ts => use-metric-order-by.test.ts} (99%) rename assets/js/dashboard/hooks/{use-order-by.ts => use-metric-order-by.ts} (100%) 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 99% 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..2df7acd1f739 100644 --- a/assets/js/dashboard/hooks/use-order-by.test.ts +++ b/assets/js/dashboard/hooks/use-metric-order-by.test.ts @@ -8,7 +8,7 @@ import { maybeStoreOrderBy, rearrangeOrderBy, validateOrderBy -} from './use-order-by' +} from './use-metric-order-by' describe(`${cycleSortDirection.name}`, () => { test.each([ diff --git a/assets/js/dashboard/hooks/use-order-by.ts b/assets/js/dashboard/hooks/use-metric-order-by.ts similarity index 100% rename from assets/js/dashboard/hooks/use-order-by.ts rename to assets/js/dashboard/hooks/use-metric-order-by.ts diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 46107fd013f9..0caf1c1f15fb 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -5,7 +5,7 @@ import { FilterKey, FilterClause } from './dashboard-state' -import { OrderBy } from './hooks/use-order-by' +import { OrderBy } from './hooks/use-metric-order-by' import { ComparisonMode, DashboardPeriod } from './dashboard-time-periods' import { formatISO } from './util/date' import { remapToApiFilters } from './util/filters' diff --git a/assets/js/dashboard/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx index 166f21aa6fcc..60715a1492d8 100644 --- a/assets/js/dashboard/stats/breakdowns.tsx +++ b/assets/js/dashboard/stats/breakdowns.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useEffect, useRef } from 'react' -import { SortDirection } from '../hooks/use-order-by' +import { SortDirection } from '../hooks/use-metric-order-by' import type { QueryResultRow, QueryResultQuery } from '../api' import { Metric } from './metrics' import { FilterInfo } from '../components/drilldown-link' diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 47b7b26480bf..f7053af41c02 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -15,7 +15,7 @@ import { SortDirection, useOrderBy, useRememberOrderBy -} from '../../hooks/use-order-by' +} from '../../hooks/use-metric-order-by' import { Metric, getBreakdownMetricLabel, isSortable } from '../metrics' import { BreakdownTable } from './breakdown-table' import { createStatsQuery, StatsQuery } from '../../stats-query' diff --git a/assets/js/dashboard/stats/pages/entry-pages.tsx b/assets/js/dashboard/stats/pages/entry-pages.tsx index 35fa97194439..e71c9f3a1180 100644 --- a/assets/js/dashboard/stats/pages/entry-pages.tsx +++ b/assets/js/dashboard/stats/pages/entry-pages.tsx @@ -13,7 +13,7 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-metric-order-by' import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' import { PAGES_BAR_COLOR } from './pages' diff --git a/assets/js/dashboard/stats/pages/exit-pages.tsx b/assets/js/dashboard/stats/pages/exit-pages.tsx index 71625afbaca5..f750a292f965 100644 --- a/assets/js/dashboard/stats/pages/exit-pages.tsx +++ b/assets/js/dashboard/stats/pages/exit-pages.tsx @@ -13,7 +13,7 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-metric-order-by' import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' import { PAGES_BAR_COLOR } from './pages' diff --git a/assets/js/dashboard/stats/pages/pages.tsx b/assets/js/dashboard/stats/pages/pages.tsx index 6eefc47943c1..4a592d99ff19 100644 --- a/assets/js/dashboard/stats/pages/pages.tsx +++ b/assets/js/dashboard/stats/pages/pages.tsx @@ -13,7 +13,7 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-metric-order-by' import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' export const PAGES_BAR_COLOR = 'bg-orange-50 group-hover/row:bg-orange-100' diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index 28a6d68fa3e3..ecf01866b5fe 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -36,7 +36,7 @@ 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 { OrderBy } from '../../hooks/use-metric-order-by' const MAX_ITEMS = 9 export const MIN_HEIGHT = 356 From 53e0a45570d0583a44543cbd7dc4fb76759378db Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 8 May 2026 20:11:47 +0300 Subject: [PATCH 03/17] SortDirection: use string union type from query-api-schema instead of enum --- .../js/dashboard/components/sort-button.tsx | 7 +-- .../js/dashboard/components/table-legacy.tsx | 2 +- .../hooks/use-metric-order-by.test.ts | 43 ++++++----------- .../js/dashboard/hooks/use-metric-order-by.ts | 20 ++++---- .../hooks/use-order-by-legacy.test.ts | 47 ++++++++----------- .../js/dashboard/hooks/use-order-by-legacy.ts | 20 ++++---- assets/js/dashboard/stats/breakdowns.tsx | 2 +- .../stats/modals/details-breakdown.tsx | 4 +- .../modals/devices/browser-versions-modal.js | 3 +- .../stats/modals/devices/browsers-modal.js | 3 +- .../operating-system-versions-modal.js | 3 +- .../modals/devices/operating-systems-modal.js | 3 +- .../stats/modals/devices/screen-sizes.js | 3 +- .../dashboard/stats/modals/locations-modal.js | 7 ++- assets/js/dashboard/stats/modals/props.js | 3 +- .../stats/modals/referrer-drilldown.js | 3 +- assets/js/dashboard/stats/modals/sources.js | 15 +++--- .../js/dashboard/stats/pages/entry-pages.tsx | 3 +- .../js/dashboard/stats/pages/exit-pages.tsx | 3 +- assets/js/dashboard/stats/pages/pages.tsx | 3 +- .../stats/reports/index-breakdown.tsx | 5 +- assets/js/types/query-api.d.ts | 6 ++- priv/json-schemas/query-api-schema.json | 11 +++-- 23 files changed, 91 insertions(+), 128 deletions(-) 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/hooks/use-metric-order-by.test.ts b/assets/js/dashboard/hooks/use-metric-order-by.test.ts index 2df7acd1f739..a8ba912d2fa6 100644 --- a/assets/js/dashboard/hooks/use-metric-order-by.test.ts +++ b/assets/js/dashboard/hooks/use-metric-order-by.test.ts @@ -1,7 +1,6 @@ import { Metric } from '../stats/metrics' import { OrderBy, - SortDirection, cycleSortDirection, getOrderByStorageKey, getStoredOrderBy, @@ -15,25 +14,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) @@ -43,21 +42,9 @@ 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]] - ] + ['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: OrderBy = [['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-metric-order-by.ts b/assets/js/dashboard/hooks/use-metric-order-by.ts index 01b5de2a2366..5848c23b28dd 100644 --- a/assets/js/dashboard/hooks/use-metric-order-by.ts +++ b/assets/js/dashboard/hooks/use-metric-order-by.ts @@ -2,11 +2,7 @@ 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' - -export enum SortDirection { - asc = 'asc', - desc = 'desc' -} +import { SortDirection } from '../../types/query-api' export type Order = [string, SortDirection] @@ -14,8 +10,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({ @@ -59,15 +55,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' } } @@ -78,7 +74,7 @@ export function rearrangeOrderBy( ): OrderBy { 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] @@ -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 } 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/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx index 60715a1492d8..b9bddef652ac 100644 --- a/assets/js/dashboard/stats/breakdowns.tsx +++ b/assets/js/dashboard/stats/breakdowns.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useEffect, useRef } from 'react' -import { SortDirection } from '../hooks/use-metric-order-by' +import { SortDirection } from '../../types/query-api' import type { QueryResultRow, QueryResultQuery } from '../api' import { Metric } from './metrics' import { FilterInfo } from '../components/drilldown-link' diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index f7053af41c02..039206ac0f34 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -12,10 +12,10 @@ import { getStoredOrderBy, Order, OrderBy, - SortDirection, useOrderBy, useRememberOrderBy } 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' @@ -111,7 +111,7 @@ export function DetailsBreakdown({ }) const effectiveOrderBy = (orderBy.length ? orderBy : storedOrderBy).concat( - dimensions.map((dim) => [dim, SortDirection.asc]) + dimensions.map((dim) => [dim, 'asc']) ) const baseStatsQuery: StatsQuery = useMemo( 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 e71c9f3a1180..5b605083de53 100644 --- a/assets/js/dashboard/stats/pages/entry-pages.tsx +++ b/assets/js/dashboard/stats/pages/entry-pages.tsx @@ -13,7 +13,6 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { SortDirection } from '../../hooks/use-metric-order-by' import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' import { PAGES_BAR_COLOR } from './pages' @@ -96,7 +95,7 @@ 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 f750a292f965..885f65b140d6 100644 --- a/assets/js/dashboard/stats/pages/exit-pages.tsx +++ b/assets/js/dashboard/stats/pages/exit-pages.tsx @@ -13,7 +13,6 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { SortDirection } from '../../hooks/use-metric-order-by' import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' import { PAGES_BAR_COLOR } from './pages' @@ -95,7 +94,7 @@ 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 4a592d99ff19..fb1334ae9d6f 100644 --- a/assets/js/dashboard/stats/pages/pages.tsx +++ b/assets/js/dashboard/stats/pages/pages.tsx @@ -13,7 +13,6 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { SortDirection } from '../../hooks/use-metric-order-by' import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' export const PAGES_BAR_COLOR = 'bg-orange-50 group-hover/row:bg-orange-100' @@ -98,7 +97,7 @@ 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 ecf01866b5fe..e7880ebb19f7 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -35,7 +35,6 @@ import { 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-metric-order-by' const MAX_ITEMS = 9 @@ -79,8 +78,8 @@ export function IndexBreakdown({ return createStatsQuery(dashboardState, { metrics, dimensions, - order_by: [['visitors', SortDirection.desc]].concat( - dimensions.map((dim) => [dim, SortDirection.asc]) + order_by: [['visitors', 'desc']].concat( + dimensions.map((dim) => [dim, 'asc']) ) as OrderBy, pagination: { limit: MAX_ITEMS, offset: 0 } }) diff --git a/assets/js/types/query-api.d.ts b/assets/js/types/query-api.d.ts index 718dd6fc4be2..75e4238863d5 100644 --- a/assets/js/types/query-api.d.ts +++ b/assets/js/types/query-api.d.ts @@ -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 { /** diff --git a/priv/json-schemas/query-api-schema.json b/priv/json-schemas/query-api-schema.json index 96db944340de..1c25982faa2e 100644 --- a/priv/json-schemas/query-api-schema.json +++ b/priv/json-schemas/query-api-schema.json @@ -503,6 +503,11 @@ { "$ref": "#/definitions/filter_tree" } ] }, + "sort_direction": { + "type": "string", + "enum": ["asc", "desc"], + "description": "Sorting order" + }, "order_by_entry": { "type": "array", "additionalItems": false, @@ -518,11 +523,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" } ] } } From 5915c9df7b0aec00a6bd6a149ab0118fb7e7ed7b Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 8 May 2026 20:26:35 +0300 Subject: [PATCH 04/17] use query-api-schema pagination type too --- assets/js/dashboard/stats-query.ts | 3 +-- assets/js/types/query-api.d.ts | 21 ++++++++------- priv/json-schemas/query-api-schema.json | 35 ++++++++++++++----------- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 0caf1c1f15fb..a506871abb40 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -8,6 +8,7 @@ import { import { OrderBy } from './hooks/use-metric-order-by' import { ComparisonMode, DashboardPeriod } from './dashboard-time-periods' import { formatISO } from './util/date' +import { Pagination } from '../types/query-api' import { remapToApiFilters } from './util/filters' export type FilterModifiers = { case_sensitive?: boolean } @@ -16,8 +17,6 @@ export type ApiFilter = | [FilterOperator, FilterKey, FilterClause[]] | [FilterOperator, FilterKey, FilterClause[], FilterModifiers] -type Pagination = { limit: number; offset: number } - type DateRange = DashboardPeriod | [string, string] type IncludeCompare = | ComparisonMode.previous_period diff --git a/assets/js/types/query-api.d.ts b/assets/js/types/query-api.d.ts index 75e4238863d5..78a5aec67d24 100644 --- a/assets/js/types/query-api.d.ts +++ b/assets/js/types/query-api.d.ts @@ -202,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/priv/json-schemas/query-api-schema.json b/priv/json-schemas/query-api-schema.json index 1c25982faa2e..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"], @@ -503,6 +488,24 @@ { "$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"], From 19ecf02531bc22969e20b4e324ba2cc837380166 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 8 May 2026 21:09:53 +0300 Subject: [PATCH 05/17] add proper OrderBy type (with dimensions) and separate MetricOrderBy --- .../hooks/use-metric-order-by.test.ts | 6 ++-- .../js/dashboard/hooks/use-metric-order-by.ts | 24 +++++++------- assets/js/dashboard/stats-query.ts | 15 +++++++-- .../stats/modals/details-breakdown.tsx | 31 +++++++++++-------- .../stats/reports/index-breakdown.tsx | 3 +- 5 files changed, 47 insertions(+), 32 deletions(-) diff --git a/assets/js/dashboard/hooks/use-metric-order-by.test.ts b/assets/js/dashboard/hooks/use-metric-order-by.test.ts index a8ba912d2fa6..29b5ef3221fc 100644 --- a/assets/js/dashboard/hooks/use-metric-order-by.test.ts +++ b/assets/js/dashboard/hooks/use-metric-order-by.test.ts @@ -1,6 +1,6 @@ import { Metric } from '../stats/metrics' import { - OrderBy, + MetricOrderBy, cycleSortDirection, getOrderByStorageKey, getStoredOrderBy, @@ -41,7 +41,7 @@ describe(`${cycleSortDirection.name}`, () => { }) describe(`${rearrangeOrderBy.name}`, () => { - const cases: [Metric, OrderBy, OrderBy][] = [ + const cases: [Metric, MetricOrderBy, MetricOrderBy][] = [ ['visitors', [['visitors', 'asc']], [['visitors', 'desc']]], ['visitors', [['visitors', 'desc']], [['visitors', 'asc']]], ['visit_duration', [['visitors', 'asc']], [['visit_duration', 'desc']]] @@ -115,7 +115,7 @@ describe(`storing detailed report preferred order`, () => { }) it('retrieves stored value correctly', () => { - const input: OrderBy = [['visitors', 'asc']] + const input: MetricOrderBy = [['visitors', 'asc']] localStorage.setItem( getOrderByStorageKey(domain, dimensionLabel), JSON.stringify(input) diff --git a/assets/js/dashboard/hooks/use-metric-order-by.ts b/assets/js/dashboard/hooks/use-metric-order-by.ts index 5848c23b28dd..652d305f0537 100644 --- a/assets/js/dashboard/hooks/use-metric-order-by.ts +++ b/assets/js/dashboard/hooks/use-metric-order-by.ts @@ -4,9 +4,9 @@ import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage' import { useSiteContext } from '../site-context' import { SortDirection } from '../../types/query-api' -export type Order = [string, SortDirection] +export type MetricOrderByEntry = [Metric, SortDirection] -export type OrderBy = Order[] +export type MetricOrderBy = MetricOrderByEntry[] export const getSortDirectionLabel = (sortDirection: SortDirection): string => ({ @@ -14,14 +14,14 @@ export const getSortDirectionLabel = (sortDirection: SortDirection): string => 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 @@ -69,9 +69,9 @@ export function cycleSortDirection( } 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 @@ -96,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 } @@ -125,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) @@ -149,7 +149,7 @@ export function maybeStoreOrderBy({ domain: string dimensionLabel: string metrics: Metric[] - orderBy: OrderBy + orderBy: MetricOrderBy }) { if ( validateOrderBy( @@ -169,7 +169,7 @@ export function useRememberOrderBy({ metrics, dimensionLabel }: { - effectiveOrderBy: OrderBy + effectiveOrderBy: MetricOrderBy metrics: Metric[] dimensionLabel: string }) { diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index a506871abb40..b23016334c81 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -5,10 +5,14 @@ import { FilterKey, FilterClause } from './dashboard-state' -import { OrderBy } from './hooks/use-metric-order-by' import { ComparisonMode, DashboardPeriod } from './dashboard-time-periods' import { formatISO } from './util/date' -import { Pagination } from '../types/query-api' +import { + Pagination, + SimpleFilterDimensions, + CustomPropertyFilterDimensions, + SortDirection +} from '../types/query-api' import { remapToApiFilters } from './util/filters' export type FilterModifiers = { case_sensitive?: boolean } @@ -17,6 +21,13 @@ export type ApiFilter = | [FilterOperator, FilterKey, FilterClause[]] | [FilterOperator, FilterKey, FilterClause[], FilterModifiers] +export type NonTimeDimension = + | SimpleFilterDimensions + | CustomPropertyFilterDimensions + +export type OrderByEntry = [Metric | NonTimeDimension, SortDirection] +export type OrderBy = OrderByEntry[] + type DateRange = DashboardPeriod | [string, string] type IncludeCompare = | ComparisonMode.previous_period diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 039206ac0f34..4ba5459b2740 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -10,15 +10,19 @@ import { usePaginatedQueryAPI } from '../../hooks/api-client' import { rootRoute } from '../../router' import { getStoredOrderBy, - Order, - OrderBy, - useOrderBy, + MetricOrderBy, + useMetricOrderBy, useRememberOrderBy } 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 { + createStatsQuery, + StatsQuery, + OrderByEntry, + NonTimeDimension +} from '../../stats-query' import { useSiteContext } from '../../site-context' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' import { @@ -53,7 +57,7 @@ type PaginatedData = { pages: QueryApiResponse[] } type DetailsBreakdownProps = SharedBreakdownReportProps & { title: ReactNode - defaultOrderBy?: OrderBy + defaultOrderBy?: MetricOrderBy addSearchFilter?: (statsQuery: StatsQuery, search: string) => StatsQuery onDataReady?: (data: PaginatedData) => void } @@ -82,7 +86,7 @@ export function DetailsBreakdown({ dimensionLabel, dimensions, metrics, - defaultOrderBy = [] as OrderBy, + defaultOrderBy = [] as MetricOrderBy, getFilterInfo, getExternalLinkUrl, addSearchFilter, @@ -99,7 +103,7 @@ export function DetailsBreakdown({ fallbackValue: defaultOrderBy }) - const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({ + const { orderBy, orderByDictionary, toggleSortByMetric } = useMetricOrderBy({ metrics, defaultOrderBy: storedOrderBy }) @@ -110,18 +114,19 @@ export function DetailsBreakdown({ dimensionLabel }) - const effectiveOrderBy = (orderBy.length ? orderBy : storedOrderBy).concat( - dimensions.map((dim) => [dim, 'asc']) - ) - const baseStatsQuery: StatsQuery = useMemo( () => createStatsQuery(dashboardState, { metrics: metrics, dimensions, - order_by: effectiveOrderBy as Order[] + order_by: [ + ...(orderBy.length ? orderBy : storedOrderBy), + ...dimensions.map( + (dim): OrderByEntry => [dim as NonTimeDimension, 'asc'] + ) + ] }), - [dashboardState, metrics, dimensions, effectiveOrderBy] + [dashboardState, metrics, dimensions, orderBy, storedOrderBy] ) const statsQuery: StatsQuery = useMemo(() => { diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index e7880ebb19f7..e439b1a465a9 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -5,7 +5,7 @@ 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 { createStatsQuery, OrderBy } from '../../stats-query' import type { StatsQuery } from '../../stats-query' import { Metric, getBreakdownMetricLabel } from '../metrics' import { @@ -35,7 +35,6 @@ import { import { useQuery, useQueryClient } from '@tanstack/react-query' import { DashboardPeriod } from '../../dashboard-time-periods' import { DashboardState } from '../../dashboard-state' -import { OrderBy } from '../../hooks/use-metric-order-by' const MAX_ITEMS = 9 export const MIN_HEIGHT = 356 From 7990e9cda6b221559d51c9a9912f62fa326922fe Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 8 May 2026 21:18:12 +0300 Subject: [PATCH 06/17] use NonTimeDimension type in v2 breakdown components --- assets/js/dashboard/stats/breakdowns.tsx | 9 +++++++-- .../js/dashboard/stats/modals/details-breakdown.tsx | 11 ++--------- assets/js/dashboard/stats/reports/index-breakdown.tsx | 9 +++++---- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/assets/js/dashboard/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx index b9bddef652ac..7a68aadd499c 100644 --- a/assets/js/dashboard/stats/breakdowns.tsx +++ b/assets/js/dashboard/stats/breakdowns.tsx @@ -6,11 +6,16 @@ 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/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 4ba5459b2740..3001b5f4e54c 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -17,12 +17,7 @@ import { import { SortDirection } from '../../../types/query-api' import { Metric, getBreakdownMetricLabel, isSortable } from '../metrics' import { BreakdownTable } from './breakdown-table' -import { - createStatsQuery, - StatsQuery, - OrderByEntry, - NonTimeDimension -} from '../../stats-query' +import { createStatsQuery, StatsQuery, OrderByEntry } from '../../stats-query' import { useSiteContext } from '../../site-context' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' import { @@ -121,9 +116,7 @@ export function DetailsBreakdown({ dimensions, order_by: [ ...(orderBy.length ? orderBy : storedOrderBy), - ...dimensions.map( - (dim): OrderByEntry => [dim as NonTimeDimension, 'asc'] - ) + ...dimensions.map((dim): OrderByEntry => [dim, 'asc']) ] }), [dashboardState, metrics, dimensions, orderBy, storedOrderBy] diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index e439b1a465a9..cd54445840d1 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -5,7 +5,7 @@ import { trimURL } from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { getStaleTime } from '../../hooks/api-client' -import { createStatsQuery, OrderBy } from '../../stats-query' +import { createStatsQuery, OrderByEntry } from '../../stats-query' import type { StatsQuery } from '../../stats-query' import { Metric, getBreakdownMetricLabel } from '../metrics' import { @@ -77,9 +77,10 @@ export function IndexBreakdown({ return createStatsQuery(dashboardState, { metrics, dimensions, - order_by: [['visitors', 'desc']].concat( - dimensions.map((dim) => [dim, 'asc']) - ) as OrderBy, + order_by: [ + ['visitors', 'desc'], + ...dimensions.map((dim): OrderByEntry => [dim, 'asc']) + ], pagination: { limit: MAX_ITEMS, offset: 0 } }) }, [dashboardState, metrics, dimensions]) From 320d00aaafdea5ccc6a35565ddd9d13d1803a341 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 8 May 2026 21:59:46 +0300 Subject: [PATCH 07/17] also create a Dimension type (incl time dims) --- assets/js/dashboard/stats-query.ts | 7 +++++-- assets/js/dashboard/stats/graph/fetch-main-graph.ts | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index b23016334c81..2262fd059ac2 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -11,6 +11,7 @@ import { Pagination, SimpleFilterDimensions, CustomPropertyFilterDimensions, + TimeDimensions, SortDirection } from '../types/query-api' import { remapToApiFilters } from './util/filters' @@ -25,6 +26,8 @@ export type NonTimeDimension = | SimpleFilterDimensions | CustomPropertyFilterDimensions +export type Dimension = NonTimeDimension | TimeDimensions | 'time:minute' + export type OrderByEntry = [Metric | NonTimeDimension, SortDirection] export type OrderBy = OrderByEntry[] @@ -48,7 +51,7 @@ type QueryInclude = { export type ReportParams = { metrics: Metric[] - dimensions?: string[] + dimensions?: Dimension[] include?: Partial order_by?: OrderBy pagination?: Pagination @@ -58,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/graph/fetch-main-graph.ts b/assets/js/dashboard/stats/graph/fetch-main-graph.ts index ff167c74f733..1d0602779f47 100644 --- a/assets/js/dashboard/stats/graph/fetch-main-graph.ts +++ b/assets/js/dashboard/stats/graph/fetch-main-graph.ts @@ -6,12 +6,13 @@ import { createStatsQuery, ReportParams } from '../../stats-query' import { isRealTimeDashboard } from '../../util/filters' import { MetricValue } from '../../api' import * as api from '../../api' +import { Interval } from './intervals' export function fetchMainGraph( site: PlausibleSite, dashboardState: DashboardState, metric: Metric, - interval: string + interval: Interval ): Promise { const metricToQuery = metric === 'conversion_rate' ? 'group_conversion_rate' : metric From 567d61514003d21af42732268c03da14a2d1e213 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 10 May 2026 10:27:43 +0300 Subject: [PATCH 08/17] create use-query-api.ts - usePaginatedQueryApi -> useSearchAndPaginateQueryApi (include addSearchFilter logic in the hook) - enforce dimensions on ReportParams type and make it a part of queryKey - define explicit StatsReportQueryKey type --- assets/js/dashboard/hooks/api-client.ts | 52 +---------- assets/js/dashboard/hooks/use-query-api.ts | 89 +++++++++++++++++++ assets/js/dashboard/stats-query.ts | 2 +- .../dashboard/stats/graph/fetch-top-stats.ts | 2 + .../stats/modals/details-breakdown.tsx | 50 +++++------ 5 files changed, 117 insertions(+), 78 deletions(-) create mode 100644 assets/js/dashboard/hooks/use-query-api.ts 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-query-api.ts b/assets/js/dashboard/hooks/use-query-api.ts new file mode 100644 index 000000000000..1091abe93356 --- /dev/null +++ b/assets/js/dashboard/hooks/use-query-api.ts @@ -0,0 +1,89 @@ +import { + QueryFilters, + useInfiniteQuery, + useQueryClient +} from '@tanstack/react-query' +import { DashboardState } from '../dashboard-state' +import { PlausibleSite } from '../site-context' +import { + createStatsQuery, + NonTimeDimension, + ReportParams +} from '../stats-query' +import { useEffect } from 'react' +import { cleanToPageOne, getStaleTime, PAGINATION_LIMIT } from './api-client' +import { QueryApiResponse, stats } from '../api' +import { addDimensionSearchFilter } from '../stats/breakdowns' + +export type StatsReportId = + | 'top-stats' + | 'main-graph' + | NonTimeDimension + | `${NonTimeDimension},${NonTimeDimension}` + +export type StatsReportQueryKey = [ + StatsReportId, + { + dashboardState: DashboardState + reportParams: ReportParams + search?: string + } +] + +/** + * 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) + } + + return 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 + }) +} diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 2262fd059ac2..1a0592bda5a0 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -51,7 +51,7 @@ type QueryInclude = { export type ReportParams = { metrics: Metric[] - dimensions?: Dimension[] + dimensions: Dimension[] include?: Partial order_by?: OrderBy pagination?: Pagination diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.ts index d33b2331ba42..6f0895591294 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.ts @@ -23,6 +23,7 @@ export function topStatsQueries( if (isRealTimeDashboard(dashboardState)) { currentVisitorsQuery = createStatsQuery(dashboardState, { + dimensions: [], metrics: ['visitors'] }) @@ -116,6 +117,7 @@ function constructTopStatsQuery( ): StatsQuery { const reportParams: ReportParams = { metrics, + dimensions: [], include: { imports_meta: true } } diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 3001b5f4e54c..4528af4488e8 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -6,7 +6,11 @@ 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, @@ -17,7 +21,7 @@ import { import { SortDirection } from '../../../types/query-api' import { Metric, getBreakdownMetricLabel, isSortable } from '../metrics' import { BreakdownTable } from './breakdown-table' -import { createStatsQuery, StatsQuery, OrderByEntry } from '../../stats-query' +import { StatsQuery, OrderByEntry } from '../../stats-query' import { useSiteContext } from '../../site-context' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' import { @@ -109,31 +113,25 @@ export function DetailsBreakdown({ dimensionLabel }) - const baseStatsQuery: StatsQuery = useMemo( - () => - createStatsQuery(dashboardState, { - metrics: metrics, - dimensions, - order_by: [ - ...(orderBy.length ? orderBy : storedOrderBy), - ...dimensions.map((dim): OrderByEntry => [dim, 'asc']) - ] - }), - [dashboardState, metrics, dimensions, orderBy, storedOrderBy] - ) - - 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 From 897104ad72ada7ed25295ab7430716277880d69f Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 10 May 2026 18:06:00 +0300 Subject: [PATCH 09/17] add a useQueryApi hook and use it in IndexBreakdown The new hook also includes automatic realtime re-fetch logic. --- assets/js/dashboard/hooks/use-query-api.ts | 87 +++++++++++++++- .../stats/reports/index-breakdown.tsx | 99 ++++++------------- 2 files changed, 114 insertions(+), 72 deletions(-) diff --git a/assets/js/dashboard/hooks/use-query-api.ts b/assets/js/dashboard/hooks/use-query-api.ts index 1091abe93356..1cfc7762ec86 100644 --- a/assets/js/dashboard/hooks/use-query-api.ts +++ b/assets/js/dashboard/hooks/use-query-api.ts @@ -1,7 +1,9 @@ import { QueryFilters, useInfiniteQuery, - useQueryClient + useQuery, + useQueryClient, + UseQueryResult } from '@tanstack/react-query' import { DashboardState } from '../dashboard-state' import { PlausibleSite } from '../site-context' @@ -10,10 +12,11 @@ import { NonTimeDimension, ReportParams } from '../stats-query' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { cleanToPageOne, getStaleTime, PAGINATION_LIMIT } from './api-client' import { QueryApiResponse, stats } from '../api' import { addDimensionSearchFilter } from '../stats/breakdowns' +import { DashboardPeriod } from '../dashboard-time-periods' export type StatsReportId = | 'top-stats' @@ -30,6 +33,86 @@ export type StatsReportQueryKey = [ } ] +/** + * 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( + site: PlausibleSite, + statsReportQueryKey: StatsReportQueryKey, + opts: { enabled: boolean } = { enabled: true } +): { + apiState: UseQueryResult + isRealtimeSilentUpdate: boolean +} { + const statsReportId = statsReportQueryKey[0] + const isRealtime = + statsReportQueryKey[1].dashboardState.period === DashboardPeriod.realtime + const [isRealtimeSilentUpdate, setIsRealtimeSilentUpdate] = useState(false) + + const queryClient = useQueryClient() + + const apiState = useQuery({ + queryKey: statsReportQueryKey, + enabled: opts.enabled, + queryFn: ({ queryKey }) => { + const [_, keyOpts] = queryKey as StatsReportQueryKey + const statsQuery = createStatsQuery( + keyOpts.dashboardState, + keyOpts.reportParams + ) + return stats(site, statsQuery) + }, + staleTime: ({ queryKey }) => { + const [_, keyOpts] = queryKey as StatsReportQueryKey + return getStaleTime({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, + ...keyOpts.dashboardState + }) + } + }) + + useEffect(() => { + if (!opts.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, opts.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]` diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index cd54445840d1..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, OrderByEntry } 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,9 +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 { + StatsReportId, + StatsReportQueryKey, + useQueryApi +} from '../../hooks/use-query-api' const MAX_ITEMS = 9 export const MIN_HEIGHT = 356 @@ -69,34 +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', 'desc'], - ...dimensions.map((dim): OrderByEntry => [dim, 'asc']) - ], - 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') { @@ -104,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 From de0c41955ebc113937ed9ea6cef19a925e9258fb Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 11 May 2026 17:19:26 +0300 Subject: [PATCH 10/17] Add CurrentVisitorsContext - fixes a bug: current visitors in realtime top stats, in shared links limited to segment, are fetched from /query api which enforces segment filter -> 400 Bad Request - uses Tanstack useQuery against the old GET endpoint - The value is now also cached for 30s (CACHE_TTL_REALTIME) - Do not fetch current visitors at all when not needed (i.e. not realtime and dashboard has filters applied). - The same context value is now used by both top-stats.js and current-visitors.js --- .../js/dashboard/current-visitors-context.tsx | 47 +++++ assets/js/dashboard/index.tsx | 33 ++-- assets/js/dashboard/stats/current-visitors.js | 30 +--- .../stats/graph/fetch-top-stats.test.ts | 161 +++++++----------- .../dashboard/stats/graph/fetch-top-stats.ts | 91 +++------- assets/js/dashboard/stats/graph/top-stats.js | 20 ++- assets/test-utils/app-context-providers.tsx | 13 +- 7 files changed, 183 insertions(+), 212 deletions(-) create mode 100644 assets/js/dashboard/current-visitors-context.tsx 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/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/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 ( }>, /** expected metrics */ Metric[], - /** expected queries */ - [StatsQuery, null | StatsQuery] + /** expected top stats query */ + StatsQuery ] const cases: TestCase[] = [ @@ -67,34 +56,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 +96,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 +136,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 +162,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' + ] + } ] ] @@ -294,7 +264,6 @@ describe(`${formatTopStatsData.name}`, () => { ) const { timeRange, comparisonTimeRange } = formatTopStatsData( response, - null, metrics ) expect(timeRange).toBe('until 10:25') @@ -308,7 +277,6 @@ describe(`${formatTopStatsData.name}`, () => { ) const { timeRange, comparisonTimeRange } = formatTopStatsData( response, - null, metrics ) expect(timeRange).toBe('until 10:25') @@ -316,17 +284,16 @@ describe(`${formatTopStatsData.name}`, () => { }) }) -describe(`${topStatsQueries.name}`, () => { +describe(`${topStatsQuery.name}`, () => { test.each(cases)( 'for %s dashboard, queries are as expected', - (_, { site: _site, ...inputDashboardState }, metrics, expectedQueries) => { + (_, { site: _site, ...inputDashboardState }, metrics, expectedQuery) => { const dashboardState = { ...dashboardStateDefaultValue, resolvedFilters: inputDashboardState.filters, ...inputDashboardState } - const queries = topStatsQueries(dashboardState, metrics) - expect(queries).toEqual(expectedQueries) + expect(topStatsQuery(dashboardState, metrics)).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 6f0895591294..00f3f360bd81 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.ts @@ -15,23 +15,33 @@ import { isRealTimeDashboard } from '../../util/filters' -export function topStatsQueries( +export function topStatsQuery( dashboardState: DashboardState, metrics: Metric[] -): [StatsQuery, StatsQuery | null] { - let currentVisitorsQuery = null +): StatsQuery { + const reportParams: ReportParams = { + metrics, + dimensions: [], + include: { imports_meta: true } + } - if (isRealTimeDashboard(dashboardState)) { - currentVisitorsQuery = createStatsQuery(dashboardState, { - dimensions: [], - metrics: ['visitors'] + const statsQuery = createStatsQuery(dashboardState, reportParams) + + if ( + !isComparisonEnabled(dashboardState.comparison) && + !isComparisonForbidden({ + period: dashboardState.period, + segmentIsExpanded: false }) + ) { + statsQuery.include.compare = ComparisonMode.previous_period + } - currentVisitorsQuery.filters = [] + if (isRealTimeDashboard(dashboardState)) { + statsQuery.date_range = DashboardPeriod.realtime_30m } - const topStatsQuery = constructTopStatsQuery(dashboardState, metrics) - return [topStatsQuery, currentVisitorsQuery] + return statsQuery } export async function fetchTopStats( @@ -39,20 +49,8 @@ export async function fetchTopStats( dashboardState: DashboardState ) { const metrics = chooseMetrics(site, dashboardState) - const [topStatsQuery, currentVisitorsQuery] = topStatsQueries( - dashboardState, - metrics - ) - const topStatsPromise = api.stats(site, topStatsQuery) - - const currentVisitorsPromise = currentVisitorsQuery - ? api.stats(site, currentVisitorsQuery) - : null - - const [topStatsResponse, currentVisitorsResponse] = await Promise.all([ - topStatsPromise, - currentVisitorsPromise - ]) + const statsQuery = topStatsQuery(dashboardState, metrics) + const topStatsResponse = await api.stats(site, statsQuery) const metricLabelSuffix = isRealTimeDashboard(dashboardState) ? ' (last 30 min)' @@ -65,11 +63,7 @@ export async function fetchTopStats( })}${metricLabelSuffix}` })) - return formatTopStatsData( - topStatsResponse, - currentVisitorsResponse, - formattedMetrics - ) + return formatTopStatsData(topStatsResponse, formattedMetrics) } export type MetricDef = { key: Metric; label: string } @@ -111,35 +105,6 @@ export function chooseMetrics( } } -function constructTopStatsQuery( - dashboardState: DashboardState, - metrics: Metric[] -): StatsQuery { - const reportParams: ReportParams = { - metrics, - dimensions: [], - 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 - } - - return statsQuery -} - type TopStatItem = { metric: Metric value: api.MetricValue @@ -151,22 +116,12 @@ type TopStatItem = { export function formatTopStatsData( topStatsResponse: api.QueryApiResponse, - currentVisitorsResponse: api.QueryApiResponse | null, metrics: MetricDef[] ) { const { query, meta, results } = 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) diff --git a/assets/js/dashboard/stats/graph/top-stats.js b/assets/js/dashboard/stats/graph/top-stats.js index b3995404f8bb..d7aa10a5169a 100644 --- a/assets/js/dashboard/stats/graph/top-stats.js +++ b/assets/js/dashboard/stats/graph/top-stats.js @@ -6,6 +6,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, @@ -209,8 +210,25 @@ export default function TopStats({ ) } + const currentVisitors = useCurrentVisitorsContext() + + const currentVisitorsStat = + dashboardState.period === 'realtime' && currentVisitors !== null + ? { + metric: 'visitors', + value: currentVisitors, + name: 'Current visitors', + graphable: false + } + : null + + const allTopStats = [ + ...(currentVisitorsStat ? [currentVisitorsStat] : []), + ...(data?.topStats ?? []) + ] + const stats = - data && data.topStats.filter((stat) => stat.value !== null).map(renderStat) + data && allTopStats.filter((stat) => stat.value !== null).map(renderStat) if (stats && dashboardState.period === 'realtime') { stats.push(blinkingDot()) 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} + + + From 2b419a1de089f381fe1cd27119a4c24c19da9004 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 13 May 2026 21:03:22 +0300 Subject: [PATCH 11/17] top stats and main graph to useQueryApi --- assets/js/dashboard/api.ts | 18 +- assets/js/dashboard/hooks/use-query-api.ts | 58 ++++-- .../dashboard/stats/graph/fetch-main-graph.ts | 69 ++++++- .../stats/graph/fetch-top-stats.test.ts | 35 ++-- .../dashboard/stats/graph/fetch-top-stats.ts | 96 +++++----- .../js/dashboard/stats/graph/main-graph.tsx | 58 +++--- assets/js/dashboard/stats/graph/top-stats.js | 35 ++-- .../dashboard/stats/graph/visitor-graph.tsx | 173 ++++-------------- 8 files changed, 281 insertions(+), 261 deletions(-) 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/hooks/use-query-api.ts b/assets/js/dashboard/hooks/use-query-api.ts index 1cfc7762ec86..04914c8aafd8 100644 --- a/assets/js/dashboard/hooks/use-query-api.ts +++ b/assets/js/dashboard/hooks/use-query-api.ts @@ -10,13 +10,16 @@ import { PlausibleSite } from '../site-context' import { createStatsQuery, NonTimeDimension, - ReportParams + ReportParams, + StatsQuery } from '../stats-query' import { useEffect, useState } from 'react' import { cleanToPageOne, getStaleTime, PAGINATION_LIMIT } from './api-client' -import { QueryApiResponse, stats } from '../api' +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' @@ -33,18 +36,41 @@ export type StatsReportQueryKey = [ } ] +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( +export function useQueryApi< + TResponse extends QueryApiResponse | MainGraphResponse = QueryApiResponse +>( site: PlausibleSite, statsReportQueryKey: StatsReportQueryKey, - opts: { enabled: boolean } = { enabled: true } + opts?: ReportOpts ): { - apiState: UseQueryResult + apiState: UseQueryResult isRealtimeSilentUpdate: boolean } { const statsReportId = statsReportQueryKey[0] @@ -52,19 +78,20 @@ export function useQueryApi( 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: opts.enabled, - queryFn: ({ queryKey }) => { + enabled, + queryFn: async ({ queryKey }) => { const [_, keyOpts] = queryKey as StatsReportQueryKey - const statsQuery = createStatsQuery( - keyOpts.dashboardState, - keyOpts.reportParams - ) - return stats(site, statsQuery) + const response = await stats(site, getStatsQuery(queryKey)) + return withExtraContext(response, keyOpts.dashboardState) }, + placeholderData: (previousData) => previousData, staleTime: ({ queryKey }) => { const [_, keyOpts] = queryKey as StatsReportQueryKey return getStaleTime({ @@ -76,7 +103,7 @@ export function useQueryApi( }) useEffect(() => { - if (!opts.enabled || !isRealtime) return + if (!enabled || !isRealtime) return const onTick = () => { setIsRealtimeSilentUpdate(true) @@ -96,7 +123,7 @@ export function useQueryApi( return () => { document.removeEventListener('tick', onTick) } - }, [queryClient, isRealtime, statsReportId, opts.enabled]) + }, [queryClient, isRealtime, statsReportId, enabled]) useEffect(() => { if (!apiState.isRefetching) { @@ -150,10 +177,11 @@ export function useSearchAndPaginateQueryAPI({ statsQuery = addDimensionSearchFilter(statsQuery, searchBy, search) } - return stats(site, { + 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 diff --git a/assets/js/dashboard/stats/graph/fetch-main-graph.ts b/assets/js/dashboard/stats/graph/fetch-main-graph.ts index 1d0602779f47..a3352974dccf 100644 --- a/assets/js/dashboard/stats/graph/fetch-main-graph.ts +++ b/assets/js/dashboard/stats/graph/fetch-main-graph.ts @@ -1,12 +1,71 @@ import { Metric } from '../metrics' import { DashboardState } from '../../dashboard-state' import { DashboardPeriod } from '../../dashboard-time-periods' -import { PlausibleSite } from '../../site-context' -import { createStatsQuery, ReportParams } from '../../stats-query' +import { PlausibleSite, useSiteContext } from '../../site-context' +import { createStatsQuery, ReportParams, StatsQuery } from '../../stats-query' import { isRealTimeDashboard } from '../../util/filters' import { MetricValue } from '../../api' import * as api from '../../api' import { Interval } from './intervals' +import { StatsReportQueryKey, useQueryApi } from '../../hooks/use-query-api' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { useMemo } from 'react' +import { UseQueryResult } from '@tanstack/react-query' + +export function useMainGraphQuery( + metric: Metric | null, + interval: Interval +): { + apiState: UseQueryResult + isRealtimeSilentUpdate: boolean +} { + const site = useSiteContext() + const { dashboardState } = useDashboardStateContext() + + const mainGraphQueryKey = useMemo((): StatsReportQueryKey => { + return [ + 'main-graph', + { + dashboardState, + reportParams: { + // Should default to visitors if metric is null? Currently possibly invalid + // query with `metrics: [null]` which will never run due to `enabled: false` + metrics: [metric!], + dimensions: [`time:${interval}`], + include: { + time_labels: true, + partial_time_labels: true, + empty_metrics: true, + present_index: true + } + } + } + ] + }, [dashboardState, metric, interval]) + + 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)) { + statsQuery.date_range = DashboardPeriod.realtime_30m + } + + return statsQuery +} export function fetchMainGraph( site: PlausibleSite, @@ -44,7 +103,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 @@ -59,5 +121,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 3580d58f6c11..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, - topStatsQuery, + getTopStatsQuery, getPartialDayTimeRange, formatTopStatsData } from './fetch-top-stats' @@ -250,22 +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, - metrics - ) + const { timeRange, comparisonTimeRange } = formatTopStatsData(response) expect(timeRange).toBe('until 10:25') expect(comparisonTimeRange).toBe('until 10:25') }) @@ -275,25 +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, - metrics - ) + const { timeRange, comparisonTimeRange } = formatTopStatsData(response) expect(timeRange).toBe('until 10:25') expect(comparisonTimeRange).toBeNull() }) }) -describe(`${topStatsQuery.name}`, () => { +describe(`${getTopStatsQuery.name}`, () => { test.each(cases)( - 'for %s dashboard, queries are as expected', + 'for %s dashboard, top stats query is as expected', (_, { site: _site, ...inputDashboardState }, metrics, expectedQuery) => { const dashboardState = { ...dashboardStateDefaultValue, resolvedFilters: inputDashboardState.filters, ...inputDashboardState } - expect(topStatsQuery(dashboardState, metrics)).toEqual(expectedQuery) + 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 00f3f360bd81..0794baa5f0a2 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.ts @@ -1,29 +1,53 @@ 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 topStatsQuery( - dashboardState: DashboardState, - metrics: Metric[] -): StatsQuery { - const reportParams: ReportParams = { - metrics, - dimensions: [], - include: { imports_meta: true } - } +export function useTopStatsQuery() { + const site = useSiteContext() + const { dashboardState } = useDashboardStateContext() + + const topStatsQueryKey = useMemo((): StatsReportQueryKey => { + return [ + 'top-stats', + { + dashboardState, + reportParams: { + metrics: chooseMetrics(site, dashboardState), + dimensions: [], + include: { imports_meta: true } + } + } + ] + }, [site, dashboardState]) + + const { apiState, isRealtimeSilentUpdate } = useQueryApi( + site, + topStatsQueryKey, + { getStatsQuery: getTopStatsQuery } + ) + + return { apiState, isRealtimeSilentUpdate } +} + +export function getTopStatsQuery(queryKey: StatsReportQueryKey): StatsQuery { + const [_reportId, keyOpts] = queryKey + const { dashboardState, reportParams } = keyOpts const statsQuery = createStatsQuery(dashboardState, reportParams) @@ -44,30 +68,6 @@ export function topStatsQuery( return statsQuery } -export async function fetchTopStats( - site: PlausibleSite, - dashboardState: DashboardState -) { - const metrics = chooseMetrics(site, dashboardState) - const statsQuery = topStatsQuery(dashboardState, metrics) - const topStatsResponse = await api.stats(site, statsQuery) - - const metricLabelSuffix = isRealTimeDashboard(dashboardState) - ? ' (last 30 min)' - : '' - - const formattedMetrics = metrics.map((key) => ({ - key, - label: `${getMetricLabel(key, { - hasConversionGoalFilter: hasConversionGoalFilter(dashboardState) - })}${metricLabelSuffix}` - })) - - return formatTopStatsData(topStatsResponse, formattedMetrics) -} - -export type MetricDef = { key: Metric; label: string } - export function chooseMetrics( site: Pick, dashboardState: DashboardState @@ -105,6 +105,15 @@ export function chooseMetrics( } } +function getTopStatMetricLabel( + metricKey: Metric, + { isRealtime, hasConversionGoalFilter }: api.ExtraContext +) { + const metricLabelSuffix = isRealtime ? ' (last 30 min)' : '' + + return `${getMetricLabel(metricKey, { hasConversionGoalFilter })}${metricLabelSuffix}` +} + type TopStatItem = { metric: Metric value: api.MetricValue @@ -114,26 +123,17 @@ type TopStatItem = { comparisonValue?: number } -export function formatTopStatsData( - topStatsResponse: api.QueryApiResponse, - metrics: MetricDef[] -) { - const { query, meta, results } = topStatsResponse +export function formatTopStatsData(topStatsResponse: api.QueryApiResponse) { + const { query, meta, results, extraContext } = topStatsResponse const topStats: TopStatItem[] = [] 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/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index 4c48f1c23bdd..fea7e67fd8bb 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -38,8 +38,7 @@ 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' const height = 368 @@ -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 = data.query.dimensions[0].split(':')[1] as Interval + 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 d7aa10a5169a..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' @@ -33,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() @@ -71,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 } @@ -183,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}
@@ -199,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} @@ -224,11 +236,12 @@ export default function TopStats({ const allTopStats = [ ...(currentVisitorsStat ? [currentVisitorsStat] : []), - ...(data?.topStats ?? []) + ...topStats ] - const stats = - data && allTopStats.filter((stat) => stat.value !== null).map(renderStat) + 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..7b79bcd3168c 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,9 +21,6 @@ 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() @@ -43,63 +35,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 +61,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 +98,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 +116,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 +145,8 @@ export default function VisitorGraph({
- {(!(topStatsQuery.data && mainGraphQuery.data) || showFullLoader) && ( - - )} + {(!(topStatsApiState.data && mainGraphApiState.data) || + showFullLoader) && } ) } From cd9eb05b73915f62e29a1ce67b92ad0dc9b0fe69 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 14 May 2026 11:11:00 +0300 Subject: [PATCH 12/17] DetailsBreakdown: addSearchFilter -> searchEnabled --- assets/js/dashboard/stats/modals/details-breakdown.tsx | 8 ++++---- assets/js/dashboard/stats/pages/entry-pages.tsx | 8 +------- assets/js/dashboard/stats/pages/exit-pages.tsx | 8 +------- assets/js/dashboard/stats/pages/pages.tsx | 8 +------- 4 files changed, 7 insertions(+), 25 deletions(-) diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 4528af4488e8..f1b49c3f5cb7 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -21,7 +21,7 @@ import { import { SortDirection } from '../../../types/query-api' import { Metric, getBreakdownMetricLabel, isSortable } from '../metrics' import { BreakdownTable } from './breakdown-table' -import { StatsQuery, OrderByEntry } from '../../stats-query' +import { OrderByEntry } from '../../stats-query' import { useSiteContext } from '../../site-context' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' import { @@ -57,7 +57,7 @@ type PaginatedData = { pages: QueryApiResponse[] } type DetailsBreakdownProps = SharedBreakdownReportProps & { title: ReactNode defaultOrderBy?: MetricOrderBy - addSearchFilter?: (statsQuery: StatsQuery, search: string) => StatsQuery + searchEnabled?: boolean onDataReady?: (data: PaginatedData) => void } @@ -88,7 +88,7 @@ export function DetailsBreakdown({ defaultOrderBy = [] as MetricOrderBy, getFilterInfo, getExternalLinkUrl, - addSearchFilter, + searchEnabled = true, onDataReady }: DetailsBreakdownProps) { const site = useSiteContext() @@ -247,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/pages/entry-pages.tsx b/assets/js/dashboard/stats/pages/entry-pages.tsx index 5b605083de53..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,7 +12,7 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' +import { getBreakdownMetrics } from '../breakdowns' import { PAGES_BAR_COLOR } from './pages' const DIMENSION = 'visit:entry_page' @@ -32,10 +31,6 @@ function getFilterInfo(row: QueryResultRow) { } } -function addSearchFilter(statsQuery: StatsQuery, search: string) { - return addDimensionSearchFilter(statsQuery, DIMENSION, search) -} - export function EntryPagesIndex({ onDataReady }: { @@ -98,7 +93,6 @@ export function EntryPagesDetails() { 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 885f65b140d6..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,7 +12,7 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' +import { getBreakdownMetrics } from '../breakdowns' import { PAGES_BAR_COLOR } from './pages' const DIMENSION = 'visit:exit_page' @@ -31,10 +30,6 @@ function getFilterInfo(row: QueryResultRow) { } } -function addSearchFilter(statsQuery: StatsQuery, search: string) { - return addDimensionSearchFilter(statsQuery, DIMENSION, search) -} - export function ExitPagesIndex({ onDataReady }: { @@ -97,7 +92,6 @@ export function ExitPagesDetails() { 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 fb1334ae9d6f..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,7 +12,7 @@ import { } from '../../util/filters' import { revenueAvailable, Filter } from '../../dashboard-state' import { QueryApiResponse, QueryResultRow } from '../../api' -import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' +import { getBreakdownMetrics } from '../breakdowns' export const PAGES_BAR_COLOR = 'bg-orange-50 group-hover/row:bg-orange-100' @@ -34,10 +33,6 @@ function getFilterInfo(row: QueryResultRow) { } } -function addSearchFilter(statsQuery: StatsQuery, search: string) { - return addDimensionSearchFilter(statsQuery, DIMENSION, search) -} - export function PagesIndex({ onDataReady }: { @@ -100,7 +95,6 @@ export function PagesDetails() { defaultOrderBy={[['visitors', 'desc']]} getFilterInfo={getFilterInfo} getExternalLinkUrl={getExternalLinkUrl} - addSearchFilter={addSearchFilter} /> ) From f395656046fb9ee106f1b1a3cb972a1608a357df Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 14 May 2026 13:22:47 +0300 Subject: [PATCH 13/17] extractIntervalFromDimensions utility --- assets/js/dashboard/stats/graph/intervals.ts | 4 ++++ assets/js/dashboard/stats/graph/main-graph.tsx | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) 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 fea7e67fd8bb..aeb5142ceecd 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -39,7 +39,7 @@ import { } from './main-graph-data' import { Metric, getMetricLabel } from '../metrics' -import { Interval } from './intervals' +import { extractIntervalFromDimensions, Interval } from './intervals' const height = 368 const marginTop = 16 @@ -85,7 +85,7 @@ export const MainGraph = ({ const { selectedIndex } = tooltip const panGestureStartTimeRef = useRef(null) const metric = data.query.metrics[0] as Metric - const interval = data.query.dimensions[0].split(':')[1] as Interval + const interval = extractIntervalFromDimensions(data.query.dimensions) const isRealtime = data.extraContext.isRealtime useEffect(() => { From b0d50346e59a7693e85a2448b9c98c5fb67b99f9 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 14 May 2026 13:24:22 +0300 Subject: [PATCH 14/17] create new statsQuery with spread --- assets/js/dashboard/stats/graph/fetch-main-graph.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/graph/fetch-main-graph.ts b/assets/js/dashboard/stats/graph/fetch-main-graph.ts index a3352974dccf..10f7deedb26c 100644 --- a/assets/js/dashboard/stats/graph/fetch-main-graph.ts +++ b/assets/js/dashboard/stats/graph/fetch-main-graph.ts @@ -61,7 +61,7 @@ function getMainGraphQuery(queryKey: StatsReportQueryKey): StatsQuery { const statsQuery = createStatsQuery(dashboardState, reportParams) if (isRealTimeDashboard(dashboardState)) { - statsQuery.date_range = DashboardPeriod.realtime_30m + return { ...statsQuery, date_range: DashboardPeriod.realtime_30m } } return statsQuery From 47380b467cc07872881e0fdac1f8b3471e96a74a Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 14 May 2026 13:30:50 +0300 Subject: [PATCH 15/17] remove redundant useMemo --- .../dashboard/stats/graph/fetch-main-graph.ts | 35 +++++++++---------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/assets/js/dashboard/stats/graph/fetch-main-graph.ts b/assets/js/dashboard/stats/graph/fetch-main-graph.ts index 10f7deedb26c..2b959694f41d 100644 --- a/assets/js/dashboard/stats/graph/fetch-main-graph.ts +++ b/assets/js/dashboard/stats/graph/fetch-main-graph.ts @@ -9,7 +9,6 @@ import * as api from '../../api' import { Interval } from './intervals' import { StatsReportQueryKey, useQueryApi } from '../../hooks/use-query-api' import { useDashboardStateContext } from '../../dashboard-state-context' -import { useMemo } from 'react' import { UseQueryResult } from '@tanstack/react-query' export function useMainGraphQuery( @@ -22,26 +21,24 @@ export function useMainGraphQuery( const site = useSiteContext() const { dashboardState } = useDashboardStateContext() - const mainGraphQueryKey = useMemo((): StatsReportQueryKey => { - return [ - 'main-graph', - { - dashboardState, - reportParams: { - // Should default to visitors if metric is null? Currently possibly invalid - // query with `metrics: [null]` which will never run due to `enabled: false` - metrics: [metric!], - dimensions: [`time:${interval}`], - include: { - time_labels: true, - partial_time_labels: true, - empty_metrics: true, - present_index: true - } + const mainGraphQueryKey: StatsReportQueryKey = [ + 'main-graph', + { + dashboardState, + reportParams: { + // Should default to visitors if metric is null? Currently possibly invalid + // query with `metrics: [null]` which will never run due to `enabled: false` + metrics: [metric!], + dimensions: [`time:${interval}`], + include: { + time_labels: true, + partial_time_labels: true, + empty_metrics: true, + present_index: true } } - ] - }, [dashboardState, metric, interval]) + } + ] const { apiState, isRealtimeSilentUpdate } = useQueryApi( site, From a8efa583d5150586f669c76ae7be6de3d450f2f3 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 14 May 2026 13:33:58 +0300 Subject: [PATCH 16/17] remove redundant as type casts --- assets/js/dashboard/hooks/use-query-api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/dashboard/hooks/use-query-api.ts b/assets/js/dashboard/hooks/use-query-api.ts index 04914c8aafd8..0d65ff588388 100644 --- a/assets/js/dashboard/hooks/use-query-api.ts +++ b/assets/js/dashboard/hooks/use-query-api.ts @@ -87,13 +87,13 @@ export function useQueryApi< queryKey: statsReportQueryKey, enabled, queryFn: async ({ queryKey }) => { - const [_, keyOpts] = queryKey as StatsReportQueryKey + const [_, keyOpts] = queryKey const response = await stats(site, getStatsQuery(queryKey)) return withExtraContext(response, keyOpts.dashboardState) }, placeholderData: (previousData) => previousData, staleTime: ({ queryKey }) => { - const [_, keyOpts] = queryKey as StatsReportQueryKey + const [_, keyOpts] = queryKey return getStaleTime({ siteTimezoneOffset: site.offset, siteStatsBegin: site.statsBegin, From 3b02b1f0a33b9d38117eee80137d1d8f8379b0b4 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 14 May 2026 14:21:16 +0300 Subject: [PATCH 17/17] move and improve comment --- assets/js/dashboard/stats/graph/fetch-main-graph.ts | 2 -- assets/js/dashboard/stats/graph/visitor-graph.tsx | 6 ++++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/assets/js/dashboard/stats/graph/fetch-main-graph.ts b/assets/js/dashboard/stats/graph/fetch-main-graph.ts index 2b959694f41d..c9f09671efbc 100644 --- a/assets/js/dashboard/stats/graph/fetch-main-graph.ts +++ b/assets/js/dashboard/stats/graph/fetch-main-graph.ts @@ -26,8 +26,6 @@ export function useMainGraphQuery( { dashboardState, reportParams: { - // Should default to visitors if metric is null? Currently possibly invalid - // query with `metrics: [null]` which will never run due to `enabled: false` metrics: [metric!], dimensions: [`time:${interval}`], include: { diff --git a/assets/js/dashboard/stats/graph/visitor-graph.tsx b/assets/js/dashboard/stats/graph/visitor-graph.tsx index 7b79bcd3168c..0c866547b5b4 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.tsx +++ b/assets/js/dashboard/stats/graph/visitor-graph.tsx @@ -24,6 +24,12 @@ export default function VisitorGraph({ 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) )