Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: changed

Move the Top tags & categories widget from Insights to Traffic, where it and its report follow the date picker instead of a fixed seven days.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ import type { StatsTagsParams } from '../queries/stats-tags-query';
export type StatsTagsResponse = StatsNormalizedReport< StatsTagsItem >;
export type { StatsTagsParams } from '../queries/stats-tags-query';

export function useStatsTags( params: StatsTagsParams = {}, options?: UseStatsOptions ) {
export function useStatsTags( params: StatsTagsParams, options?: UseStatsOptions ) {
return useStatsQuery< StatsTagsResponse >( statsTagsQuery( params ), options );
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function sanitizeStatsTagsResponse(
response: unknown,
query?: StatsQueryParams
): StatsNormalizedReport< StatsTagsItem > {
// `stats/tags` takes only `max`, so it never returns `days` or a `summary`.
// `stats/tags` returns one ranked list, never `days` or a `summary`.
const tags = coerceStatsArray< StatsRecord >( coerceStatsRecord( response ).tags ).map(
normalizeStatsTagsItem
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,12 @@ describe( 'all-time report fetchers', () => {
],
} );

const rows = await fetchStatsTagsRows( { max: 1000 } );
const rows = await fetchStatsTagsRows( {
from: '2026-01-01',
to: '2026-01-31',
interval: 'day',
max: 1000,
} );

expect( requestedPaths() ).toHaveLength( 1 );
expect( requestedPaths()[ 0 ] ).toContain( 'stats/tags' );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -621,39 +621,51 @@ describe( 'Stats query factories', () => {

// The endpoint rewrites `max < 1` back to its default of 10 rather than reading
// it as "all rows", so a `0` must not reach it.
it( 'leaves a non-positive tags max off the request', () => {
expect( statsTagsQuery( { max: 0 } ).queryKey ).toEqual( [
it( 'sends the tags window as date and start_date, with max only when positive', () => {
const query = statsTagsQuery( {
from: '2026-06-01',
to: '2026-06-07',
interval: 'day',
max: 0,
} );

expect( query.queryKey ).toEqual( [
'stats',
'tags',
'1.1',
'stats/tags',
'GET',
{},
{ period: 'day', date: '2026-06-07', start_date: '2026-06-01', summarize: 1 },
undefined,
'tags',
'UTC',
] );
expect(
statsTagsQuery( { from: '2026-06-01', to: '2026-06-07', interval: 'day', max: 10 } )
.queryKey[ 5 ]
).toEqual( {
period: 'day',
date: '2026-06-07',
start_date: '2026-06-01',
max: 10,
summarize: 1,
} );
} );

// A date would change the cache key without changing a row. `StatsTagsParams`
// already rejects one, so this spreads past the type to cover untyped callers.
it( 'never sends a date on the tags query, whatever the selected period', () => {
const tagsQueryKey = ( period: Record< string, unknown > ) =>
statsTagsQuery( { max: 10, ...period } ).queryKey;
const maxOnly = [
'stats',
'tags',
'1.1',
'stats/tags',
'GET',
{ max: 10 },
undefined,
'tags',
'UTC',
];

expect( tagsQueryKey( { to: '2026-01-31T23:59:59.999-08:00' } ) ).toEqual( maxOnly );
expect( tagsQueryKey( { date: '2026-06-30', period: 'year', days: 365 } ) ).toEqual( maxOnly );
// One ranking pass whatever the length: per-day keeps 50 posts a day and one
// pass keeps 50 for the window, so switching by length would make a longer
// range count fewer posts than a shorter one.
it.each( [
[ '1 day', '2026-06-01', '2026-06-01' ],
[ '365 days', '2025-07-02', '2026-07-01' ],
] )( 'summarizes the tags query at every length (%s)', ( _label, from, to ) => {
const params = statsTagsQuery( { from, to, interval: 'day' } ).queryKey[ 5 ] as Record<
string,
unknown
>;

expect( params.summarize ).toBe( 1 );
expect( params.days ).toBeUndefined();
} );

it( 'builds devices query keys from the selected device property', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
/**
* Internal dependencies
*/
import { statsProxyQuery, type StatsReportQueryOptions } from './stats-query';
import {
statsReportQuery,
type StatsReportParams,
type StatsReportQueryOptions,
} from './stats-query';

/**
* `stats/tags` declares `max` as its only query parameter, so WPCOM strips
* everything else — `date` included — before the handler runs: the window is a
* hardcoded seven days ending yesterday in site time, and nothing can move it.
*/
export type StatsTagsParams = {
export type StatsTagsParams = StatsReportParams & {
/**
* Rows to request. `max` only truncates an already-ranked list, so a larger one
* adds rows without moving a row's views. `0` is not "all rows" here — anything
Expand All @@ -18,11 +17,24 @@ export type StatsTagsParams = {
max?: number;
};

export const statsTagsQuery = ( params: StatsTagsParams = {} ): StatsReportQueryOptions< 'tags' > =>
statsProxyQuery( {
name: 'tags',
version: '1.1',
endpoint: 'stats/tags',
params: ( params.max ?? 0 ) > 0 ? { max: params.max } : {},
sanitizer: 'tags',
} );
/**
* `stats/tags` sizes its window from `date` and `start_date` (the days are
* derived server-side, so `days` stays off the request) and always ranks the
* window's top posts in one pass. Per-day ranking keeps 50 posts a day while
* one pass keeps 50 for the window, so mixing the two by window length would
* let a longer range count fewer posts than a shorter one.
*/
export const statsTagsQuery = ( params: StatsTagsParams ): StatsReportQueryOptions< 'tags' > => {
const { max, ...reportParams } = params;

return statsReportQuery(
'tags',
'stats/tags',
reportParams,
'tags',
'1.1',
// Explicit: the shared derivation adds `summarize` only past one day.
{ ...( ( max ?? 0 ) > 0 ? { max } : {} ), summarize: 1 },
{ omitParams: [ 'days' ] }
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import type { ReportCsvExporter } from './types';

/**
* `stats/tags` has no "all rows" value (see `StatsTagsParams`), so the report names a ceiling
* past what a real site produces (the endpoint ranks at most ~51 posts a day over seven days).
* past what a real site produces.
*/
export const TAGS_REPORT_ROW_LIMIT = 1000;

export const tagsCsvExporter: ReportCsvExporter< StatsTagsItem, StatsTagsItem > = {
filenamePrefix: 'tags-and-categories',
hasDateRange: false,
fetchItems: () => fetchStatsTagsRows( { max: TAGS_REPORT_ROW_LIMIT } ),
hasDateRange: true,
fetchItems: reportParams => fetchStatsTagsRows( { ...reportParams, max: TAGS_REPORT_ROW_LIMIT } ),
toCsvRows: items => [ ...items ].sort( ( a, b ) => b.value - a.value ),
getColumns: () => [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,12 @@ describe( 'REPORTS', () => {
'locations',
'posts',
'search-terms',
'tags',
'videos',
'utm',
'referrers',
],
insights: [ 'annual-insights', 'comments', 'tags' ],
insights: [ 'annual-insights', 'comments' ],
subscribers: [ 'comment-followers', 'emails' ],
ads: [ 'earnings' ],
} );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export const REPORTS: Record< string, ReportDefinition > = {
},
tags: {
id: 'tags',
dashboardSection: 'insights',
dashboardSection: 'traffic',
getLabel: () => __( 'Tags & categories', 'jetpack-premium-analytics-pkg' ),
load: () => import( './tags/page' ),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { useStatsTags } from '@jetpack-premium-analytics/data';
import { getDefaultQueryParams, useStatsTags } from '@jetpack-premium-analytics/data';
import { renderHook } from '@testing-library/react';
/**
* Internal dependencies
Expand Down Expand Up @@ -64,14 +64,15 @@ describe( 'useTagsReportRecords', () => {
jest.clearAllMocks();
} );

it( 'requests more rows than the endpoint would return by default', () => {
renderHook( () => useTagsReportRecords() );
it( 'requests the report window with more rows than the endpoint returns by default', () => {
const reportParams = getDefaultQueryParams();
renderHook( () => useTagsReportRecords( reportParams ) );

expect( mockUseStatsTags ).toHaveBeenCalledWith( { max: 1000 } );
expect( mockUseStatsTags ).toHaveBeenCalledWith( { ...reportParams, max: 1000 } );
} );

it( 'returns the normalized rows', () => {
const { result } = renderHook( () => useTagsReportRecords() );
const { result } = renderHook( () => useTagsReportRecords( getDefaultQueryParams() ) );

expect( result.current.rows ).toEqual( [
expect.objectContaining( { labelText: 'Recipes', value: 1240 } ),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
/**
* External dependencies
*/
import { useStatsTags, type StatsTagsItem } from '@jetpack-premium-analytics/data';
import {
useStatsTags,
type ReportParams,
type StatsTagsItem,
} from '@jetpack-premium-analytics/data';
import { TAGS_REPORT_ROW_LIMIT } from '@jetpack-premium-analytics/widgets-toolkit';
import { useMemo } from '@wordpress/element';

Expand All @@ -16,12 +20,13 @@ export function getTagRowId( item: StatsTagsItem ): string {
}

/**
* Fetch the Tags & categories rows.
* Fetch the Tags & categories rows for the report window.
*
* @param reportParams - The report's date window.
* @return Table rows and fetch state.
*/
export function useTagsReportRecords() {
const tags = useStatsTags( { max: TAGS_REPORT_ROW_LIMIT } );
export function useTagsReportRecords( reportParams: ReportParams ) {
const tags = useStatsTags( { ...reportParams, max: TAGS_REPORT_ROW_LIMIT } );
const rows = useMemo< StatsTagsItem[] >(
() => tags.data?.data?.[ 0 ]?.items ?? [],
[ tags.data ]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
import { useReportDateFilters } from '@jetpack-premium-analytics/routing';
import { StatsBreadcrumbs, StatsPageIcon } from '@jetpack-premium-analytics/ui';
import {
ExporterCsvAction,
Expand All @@ -15,12 +16,15 @@ import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { route } from '../package.json';
import { REPORTS } from '../registry';
import { useReportParams } from '../use-report-params';
import { getTagRowId, getTagsFields, useTagsReportRecords } from './config';
import type { StatsTagsItem } from '@jetpack-premium-analytics/data';
import type { JSX } from 'react';

const ROUTE_FROM = route.path;

/**
* Initial records-table view: views sort descending, the label column absorbs
* spare width, and the numeric column stays compact and right-aligned.
Expand All @@ -38,16 +42,17 @@ const RECORDS_VIEW = {
/**
* Premium Analytics Tags & categories report page component.
*
* `stats/tags` returns one flat list over the seven days ending yesterday and ignores
* date-window params, so this page has no date filters, tabs, or performance chart —
* just the header and records table.
* `stats/tags` returns one flat list ranked over the report window and reports no
* comparison, so this page has date filters and the records table, with no tabs or
* performance chart.
*
* @return The Tags & categories report page.
*/
function TagsReport(): JSX.Element {
const records = useTagsReportRecords();
const reportParams = useReportParams();
const records = useTagsReportRecords( reportParams );
const fields = useMemo( () => getTagsFields(), [] );
const dateFilters = useReportDateFilters( ROUTE_FROM );

const { getLabel } = REPORTS.tags;

Expand All @@ -64,7 +69,7 @@ function TagsReport(): JSX.Element {
/>
}
>
<ReportPageLayout title={ getLabel() }>
<ReportPageLayout title={ getLabel() } dateFilters={ dateFilters }>
<ReportErrorState
status={ records }
retryDescription={ __(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ function get_traffic_section_default_layout() {
1,
2
),
// Row 6: tags & categories, ranked over the tab's period.

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.

Should tags.dashboardSection in routes/reports/registry.ts move to 'traffic' too? It still says 'insights', so the Tags report is gated on the Insights tab being available.

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.

Good catch, it was still gated on Insights. Moved to traffic in 29a3858, with the registry test's tab map updated.

get_dashboard_default_widget_instance(
'default-tags-widget-instance',
'jpa/tags',
12,
2,
2
),
);
}

Expand Down Expand Up @@ -270,36 +278,29 @@ function get_insights_section_default_layout() {
3,
2
),
// Row 6: tags + most commented posts.
// Row 6: most commented posts + most commented authors fill the row, so shares
// (Simple only) opens row 7 instead of pushing authors down with a gap on both rows.
get_dashboard_default_widget_instance(
'default-tags-widget-instance',
'jpa/tags',
'default-most-commented-posts-widget-instance',
'jpa/most-commented-posts',
8,
2,
1,
2
),
get_dashboard_default_widget_instance(
'default-most-commented-posts-widget-instance',
'jpa/most-commented-posts',
'default-most-commented-authors-widget-instance',
'jpa/most-commented-authors',
9,
1,
2,
2
),
// Row 7: shares + most commented authors.
get_dashboard_default_widget_instance(
'default-shares-widget-instance',
'jpa/shares',
10,
1,
2
),
get_dashboard_default_widget_instance(
'default-most-commented-authors-widget-instance',
'jpa/most-commented-authors',
11,
2,
2
),
);
}

Expand Down
Loading
Loading