Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: added

Stats: Add core proxy query definitions.
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,5 @@ export {
type StatsProxyFetchParams,
type StatsProxyMethod,
type StatsProxyParams,
type StatsProxyQueryParams,
type StatsProxyVersion,
} from './stats-proxy-fetch';
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,3 @@ export async function fetchStatsProxy< TResponse = unknown, TBody = unknown >( {
...( method === 'POST' ? { data: body } : {} ),
} );
}

export type StatsProxyQueryParams = StatsProxyParams;
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ export type {
StatsProxyFetchParams,
StatsProxyMethod,
StatsProxyParams,
StatsProxyQueryParams,
StatsProxyVersion,
} from './api';
export type {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Internal dependencies
*/
import { statsLocationsQuery } from '../stats-locations-query';
import { statsTopPostsQuery } from '../stats-top-posts-query';
import type { StatsReportParams } from '../stats-query';

describe( 'Stats query factories', () => {
it( 'disables report queries until a date range is available', () => {
expect( statsTopPostsQuery( {} as StatsReportParams ).enabled ).toBe( false );
} );

it( 'builds location query keys from geoMode', () => {
const query = statsLocationsQuery( {
from: '2026-06-16',
to: '2026-06-16',
interval: 'day',
geoMode: 'city',
} );

expect( query.enabled ).toBe( true );
expect( query.queryKey ).toEqual( [
'stats',
'locations-city',
'1.1',
'stats/location-views/city',
'GET',
expect.objectContaining( { date: '2026-06-16' } ),
undefined,
'locations',
] );
} );

it( 'requests summarized data for multi-day report ranges', () => {
const query = statsTopPostsQuery( {
from: '2026-06-01',
to: '2026-06-07',
interval: 'day',
} );

expect( query.queryKey ).toEqual(
expect.arrayContaining( [
expect.objectContaining( {
date: '2026-06-07',
start_date: '2026-06-01',
days: 7,
summarize: 1,
} ),
] )
);
} );

it( 'preserves explicit summarize params', () => {
const query = statsTopPostsQuery( {
from: '2026-06-01',
to: '2026-06-07',
interval: 'day',
summarize: false,
} );

expect( query.queryKey ).toEqual(
expect.arrayContaining( [
expect.objectContaining( {
date: '2026-06-07',
days: 7,
summarize: false,
} ),
] )
);
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,15 @@ export { reportVisitorsQuery } from './report-visitors-query';
export { reportVisitorsByLocationQuery } from './report-visitors-by-location-query';
export { reportSessionsByDeviceQuery } from './report-sessions-by-device-query';
export { reportBookingsQuery } from './report-bookings-query';
export { statsProxyQuery, statsReportQuery } from './stats-query';
export type { StatsQueryConfig, StatsReportParams, StatsSanitizerKey } from './stats-query';
export { statsSiteQuery } from './stats-site-query';
export { statsTopPostsQuery } from './stats-top-posts-query';
export { statsReferrersQuery } from './stats-referrers-query';
export { statsClicksQuery } from './stats-clicks-query';
export { statsSearchTermsQuery } from './stats-search-terms-query';
export { statsFileDownloadsQuery } from './stats-file-downloads-query';
export { statsTopAuthorsQuery } from './stats-top-authors-query';
export { statsLocationsQuery } from './stats-locations-query';
export { statsCountryViewsQuery } from './stats-country-views-query';
export { statsVideoPlaysQuery } from './stats-video-plays-query';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Internal dependencies
*/
import { statsReportQuery, type StatsReportParams } from './stats-query';

export const statsClicksQuery = ( params: StatsReportParams ) =>
statsReportQuery( 'clicks', 'stats/clicks', params, 'clicks' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Internal dependencies
*/
import { statsReportQuery, type StatsReportParams } from './stats-query';

export const statsCountryViewsQuery = ( params: StatsReportParams ) =>
statsReportQuery( 'country-views', 'stats/country-views', params, 'locations' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Internal dependencies
*/
import { statsReportQuery, type StatsReportParams } from './stats-query';

export const statsFileDownloadsQuery = ( params: StatsReportParams ) =>
statsReportQuery( 'file-downloads', 'stats/file-downloads', params, 'fileDownloads' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Internal dependencies
*/
import { statsReportQuery, type StatsReportParams } from './stats-query';

export const statsLocationsQuery = (
params: StatsReportParams & { geoMode?: 'country' | 'region' | 'city' }
) => {
const geoMode = params.geoMode ?? 'country';

return statsReportQuery(
`locations-${ geoMode }`,
`stats/location-views/${ geoMode }`,
params,
'locations'
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { fetchStatsProxy, type StatsProxyMethod, type StatsProxyVersion } from '../api';
import {
sanitizeStatsClicksResponse,
sanitizeStatsFileDownloadsResponse,
sanitizeStatsLocationsResponse,
sanitizeStatsPassthroughResponse,
sanitizeStatsReferrersResponse,
sanitizeStatsSearchTermsResponse,
sanitizeStatsSiteResponse,
sanitizeStatsTopAuthorsResponse,
sanitizeStatsTopPostsResponse,
sanitizeStatsVideoPlaysResponse,
} from '../processing/stats';
import {
reportParamsToStatsQueryParams,
statsQueryParamsToApiParams,
type StatsQueryParams,
} from '../utils/stats-params';
import type { ReportParams } from '../utils/search';
import type { UseQueryOptions } from '@tanstack/react-query';

export type StatsReportParams = ReportParams & StatsQueryParams;
type StatsSanitizer< TData = unknown > = ( response: unknown, params?: StatsQueryParams ) => TData;

const statsSanitizers = {
passthrough: sanitizeStatsPassthroughResponse,
site: sanitizeStatsSiteResponse,
topPosts: sanitizeStatsTopPostsResponse,
referrers: sanitizeStatsReferrersResponse,
clicks: sanitizeStatsClicksResponse,
searchTerms: sanitizeStatsSearchTermsResponse,
fileDownloads: sanitizeStatsFileDownloadsResponse,
topAuthors: sanitizeStatsTopAuthorsResponse,
locations: sanitizeStatsLocationsResponse,
videoPlays: sanitizeStatsVideoPlaysResponse,
} satisfies Record< string, StatsSanitizer >;

export type StatsSanitizerKey = keyof typeof statsSanitizers;
type StatsSanitizerData = ReturnType< ( typeof statsSanitizers )[ StatsSanitizerKey ] >;

export type StatsQueryConfig = {
name: string;
version: StatsProxyVersion;
endpoint: string;
params?: StatsQueryParams;
method?: StatsProxyMethod;
body?: unknown;
sanitizer?: StatsSanitizerKey;
enabled?: boolean;
};

export function statsProxyQuery( config: StatsQueryConfig ): UseQueryOptions< StatsSanitizerData > {
const { name, version, endpoint, params, method = 'GET', body, enabled = true } = config;
const sanitizer = config.sanitizer ?? 'passthrough';
const apiParams = statsQueryParamsToApiParams( params );

return {
queryKey: [ 'stats', name, version, endpoint, method, apiParams, body, sanitizer ],
queryFn: async () => {
const response = await fetchStatsProxy( {
version,
endpoint,
params: apiParams,
method,
body,
} );
return statsSanitizers[ sanitizer ]( response, apiParams );
},
enabled,
placeholderData: previousData => previousData,
};
}

export function statsReportQuery(
name: string,
endpoint: string,
params: StatsReportParams,
sanitizer: StatsSanitizerKey,
version: StatsProxyVersion = '1.1'
): UseQueryOptions< StatsSanitizerData > {
const statsParams = reportParamsToStatsQueryParams( params );
const reportParams = {
...statsParams,
...( statsParams.summarize === undefined &&
typeof statsParams.days === 'number' &&
statsParams.days > 1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

statsReportQuery is a report-specific wrapper around statsProxyQuery.

It accepts Woo/report-style date params like from, to, interval, converts them into Stats params like period, start_date, end_date/API date,
computes days, enables the query only when a date range exists, and now defaults multi-day ranges to summarize: 1.

? { summarize: 1 }
: {} ),
};

return statsProxyQuery( {
name,
version,
endpoint,
params: reportParams,
sanitizer,
enabled: !! ( reportParams.end_date || reportParams.date || reportParams.start_date ),
} );
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Internal dependencies
*/
import { statsReportQuery, type StatsReportParams } from './stats-query';

export const statsReferrersQuery = ( params: StatsReportParams ) =>
statsReportQuery( 'referrers', 'stats/referrers', params, 'referrers' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Internal dependencies
*/
import { statsReportQuery, type StatsReportParams } from './stats-query';

export const statsSearchTermsQuery = ( params: StatsReportParams ) =>
statsReportQuery( 'search-terms', 'stats/search-terms', params, 'searchTerms' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Internal dependencies
*/
import { statsProxyQuery } from './stats-query';
import type { StatsQueryParams } from '../utils/stats-params';

export const statsSiteQuery = ( params: StatsQueryParams = {} ) =>
statsProxyQuery( {
name: 'site',
version: '1.1',
endpoint: 'stats',
params,
sanitizer: 'site',
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Internal dependencies
*/
import { statsReportQuery, type StatsReportParams } from './stats-query';

export const statsTopAuthorsQuery = ( params: StatsReportParams ) =>
statsReportQuery( 'top-authors', 'stats/top-authors', params, 'topAuthors' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Internal dependencies
*/
import { statsReportQuery, type StatsReportParams } from './stats-query';

export const statsTopPostsQuery = ( params: StatsReportParams ) =>
statsReportQuery( 'top-posts', 'stats/top-posts', params, 'topPosts' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Internal dependencies
*/
import { statsReportQuery, type StatsReportParams } from './stats-query';

export const statsVideoPlaysQuery = ( params: StatsReportParams ) =>
statsReportQuery( 'video-plays', 'stats/video-plays', params, 'videoPlays' );
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ describe( 'reportParamsToStatsQueryParams', () => {
expect( params ).not.toHaveProperty( 'deviceProperty' );
} );

it( 'does not forward unknown params to Stats endpoints', () => {
const params = reportParamsToStatsQueryParams( {
from: '2026-06-01',
to: '2026-06-01',
unknown_param: 'leak',
} );

expect( params ).not.toHaveProperty( 'unknown_param' );
} );

it( 'omits empty date params when no dates are provided', () => {
const params = reportParamsToStatsQueryParams();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,17 @@ type StatsQueryParamInput = Partial< ReportParams > & {
[ key: string ]: unknown;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can we make StatsQueryParamInput extend from StatsQueryParams? It repeats the same 7-field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially addressed: the repeated explicit field list is gone and the output still uses the explicit StatsQueryParamFields allowlist. I kept the input type flexible because raw ReportParams can include non-proxy values such as filters objects; narrowing the input to StatsQueryParams makes those allowlist tests fail at compile time instead of proving they are dropped.

} & Partial< StatsQueryParamFields >;

type ReportOnlyParam = keyof ReportParams | 'geoMode' | 'utmParams' | 'deviceProperty';

const reportOnlyKeys: ReportOnlyParam[] = [
'from',
'to',
'interval',
'preset',
'compare_from',
'compare_to',
'compare_preset',
'comp',
'filters',
'section',
'date_type',
'view',
'geoMode',
'utmParams',
'deviceProperty',
];
const statsParamKeys = [
'period',
'end_date',
'date',
'start_date',
'days',
'num',
'max',
'summarize',
'complete_stats',
] as const satisfies Array< keyof StatsQueryParamFields >;

function datePart( value?: string ) {
return value?.split( 'T' )[ 0 ];
Expand All @@ -69,11 +61,11 @@ export function getStatsPeriodFromInterval( interval?: string ): StatsPeriod {
export function reportParamsToStatsQueryParams(
params: StatsQueryParamInput = {}
): StatsQueryParams {
const statsParams = { ...params };

reportOnlyKeys.forEach( key => {
delete statsParams[ key ];
} );
const statsParams = Object.fromEntries(
statsParamKeys
.filter( key => params[ key ] !== undefined && params[ key ] !== null )
.map( key => [ key, params[ key ] ] )
) as StatsQueryParams;

const from = datePart( params.from );
const to = datePart( params.to );
Expand All @@ -85,7 +77,7 @@ export function reportParamsToStatsQueryParams(
( startDate && endDate ? getDaysBetweenInclusive( startDate, endDate ) : undefined );

return {
...( statsParams as StatsQueryParams ),
...statsParams,
period,
...( endDate ? { end_date: endDate } : {} ),
...( startDate ? { start_date: startDate } : {} ),
Expand Down
Loading