From e7194858a66c36291f21d77dd823d61f663a4517 Mon Sep 17 00:00:00 2001 From: dognose24 Date: Wed, 7 Oct 2026 21:02:36 +0800 Subject: [PATCH 1/6] Premium Analytics: move Top tags & categories to Traffic and follow the date picker stats/tags takes a date window now (wpcom #245720), so the widget and its report read the dashboard period: date and start_date on the request, summarize past a month. The widget moves from the Insights default layout to the Traffic one, its copy stops naming seven days, and the CSV export names the window. Co-Authored-By: Claude Fable 5.1 --- .../add-premium-analytics-tags-widget-traffic | 4 ++ .../packages/data/src/hooks/use-stats-tags.ts | 2 +- .../__tests__/fetch-stats-report-rows.test.ts | 7 ++- .../queries/__tests__/stats-queries.test.ts | 49 +++++++++-------- .../data/src/queries/stats-tags-query.ts | 44 ++++++++++----- .../src/report-exports/tags.ts | 6 +- .../tags/config/use-report-records.test.ts | 11 ++-- .../reports/tags/config/use-report-records.ts | 13 +++-- .../routes/reports/tags/page.tsx | 15 +++-- .../src/default-dashboard-sections.php | 24 ++++---- .../js/widget-report-csv-parity.test.tsx | 14 ++++- .../tests/php/Dashboard_Layout_Test.php | 12 ++-- .../widgets/tags/__tests__/tags.test.tsx | 55 ++++++++++++++++--- .../premium-analytics/widgets/tags/render.tsx | 13 ++++- .../tags/stories/tags-widget.stories.tsx | 2 +- .../widgets/tags/use-tag-views.ts | 20 +++++-- .../widgets/tags/widget.json | 6 +- .../add-premium-analytics-tags-widget-traffic | 4 ++ .../add-premium-analytics-tags-widget-traffic | 4 ++ .../add-premium-analytics-tags-widget-traffic | 4 ++ 20 files changed, 216 insertions(+), 93 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/add-premium-analytics-tags-widget-traffic create mode 100644 projects/plugins/jetpack/changelog/add-premium-analytics-tags-widget-traffic create mode 100644 projects/plugins/premium-analytics/changelog/add-premium-analytics-tags-widget-traffic create mode 100644 projects/plugins/wpcomsh/changelog/add-premium-analytics-tags-widget-traffic diff --git a/projects/packages/premium-analytics/changelog/add-premium-analytics-tags-widget-traffic b/projects/packages/premium-analytics/changelog/add-premium-analytics-tags-widget-traffic new file mode 100644 index 000000000000..4aa2c88b497d --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-premium-analytics-tags-widget-traffic @@ -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. diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-tags.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-tags.ts index 6fcff57eff2a..503a35665ece 100644 --- a/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-tags.ts +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-stats-tags.ts @@ -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 ); } diff --git a/projects/packages/premium-analytics/packages/data/src/queries/__tests__/fetch-stats-report-rows.test.ts b/projects/packages/premium-analytics/packages/data/src/queries/__tests__/fetch-stats-report-rows.test.ts index 6b5cd22f47c4..434a90079260 100644 --- a/projects/packages/premium-analytics/packages/data/src/queries/__tests__/fetch-stats-report-rows.test.ts +++ b/projects/packages/premium-analytics/packages/data/src/queries/__tests__/fetch-stats-report-rows.test.ts @@ -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' ); diff --git a/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts b/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts index 8e047341d632..9edc638f0e3c 100644 --- a/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts +++ b/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts @@ -591,39 +591,44 @@ 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' }, 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 } ); } ); - // 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 ); + // Under a month the endpoint ranks day by day, as classic Stats reads it; past + // that it ranks the whole window at once so a year stays a few queries. + it.each( [ + [ '31 days', '2026-06-01', '2026-07-01', undefined ], + [ '32 days', '2026-06-01', '2026-07-02', 1 ], + ] )( 'summarizes the tags query past a month (%s)', ( _label, from, to, summarize ) => { + const params = statsTagsQuery( { from, to, interval: 'day' } ).queryKey[ 5 ] as Record< + string, + unknown + >; + + expect( params.summarize ).toBe( summarize ); + expect( params.days ).toBeUndefined(); } ); it( 'builds devices query keys from the selected device property', () => { diff --git a/projects/packages/premium-analytics/packages/data/src/queries/stats-tags-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/stats-tags-query.ts index f72734ce98f3..7e7261fdeba6 100644 --- a/projects/packages/premium-analytics/packages/data/src/queries/stats-tags-query.ts +++ b/projects/packages/premium-analytics/packages/data/src/queries/stats-tags-query.ts @@ -1,14 +1,20 @@ /** * Internal dependencies */ -import { statsProxyQuery, type StatsReportQueryOptions } from './stats-query'; +import { reportParamsToStatsQueryParams } from '../utils/stats-params'; +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. + * Past this many days the endpoint ranks the window's top posts in one pass + * instead of day by day, which keeps a long window to a few queries. */ -export type StatsTagsParams = { +const SUMMARIZE_MIN_DAYS = 31; + +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 @@ -18,11 +24,23 @@ 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 takes `summarize` + * on its own terms: per-day ranking under a month, as classic Stats reads it. + */ +export const statsTagsQuery = ( params: StatsTagsParams ): StatsReportQueryOptions< 'tags' > => { + const { max, ...reportParams } = params; + const { days } = reportParamsToStatsQueryParams( reportParams ); + const summarize = typeof days === 'number' && days > SUMMARIZE_MIN_DAYS; + + return statsReportQuery( + 'tags', + 'stats/tags', + reportParams, + 'tags', + '1.1', + ( max ?? 0 ) > 0 ? { max } : undefined, + { omitParams: summarize ? [ 'days' ] : [ 'days', 'summarize' ] } + ); +}; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/report-exports/tags.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/report-exports/tags.ts index 982b046d618c..9712f39ed52a 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/report-exports/tags.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/report-exports/tags.ts @@ -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 (the endpoint ranks at most ~50 posts a day). */ 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: () => [ { diff --git a/projects/packages/premium-analytics/routes/reports/tags/config/use-report-records.test.ts b/projects/packages/premium-analytics/routes/reports/tags/config/use-report-records.test.ts index d45a5eba74b6..b004b7b963bb 100644 --- a/projects/packages/premium-analytics/routes/reports/tags/config/use-report-records.test.ts +++ b/projects/packages/premium-analytics/routes/reports/tags/config/use-report-records.test.ts @@ -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 @@ -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 } ), diff --git a/projects/packages/premium-analytics/routes/reports/tags/config/use-report-records.ts b/projects/packages/premium-analytics/routes/reports/tags/config/use-report-records.ts index 52613e126bd1..df84ea9e964a 100644 --- a/projects/packages/premium-analytics/routes/reports/tags/config/use-report-records.ts +++ b/projects/packages/premium-analytics/routes/reports/tags/config/use-report-records.ts @@ -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'; @@ -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 ] diff --git a/projects/packages/premium-analytics/routes/reports/tags/page.tsx b/projects/packages/premium-analytics/routes/reports/tags/page.tsx index 263314d46114..89bfd0b7fcd1 100644 --- a/projects/packages/premium-analytics/routes/reports/tags/page.tsx +++ b/projects/packages/premium-analytics/routes/reports/tags/page.tsx @@ -1,6 +1,7 @@ /** * External dependencies */ +import { useReportDateFilters } from '@jetpack-premium-analytics/routing'; import { StatsBreadcrumbs, StatsPageIcon } from '@jetpack-premium-analytics/ui'; import { ExporterCsvAction, @@ -17,12 +18,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. @@ -40,17 +44,18 @@ 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 retry = useReportRetry( records.refetch ); + const dateFilters = useReportDateFilters( ROUTE_FROM ); const { getLabel } = REPORTS.tags; @@ -67,7 +72,7 @@ function TagsReport(): JSX.Element { /> } > - + { records.isError ? ( { } ); + // Dated like the CASES above, but with no comparison to fail. + it( 'downloads the same tags-and-categories CSV from the widget as from the report page', async () => { + const reportFile = await download( ); + queryClient.clear(); + const widgetFile = await download( + + ); + + expect( widgetFile ).toEqual( reportFile ); + expect( widgetFile.filename ).toBe( 'tags-and-categories-2026-03-01_2026-03-10.csv' ); + expect( widgetFile.csv.replace( '\ufeff', '' ).split( '\n' ).length ).toBeGreaterThan( 11 ); + } ); + it.each( [ [ 'annual-insights.csv', AnnualInsightsReportPage, AnnualHighlightsWidget, undefined ], [ 'comments-authors.csv', CommentsReportPage, MostCommentedAuthorsWidget, 'authors' ], [ 'comments-posts.csv', CommentsReportPage, MostCommentedPostsWidget, 'posts' ], - [ 'tags-and-categories.csv', TagsReportPage, TagsWidget, undefined ], [ 'emails.csv', EmailsReportPage, EmailsWidget, undefined ], ] as const )( 'downloads the same all-time %s from the widget as from the report page', diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php index 3abacb032312..998c1f6959e6 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php @@ -194,7 +194,7 @@ public function test_insights_default_excludes_shares_on_self_hosted() { $layout_types = $this->served_layout_types( 'analytics/insights' ); $this->assertNotContains( 'jpa/shares', $layout_types, 'Simple-only widget instances must not be part of the default layout on self-hosted sites.' ); - $this->assertContains( 'jpa/tags', $layout_types, 'Regular widget instances remain in the default layout.' ); + $this->assertContains( 'jpa/most-commented-posts', $layout_types, 'Regular widget instances remain in the default layout.' ); } /** @@ -431,6 +431,7 @@ public function test_traffic_section_declares_the_bundled_widgets() { 'default-authors-widget-instance' => array( 'jpa/authors', 1, 2, 9 ), 'default-search-terms-widget-instance' => array( 'jpa/search-terms', 1, 2, 10 ), 'default-file-downloads-widget-instance' => array( 'jpa/file-downloads', 1, 2, 11 ), + 'default-tags-widget-instance' => array( 'jpa/tags', 2, 2, 12 ), ), $layout ); @@ -465,15 +466,16 @@ public function test_insights_section_declares_the_bundled_widgets() { 'default-latest-post-widget-instance' => array( 'jpa/latest-post', 1, 2, 5 ), 'default-posting-activity-widget-instance' => array( 'jpa/posting-activity', 3, 1, 6 ), 'default-views-over-years-widget-instance' => array( 'jpa/views-over-years', 3, 2, 7 ), - 'default-tags-widget-instance' => array( 'jpa/tags', 2, 2, 8 ), - 'default-most-commented-posts-widget-instance' => array( 'jpa/most-commented-posts', 1, 2, 9 ), - 'default-shares-widget-instance' => array( 'jpa/shares', 1, 2, 10 ), - 'default-most-commented-authors-widget-instance' => array( 'jpa/most-commented-authors', 2, 2, 11 ), + 'default-most-commented-posts-widget-instance' => array( 'jpa/most-commented-posts', 1, 2, 8 ), + 'default-shares-widget-instance' => array( 'jpa/shares', 1, 2, 9 ), + 'default-most-commented-authors-widget-instance' => array( 'jpa/most-commented-authors', 2, 2, 10 ), ), $layout ); $this->assertNotContains( 'jpa/authors', $layout_types ); + // Tags follow the date picker, so they sit on the Traffic tab. + $this->assertNotContains( 'jpa/tags', $layout_types ); // Emails is not an Insights module — it lives on the Subscribers tab. $this->assertNotContains( 'jpa/stats-emails', $layout_types ); // The Comments module ships as two focused widgets, not one toggled widget. diff --git a/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx b/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx index db99a1f8a87f..d6677525bce2 100644 --- a/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx +++ b/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx @@ -69,6 +69,14 @@ function rowLink( label: string ) { return screen.getByText( label ).closest( 'a' ); } +/** The query string of every `stats/tags` request so far. */ +function tagsRequests(): URLSearchParams[] { + return mockApiFetch.mock.calls + .map( ( [ options ] ) => String( options?.path ?? '' ) ) + .filter( path => path.includes( 'stats/tags' ) ) + .map( path => new URLSearchParams( path.split( '?' )[ 1 ] ) ); +} + describe( 'TagsWidget', () => { beforeEach( () => { queryClient.clear(); @@ -132,20 +140,46 @@ describe( 'TagsWidget', () => { } ); // Calypso sends no query, so Jetpack Stats gets the endpoint's default of 10. - it( 'asks for the row count Jetpack Stats gets by default', async () => { + it( 'asks for the row count Jetpack Stats gets by default, over the dashboard window', async () => { render( ); await expect( screen.findByText( 'Recipes' ) ).resolves.toBeInTheDocument(); - const requestedPaths = mockApiFetch.mock.calls - .map( ( [ options ] ) => String( options?.path ?? '' ) ) - .filter( path => path.includes( 'stats/tags' ) ); - - expect( requestedPaths ).not.toHaveLength( 0 ); - requestedPaths.forEach( path => { - expect( new URLSearchParams( path.split( '?' )[ 1 ] ).get( 'max' ) ).toBe( '10' ); + const requested = tagsRequests(); + expect( requested ).not.toHaveLength( 0 ); + requested.forEach( params => { + expect( params.get( 'max' ) ).toBe( '10' ); + expect( params.get( 'date' ) ).toBe( getDefaultQueryParams().to ); + expect( params.get( 'start_date' ) ).toBe( getDefaultQueryParams().from ); } ); } ); + + it( 'refetches with the new window when the dashboard period changes', async () => { + const { rerender } = render( + + ); + await expect( screen.findByText( 'Recipes' ) ).resolves.toBeInTheDocument(); + + rerender( + + ); + + await waitFor( () => + expect( tagsRequests().map( params => params.get( 'start_date' ) ) ).toContain( + '2026-01-01T00:00:00.000+00:00' + ) + ); + expect( tagsRequests().at( -1 )?.get( 'date' ) ).toBe( '2026-01-31T23:59:59.999+00:00' ); + } ); } ); describe( 'TagsWidget CSV export', () => { @@ -199,6 +233,9 @@ describe( 'TagsWidget CSV export', () => { expect( lines[ 0 ] ).toBe( '"Tag or category","Views","URL"' ); expect( lines ).toContain( '"Desserts, chocolate","760",""' ); expect( lines ).toHaveLength( 13 ); - expect( downloads.files[ 0 ].filename ).toBe( 'tags-and-categories.csv' ); + // The window is part of the export now, so the filename names it. + expect( downloads.files[ 0 ].filename ).toMatch( + /^tags-and-categories-\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\.csv$/ + ); } ); } ); diff --git a/projects/packages/premium-analytics/widgets/tags/render.tsx b/projects/packages/premium-analytics/widgets/tags/render.tsx index 5bbe57c3925c..302bfd2babf5 100644 --- a/projects/packages/premium-analytics/widgets/tags/render.tsx +++ b/projects/packages/premium-analytics/widgets/tags/render.tsx @@ -17,6 +17,7 @@ import { sharePercentage, tagRowGlyph, useWidgetDrillDown, + useWidgetRootContext, ExporterCsvDownloadButton, tagsCsvExporter, type LeaderboardChartData, @@ -25,6 +26,7 @@ import { import { tag as tagIllustration } from '@jetpack-premium-analytics/icons'; import { useEffect, useMemo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; +import { ReportScopeProvider } from '@jetpack-premium-analytics/data'; import { Stack } from '@jetpack-premium-analytics/externals'; /** * Internal dependencies @@ -68,7 +70,9 @@ function TagGroupMembers( { members }: TagGroupMembersProps ) { } function TagsInner() { + const { reportParams } = useWidgetRootContext(); const { data, isLoading, isFetching, isError, refetch } = useTagViews( { + reportParams, max: WIDGET_ROW_LIMIT, } ); @@ -196,8 +200,11 @@ function TagsInner() { */ export default function Tags( { attributes = {} }: TagsWidgetProps ) { return ( - - - + // `stats/tags` reports one period, so the widget never offers the comparison. + + + + + ); } diff --git a/projects/packages/premium-analytics/widgets/tags/stories/tags-widget.stories.tsx b/projects/packages/premium-analytics/widgets/tags/stories/tags-widget.stories.tsx index a469c4ef9fc1..fa81b4257c73 100644 --- a/projects/packages/premium-analytics/widgets/tags/stories/tags-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/tags/stories/tags-widget.stories.tsx @@ -43,7 +43,7 @@ const meta = { docs: { description: { component: - 'The "Tags & categories" widget. Displays the site\'s most visited tags and categories over the seven days ending yesterday, ranked by views — the endpoint takes no date parameters, so the section\'s date filter does not reach it. Single tags/categories link to their archive; grouped rows (several tags/categories sharing posts) drill down to their members. Ported from the Jetpack Stats Tags & categories module.', + 'The "Tags & categories" widget. Displays the tags and categories of the site\'s most viewed posts over the dashboard\'s period, ranked by views; the endpoint reports no comparison. Single tags/categories link to their archive; grouped rows (several tags/categories sharing posts) drill down to their members. Ported from the Jetpack Stats Tags & categories module.', }, }, }, diff --git a/projects/packages/premium-analytics/widgets/tags/use-tag-views.ts b/projects/packages/premium-analytics/widgets/tags/use-tag-views.ts index 8cf01eb4f37f..5698a2024438 100644 --- a/projects/packages/premium-analytics/widgets/tags/use-tag-views.ts +++ b/projects/packages/premium-analytics/widgets/tags/use-tag-views.ts @@ -6,7 +6,11 @@ import { useMemo } from '@wordpress/element'; * Internal dependencies */ import { useStatsTags } from '@jetpack-premium-analytics/data'; -import type { StatsNormalizedReport, StatsTagsItem } from '@jetpack-premium-analytics/data'; +import type { + ReportParams, + StatsNormalizedReport, + StatsTagsItem, +} from '@jetpack-premium-analytics/data'; /** * A single tag or category grouped under a parent row. Grouped rows have no @@ -66,6 +70,10 @@ export interface TagView { } interface UseTagViewsArgs { + /** + * The dashboard window the rows are ranked over. + */ + reportParams: ReportParams; /** * Rows to request and display. */ @@ -85,11 +93,13 @@ interface TagViewsState { * via the shared Stats data layer. * * Delegates to `useStatsTags`, then maps the normalized rows onto the leaderboard - * shape and trims to `max`. Single period, no comparison: `stats/tags` takes no - * date parameters, so the dashboard's date filter never reaches this widget. + * shape and trims to `max`. Single period: `stats/tags` reports no comparison. */ -export default function useTagViews( { max }: UseTagViewsArgs ): TagViewsState { - const { data, isLoading, isFetching, isError, refetch } = useStatsTags( { max } ); +export default function useTagViews( { reportParams, max }: UseTagViewsArgs ): TagViewsState { + const { data, isLoading, isFetching, isError, refetch } = useStatsTags( { + ...reportParams, + max, + } ); // Memoize on the query's stable `data` reference so the row array keeps a // stable identity across unrelated re-renders; otherwise every render hands a diff --git a/projects/packages/premium-analytics/widgets/tags/widget.json b/projects/packages/premium-analytics/widgets/tags/widget.json index f201d8e20966..08ff12b36162 100644 --- a/projects/packages/premium-analytics/widgets/tags/widget.json +++ b/projects/packages/premium-analytics/widgets/tags/widget.json @@ -1,9 +1,9 @@ { "name": "jpa/tags", - "title": "Top tags & categories in the last 7 days", - "description": "Your most visited tags and categories, ranked by views.", + "title": "Top tags & categories", + "description": "The tags and categories of your most viewed posts in the selected period, ranked by views.", "help": { - "content": "The tags and categories associated with your most-viewed content, sorted by views.", + "content": "The tags and categories of your most viewed posts in the selected period, ranked by views. A post counts toward each of its current tags and categories, so this is a breakdown of popular posts by topic rather than a filter over all traffic.", "links": [ { "label": "Learn more", diff --git a/projects/plugins/jetpack/changelog/add-premium-analytics-tags-widget-traffic b/projects/plugins/jetpack/changelog/add-premium-analytics-tags-widget-traffic new file mode 100644 index 000000000000..523e5e20a20c --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-premium-analytics-tags-widget-traffic @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Premium Analytics: Move the Top tags & categories widget to the Traffic tab, where it and its report follow the date picker. diff --git a/projects/plugins/premium-analytics/changelog/add-premium-analytics-tags-widget-traffic b/projects/plugins/premium-analytics/changelog/add-premium-analytics-tags-widget-traffic new file mode 100644 index 000000000000..fbe2a3d30c3e --- /dev/null +++ b/projects/plugins/premium-analytics/changelog/add-premium-analytics-tags-widget-traffic @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Move the Top tags & categories widget to the Traffic tab, where it and its report follow the date picker. diff --git a/projects/plugins/wpcomsh/changelog/add-premium-analytics-tags-widget-traffic b/projects/plugins/wpcomsh/changelog/add-premium-analytics-tags-widget-traffic new file mode 100644 index 000000000000..fea4f697aa62 --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/add-premium-analytics-tags-widget-traffic @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Premium Analytics: Move the Top tags & categories widget to the Traffic tab, where it and its report follow the date picker. From acf02f916149bfea73e184ebac367a86d4a89712 Mon Sep 17 00:00:00 2001 From: dognose24 Date: Wed, 7 Oct 2026 22:10:29 +0800 Subject: [PATCH 2/6] Move a stored Tags tile from Insights to Traffic once per reader A customized layout does not follow the default, so the first section layout migration rewrites the stored map: Tags leaves a customized Insights and joins a customized Traffic, recorded by id so a tile the reader removes afterwards stays removed. Co-Authored-By: Claude Fable 5.1 --- .../docs/dashboard-sections.md | 2 + .../routes/dashboard/config/index.ts | 6 ++ .../config/section-layout-migrations.test.ts | 75 +++++++++++++ .../config/section-layout-migrations.ts | 102 ++++++++++++++++++ .../routes/dashboard/hooks/constants.ts | 3 + .../routes/dashboard/hooks/index.ts | 1 + .../use-section-layout-migrations/index.ts | 1 + .../use-section-layout-migrations.test.ts | 90 ++++++++++++++++ .../use-section-layout-migrations.ts | 60 +++++++++++ .../routes/dashboard/stage.test.tsx | 1 + .../routes/dashboard/stage.tsx | 3 + 11 files changed, 344 insertions(+) create mode 100644 projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.test.ts create mode 100644 projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.ts create mode 100644 projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/index.ts create mode 100644 projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts create mode 100644 projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.ts diff --git a/projects/packages/premium-analytics/docs/dashboard-sections.md b/projects/packages/premium-analytics/docs/dashboard-sections.md index df84336118d3..b92e151e1ec7 100644 --- a/projects/packages/premium-analytics/docs/dashboard-sections.md +++ b/projects/packages/premium-analytics/docs/dashboard-sections.md @@ -165,6 +165,8 @@ The same callback drops an instance whose type the widget type registry has not The client stores customized layouts in the `dashboardSectionLayouts` preference, keyed by slug. `useDashboardSectionLayout()` renders the stored layout when there is one and the section's `default_layout` otherwise. +A change to the defaults does not reach a stored layout on its own. When one should, such as a widget moving to another tab, `routes/dashboard/config/section-layout-migrations.ts` lists a migration for it: a one-off rewrite of the stored map, run by `useSectionLayoutMigrations()` once the sections have resolved and recorded by id in the `dashboardLayoutMigrations` preference, so a widget the reader removes afterwards stays removed. + Reset deletes the stored entry rather than copying the default into it, so a section that was reset follows later changes to the default. A section that declares no layout opens in customize mode on the empty state, since `WidgetDashboard` treats an empty layout as "no widgets". A section meant to be read declares one. diff --git a/projects/packages/premium-analytics/routes/dashboard/config/index.ts b/projects/packages/premium-analytics/routes/dashboard/config/index.ts index 436bee194af8..a10ddd5014ab 100644 --- a/projects/packages/premium-analytics/routes/dashboard/config/index.ts +++ b/projects/packages/premium-analytics/routes/dashboard/config/index.ts @@ -18,6 +18,12 @@ export { export { isDashboardSectionLayouts, type DashboardSectionLayouts } from './section-layouts'; +export { + SECTION_LAYOUT_MIGRATIONS, + migrateSectionLayouts, + type SectionLayoutMigration, +} from './section-layout-migrations'; + export { NO_WIDGET_TYPE_RENAMES, buildWidgetTypeRenames, diff --git a/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.test.ts b/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.test.ts new file mode 100644 index 000000000000..911003f9fa49 --- /dev/null +++ b/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.test.ts @@ -0,0 +1,75 @@ +/** + * Internal dependencies + */ +import { migrateSectionLayouts, type SectionLayoutMigration } from './section-layout-migrations'; +import type { DashboardSection } from './sections'; +import type { DashboardWidget } from '@wordpress/widget-dashboard'; + +const tags: DashboardWidget = { + uuid: 'default-tags-widget-instance', + type: 'jpa/tags', + placement: { width: 2, height: 2, order: 12 }, +}; +const widget = ( uuid: string ): DashboardWidget => ( { uuid, type: `jpa/${ uuid }` } ); +const section = ( slug: string, defaultLayout: DashboardWidget[] ): DashboardSection => ( { + id: `analytics/${ slug }`, + slug, + label: slug, + order: 0, + default_layout: defaultLayout, +} ); +const sections = [ + section( 'traffic', [ widget( 'clicks' ), tags ] ), + section( 'insights', [ widget( 'all-time-stats' ) ] ), +]; + +describe( 'migrateSectionLayouts', () => { + it( 'returns null once every migration is recorded as applied', () => { + expect( migrateSectionLayouts( {}, [ 'tags-to-traffic' ], sections ) ).toBeNull(); + } ); + + it( 'runs only the pending migrations, in order, and records them', () => { + const first: SectionLayoutMigration = { id: 'first', run: layouts => layouts }; + const second: SectionLayoutMigration = { + id: 'second', + run: layouts => ( { ...layouts, traffic: [ widget( 'second' ) ] } ), + }; + + expect( migrateSectionLayouts( {}, [ 'first' ], sections, [ first, second ] ) ).toEqual( { + layouts: { traffic: [ widget( 'second' ) ] }, + applied: [ 'first', 'second' ], + } ); + } ); + + describe( 'tags-to-traffic', () => { + it( 'takes Tags off a customized Insights and appends the default instance to a customized Traffic', () => { + const result = migrateSectionLayouts( + { + insights: [ widget( 'all-time-stats' ), tags, widget( 'shares' ) ], + traffic: [ widget( 'referrers' ) ], + }, + [], + sections + ); + + expect( result?.layouts ).toEqual( { + insights: [ widget( 'all-time-stats' ), widget( 'shares' ) ], + traffic: [ widget( 'referrers' ), tags ], + } ); + expect( result?.applied ).toEqual( [ 'tags-to-traffic' ] ); + } ); + + it( 'leaves a Traffic layout that already has Tags, and an uncustomized tab, alone', () => { + const layouts = { traffic: [ tags, widget( 'referrers' ) ] }; + + expect( migrateSectionLayouts( layouts, [], sections )?.layouts ).toBe( layouts ); + } ); + + it( 'records the migration for a reader with nothing stored, without inventing layouts', () => { + expect( migrateSectionLayouts( {}, [], sections ) ).toEqual( { + layouts: {}, + applied: [ 'tags-to-traffic' ], + } ); + } ); + } ); +} ); diff --git a/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.ts b/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.ts new file mode 100644 index 000000000000..e666aa90f546 --- /dev/null +++ b/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.ts @@ -0,0 +1,102 @@ +/** + * Internal dependencies + */ +import type { DashboardSectionLayouts } from './section-layouts'; +import type { DashboardSection } from './sections'; +import type { DashboardWidget } from '@wordpress/widget-dashboard'; + +/** + * A one-off rewrite of the stored section layouts, run once per reader and + * recorded by id so a widget the reader later removes is not put back. + */ +export type SectionLayoutMigration = { + id: string; + /** + * Rewrite the stored layouts, or return them as they are when nothing applies. + * + * @param layouts - The stored section layouts. + * @param sections - The dashboard sections, carrying their defaults. + * @return The layouts to store. + */ + run: ( + layouts: DashboardSectionLayouts, + sections: DashboardSection[] + ) => DashboardSectionLayouts; +}; + +const TAGS_TYPE = 'jpa/tags'; + +/** + * The instance a default layout carries for a type, when it has one. + * + * @param sections - The dashboard sections. + * @param slug - The section to read. + * @param type - The widget type to find. + * @return The default instance, or undefined. + */ +function defaultInstanceOf( + sections: DashboardSection[], + slug: string, + type: string +): DashboardWidget | undefined { + return sections + .find( section => section.slug === slug ) + ?.default_layout?.find( widget => widget.type === type ); +} + +/** + * Top tags & categories moved from Insights to Traffic once `stats/tags` took a + * date window. A reader who customized Insights keeps everything but that tile; + * one who customized Traffic gains it at the end, as the default places it. + */ +const tagsToTraffic: SectionLayoutMigration = { + id: 'tags-to-traffic', + run( layouts, sections ) { + let next = layouts; + + const insights = layouts.insights; + if ( insights?.some( widget => widget.type === TAGS_TYPE ) ) { + next = { ...next, insights: insights.filter( widget => widget.type !== TAGS_TYPE ) }; + } + + // The default instance, placement included: the default lists it last. + const traffic = layouts.traffic; + const instance = defaultInstanceOf( sections, 'traffic', TAGS_TYPE ); + if ( traffic && instance && ! traffic.some( widget => widget.type === TAGS_TYPE ) ) { + next = { ...next, traffic: [ ...traffic, instance ] }; + } + + return next; + }, +}; + +export const SECTION_LAYOUT_MIGRATIONS: readonly SectionLayoutMigration[] = [ tagsToTraffic ]; + +/** + * Run the migrations not yet applied, in order. + * + * @param layouts - The stored section layouts. + * @param applied - Ids of the migrations already applied. + * @param sections - The dashboard sections, carrying their defaults. + * @param migrations - The migrations to consider. + * @return The layouts and applied ids to store, or null when every migration has run. + */ +export function migrateSectionLayouts( + layouts: DashboardSectionLayouts, + applied: readonly string[], + sections: DashboardSection[], + migrations: readonly SectionLayoutMigration[] = SECTION_LAYOUT_MIGRATIONS +): { layouts: DashboardSectionLayouts; applied: string[] } | null { + const pending = migrations.filter( migration => ! applied.includes( migration.id ) ); + if ( ! pending.length ) { + return null; + } + + return { + layouts: pending.reduce( + ( current, migration ) => migration.run( current, sections ), + layouts + ), + applied: [ ...applied, ...pending.map( migration => migration.id ) ], + }; +} diff --git a/projects/packages/premium-analytics/routes/dashboard/hooks/constants.ts b/projects/packages/premium-analytics/routes/dashboard/hooks/constants.ts index 69fc9b0680a5..1d5c5ea7bc9b 100644 --- a/projects/packages/premium-analytics/routes/dashboard/hooks/constants.ts +++ b/projects/packages/premium-analytics/routes/dashboard/hooks/constants.ts @@ -16,6 +16,9 @@ export { DASHBOARD_SECTION_LAYOUTS_KEY, } from '@jetpack-premium-analytics/widgets-toolkit'; +/** Preferences key holding the ids of the section layout migrations already applied. */ +export const DASHBOARD_LAYOUT_MIGRATIONS_KEY = 'dashboardLayoutMigrations'; + /** Preferences key holding the dashboard grid settings. */ export const DASHBOARD_GRID_SETTINGS_KEY = 'dashboardGridSettings'; diff --git a/projects/packages/premium-analytics/routes/dashboard/hooks/index.ts b/projects/packages/premium-analytics/routes/dashboard/hooks/index.ts index 025cc13e909d..1098fd140fd5 100644 --- a/projects/packages/premium-analytics/routes/dashboard/hooks/index.ts +++ b/projects/packages/premium-analytics/routes/dashboard/hooks/index.ts @@ -6,3 +6,4 @@ export { useDashboardSections } from './use-dashboard-sections'; export { useFeedbackBanner } from './use-feedback-banner'; export { useOnboarding } from './use-onboarding'; export { useSectionDateFilter } from './use-section-date-filter'; +export { useSectionLayoutMigrations } from './use-section-layout-migrations'; diff --git a/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/index.ts b/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/index.ts new file mode 100644 index 000000000000..293b1a215acc --- /dev/null +++ b/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/index.ts @@ -0,0 +1 @@ +export { useSectionLayoutMigrations } from './use-section-layout-migrations'; diff --git a/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts b/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts new file mode 100644 index 000000000000..e3b76dd7b5f3 --- /dev/null +++ b/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts @@ -0,0 +1,90 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react'; +import { dispatch, select } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; +/** + * Internal dependencies + */ +import { + DASHBOARD_LAYOUT_MIGRATIONS_KEY, + DASHBOARD_PREFERENCES_SCOPE, + DASHBOARD_SECTION_LAYOUTS_KEY, +} from '../constants'; +import { useSectionLayoutMigrations } from './use-section-layout-migrations'; +import type { DashboardSection } from '../../config'; +import type { DashboardWidget } from '@wordpress/widget-dashboard'; + +const tags: DashboardWidget = { uuid: 'default-tags-widget-instance', type: 'jpa/tags' }; +const widget = ( uuid: string ): DashboardWidget => ( { uuid, type: `jpa/${ uuid }` } ); +const sections: DashboardSection[] = [ + { + id: 'analytics/traffic', + slug: 'traffic', + label: 'Traffic', + order: 0, + default_layout: [ tags ], + }, + { id: 'analytics/insights', slug: 'insights', label: 'Insights', order: 1, default_layout: [] }, +]; + +/** + * + * @param key + */ +/** + * Read a dashboard preference. + * + * @param key - The preference key. + * @return The stored value. + */ +function stored( key: string ): unknown { + return ( + select( preferencesStore ) as unknown as { get: ( scope: string, key: string ) => unknown } + ).get( DASHBOARD_PREFERENCES_SCOPE, key ); +} + +describe( 'useSectionLayoutMigrations', () => { + beforeEach( () => { + dispatch( preferencesStore ).set( DASHBOARD_PREFERENCES_SCOPE, DASHBOARD_SECTION_LAYOUTS_KEY, { + insights: [ widget( 'all-time-stats' ), tags ], + traffic: [ widget( 'referrers' ) ], + } ); + dispatch( preferencesStore ).set( + DASHBOARD_PREFERENCES_SCOPE, + DASHBOARD_LAYOUT_MIGRATIONS_KEY, + [] + ); + } ); + + it( 'waits for the sections, then rewrites the stored layouts once and records it', () => { + const { rerender } = renderHook( + ( { hasResolved } ) => useSectionLayoutMigrations( sections, hasResolved ), + { initialProps: { hasResolved: false } } + ); + + expect( stored( DASHBOARD_LAYOUT_MIGRATIONS_KEY ) ).toEqual( [] ); + + rerender( { hasResolved: true } ); + + expect( stored( DASHBOARD_SECTION_LAYOUTS_KEY ) ).toEqual( { + insights: [ widget( 'all-time-stats' ) ], + traffic: [ widget( 'referrers' ), tags ], + } ); + expect( stored( DASHBOARD_LAYOUT_MIGRATIONS_KEY ) ).toEqual( [ 'tags-to-traffic' ] ); + } ); + + it( 'leaves a layout the reader changed after the migration alone', () => { + dispatch( preferencesStore ).set( + DASHBOARD_PREFERENCES_SCOPE, + DASHBOARD_LAYOUT_MIGRATIONS_KEY, + [ 'tags-to-traffic' ] + ); + const layouts = stored( DASHBOARD_SECTION_LAYOUTS_KEY ); + + renderHook( () => useSectionLayoutMigrations( sections, true ) ); + + expect( stored( DASHBOARD_SECTION_LAYOUTS_KEY ) ).toBe( layouts ); + } ); +} ); diff --git a/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.ts b/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.ts new file mode 100644 index 000000000000..b4252beef950 --- /dev/null +++ b/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.ts @@ -0,0 +1,60 @@ +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; +import { useEffect } from 'react'; +import { isDashboardSectionLayouts, migrateSectionLayouts } from '../../config'; +import { + DASHBOARD_LAYOUT_MIGRATIONS_KEY, + DASHBOARD_PREFERENCES_SCOPE, + DASHBOARD_SECTION_LAYOUTS_KEY, +} from '../constants'; +import type { DashboardSection } from '../../config'; + +type PreferencesActions = { + set: ( scope: string, key: string, value: unknown ) => Promise< void > | void; +}; + +const NO_IDS: readonly string[] = []; + +/** + * Apply the section layout migrations a reader has not had yet, once the + * sections have resolved: a migration may read a section's default. + * + * A reader with no stored layouts gets the defaults, which already carry every + * move; the migrations are still recorded so a later customization is not rewritten. + * + * @param sections - The dashboard sections, carrying their defaults. + * @param hasResolved - Whether the sections have resolved. + */ +export function useSectionLayoutMigrations( sections: DashboardSection[], hasResolved: boolean ) { + const { layouts, applied } = useSelect( select => { + const { get } = select( preferencesStore ) as unknown as { + get: ( scope: string, key: string ) => unknown; + }; + const storedLayouts = get( DASHBOARD_PREFERENCES_SCOPE, DASHBOARD_SECTION_LAYOUTS_KEY ); + const storedApplied = get( DASHBOARD_PREFERENCES_SCOPE, DASHBOARD_LAYOUT_MIGRATIONS_KEY ); + + return { + layouts: isDashboardSectionLayouts( storedLayouts ) ? storedLayouts : undefined, + applied: Array.isArray( storedApplied ) + ? storedApplied.filter( ( id ): id is string => typeof id === 'string' ) + : NO_IDS, + }; + }, [] ); + const { set } = useDispatch( preferencesStore ) as unknown as PreferencesActions; + + useEffect( () => { + if ( ! hasResolved ) { + return; + } + + const result = migrateSectionLayouts( layouts ?? {}, applied, sections ); + if ( ! result ) { + return; + } + + if ( layouts && result.layouts !== layouts ) { + void set( DASHBOARD_PREFERENCES_SCOPE, DASHBOARD_SECTION_LAYOUTS_KEY, result.layouts ); + } + void set( DASHBOARD_PREFERENCES_SCOPE, DASHBOARD_LAYOUT_MIGRATIONS_KEY, result.applied ); + }, [ hasResolved, layouts, applied, sections, set ] ); +} diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx index b56393f995dd..dd30e0e4dc54 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx @@ -371,6 +371,7 @@ jest.mock( './hooks', () => ( { useDashboardSections: jest.fn(), useOnboarding: jest.fn(), useSectionDateFilter: jest.fn(), + useSectionLayoutMigrations: jest.fn(), } ) ); beforeEach( () => { diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.tsx index 2c3e056c3c5c..1d2076e69330 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.tsx @@ -56,6 +56,7 @@ import { useDashboardSections, useOnboarding, useSectionDateFilter, + useSectionLayoutMigrations, } from './hooks'; import './overlay-focus-ring.scss'; import styles from './stage.module.scss'; @@ -71,6 +72,8 @@ import type { JSX } from 'react'; function Dashboard(): JSX.Element { const { sections, hasResolved: hasResolvedSections } = useDashboardSections(); const [ activeSection, setActiveSection ] = useActiveSection( sections ); + // Before the layout reads the stored map, so a moved widget lands on its new tab. + useSectionLayoutMigrations( sections, hasResolvedSections ); const widgetModules = useWidgetModules(); const widgetTypeRenames = useMemo( () => buildWidgetTypeRenames( widgetModules ), From 29a385826f63bbd3e811311a019d236b3900c5bd Mon Sep 17 00:00:00 2001 From: dognose24 Date: Fri, 9 Oct 2026 00:55:43 +0800 Subject: [PATCH 3/6] Address review: gate the Tags report on Traffic, drop the per-day notes and a leftover stub Co-Authored-By: Claude Fable 5.1 --- .../packages/data/src/processing/stats/tags.ts | 2 +- .../packages/widgets-toolkit/src/report-exports/tags.ts | 2 +- .../use-section-layout-migrations.test.ts | 4 ---- .../premium-analytics/routes/reports/registry.test.ts | 3 ++- .../packages/premium-analytics/routes/reports/registry.ts | 2 +- 5 files changed, 5 insertions(+), 8 deletions(-) diff --git a/projects/packages/premium-analytics/packages/data/src/processing/stats/tags.ts b/projects/packages/premium-analytics/packages/data/src/processing/stats/tags.ts index e9525bd5d734..21d4e634726b 100644 --- a/projects/packages/premium-analytics/packages/data/src/processing/stats/tags.ts +++ b/projects/packages/premium-analytics/packages/data/src/processing/stats/tags.ts @@ -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 ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/report-exports/tags.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/report-exports/tags.ts index 9712f39ed52a..fe1ad476fc9a 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/report-exports/tags.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/report-exports/tags.ts @@ -10,7 +10,7 @@ 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 ~50 posts a day). + * past what a real site produces. */ export const TAGS_REPORT_ROW_LIMIT = 1000; diff --git a/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts b/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts index e3b76dd7b5f3..2256fb9d0eb8 100644 --- a/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts +++ b/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts @@ -29,10 +29,6 @@ const sections: DashboardSection[] = [ { id: 'analytics/insights', slug: 'insights', label: 'Insights', order: 1, default_layout: [] }, ]; -/** - * - * @param key - */ /** * Read a dashboard preference. * diff --git a/projects/packages/premium-analytics/routes/reports/registry.test.ts b/projects/packages/premium-analytics/routes/reports/registry.test.ts index 61abfd1e168e..37fd38d5da81 100644 --- a/projects/packages/premium-analytics/routes/reports/registry.test.ts +++ b/projects/packages/premium-analytics/routes/reports/registry.test.ts @@ -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' ], } ); diff --git a/projects/packages/premium-analytics/routes/reports/registry.ts b/projects/packages/premium-analytics/routes/reports/registry.ts index a8d3b50b9128..f27433adb90c 100644 --- a/projects/packages/premium-analytics/routes/reports/registry.ts +++ b/projects/packages/premium-analytics/routes/reports/registry.ts @@ -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' ), }, From 802a935e36c96a719baa3328c5fd68068736878a Mon Sep 17 00:00:00 2001 From: dognose24 Date: Fri, 9 Oct 2026 02:40:44 +0800 Subject: [PATCH 4/6] Rank the tags window in one pass at every length Per-day ranking keeps 50 posts a day and the summarized pass keeps 50 for the whole window, so switching between them at a month let a longer range count fewer posts than a shorter one. Co-Authored-By: Claude Fable 5.1 --- .../queries/__tests__/stats-queries.test.ts | 24 ++++++++++++------- .../data/src/queries/stats-tags-query.ts | 20 ++++++---------- .../widgets/tags/__tests__/tags.test.tsx | 1 + 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts b/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts index 9edc638f0e3c..1f7d547433d3 100644 --- a/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts +++ b/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts @@ -605,7 +605,7 @@ describe( 'Stats query factories', () => { '1.1', 'stats/tags', 'GET', - { period: 'day', date: '2026-06-07', start_date: '2026-06-01' }, + { period: 'day', date: '2026-06-07', start_date: '2026-06-01', summarize: 1 }, undefined, 'tags', 'UTC', @@ -613,21 +613,29 @@ describe( 'Stats query factories', () => { 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 } ); + ).toEqual( { + period: 'day', + date: '2026-06-07', + start_date: '2026-06-01', + max: 10, + summarize: 1, + } ); } ); - // Under a month the endpoint ranks day by day, as classic Stats reads it; past - // that it ranks the whole window at once so a year stays a few queries. + // 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( [ - [ '31 days', '2026-06-01', '2026-07-01', undefined ], - [ '32 days', '2026-06-01', '2026-07-02', 1 ], - ] )( 'summarizes the tags query past a month (%s)', ( _label, from, to, summarize ) => { + [ '1 day', '2026-06-01', '2026-06-01' ], + [ '31 days', '2026-06-01', '2026-07-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( summarize ); + expect( params.summarize ).toBe( 1 ); expect( params.days ).toBeUndefined(); } ); diff --git a/projects/packages/premium-analytics/packages/data/src/queries/stats-tags-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/stats-tags-query.ts index 7e7261fdeba6..7d7455e2da84 100644 --- a/projects/packages/premium-analytics/packages/data/src/queries/stats-tags-query.ts +++ b/projects/packages/premium-analytics/packages/data/src/queries/stats-tags-query.ts @@ -1,19 +1,12 @@ /** * Internal dependencies */ -import { reportParamsToStatsQueryParams } from '../utils/stats-params'; import { statsReportQuery, type StatsReportParams, type StatsReportQueryOptions, } from './stats-query'; -/** - * Past this many days the endpoint ranks the window's top posts in one pass - * instead of day by day, which keeps a long window to a few queries. - */ -const SUMMARIZE_MIN_DAYS = 31; - export type StatsTagsParams = StatsReportParams & { /** * Rows to request. `max` only truncates an already-ranked list, so a larger one @@ -26,13 +19,13 @@ export type StatsTagsParams = StatsReportParams & { /** * `stats/tags` sizes its window from `date` and `start_date` (the days are - * derived server-side, so `days` stays off the request) and takes `summarize` - * on its own terms: per-day ranking under a month, as classic Stats reads it. + * 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; - const { days } = reportParamsToStatsQueryParams( reportParams ); - const summarize = typeof days === 'number' && days > SUMMARIZE_MIN_DAYS; return statsReportQuery( 'tags', @@ -40,7 +33,8 @@ export const statsTagsQuery = ( params: StatsTagsParams ): StatsReportQueryOptio reportParams, 'tags', '1.1', - ( max ?? 0 ) > 0 ? { max } : undefined, - { omitParams: summarize ? [ 'days' ] : [ 'days', 'summarize' ] } + // Explicit: the shared derivation adds `summarize` only past one day. + { ...( ( max ?? 0 ) > 0 ? { max } : {} ), summarize: 1 }, + { omitParams: [ 'days' ] } ); }; diff --git a/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx b/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx index d6677525bce2..50d294d52a96 100644 --- a/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx +++ b/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx @@ -151,6 +151,7 @@ describe( 'TagsWidget', () => { expect( params.get( 'max' ) ).toBe( '10' ); expect( params.get( 'date' ) ).toBe( getDefaultQueryParams().to ); expect( params.get( 'start_date' ) ).toBe( getDefaultQueryParams().from ); + expect( params.get( 'summarize' ) ).toBe( '1' ); } ); } ); From 7ee6ece3d1b0f100c5421ea01253d109184821a9 Mon Sep 17 00:00:00 2001 From: dognose24 Date: Fri, 9 Oct 2026 03:10:29 +0800 Subject: [PATCH 5/6] Leave stored layouts alone for now: the Tags move migration goes to a follow-up Whether a reader who customized Insights should have Tags moved for them, or only added on Traffic, is a product call still open on the review. The mechanism ships separately once it is made. Co-Authored-By: Claude Fable 5.1 --- .../docs/dashboard-sections.md | 2 - .../routes/dashboard/config/index.ts | 6 -- .../config/section-layout-migrations.test.ts | 75 ------------- .../config/section-layout-migrations.ts | 102 ------------------ .../routes/dashboard/hooks/constants.ts | 3 - .../routes/dashboard/hooks/index.ts | 1 - .../use-section-layout-migrations/index.ts | 1 - .../use-section-layout-migrations.test.ts | 86 --------------- .../use-section-layout-migrations.ts | 60 ----------- .../routes/dashboard/stage.test.tsx | 1 - .../routes/dashboard/stage.tsx | 3 - 11 files changed, 340 deletions(-) delete mode 100644 projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.test.ts delete mode 100644 projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.ts delete mode 100644 projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/index.ts delete mode 100644 projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts delete mode 100644 projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.ts diff --git a/projects/packages/premium-analytics/docs/dashboard-sections.md b/projects/packages/premium-analytics/docs/dashboard-sections.md index 1c993531ce36..9a6d7ea9b230 100644 --- a/projects/packages/premium-analytics/docs/dashboard-sections.md +++ b/projects/packages/premium-analytics/docs/dashboard-sections.md @@ -165,8 +165,6 @@ The same callback drops an instance whose type the widget type registry has not The client stores customized layouts in the `dashboardSectionLayouts` preference, keyed by slug. `useDashboardSectionLayout()` renders the stored layout when there is one and the section's `default_layout` otherwise. -A change to the defaults does not reach a stored layout on its own. When one should, such as a widget moving to another tab, `routes/dashboard/config/section-layout-migrations.ts` lists a migration for it: a one-off rewrite of the stored map, run by `useSectionLayoutMigrations()` once the sections have resolved and recorded by id in the `dashboardLayoutMigrations` preference, so a widget the reader removes afterwards stays removed. - Reset deletes the stored entry rather than copying the default into it, so a section that was reset follows later changes to the default. A section that declares no layout opens in customize mode on the empty state, since `WidgetDashboard` treats an empty layout as "no widgets". A section meant to be read declares one. diff --git a/projects/packages/premium-analytics/routes/dashboard/config/index.ts b/projects/packages/premium-analytics/routes/dashboard/config/index.ts index a10ddd5014ab..436bee194af8 100644 --- a/projects/packages/premium-analytics/routes/dashboard/config/index.ts +++ b/projects/packages/premium-analytics/routes/dashboard/config/index.ts @@ -18,12 +18,6 @@ export { export { isDashboardSectionLayouts, type DashboardSectionLayouts } from './section-layouts'; -export { - SECTION_LAYOUT_MIGRATIONS, - migrateSectionLayouts, - type SectionLayoutMigration, -} from './section-layout-migrations'; - export { NO_WIDGET_TYPE_RENAMES, buildWidgetTypeRenames, diff --git a/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.test.ts b/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.test.ts deleted file mode 100644 index 911003f9fa49..000000000000 --- a/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Internal dependencies - */ -import { migrateSectionLayouts, type SectionLayoutMigration } from './section-layout-migrations'; -import type { DashboardSection } from './sections'; -import type { DashboardWidget } from '@wordpress/widget-dashboard'; - -const tags: DashboardWidget = { - uuid: 'default-tags-widget-instance', - type: 'jpa/tags', - placement: { width: 2, height: 2, order: 12 }, -}; -const widget = ( uuid: string ): DashboardWidget => ( { uuid, type: `jpa/${ uuid }` } ); -const section = ( slug: string, defaultLayout: DashboardWidget[] ): DashboardSection => ( { - id: `analytics/${ slug }`, - slug, - label: slug, - order: 0, - default_layout: defaultLayout, -} ); -const sections = [ - section( 'traffic', [ widget( 'clicks' ), tags ] ), - section( 'insights', [ widget( 'all-time-stats' ) ] ), -]; - -describe( 'migrateSectionLayouts', () => { - it( 'returns null once every migration is recorded as applied', () => { - expect( migrateSectionLayouts( {}, [ 'tags-to-traffic' ], sections ) ).toBeNull(); - } ); - - it( 'runs only the pending migrations, in order, and records them', () => { - const first: SectionLayoutMigration = { id: 'first', run: layouts => layouts }; - const second: SectionLayoutMigration = { - id: 'second', - run: layouts => ( { ...layouts, traffic: [ widget( 'second' ) ] } ), - }; - - expect( migrateSectionLayouts( {}, [ 'first' ], sections, [ first, second ] ) ).toEqual( { - layouts: { traffic: [ widget( 'second' ) ] }, - applied: [ 'first', 'second' ], - } ); - } ); - - describe( 'tags-to-traffic', () => { - it( 'takes Tags off a customized Insights and appends the default instance to a customized Traffic', () => { - const result = migrateSectionLayouts( - { - insights: [ widget( 'all-time-stats' ), tags, widget( 'shares' ) ], - traffic: [ widget( 'referrers' ) ], - }, - [], - sections - ); - - expect( result?.layouts ).toEqual( { - insights: [ widget( 'all-time-stats' ), widget( 'shares' ) ], - traffic: [ widget( 'referrers' ), tags ], - } ); - expect( result?.applied ).toEqual( [ 'tags-to-traffic' ] ); - } ); - - it( 'leaves a Traffic layout that already has Tags, and an uncustomized tab, alone', () => { - const layouts = { traffic: [ tags, widget( 'referrers' ) ] }; - - expect( migrateSectionLayouts( layouts, [], sections )?.layouts ).toBe( layouts ); - } ); - - it( 'records the migration for a reader with nothing stored, without inventing layouts', () => { - expect( migrateSectionLayouts( {}, [], sections ) ).toEqual( { - layouts: {}, - applied: [ 'tags-to-traffic' ], - } ); - } ); - } ); -} ); diff --git a/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.ts b/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.ts deleted file mode 100644 index e666aa90f546..000000000000 --- a/projects/packages/premium-analytics/routes/dashboard/config/section-layout-migrations.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Internal dependencies - */ -import type { DashboardSectionLayouts } from './section-layouts'; -import type { DashboardSection } from './sections'; -import type { DashboardWidget } from '@wordpress/widget-dashboard'; - -/** - * A one-off rewrite of the stored section layouts, run once per reader and - * recorded by id so a widget the reader later removes is not put back. - */ -export type SectionLayoutMigration = { - id: string; - /** - * Rewrite the stored layouts, or return them as they are when nothing applies. - * - * @param layouts - The stored section layouts. - * @param sections - The dashboard sections, carrying their defaults. - * @return The layouts to store. - */ - run: ( - layouts: DashboardSectionLayouts, - sections: DashboardSection[] - ) => DashboardSectionLayouts; -}; - -const TAGS_TYPE = 'jpa/tags'; - -/** - * The instance a default layout carries for a type, when it has one. - * - * @param sections - The dashboard sections. - * @param slug - The section to read. - * @param type - The widget type to find. - * @return The default instance, or undefined. - */ -function defaultInstanceOf( - sections: DashboardSection[], - slug: string, - type: string -): DashboardWidget | undefined { - return sections - .find( section => section.slug === slug ) - ?.default_layout?.find( widget => widget.type === type ); -} - -/** - * Top tags & categories moved from Insights to Traffic once `stats/tags` took a - * date window. A reader who customized Insights keeps everything but that tile; - * one who customized Traffic gains it at the end, as the default places it. - */ -const tagsToTraffic: SectionLayoutMigration = { - id: 'tags-to-traffic', - run( layouts, sections ) { - let next = layouts; - - const insights = layouts.insights; - if ( insights?.some( widget => widget.type === TAGS_TYPE ) ) { - next = { ...next, insights: insights.filter( widget => widget.type !== TAGS_TYPE ) }; - } - - // The default instance, placement included: the default lists it last. - const traffic = layouts.traffic; - const instance = defaultInstanceOf( sections, 'traffic', TAGS_TYPE ); - if ( traffic && instance && ! traffic.some( widget => widget.type === TAGS_TYPE ) ) { - next = { ...next, traffic: [ ...traffic, instance ] }; - } - - return next; - }, -}; - -export const SECTION_LAYOUT_MIGRATIONS: readonly SectionLayoutMigration[] = [ tagsToTraffic ]; - -/** - * Run the migrations not yet applied, in order. - * - * @param layouts - The stored section layouts. - * @param applied - Ids of the migrations already applied. - * @param sections - The dashboard sections, carrying their defaults. - * @param migrations - The migrations to consider. - * @return The layouts and applied ids to store, or null when every migration has run. - */ -export function migrateSectionLayouts( - layouts: DashboardSectionLayouts, - applied: readonly string[], - sections: DashboardSection[], - migrations: readonly SectionLayoutMigration[] = SECTION_LAYOUT_MIGRATIONS -): { layouts: DashboardSectionLayouts; applied: string[] } | null { - const pending = migrations.filter( migration => ! applied.includes( migration.id ) ); - if ( ! pending.length ) { - return null; - } - - return { - layouts: pending.reduce( - ( current, migration ) => migration.run( current, sections ), - layouts - ), - applied: [ ...applied, ...pending.map( migration => migration.id ) ], - }; -} diff --git a/projects/packages/premium-analytics/routes/dashboard/hooks/constants.ts b/projects/packages/premium-analytics/routes/dashboard/hooks/constants.ts index 79c8a2b46743..4b0c6b6a4f5d 100644 --- a/projects/packages/premium-analytics/routes/dashboard/hooks/constants.ts +++ b/projects/packages/premium-analytics/routes/dashboard/hooks/constants.ts @@ -9,9 +9,6 @@ export { DASHBOARD_PREFERENCES_SCOPE } from '@jetpack-premium-analytics/data'; /** Re-exported from the widgets toolkit, whose feedback modal cannot import this route. */ export { DASHBOARD_SECTION_LAYOUTS_KEY } from '@jetpack-premium-analytics/widgets-toolkit'; -/** Preferences key holding the ids of the section layout migrations already applied. */ -export const DASHBOARD_LAYOUT_MIGRATIONS_KEY = 'dashboardLayoutMigrations'; - /** Preferences key holding the dashboard grid settings. */ export const DASHBOARD_GRID_SETTINGS_KEY = 'dashboardGridSettings'; diff --git a/projects/packages/premium-analytics/routes/dashboard/hooks/index.ts b/projects/packages/premium-analytics/routes/dashboard/hooks/index.ts index d7acbdc28556..e03ff96b2905 100644 --- a/projects/packages/premium-analytics/routes/dashboard/hooks/index.ts +++ b/projects/packages/premium-analytics/routes/dashboard/hooks/index.ts @@ -7,4 +7,3 @@ export { useFeedbackBanner } from './use-feedback-banner'; export { useOnboarding } from './use-onboarding'; export { useRememberAppliedPreset } from './use-remember-applied-preset'; export { useSectionDateFilter } from './use-section-date-filter'; -export { useSectionLayoutMigrations } from './use-section-layout-migrations'; diff --git a/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/index.ts b/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/index.ts deleted file mode 100644 index 293b1a215acc..000000000000 --- a/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useSectionLayoutMigrations } from './use-section-layout-migrations'; diff --git a/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts b/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts deleted file mode 100644 index 2256fb9d0eb8..000000000000 --- a/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * External dependencies - */ -import { renderHook } from '@testing-library/react'; -import { dispatch, select } from '@wordpress/data'; -import { store as preferencesStore } from '@wordpress/preferences'; -/** - * Internal dependencies - */ -import { - DASHBOARD_LAYOUT_MIGRATIONS_KEY, - DASHBOARD_PREFERENCES_SCOPE, - DASHBOARD_SECTION_LAYOUTS_KEY, -} from '../constants'; -import { useSectionLayoutMigrations } from './use-section-layout-migrations'; -import type { DashboardSection } from '../../config'; -import type { DashboardWidget } from '@wordpress/widget-dashboard'; - -const tags: DashboardWidget = { uuid: 'default-tags-widget-instance', type: 'jpa/tags' }; -const widget = ( uuid: string ): DashboardWidget => ( { uuid, type: `jpa/${ uuid }` } ); -const sections: DashboardSection[] = [ - { - id: 'analytics/traffic', - slug: 'traffic', - label: 'Traffic', - order: 0, - default_layout: [ tags ], - }, - { id: 'analytics/insights', slug: 'insights', label: 'Insights', order: 1, default_layout: [] }, -]; - -/** - * Read a dashboard preference. - * - * @param key - The preference key. - * @return The stored value. - */ -function stored( key: string ): unknown { - return ( - select( preferencesStore ) as unknown as { get: ( scope: string, key: string ) => unknown } - ).get( DASHBOARD_PREFERENCES_SCOPE, key ); -} - -describe( 'useSectionLayoutMigrations', () => { - beforeEach( () => { - dispatch( preferencesStore ).set( DASHBOARD_PREFERENCES_SCOPE, DASHBOARD_SECTION_LAYOUTS_KEY, { - insights: [ widget( 'all-time-stats' ), tags ], - traffic: [ widget( 'referrers' ) ], - } ); - dispatch( preferencesStore ).set( - DASHBOARD_PREFERENCES_SCOPE, - DASHBOARD_LAYOUT_MIGRATIONS_KEY, - [] - ); - } ); - - it( 'waits for the sections, then rewrites the stored layouts once and records it', () => { - const { rerender } = renderHook( - ( { hasResolved } ) => useSectionLayoutMigrations( sections, hasResolved ), - { initialProps: { hasResolved: false } } - ); - - expect( stored( DASHBOARD_LAYOUT_MIGRATIONS_KEY ) ).toEqual( [] ); - - rerender( { hasResolved: true } ); - - expect( stored( DASHBOARD_SECTION_LAYOUTS_KEY ) ).toEqual( { - insights: [ widget( 'all-time-stats' ) ], - traffic: [ widget( 'referrers' ), tags ], - } ); - expect( stored( DASHBOARD_LAYOUT_MIGRATIONS_KEY ) ).toEqual( [ 'tags-to-traffic' ] ); - } ); - - it( 'leaves a layout the reader changed after the migration alone', () => { - dispatch( preferencesStore ).set( - DASHBOARD_PREFERENCES_SCOPE, - DASHBOARD_LAYOUT_MIGRATIONS_KEY, - [ 'tags-to-traffic' ] - ); - const layouts = stored( DASHBOARD_SECTION_LAYOUTS_KEY ); - - renderHook( () => useSectionLayoutMigrations( sections, true ) ); - - expect( stored( DASHBOARD_SECTION_LAYOUTS_KEY ) ).toBe( layouts ); - } ); -} ); diff --git a/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.ts b/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.ts deleted file mode 100644 index b4252beef950..000000000000 --- a/projects/packages/premium-analytics/routes/dashboard/hooks/use-section-layout-migrations/use-section-layout-migrations.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useDispatch, useSelect } from '@wordpress/data'; -import { store as preferencesStore } from '@wordpress/preferences'; -import { useEffect } from 'react'; -import { isDashboardSectionLayouts, migrateSectionLayouts } from '../../config'; -import { - DASHBOARD_LAYOUT_MIGRATIONS_KEY, - DASHBOARD_PREFERENCES_SCOPE, - DASHBOARD_SECTION_LAYOUTS_KEY, -} from '../constants'; -import type { DashboardSection } from '../../config'; - -type PreferencesActions = { - set: ( scope: string, key: string, value: unknown ) => Promise< void > | void; -}; - -const NO_IDS: readonly string[] = []; - -/** - * Apply the section layout migrations a reader has not had yet, once the - * sections have resolved: a migration may read a section's default. - * - * A reader with no stored layouts gets the defaults, which already carry every - * move; the migrations are still recorded so a later customization is not rewritten. - * - * @param sections - The dashboard sections, carrying their defaults. - * @param hasResolved - Whether the sections have resolved. - */ -export function useSectionLayoutMigrations( sections: DashboardSection[], hasResolved: boolean ) { - const { layouts, applied } = useSelect( select => { - const { get } = select( preferencesStore ) as unknown as { - get: ( scope: string, key: string ) => unknown; - }; - const storedLayouts = get( DASHBOARD_PREFERENCES_SCOPE, DASHBOARD_SECTION_LAYOUTS_KEY ); - const storedApplied = get( DASHBOARD_PREFERENCES_SCOPE, DASHBOARD_LAYOUT_MIGRATIONS_KEY ); - - return { - layouts: isDashboardSectionLayouts( storedLayouts ) ? storedLayouts : undefined, - applied: Array.isArray( storedApplied ) - ? storedApplied.filter( ( id ): id is string => typeof id === 'string' ) - : NO_IDS, - }; - }, [] ); - const { set } = useDispatch( preferencesStore ) as unknown as PreferencesActions; - - useEffect( () => { - if ( ! hasResolved ) { - return; - } - - const result = migrateSectionLayouts( layouts ?? {}, applied, sections ); - if ( ! result ) { - return; - } - - if ( layouts && result.layouts !== layouts ) { - void set( DASHBOARD_PREFERENCES_SCOPE, DASHBOARD_SECTION_LAYOUTS_KEY, result.layouts ); - } - void set( DASHBOARD_PREFERENCES_SCOPE, DASHBOARD_LAYOUT_MIGRATIONS_KEY, result.applied ); - }, [ hasResolved, layouts, applied, sections, set ] ); -} diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx index 2fa891549c32..9b4738709e81 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.test.tsx @@ -397,7 +397,6 @@ jest.mock( './hooks', () => ( { useRememberAppliedPreset: jest.requireActual( './hooks/use-remember-applied-preset' ) .useRememberAppliedPreset, useSectionDateFilter: jest.fn(), - useSectionLayoutMigrations: jest.fn(), } ) ); beforeEach( () => { diff --git a/projects/packages/premium-analytics/routes/dashboard/stage.tsx b/projects/packages/premium-analytics/routes/dashboard/stage.tsx index 6d5555ddce14..c56956b71134 100644 --- a/projects/packages/premium-analytics/routes/dashboard/stage.tsx +++ b/projects/packages/premium-analytics/routes/dashboard/stage.tsx @@ -59,7 +59,6 @@ import { useOnboarding, useRememberAppliedPreset, useSectionDateFilter, - useSectionLayoutMigrations, } from './hooks'; import './overlay-focus-ring.scss'; import styles from './stage.module.scss'; @@ -75,8 +74,6 @@ import type { JSX } from 'react'; function Dashboard(): JSX.Element { const { sections, hasResolved: hasResolvedSections } = useDashboardSections(); const [ activeSection, setActiveSection ] = useActiveSection( sections ); - // Before the layout reads the stored map, so a moved widget lands on its new tab. - useSectionLayoutMigrations( sections, hasResolvedSections ); const widgetModules = useWidgetModules(); const widgetTypeRenames = useMemo( () => buildWidgetTypeRenames( widgetModules ), From b46ca97b3476574357e7c444f9356c1ccf1b1d33 Mon Sep 17 00:00:00 2001 From: dognose24 Date: Mon, 12 Oct 2026 22:18:31 +0800 Subject: [PATCH 6/6] Address review: fill the Insights row before shares, drop the comparison wrapper and two redundant checks Co-Authored-By: Claude Fable 5.1 --- .../src/queries/__tests__/stats-queries.test.ts | 1 - .../src/default-dashboard-sections.php | 15 ++++++++------- .../tests/php/Dashboard_Layout_Test.php | 4 ++-- .../widgets/tags/__tests__/tags.test.tsx | 4 ---- .../premium-analytics/widgets/tags/render.tsx | 10 +++------- 5 files changed, 13 insertions(+), 21 deletions(-) diff --git a/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts b/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts index 294b96d50308..61510aaed1a9 100644 --- a/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts +++ b/projects/packages/premium-analytics/packages/data/src/queries/__tests__/stats-queries.test.ts @@ -657,7 +657,6 @@ describe( 'Stats query factories', () => { // range count fewer posts than a shorter one. it.each( [ [ '1 day', '2026-06-01', '2026-06-01' ], - [ '31 days', '2026-06-01', '2026-07-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< diff --git a/projects/packages/premium-analytics/src/default-dashboard-sections.php b/projects/packages/premium-analytics/src/default-dashboard-sections.php index d10f5f7f93e2..96f353014249 100644 --- a/projects/packages/premium-analytics/src/default-dashboard-sections.php +++ b/projects/packages/premium-analytics/src/default-dashboard-sections.php @@ -278,7 +278,8 @@ function get_insights_section_default_layout() { 3, 2 ), - // Row 6: most commented posts + shares (Simple only) + most commented authors. + // 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-most-commented-posts-widget-instance', 'jpa/most-commented-posts', @@ -287,17 +288,17 @@ function get_insights_section_default_layout() { 2 ), get_dashboard_default_widget_instance( - 'default-shares-widget-instance', - 'jpa/shares', + 'default-most-commented-authors-widget-instance', + 'jpa/most-commented-authors', 9, - 1, + 2, 2 ), get_dashboard_default_widget_instance( - 'default-most-commented-authors-widget-instance', - 'jpa/most-commented-authors', + 'default-shares-widget-instance', + 'jpa/shares', 10, - 2, + 1, 2 ), ); diff --git a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php index ec8bf07a5a53..9acb17bfda60 100644 --- a/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php +++ b/projects/packages/premium-analytics/tests/php/Dashboard_Layout_Test.php @@ -464,8 +464,8 @@ public function test_insights_section_declares_the_bundled_widgets() { 'default-posting-activity-widget-instance' => array( 'jpa/posting-activity', 3, 2, 6 ), 'default-views-over-years-widget-instance' => array( 'jpa/views-over-years', 3, 2, 7 ), 'default-most-commented-posts-widget-instance' => array( 'jpa/most-commented-posts', 1, 2, 8 ), - 'default-shares-widget-instance' => array( 'jpa/shares', 1, 2, 9 ), - 'default-most-commented-authors-widget-instance' => array( 'jpa/most-commented-authors', 2, 2, 10 ), + 'default-most-commented-authors-widget-instance' => array( 'jpa/most-commented-authors', 2, 2, 9 ), + 'default-shares-widget-instance' => array( 'jpa/shares', 1, 2, 10 ), ), $layout ); diff --git a/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx b/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx index 50d294d52a96..c678ad9b63e7 100644 --- a/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx +++ b/projects/packages/premium-analytics/widgets/tags/__tests__/tags.test.tsx @@ -234,9 +234,5 @@ describe( 'TagsWidget CSV export', () => { expect( lines[ 0 ] ).toBe( '"Tag or category","Views","URL"' ); expect( lines ).toContain( '"Desserts, chocolate","760",""' ); expect( lines ).toHaveLength( 13 ); - // The window is part of the export now, so the filename names it. - expect( downloads.files[ 0 ].filename ).toMatch( - /^tags-and-categories-\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\.csv$/ - ); } ); } ); diff --git a/projects/packages/premium-analytics/widgets/tags/render.tsx b/projects/packages/premium-analytics/widgets/tags/render.tsx index 302bfd2babf5..cb589d1eb59f 100644 --- a/projects/packages/premium-analytics/widgets/tags/render.tsx +++ b/projects/packages/premium-analytics/widgets/tags/render.tsx @@ -26,7 +26,6 @@ import { import { tag as tagIllustration } from '@jetpack-premium-analytics/icons'; import { useEffect, useMemo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { ReportScopeProvider } from '@jetpack-premium-analytics/data'; import { Stack } from '@jetpack-premium-analytics/externals'; /** * Internal dependencies @@ -200,11 +199,8 @@ function TagsInner() { */ export default function Tags( { attributes = {} }: TagsWidgetProps ) { return ( - // `stats/tags` reports one period, so the widget never offers the comparison. - - - - - + + + ); }