From 08603a85ace07af09e40157def7c08cc8f818302 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Fri, 12 Jun 2026 10:55:46 +0900 Subject: [PATCH 01/12] add authors widget backed by Jetpack Stats - widgets-toolkit: add AuthorsWidget (consumes the useStatsTopAuthors hook from the data package) and the buildTopAuthorsData leaderboard helper - widgets/authors: register the jpa/authors dashboard widget; render.tsx fetches via the existing /jetpack-premium-analytics/v1/proxy Stats proxy (v1.1 stats/top-authors) and wraps the widget in WidgetRoot - query client provider: add withDevtools prop so per-widget providers don't each render React Query devtools - widget-dashboard: inset picker previews with padding instead of scaling so content clears the selection checkbox --- .../changelog/update-pa-authors-widget | 4 + .../src/helpers/build-top-authors-data.ts | 99 ++++++++++++++++ .../widgets-toolkit/src/helpers/index.ts | 1 + .../packages/widgets-toolkit/src/index.ts | 1 + .../src/widgets/authors/authors-widget.tsx | 109 ++++++++++++++++++ .../src/widgets/authors/index.ts | 1 + .../widgets-toolkit/src/widgets/index.ts | 1 + .../widgets/authors/package.json | 9 ++ .../widgets/authors/render.tsx | 66 +++++++++++ .../widgets/authors/widget.json | 6 + .../widgets/authors/widget.ts | 26 +++++ 11 files changed, 323 insertions(+) create mode 100644 projects/packages/premium-analytics/changelog/update-pa-authors-widget create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-top-authors-data.ts create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/index.ts create mode 100644 projects/packages/premium-analytics/widgets/authors/package.json create mode 100644 projects/packages/premium-analytics/widgets/authors/render.tsx create mode 100644 projects/packages/premium-analytics/widgets/authors/widget.json create mode 100644 projects/packages/premium-analytics/widgets/authors/widget.ts diff --git a/projects/packages/premium-analytics/changelog/update-pa-authors-widget b/projects/packages/premium-analytics/changelog/update-pa-authors-widget new file mode 100644 index 000000000000..85cab3622bd7 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/update-pa-authors-widget @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add an Authors dashboard widget showing top authors by views via the Jetpack Stats API (through the stats-admin proxy), with a common stats query in the data package. diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-top-authors-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-top-authors-data.ts new file mode 100644 index 000000000000..234e971eb076 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-top-authors-data.ts @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +import type { + StatsNormalizedReport, + StatsTopAuthorsItem, +} from '@jetpack-premium-analytics/data'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { calculateDelta } from './calculate-delta'; +import type { LeaderboardChartData } from '../components/chart-leaderboard'; + +type TopAuthorLeaderboardEntry = { + id: string; + label: string; + views: number; +}; + +function getAuthorLabel( author: StatsTopAuthorsItem ) { + return typeof author.label === 'string' && author.label + ? author.label + : __( 'Untracked authors', 'jetpack-premium-analytics' ); +} + +function summarizeAuthors( + report: StatsNormalizedReport< StatsTopAuthorsItem > | undefined +): TopAuthorLeaderboardEntry[] { + const authorViews = new Map< string, TopAuthorLeaderboardEntry >(); + + for ( const dataPoint of report?.data ?? [] ) { + for ( const author of dataPoint.items ) { + const label = getAuthorLabel( author ); + const existing = authorViews.get( label ); + + authorViews.set( label, { + id: existing?.id ?? label, + label, + views: ( existing?.views ?? 0 ) + author.views, + } ); + } + } + + return Array.from( authorViews.values() ).sort( ( a, b ) => b.views - a.views ); +} + +/** + * Builds leaderboard chart data for the Authors widget. + * + * Transforms Jetpack Stats top-authors data into the format required by + * LeaderboardChart, with comparison values aligned by author name (authors + * missing from the comparison period count as zero). + * + * @param primary - Primary period top-authors data + * @param comparison - Comparison period top-authors data + * @param maxEntries - Maximum number of entries to include in the leaderboard + * @return Processed data ready for LeaderboardChart component + */ +export function buildTopAuthorsData( + primary: StatsNormalizedReport< StatsTopAuthorsItem > | undefined, + comparison: StatsNormalizedReport< StatsTopAuthorsItem > | undefined, + maxEntries = 7 +): LeaderboardChartData { + const primaryAuthors = summarizeAuthors( primary ); + + if ( primaryAuthors.length === 0 ) { + return []; + } + + const comparisonViews = new Map( + summarizeAuthors( comparison ).map( author => [ author.label, author.views ] ) + ); + + const data = primaryAuthors.slice( 0, maxEntries ); + + // Find the max value for share calculation + const maxValue = Math.max( + ...data.map( author => Math.max( author.views, comparisonViews.get( author.label ) ?? 0 ) ), + 1 // Prevent division by zero + ); + + return data.map( author => { + const currentValue = author.views; + const previousValue = comparisonViews.get( author.label ) ?? 0; + const delta = calculateDelta( currentValue, previousValue ); + + return { + id: author.id, + label: author.label, + currentValue, + previousValue, + currentShare: ( currentValue / maxValue ) * 100, + previousShare: ( previousValue / maxValue ) * 100, + delta, + }; + } ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts index 4ef35963fa5b..a98b28a61c30 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts @@ -44,3 +44,4 @@ export { } from './build-visitors-by-location-data'; export { flagUrl } from './flag-url'; export { isEmptyChartData, isEmptyPieChartData, getEmptyChartDomain } from './chart-empty-state'; +export { buildTopAuthorsData } from './build-top-authors-data'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts index abf391ef8472..89115981adb6 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -83,6 +83,7 @@ export { BookingsByAttendanceWidget, BookingsRevenueByCustomerTypeWidget, BookingConversionRateWidget, + AuthorsWidget, ConversionRateWidget, CouponUseWidget, MetricComparisonWidget, diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx new file mode 100644 index 000000000000..ff487c057f0c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +import { + useStatsTopAuthors, + type StatsNormalizedReport, + type StatsReportParams, + type StatsTopAuthorsItem, +} from '@jetpack-premium-analytics/data'; +import { customer } from '@jetpack-premium-analytics/icons'; +import { __ } from '@wordpress/i18n'; +import { useMemo } from 'react'; + +/** + * Internal dependencies + */ +import { LeaderboardChart } from '../../components/chart-leaderboard'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildTopAuthorsData, formatLegendLabels } from '../../helpers'; +import { useWidgetError } from '../../hooks'; + +type AuthorsWidgetProps = { + /** + * Maximum number of authors to display. + */ + max?: number; +}; + +type StatsTopAuthorsReport = StatsNormalizedReport< StatsTopAuthorsItem >; + +/** + * Authors Widget Component + * + * Displays a leaderboard chart showing the site's top authors by views, + * sourced from the Jetpack Stats API. + * + * Features: + * - Comparison support (current vs previous period) + * - Configurable author limit + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @param props - Component props + * @param props.max - Maximum number of authors to display + * + * @example + * + * + * + */ +export function AuthorsWidget( { max }: AuthorsWidgetProps ) { + const { reportParams } = useWidgetRootContext(); + + const { + primary, + comparison, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + error, + refetch, + } = useStatsTopAuthors( reportParams as StatsReportParams ); + + // `primary.isPending` also covers the brief window where the query is disabled + // while the report params resolve (isLoading is false there). + const isInitialLoading = ( isLoading || primary.isPending ) && ! hasData; + const isRefetching = isFetching && hasData; + const primaryData = primary.data as StatsTopAuthorsReport | undefined; + const comparisonData = comparison.data as StatsTopAuthorsReport | undefined; + + const chartData = useMemo( + () => buildTopAuthorsData( primaryData, comparisonData, max ), + [ primaryData, comparisonData, max ] + ); + + const legendLabels = useMemo( () => formatLegendLabels( reportParams ), [ reportParams ] ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/index.ts new file mode 100644 index 000000000000..8b3753a8b349 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/index.ts @@ -0,0 +1 @@ +export { AuthorsWidget } from './authors-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts index e19dede3bd8b..16f6ba98eaf5 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts @@ -22,3 +22,4 @@ export { export { CouponUseWidget } from './coupon-use'; export { OrdersFulfillmentWidget } from './orders-fulfillment'; export { VisitorsByLocationWidget } from './visitors-by-location'; +export { AuthorsWidget } from './authors'; diff --git a/projects/packages/premium-analytics/widgets/authors/package.json b/projects/packages/premium-analytics/widgets/authors/package.json new file mode 100644 index 000000000000..bced4ef5f184 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/authors/package.json @@ -0,0 +1,9 @@ +{ + "name": "@automattic/jetpack-premium-analytics-widget-authors", + "version": "0.1.0-alpha", + "private": true, + "type": "module", + "dependencies": { + "react": "18.3.1" + } +} diff --git a/projects/packages/premium-analytics/widgets/authors/render.tsx b/projects/packages/premium-analytics/widgets/authors/render.tsx new file mode 100644 index 000000000000..25940033acab --- /dev/null +++ b/projects/packages/premium-analytics/widgets/authors/render.tsx @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { AuthorsWidget, WidgetRoot } from '@jetpack-premium-analytics/widgets-toolkit'; +import { useMemo } from 'react'; +import type { ComponentProps } from 'react'; + +const DEFAULT_MAX = 7; + +type AuthorsAttributes = NonNullable< ComponentProps< typeof WidgetRoot >[ 'attributes' ] > & { + max?: string; +}; + +type AuthorsRenderProps = { + attributes?: AuthorsAttributes; +}; + +const toPositiveInt = ( value: string | undefined, fallback: number ) => { + const parsed = Number.parseInt( value ?? '', 10 ); + + return Number.isFinite( parsed ) && parsed > 0 ? parsed : fallback; +}; + +const toDateString = ( date: Date ) => { + const pad = ( part: number ) => String( part ).padStart( 2, '0' ); + + return `${ date.getFullYear() }-${ pad( date.getMonth() + 1 ) }-${ pad( date.getDate() ) }`; +}; + +/** + * Build a "very long" default report range (all time, through the end of + * today) used when the host doesn't pass explicit report params. Explicit + * from/to pass through `normalizeReportParams` untouched, so this wide range + * survives WidgetRoot's normalization instead of the rolling default preset. + * + * TODO: Remove the default range once we have a way to pass the launched date to the widget. + */ +const getDefaultReportParams = () => ( { + from: '2000-01-01T00:00:00', + to: `${ toDateString( new Date() ) }T23:59:59`, + interval: 'day' as const, +} ); + +/** + * Authors widget render entry point. + * + * WidgetRoot provides the analytics query client, chart theme, and the + * resolved report params consumed by the toolkit widget. + * + * @param props - Render props. + * @param props.attributes - Widget attributes. + */ +export default function Authors( { attributes }: AuthorsRenderProps ) { + const attributesWithDefaults = useMemo( () => { + const hasReportParams = + !! attributes?.reportParams && Object.keys( attributes.reportParams ).length > 0; + + return hasReportParams ? attributes : { ...attributes, reportParams: getDefaultReportParams() }; + }, [ attributes ] ); + + return ( + + + + ); +} diff --git a/projects/packages/premium-analytics/widgets/authors/widget.json b/projects/packages/premium-analytics/widgets/authors/widget.json new file mode 100644 index 000000000000..f4fa210d7d54 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/authors/widget.json @@ -0,0 +1,6 @@ +{ + "name": "jpa/authors", + "title": "Authors", + "description": "Top authors by views, with their most viewed posts.", + "category": "stats" +} diff --git a/projects/packages/premium-analytics/widgets/authors/widget.ts b/projects/packages/premium-analytics/widgets/authors/widget.ts new file mode 100644 index 000000000000..9561c5a150db --- /dev/null +++ b/projects/packages/premium-analytics/widgets/authors/widget.ts @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { postAuthor } from '@wordpress/icons'; + +/** + * Widget type definition. + */ +export default { + name: 'jpa/authors', + title: __( 'Authors', 'jetpack-premium-analytics' ), + icon: postAuthor, + attributes: [ + { + id: 'max', + label: __( 'Maximum authors', 'jetpack-premium-analytics' ), + type: 'text', + }, + ], + example: { + attributes: { + max: '7', + }, + }, +}; From 37c5777faabe49cd175abcf764e1d7ef0ef85f7a Mon Sep 17 00:00:00 2001 From: Nikhil Date: Wed, 24 Jun 2026 12:06:38 +0530 Subject: [PATCH 02/12] Authors widget: address review feedback - Forward `max` to the Stats top-authors query so the API limit matches the rendered leaderboard instead of relying on the endpoint default. - Dedup and align authors by stable `author_id` (falling back to the display label) so distinct authors sharing a name aren't merged. - Translate the "Untracked authors" label in a single place: the processing layer leaves an empty label and the widget supplies the translated fallback. - Drop `StatsProxyParams`' index signature from `StatsReportParams` so `reportParams` no longer needs casting; make `useStatsReport` generic so `useStatsTopAuthors` returns a typed report (removes data casts). - Declare the widget's runtime deps (widgets-toolkit, @wordpress/i18n, @wordpress/icons) in its package.json. - Forward `setError` from the render entry to WidgetRoot. --- .../src/helpers/build-top-authors-data.ts | 36 ++++++++++--------- .../src/widgets/authors/authors-widget.tsx | 27 +++++++------- .../widgets/authors/package.json | 3 ++ .../widgets/authors/render.tsx | 9 +++-- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-top-authors-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-top-authors-data.ts index 234e971eb076..c93892106566 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-top-authors-data.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-top-authors-data.ts @@ -1,20 +1,18 @@ /** * External dependencies */ -import type { - StatsNormalizedReport, - StatsTopAuthorsItem, -} from '@jetpack-premium-analytics/data'; import { __ } from '@wordpress/i18n'; - /** * Internal dependencies */ import { calculateDelta } from './calculate-delta'; import type { LeaderboardChartData } from '../components/chart-leaderboard'; +import type { StatsNormalizedReport, StatsTopAuthorsItem } from '@jetpack-premium-analytics/data'; type TopAuthorLeaderboardEntry = { - id: string; + // Stable key used to dedup and to align primary/comparison periods. Prefer + // the author id; fall back to the display label when no id is available. + key: string; label: string; views: number; }; @@ -25,6 +23,10 @@ function getAuthorLabel( author: StatsTopAuthorsItem ) { : __( 'Untracked authors', 'jetpack-premium-analytics' ); } +function getAuthorKey( author: StatsTopAuthorsItem, label: string ) { + return author.id !== undefined && author.id !== null ? String( author.id ) : label; +} + function summarizeAuthors( report: StatsNormalizedReport< StatsTopAuthorsItem > | undefined ): TopAuthorLeaderboardEntry[] { @@ -33,11 +35,12 @@ function summarizeAuthors( for ( const dataPoint of report?.data ?? [] ) { for ( const author of dataPoint.items ) { const label = getAuthorLabel( author ); - const existing = authorViews.get( label ); + const key = getAuthorKey( author, label ); + const existing = authorViews.get( key ); - authorViews.set( label, { - id: existing?.id ?? label, - label, + authorViews.set( key, { + key, + label: existing?.label ?? label, views: ( existing?.views ?? 0 ) + author.views, } ); } @@ -50,8 +53,9 @@ function summarizeAuthors( * Builds leaderboard chart data for the Authors widget. * * Transforms Jetpack Stats top-authors data into the format required by - * LeaderboardChart, with comparison values aligned by author name (authors - * missing from the comparison period count as zero). + * LeaderboardChart, with comparison values aligned by author (by stable author + * id, falling back to display name when none is available; authors missing from + * the comparison period count as zero). * * @param primary - Primary period top-authors data * @param comparison - Comparison period top-authors data @@ -70,24 +74,24 @@ export function buildTopAuthorsData( } const comparisonViews = new Map( - summarizeAuthors( comparison ).map( author => [ author.label, author.views ] ) + summarizeAuthors( comparison ).map( author => [ author.key, author.views ] ) ); const data = primaryAuthors.slice( 0, maxEntries ); // Find the max value for share calculation const maxValue = Math.max( - ...data.map( author => Math.max( author.views, comparisonViews.get( author.label ) ?? 0 ) ), + ...data.map( author => Math.max( author.views, comparisonViews.get( author.key ) ?? 0 ) ), 1 // Prevent division by zero ); return data.map( author => { const currentValue = author.views; - const previousValue = comparisonViews.get( author.label ) ?? 0; + const previousValue = comparisonViews.get( author.key ) ?? 0; const delta = calculateDelta( currentValue, previousValue ); return { - id: author.id, + id: author.key, label: author.label, currentValue, previousValue, diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx index ff487c057f0c..8e14cdfd9cad 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx @@ -1,16 +1,10 @@ /** * External dependencies */ -import { - useStatsTopAuthors, - type StatsNormalizedReport, - type StatsReportParams, - type StatsTopAuthorsItem, -} from '@jetpack-premium-analytics/data'; +import { useStatsTopAuthors } from '@jetpack-premium-analytics/data'; import { customer } from '@jetpack-premium-analytics/icons'; import { __ } from '@wordpress/i18n'; import { useMemo } from 'react'; - /** * Internal dependencies */ @@ -20,6 +14,8 @@ import { useWidgetRootContext } from '../../components/widget-root'; import { buildTopAuthorsData, formatLegendLabels } from '../../helpers'; import { useWidgetError } from '../../hooks'; +const DEFAULT_MAX = 7; + type AuthorsWidgetProps = { /** * Maximum number of authors to display. @@ -27,8 +23,6 @@ type AuthorsWidgetProps = { max?: number; }; -type StatsTopAuthorsReport = StatsNormalizedReport< StatsTopAuthorsItem >; - /** * Authors Widget Component * @@ -51,6 +45,11 @@ type StatsTopAuthorsReport = StatsNormalizedReport< StatsTopAuthorsItem >; */ export function AuthorsWidget( { max }: AuthorsWidgetProps ) { const { reportParams } = useWidgetRootContext(); + const maxAuthors = max ?? DEFAULT_MAX; + const statsParams = useMemo( + () => ( { ...reportParams, max: maxAuthors } ), + [ reportParams, maxAuthors ] + ); const { primary, @@ -62,18 +61,18 @@ export function AuthorsWidget( { max }: AuthorsWidgetProps ) { isError, error, refetch, - } = useStatsTopAuthors( reportParams as StatsReportParams ); + } = useStatsTopAuthors( statsParams ); // `primary.isPending` also covers the brief window where the query is disabled // while the report params resolve (isLoading is false there). const isInitialLoading = ( isLoading || primary.isPending ) && ! hasData; const isRefetching = isFetching && hasData; - const primaryData = primary.data as StatsTopAuthorsReport | undefined; - const comparisonData = comparison.data as StatsTopAuthorsReport | undefined; + const primaryData = primary.data; + const comparisonData = comparison.data; const chartData = useMemo( - () => buildTopAuthorsData( primaryData, comparisonData, max ), - [ primaryData, comparisonData, max ] + () => buildTopAuthorsData( primaryData, comparisonData, maxAuthors ), + [ primaryData, comparisonData, maxAuthors ] ); const legendLabels = useMemo( () => formatLegendLabels( reportParams ), [ reportParams ] ); diff --git a/projects/packages/premium-analytics/widgets/authors/package.json b/projects/packages/premium-analytics/widgets/authors/package.json index bced4ef5f184..8681368938ed 100644 --- a/projects/packages/premium-analytics/widgets/authors/package.json +++ b/projects/packages/premium-analytics/widgets/authors/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "dependencies": { + "@jetpack-premium-analytics/widgets-toolkit": "workspace:*", + "@wordpress/i18n": "^6.9.0", + "@wordpress/icons": "^13.0.0", "react": "18.3.1" } } diff --git a/projects/packages/premium-analytics/widgets/authors/render.tsx b/projects/packages/premium-analytics/widgets/authors/render.tsx index 25940033acab..aea9553751a8 100644 --- a/projects/packages/premium-analytics/widgets/authors/render.tsx +++ b/projects/packages/premium-analytics/widgets/authors/render.tsx @@ -13,6 +13,7 @@ type AuthorsAttributes = NonNullable< ComponentProps< typeof WidgetRoot >[ 'attr type AuthorsRenderProps = { attributes?: AuthorsAttributes; + setError?: ComponentProps< typeof WidgetRoot >[ 'setError' ]; }; const toPositiveInt = ( value: string | undefined, fallback: number ) => { @@ -34,6 +35,8 @@ const toDateString = ( date: Date ) => { * survives WidgetRoot's normalization instead of the rolling default preset. * * TODO: Remove the default range once we have a way to pass the launched date to the widget. + * + * @return The default report params covering an all-time range. */ const getDefaultReportParams = () => ( { from: '2000-01-01T00:00:00', @@ -49,8 +52,10 @@ const getDefaultReportParams = () => ( { * * @param props - Render props. * @param props.attributes - Widget attributes. + * @param props.setError - Dashboard error handler. + * @return The rendered Authors widget. */ -export default function Authors( { attributes }: AuthorsRenderProps ) { +export default function Authors( { attributes, setError }: AuthorsRenderProps ) { const attributesWithDefaults = useMemo( () => { const hasReportParams = !! attributes?.reportParams && Object.keys( attributes.reportParams ).length > 0; @@ -59,7 +64,7 @@ export default function Authors( { attributes }: AuthorsRenderProps ) { }, [ attributes ] ); return ( - + ); From 40518d35066a695fec2b4de106382b41a2c04e95 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Wed, 24 Jun 2026 13:53:21 +0530 Subject: [PATCH 03/12] Fix grammar for the empty state text Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../widgets-toolkit/src/widgets/authors/authors-widget.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx index 8e14cdfd9cad..8066649d8cc8 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx @@ -96,9 +96,8 @@ export function AuthorsWidget( { max }: AuthorsWidgetProps ) { type: 'number', options: { useMultipliers: false, decimals: 0 }, } } - emptyStateIcon={ customer } emptyStateText={ __( - 'Learn about your most popular authors to better understand how they contribute to grow your site.', + 'Learn about your most popular authors to better understand how they contribute to growing your site.', 'jetpack-premium-analytics' ) } /> From a066ea18d1e300f106c448068bda0a672a1af7bd Mon Sep 17 00:00:00 2001 From: Nikhil Date: Wed, 24 Jun 2026 14:05:23 +0530 Subject: [PATCH 04/12] Authors widget: remove unused customer icon import The empty-state icon was dropped earlier but the import was left behind, failing ESLint and the exclude-list check in CI. --- .../widgets-toolkit/src/widgets/authors/authors-widget.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx index 8066649d8cc8..7afb0aeac6d8 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx @@ -2,7 +2,6 @@ * External dependencies */ import { useStatsTopAuthors } from '@jetpack-premium-analytics/data'; -import { customer } from '@jetpack-premium-analytics/icons'; import { __ } from '@wordpress/i18n'; import { useMemo } from 'react'; /** From 33ff8000a95dddb1ec24f39f8f4d5c16759541a6 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Wed, 24 Jun 2026 14:15:35 +0530 Subject: [PATCH 05/12] Authors widget: add tests for buildTopAuthorsData --- .../__tests__/build-top-authors-data.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-top-authors-data.test.ts diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-top-authors-data.test.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-top-authors-data.test.ts new file mode 100644 index 000000000000..c73ee871d43f --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-top-authors-data.test.ts @@ -0,0 +1,154 @@ +/** + * Internal dependencies + */ +import { buildTopAuthorsData } from '../build-top-authors-data'; +import type { StatsNormalizedReport, StatsTopAuthorsItem } from '@jetpack-premium-analytics/data'; + +type AuthorSeed = { + id?: string | number; + label?: string; + views: number; +}; + +function makeAuthor( { id, label = 'Author', views }: AuthorSeed ): StatsTopAuthorsItem { + return { + id, + label, + views, + icon: null, + iconClassName: 'avatar-user', + className: 'module-content-list-item-large', + children: null, + }; +} + +/** + * Builds a normalized top-authors report. Each inner array represents the + * authors for one data point (time interval), so multiple data points can be + * passed to exercise cross-interval aggregation. + * + * @param dataPoints - Authors grouped per data point. + * @return A normalized top-authors report. + */ +function makeReport( dataPoints: AuthorSeed[][] ): StatsNormalizedReport< StatsTopAuthorsItem > { + return { + summary: { date_start: '2024-01-01', date_end: '2024-01-31' }, + data: dataPoints.map( ( authors, index ) => ( { + time_interval: `2024-01-${ String( index + 1 ).padStart( 2, '0' ) }`, + date_start: '2024-01-01', + date_end: '2024-01-31', + items: authors.map( makeAuthor ), + } ) ), + }; +} + +describe( 'buildTopAuthorsData', () => { + it( 'returns an empty array when the primary report is undefined', () => { + expect( buildTopAuthorsData( undefined, undefined ) ).toEqual( [] ); + } ); + + it( 'returns an empty array when the primary report has no authors', () => { + expect( buildTopAuthorsData( makeReport( [ [] ] ), undefined ) ).toEqual( [] ); + } ); + + it( 'maps a single author into leaderboard data', () => { + const result = buildTopAuthorsData( + makeReport( [ [ { id: 1, label: 'Alice', views: 10 } ] ] ), + undefined + ); + + expect( result ).toHaveLength( 1 ); + expect( result[ 0 ] ).toMatchObject( { + id: '1', + label: 'Alice', + currentValue: 10, + previousValue: 0, + currentShare: 100, + previousShare: 0, + // No comparison value, so the author reads as newly appeared. + delta: 100, + } ); + } ); + + it( 'aggregates views for the same author across data points', () => { + const result = buildTopAuthorsData( + makeReport( [ + [ { id: 1, label: 'Alice', views: 4 } ], + [ { id: 1, label: 'Alice', views: 6 } ], + ] ), + undefined + ); + + expect( result ).toHaveLength( 1 ); + expect( result[ 0 ].currentValue ).toBe( 10 ); + } ); + + it( 'sorts authors by views in descending order', () => { + const result = buildTopAuthorsData( + makeReport( [ + [ + { id: 1, label: 'Alice', views: 5 }, + { id: 2, label: 'Bob', views: 20 }, + { id: 3, label: 'Carol', views: 12 }, + ], + ] ), + undefined + ); + + expect( result.map( author => author.label ) ).toEqual( [ 'Bob', 'Carol', 'Alice' ] ); + } ); + + it( 'truncates the leaderboard to maxEntries', () => { + const result = buildTopAuthorsData( + makeReport( [ + [ + { id: 1, label: 'Alice', views: 50 }, + { id: 2, label: 'Bob', views: 40 }, + { id: 3, label: 'Carol', views: 30 }, + ], + ] ), + undefined, + 2 + ); + + expect( result.map( author => author.label ) ).toEqual( [ 'Alice', 'Bob' ] ); + } ); + + it( 'aligns comparison values by author id', () => { + const result = buildTopAuthorsData( + makeReport( [ [ { id: 1, label: 'Alice', views: 150 } ] ] ), + makeReport( [ [ { id: 1, label: 'Alice', views: 100 } ] ] ) + ); + + expect( result[ 0 ] ).toMatchObject( { + currentValue: 150, + previousValue: 100, + delta: 50, + } ); + } ); + + it( 'treats authors missing from the comparison period as zero', () => { + const result = buildTopAuthorsData( + makeReport( [ + [ + { id: 1, label: 'Alice', views: 10 }, + { id: 2, label: 'Bob', views: 8 }, + ], + ] ), + makeReport( [ [ { id: 1, label: 'Alice', views: 5 } ] ] ) + ); + + const bob = result.find( author => author.label === 'Bob' ); + expect( bob ).toMatchObject( { previousValue: 0, delta: 100 } ); + } ); + + it( 'falls back to the display label as the key when no id is present', () => { + const result = buildTopAuthorsData( + makeReport( [ [ { label: 'Alice', views: 4 } ], [ { label: 'Alice', views: 6 } ] ] ), + undefined + ); + + expect( result ).toHaveLength( 1 ); + expect( result[ 0 ] ).toMatchObject( { id: 'Alice', currentValue: 10 } ); + } ); +} ); From 0bec55e8f9e9120099ab6ba0790a9fe387fe955c Mon Sep 17 00:00:00 2001 From: Nikhil Date: Wed, 24 Jun 2026 18:16:36 +0530 Subject: [PATCH 06/12] Authors widget: align with widget contract - Declare presentation (framed) in widget.json. - Type render props via WidgetRenderProps from @wordpress/widget-primitives and default attributes to {}. - Add @wordpress/widget-primitives as a devDependency (type-only import, erased at build, keeps runtime deps host-agnostic). --- .../premium-analytics/widgets/authors/package.json | 3 +++ .../premium-analytics/widgets/authors/render.tsx | 10 +++++----- .../premium-analytics/widgets/authors/widget.json | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/projects/packages/premium-analytics/widgets/authors/package.json b/projects/packages/premium-analytics/widgets/authors/package.json index 8681368938ed..7a0bc3855eca 100644 --- a/projects/packages/premium-analytics/widgets/authors/package.json +++ b/projects/packages/premium-analytics/widgets/authors/package.json @@ -8,5 +8,8 @@ "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^13.0.0", "react": "18.3.1" + }, + "devDependencies": { + "@wordpress/widget-primitives": "next" } } diff --git a/projects/packages/premium-analytics/widgets/authors/render.tsx b/projects/packages/premium-analytics/widgets/authors/render.tsx index aea9553751a8..281e484a343d 100644 --- a/projects/packages/premium-analytics/widgets/authors/render.tsx +++ b/projects/packages/premium-analytics/widgets/authors/render.tsx @@ -3,6 +3,7 @@ */ import { AuthorsWidget, WidgetRoot } from '@jetpack-premium-analytics/widgets-toolkit'; import { useMemo } from 'react'; +import type { WidgetRenderProps } from '@wordpress/widget-primitives'; import type { ComponentProps } from 'react'; const DEFAULT_MAX = 7; @@ -11,8 +12,7 @@ type AuthorsAttributes = NonNullable< ComponentProps< typeof WidgetRoot >[ 'attr max?: string; }; -type AuthorsRenderProps = { - attributes?: AuthorsAttributes; +type AuthorsRenderProps = WidgetRenderProps< AuthorsAttributes > & { setError?: ComponentProps< typeof WidgetRoot >[ 'setError' ]; }; @@ -55,17 +55,17 @@ const getDefaultReportParams = () => ( { * @param props.setError - Dashboard error handler. * @return The rendered Authors widget. */ -export default function Authors( { attributes, setError }: AuthorsRenderProps ) { +export default function Authors( { attributes = {}, setError }: AuthorsRenderProps ) { const attributesWithDefaults = useMemo( () => { const hasReportParams = - !! attributes?.reportParams && Object.keys( attributes.reportParams ).length > 0; + !! attributes.reportParams && Object.keys( attributes.reportParams ).length > 0; return hasReportParams ? attributes : { ...attributes, reportParams: getDefaultReportParams() }; }, [ attributes ] ); return ( - + ); } diff --git a/projects/packages/premium-analytics/widgets/authors/widget.json b/projects/packages/premium-analytics/widgets/authors/widget.json index f4fa210d7d54..81328fbd4aeb 100644 --- a/projects/packages/premium-analytics/widgets/authors/widget.json +++ b/projects/packages/premium-analytics/widgets/authors/widget.json @@ -2,5 +2,6 @@ "name": "jpa/authors", "title": "Authors", "description": "Top authors by views, with their most viewed posts.", - "category": "stats" + "category": "stats", + "presentation": "framed" } From 0ccd41be2f728492a15e3ccafb1bd84d6b87248c Mon Sep 17 00:00:00 2001 From: Nikhil Date: Thu, 25 Jun 2026 11:23:15 +0530 Subject: [PATCH 07/12] Authors widget: move logic from widgets-toolkit into widget folder Relocate buildTopAuthorsData and the authors render logic out of the shared widgets-toolkit package into widgets/authors, colocating the widget's tests. Wire the widget to its own dependencies, change the max authors field to integer, and stub CSS imports in jest so widgets-toolkit CSS doesn't get parsed as JS. --- .../widgets-toolkit/src/helpers/index.ts | 1 - .../packages/widgets-toolkit/src/index.ts | 2 +- .../src/widgets/authors/authors-widget.tsx | 106 ------------------ .../src/widgets/authors/index.ts | 1 - .../widgets-toolkit/src/widgets/index.ts | 1 - .../__tests__/build-top-authors-data.test.ts | 55 ++++----- .../authors}/build-top-authors-data.ts | 54 +++++---- .../widgets/authors/package.json | 7 +- .../widgets/authors/render.tsx | 95 +++++++++++++++- .../widgets/authors/widget.ts | 2 +- 10 files changed, 148 insertions(+), 176 deletions(-) delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx delete mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/index.ts rename projects/packages/premium-analytics/{packages/widgets-toolkit/src/helpers => widgets/authors}/__tests__/build-top-authors-data.test.ts (73%) rename projects/packages/premium-analytics/{packages/widgets-toolkit/src/helpers => widgets/authors}/build-top-authors-data.ts (64%) diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts index a98b28a61c30..4ef35963fa5b 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts @@ -44,4 +44,3 @@ export { } from './build-visitors-by-location-data'; export { flagUrl } from './flag-url'; export { isEmptyChartData, isEmptyPieChartData, getEmptyChartDomain } from './chart-empty-state'; -export { buildTopAuthorsData } from './build-top-authors-data'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts index 89115981adb6..3b38578328e4 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -54,6 +54,7 @@ export { type TimeSeriesData, calculateDelta, flagUrl, + formatLegendLabels, BOOKINGS_FILTER, PHYSICAL_PRODUCTS_FILTER, FULFILLED_ORDERS_FILTER, @@ -83,7 +84,6 @@ export { BookingsByAttendanceWidget, BookingsRevenueByCustomerTypeWidget, BookingConversionRateWidget, - AuthorsWidget, ConversionRateWidget, CouponUseWidget, MetricComparisonWidget, diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx deleted file mode 100644 index 7afb0aeac6d8..000000000000 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/authors-widget.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * External dependencies - */ -import { useStatsTopAuthors } from '@jetpack-premium-analytics/data'; -import { __ } from '@wordpress/i18n'; -import { useMemo } from 'react'; -/** - * Internal dependencies - */ -import { LeaderboardChart } from '../../components/chart-leaderboard'; -import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; -import { useWidgetRootContext } from '../../components/widget-root'; -import { buildTopAuthorsData, formatLegendLabels } from '../../helpers'; -import { useWidgetError } from '../../hooks'; - -const DEFAULT_MAX = 7; - -type AuthorsWidgetProps = { - /** - * Maximum number of authors to display. - */ - max?: number; -}; - -/** - * Authors Widget Component - * - * Displays a leaderboard chart showing the site's top authors by views, - * sourced from the Jetpack Stats API. - * - * Features: - * - Comparison support (current vs previous period) - * - Configurable author limit - * - * Must be used within a WidgetRoot which provides reportParams via context. - * - * @param props - Component props - * @param props.max - Maximum number of authors to display - * - * @example - * - * - * - */ -export function AuthorsWidget( { max }: AuthorsWidgetProps ) { - const { reportParams } = useWidgetRootContext(); - const maxAuthors = max ?? DEFAULT_MAX; - const statsParams = useMemo( - () => ( { ...reportParams, max: maxAuthors } ), - [ reportParams, maxAuthors ] - ); - - const { - primary, - comparison, - hasComparison, - isLoading, - isFetching, - hasData, - isError, - error, - refetch, - } = useStatsTopAuthors( statsParams ); - - // `primary.isPending` also covers the brief window where the query is disabled - // while the report params resolve (isLoading is false there). - const isInitialLoading = ( isLoading || primary.isPending ) && ! hasData; - const isRefetching = isFetching && hasData; - const primaryData = primary.data; - const comparisonData = comparison.data; - - const chartData = useMemo( - () => buildTopAuthorsData( primaryData, comparisonData, maxAuthors ), - [ primaryData, comparisonData, maxAuthors ] - ); - - const legendLabels = useMemo( () => formatLegendLabels( reportParams ), [ reportParams ] ); - - const hasError = useWidgetError( isError, error, refetch ); - if ( hasError ) { - return null; - } - - if ( isInitialLoading ) { - return ; - } - - return ( - <> - - { isRefetching && } - - ); -} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/index.ts deleted file mode 100644 index 8b3753a8b349..000000000000 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/authors/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AuthorsWidget } from './authors-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts index 16f6ba98eaf5..e19dede3bd8b 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts @@ -22,4 +22,3 @@ export { export { CouponUseWidget } from './coupon-use'; export { OrdersFulfillmentWidget } from './orders-fulfillment'; export { VisitorsByLocationWidget } from './visitors-by-location'; -export { AuthorsWidget } from './authors'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-top-authors-data.test.ts b/projects/packages/premium-analytics/widgets/authors/__tests__/build-top-authors-data.test.ts similarity index 73% rename from projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-top-authors-data.test.ts rename to projects/packages/premium-analytics/widgets/authors/__tests__/build-top-authors-data.test.ts index c73ee871d43f..db118faafa6d 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-top-authors-data.test.ts +++ b/projects/packages/premium-analytics/widgets/authors/__tests__/build-top-authors-data.test.ts @@ -5,14 +5,20 @@ import { buildTopAuthorsData } from '../build-top-authors-data'; import type { StatsNormalizedReport, StatsTopAuthorsItem } from '@jetpack-premium-analytics/data'; type AuthorSeed = { - id?: string | number; label?: string; views: number; }; -function makeAuthor( { id, label = 'Author', views }: AuthorSeed ): StatsTopAuthorsItem { +/** + * Builds a single normalized top-authors item from a compact seed. + * + * @param seed - The author seed. + * @param seed.label - Display label (defaults to `Author`). + * @param seed.views - View count for the period. + * @return A normalized top-authors item. + */ +function makeAuthor( { label = 'Author', views }: AuthorSeed ): StatsTopAuthorsItem { return { - id, label, views, icon: null, @@ -53,13 +59,13 @@ describe( 'buildTopAuthorsData', () => { it( 'maps a single author into leaderboard data', () => { const result = buildTopAuthorsData( - makeReport( [ [ { id: 1, label: 'Alice', views: 10 } ] ] ), + makeReport( [ [ { label: 'Alice', views: 10 } ] ] ), undefined ); expect( result ).toHaveLength( 1 ); expect( result[ 0 ] ).toMatchObject( { - id: '1', + id: 'Alice', label: 'Alice', currentValue: 10, previousValue: 0, @@ -72,10 +78,7 @@ describe( 'buildTopAuthorsData', () => { it( 'aggregates views for the same author across data points', () => { const result = buildTopAuthorsData( - makeReport( [ - [ { id: 1, label: 'Alice', views: 4 } ], - [ { id: 1, label: 'Alice', views: 6 } ], - ] ), + makeReport( [ [ { label: 'Alice', views: 4 } ], [ { label: 'Alice', views: 6 } ] ] ), undefined ); @@ -87,9 +90,9 @@ describe( 'buildTopAuthorsData', () => { const result = buildTopAuthorsData( makeReport( [ [ - { id: 1, label: 'Alice', views: 5 }, - { id: 2, label: 'Bob', views: 20 }, - { id: 3, label: 'Carol', views: 12 }, + { label: 'Alice', views: 5 }, + { label: 'Bob', views: 20 }, + { label: 'Carol', views: 12 }, ], ] ), undefined @@ -102,9 +105,9 @@ describe( 'buildTopAuthorsData', () => { const result = buildTopAuthorsData( makeReport( [ [ - { id: 1, label: 'Alice', views: 50 }, - { id: 2, label: 'Bob', views: 40 }, - { id: 3, label: 'Carol', views: 30 }, + { label: 'Alice', views: 50 }, + { label: 'Bob', views: 40 }, + { label: 'Carol', views: 30 }, ], ] ), undefined, @@ -114,10 +117,10 @@ describe( 'buildTopAuthorsData', () => { expect( result.map( author => author.label ) ).toEqual( [ 'Alice', 'Bob' ] ); } ); - it( 'aligns comparison values by author id', () => { + it( 'aligns comparison values by author label', () => { const result = buildTopAuthorsData( - makeReport( [ [ { id: 1, label: 'Alice', views: 150 } ] ] ), - makeReport( [ [ { id: 1, label: 'Alice', views: 100 } ] ] ) + makeReport( [ [ { label: 'Alice', views: 150 } ] ] ), + makeReport( [ [ { label: 'Alice', views: 100 } ] ] ) ); expect( result[ 0 ] ).toMatchObject( { @@ -131,24 +134,14 @@ describe( 'buildTopAuthorsData', () => { const result = buildTopAuthorsData( makeReport( [ [ - { id: 1, label: 'Alice', views: 10 }, - { id: 2, label: 'Bob', views: 8 }, + { label: 'Alice', views: 10 }, + { label: 'Bob', views: 8 }, ], ] ), - makeReport( [ [ { id: 1, label: 'Alice', views: 5 } ] ] ) + makeReport( [ [ { label: 'Alice', views: 5 } ] ] ) ); const bob = result.find( author => author.label === 'Bob' ); expect( bob ).toMatchObject( { previousValue: 0, delta: 100 } ); } ); - - it( 'falls back to the display label as the key when no id is present', () => { - const result = buildTopAuthorsData( - makeReport( [ [ { label: 'Alice', views: 4 } ], [ { label: 'Alice', views: 6 } ] ] ), - undefined - ); - - expect( result ).toHaveLength( 1 ); - expect( result[ 0 ] ).toMatchObject( { id: 'Alice', currentValue: 10 } ); - } ); } ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-top-authors-data.ts b/projects/packages/premium-analytics/widgets/authors/build-top-authors-data.ts similarity index 64% rename from projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-top-authors-data.ts rename to projects/packages/premium-analytics/widgets/authors/build-top-authors-data.ts index c93892106566..ef0e1451dab4 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-top-authors-data.ts +++ b/projects/packages/premium-analytics/widgets/authors/build-top-authors-data.ts @@ -1,32 +1,41 @@ /** * External dependencies */ +import { + calculateDelta, + type LeaderboardChartData, +} from '@jetpack-premium-analytics/widgets-toolkit'; import { __ } from '@wordpress/i18n'; -/** - * Internal dependencies - */ -import { calculateDelta } from './calculate-delta'; -import type { LeaderboardChartData } from '../components/chart-leaderboard'; import type { StatsNormalizedReport, StatsTopAuthorsItem } from '@jetpack-premium-analytics/data'; type TopAuthorLeaderboardEntry = { - // Stable key used to dedup and to align primary/comparison periods. Prefer - // the author id; fall back to the display label when no id is available. - key: string; + // Display label, also used as the key to dedup and align primary/comparison + // periods. The Stats top-authors response exposes no stable author id, so two + // distinct authors sharing a display name collapse into one row. label: string; views: number; }; +/** + * Resolve a display label for an author, falling back to a translated + * "Untracked authors" label when the API provides none. + * + * @param author - The top-authors item. + * @return The author's display label. + */ function getAuthorLabel( author: StatsTopAuthorsItem ) { return typeof author.label === 'string' && author.label ? author.label : __( 'Untracked authors', 'jetpack-premium-analytics' ); } -function getAuthorKey( author: StatsTopAuthorsItem, label: string ) { - return author.id !== undefined && author.id !== null ? String( author.id ) : label; -} - +/** + * Aggregate a top-authors report into per-author view totals, keyed by display + * label, summing across data points and sorting by views descending. + * + * @param report - The normalized top-authors report, or undefined while loading. + * @return The aggregated, sorted author entries. + */ function summarizeAuthors( report: StatsNormalizedReport< StatsTopAuthorsItem > | undefined ): TopAuthorLeaderboardEntry[] { @@ -35,12 +44,10 @@ function summarizeAuthors( for ( const dataPoint of report?.data ?? [] ) { for ( const author of dataPoint.items ) { const label = getAuthorLabel( author ); - const key = getAuthorKey( author, label ); - const existing = authorViews.get( key ); + const existing = authorViews.get( label ); - authorViews.set( key, { - key, - label: existing?.label ?? label, + authorViews.set( label, { + label, views: ( existing?.views ?? 0 ) + author.views, } ); } @@ -53,9 +60,8 @@ function summarizeAuthors( * Builds leaderboard chart data for the Authors widget. * * Transforms Jetpack Stats top-authors data into the format required by - * LeaderboardChart, with comparison values aligned by author (by stable author - * id, falling back to display name when none is available; authors missing from - * the comparison period count as zero). + * LeaderboardChart, with comparison values aligned by author display label + * (authors missing from the comparison period count as zero). * * @param primary - Primary period top-authors data * @param comparison - Comparison period top-authors data @@ -74,24 +80,24 @@ export function buildTopAuthorsData( } const comparisonViews = new Map( - summarizeAuthors( comparison ).map( author => [ author.key, author.views ] ) + summarizeAuthors( comparison ).map( author => [ author.label, author.views ] ) ); const data = primaryAuthors.slice( 0, maxEntries ); // Find the max value for share calculation const maxValue = Math.max( - ...data.map( author => Math.max( author.views, comparisonViews.get( author.key ) ?? 0 ) ), + ...data.map( author => Math.max( author.views, comparisonViews.get( author.label ) ?? 0 ) ), 1 // Prevent division by zero ); return data.map( author => { const currentValue = author.views; - const previousValue = comparisonViews.get( author.key ) ?? 0; + const previousValue = comparisonViews.get( author.label ) ?? 0; const delta = calculateDelta( currentValue, previousValue ); return { - id: author.key, + id: author.label, label: author.label, currentValue, previousValue, diff --git a/projects/packages/premium-analytics/widgets/authors/package.json b/projects/packages/premium-analytics/widgets/authors/package.json index 7a0bc3855eca..9e43d985e63d 100644 --- a/projects/packages/premium-analytics/widgets/authors/package.json +++ b/projects/packages/premium-analytics/widgets/authors/package.json @@ -4,12 +4,11 @@ "private": true, "type": "module", "dependencies": { - "@jetpack-premium-analytics/widgets-toolkit": "workspace:*", + "@jetpack-premium-analytics/data": "link:../../packages/data", + "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^13.0.0", + "@wordpress/widget-primitives": "next", "react": "18.3.1" - }, - "devDependencies": { - "@wordpress/widget-primitives": "next" } } diff --git a/projects/packages/premium-analytics/widgets/authors/render.tsx b/projects/packages/premium-analytics/widgets/authors/render.tsx index 281e484a343d..5feaa2cdd397 100644 --- a/projects/packages/premium-analytics/widgets/authors/render.tsx +++ b/projects/packages/premium-analytics/widgets/authors/render.tsx @@ -1,23 +1,37 @@ /** * External dependencies */ -import { AuthorsWidget, WidgetRoot } from '@jetpack-premium-analytics/widgets-toolkit'; +import { useStatsTopAuthors } from '@jetpack-premium-analytics/data'; +import { + LeaderboardChart, + WidgetLoadingOverlay, + WidgetRoot, + formatLegendLabels, + useWidgetError, + useWidgetRootContext, +} from '@jetpack-premium-analytics/widgets-toolkit'; +import { __ } from '@wordpress/i18n'; +import { postAuthor } from '@wordpress/icons'; import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import { buildTopAuthorsData } from './build-top-authors-data'; import type { WidgetRenderProps } from '@wordpress/widget-primitives'; import type { ComponentProps } from 'react'; const DEFAULT_MAX = 7; type AuthorsAttributes = NonNullable< ComponentProps< typeof WidgetRoot >[ 'attributes' ] > & { - max?: string; + max?: string | number; }; type AuthorsRenderProps = WidgetRenderProps< AuthorsAttributes > & { setError?: ComponentProps< typeof WidgetRoot >[ 'setError' ]; }; -const toPositiveInt = ( value: string | undefined, fallback: number ) => { - const parsed = Number.parseInt( value ?? '', 10 ); +const toPositiveInt = ( value: string | number | undefined, fallback: number ) => { + const parsed = typeof value === 'number' ? value : Number.parseInt( value ?? '', 10 ); return Number.isFinite( parsed ) && parsed > 0 ? parsed : fallback; }; @@ -44,11 +58,80 @@ const getDefaultReportParams = () => ( { interval: 'day' as const, } ); +/** + * Authors widget inner component. Reads report params from WidgetRoot context, + * fetches the site's top authors by views from the Jetpack Stats API, and + * renders them as a leaderboard with optional period comparison. + * + * @param props - Component props. + * @param props.max - Maximum number of authors to display. + * @return The rendered leaderboard content. + */ +function AuthorsLeaderboard( { max }: { max: number } ) { + const { reportParams } = useWidgetRootContext(); + const statsParams = useMemo( () => ( { ...reportParams, max } ), [ reportParams, max ] ); + + const { + primary, + comparison, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + error, + refetch, + } = useStatsTopAuthors( statsParams ); + + // `primary.isPending` also covers the brief window where the query is disabled + // while the report params resolve (isLoading is false there). + const isInitialLoading = ( isLoading || primary.isPending ) && ! hasData; + const isRefetching = isFetching && hasData; + const primaryData = primary.data; + const comparisonData = comparison.data; + + const chartData = useMemo( + () => buildTopAuthorsData( primaryData, comparisonData, max ), + [ primaryData, comparisonData, max ] + ); + + const legendLabels = useMemo( () => formatLegendLabels( reportParams ), [ reportParams ] ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + { isRefetching && } + + ); +} + /** * Authors widget render entry point. * * WidgetRoot provides the analytics query client, chart theme, and the - * resolved report params consumed by the toolkit widget. + * resolved report params consumed by the inner leaderboard. * * @param props - Render props. * @param props.attributes - Widget attributes. @@ -65,7 +148,7 @@ export default function Authors( { attributes = {}, setError }: AuthorsRenderPro return ( - + ); } diff --git a/projects/packages/premium-analytics/widgets/authors/widget.ts b/projects/packages/premium-analytics/widgets/authors/widget.ts index 9561c5a150db..0d631efd86f3 100644 --- a/projects/packages/premium-analytics/widgets/authors/widget.ts +++ b/projects/packages/premium-analytics/widgets/authors/widget.ts @@ -15,7 +15,7 @@ export default { { id: 'max', label: __( 'Maximum authors', 'jetpack-premium-analytics' ), - type: 'text', + type: 'integer', }, ], example: { From 5eb60f66e2d67e9da821856a59f77287033f0e7a Mon Sep 17 00:00:00 2001 From: Nikhil Date: Thu, 25 Jun 2026 13:41:59 +0530 Subject: [PATCH 08/12] Authors widget: add Storybook story --- .../stories/authors-widget.stories.tsx | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 projects/packages/premium-analytics/widgets/authors/stories/authors-widget.stories.tsx diff --git a/projects/packages/premium-analytics/widgets/authors/stories/authors-widget.stories.tsx b/projects/packages/premium-analytics/widgets/authors/stories/authors-widget.stories.tsx new file mode 100644 index 000000000000..fadab33210cb --- /dev/null +++ b/projects/packages/premium-analytics/widgets/authors/stories/authors-widget.stories.tsx @@ -0,0 +1,241 @@ +/** + * Internal dependencies + */ +import { withChartTheme } from '../../../packages/widgets-toolkit/src/stories/with-chart-theme'; +import { AuthorsLeaderboard } from '../render'; +import type { LeaderboardChartData } from '@jetpack-premium-analytics/widgets-toolkit'; +import type { Decorator, Meta, StoryObj } from '@storybook/react'; + +const meta: Meta< typeof AuthorsLeaderboard > = { + title: 'Packages/Premium Analytics/Widgets/Authors', + component: AuthorsLeaderboard, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + "The Authors widget. Renders the site's top authors by views as a leaderboard, sourced from the Jetpack Stats API, with optional period-over-period comparison. This is the presentational component — it takes already-built leaderboard rows via props and handles the loading, empty, and populated states.", + }, + }, + }, + decorators: [ withChartTheme ], +}; + +export default meta; + +type Story = StoryObj< typeof AuthorsLeaderboard >; + +const MAX_VIEWS = 4820; + +/** + * Compute the share (0–100) of a value relative to the most-viewed author, so + * the overlay bars stay proportional — mirroring `buildTopAuthorsData`. + * + * @param value - The view count. + * @return The share as a percentage of the top author's views. + */ +const share = ( value: number ) => ( value / MAX_VIEWS ) * 100; + +const mockAuthors: LeaderboardChartData = [ + { + id: 'Jane Cooper', + label: 'Jane Cooper', + currentValue: 4820, + previousValue: 0, + currentShare: share( 4820 ), + previousShare: 0, + delta: 0, + }, + { + id: 'Wade Warren', + label: 'Wade Warren', + currentValue: 3110, + previousValue: 0, + currentShare: share( 3110 ), + previousShare: 0, + delta: 0, + }, + { + id: 'Esther Howard', + label: 'Esther Howard', + currentValue: 2540, + previousValue: 0, + currentShare: share( 2540 ), + previousShare: 0, + delta: 0, + }, + { + id: 'Cameron Williamson', + label: 'Cameron Williamson', + currentValue: 1890, + previousValue: 0, + currentShare: share( 1890 ), + previousShare: 0, + delta: 0, + }, + { + id: 'Brooklyn Simmons', + label: 'Brooklyn Simmons', + currentValue: 1320, + previousValue: 0, + currentShare: share( 1320 ), + previousShare: 0, + delta: 0, + }, + { + id: 'Leslie Alexander', + label: 'Leslie Alexander', + currentValue: 760, + previousValue: 0, + currentShare: share( 760 ), + previousShare: 0, + delta: 0, + }, + { + id: 'Untracked authors', + label: 'Untracked authors', + currentValue: 410, + previousValue: 0, + currentShare: share( 410 ), + previousShare: 0, + delta: 0, + }, +]; + +const mockAuthorsWithComparison: LeaderboardChartData = [ + { + id: 'Jane Cooper', + label: 'Jane Cooper', + currentValue: 4820, + previousValue: 3900, + currentShare: share( 4820 ), + previousShare: share( 3900 ), + delta: 23.6, + }, + { + id: 'Wade Warren', + label: 'Wade Warren', + currentValue: 3110, + previousValue: 3540, + currentShare: share( 3110 ), + previousShare: share( 3540 ), + delta: -12.1, + }, + { + id: 'Esther Howard', + label: 'Esther Howard', + currentValue: 2540, + previousValue: 1980, + currentShare: share( 2540 ), + previousShare: share( 1980 ), + delta: 28.3, + }, + { + id: 'Cameron Williamson', + label: 'Cameron Williamson', + currentValue: 1890, + previousValue: 2010, + currentShare: share( 1890 ), + previousShare: share( 2010 ), + delta: -6, + }, + { + id: 'Brooklyn Simmons', + label: 'Brooklyn Simmons', + currentValue: 1320, + previousValue: 0, + currentShare: share( 1320 ), + previousShare: 0, + delta: 100, + }, +]; + +/** + * Default populated state — top authors ranked by views for the period. + */ +export const Default: Story = { + args: { + data: mockAuthors, + }, +}; + +/** + * Comparison state — each value shows its change versus the previous period + * (green for gains, red for losses), driven by each row's `previousValue`. + */ +export const WithComparison: Story = { + args: { + data: mockAuthorsWithComparison, + withComparison: true, + legendLabels: { + primary: 'Jun 1 – 18, 2026', + comparison: 'May 14 – 31, 2026', + }, + }, +}; + +/** + * Loading state — the initial loading overlay renders while data is fetched. + */ +export const Loading: Story = { + args: { + data: [], + isLoading: true, + }, +}; + +/** + * Empty state — no authors recorded any views for the selected period. + */ +export const Empty: Story = { + args: { + data: [], + }, +}; + +/** + * Creates a decorator that wraps the story in a fixed-size container so the + * widget's responsiveness can be inspected at a given width. + * + * @param width - The container width (any CSS length). + * @param [height] - The container height; defaults to `auto`. + * @return A Storybook decorator. + */ +const createSizeDecorator = ( width: string, height = 'auto' ): Decorator => { + return Story => ( +
+ +
+ ); +}; + +/** + * Medium container (448px / md breakpoint). + */ +export const SizeMedium: Story = { + args: { + data: mockAuthors, + }, + decorators: [ createSizeDecorator( '448px' ) ], +}; + +/** + * Large container (576px / xl breakpoint). + */ +export const SizeLarge: Story = { + args: { + data: mockAuthors, + }, + decorators: [ createSizeDecorator( '576px' ) ], +}; From 1e41324cc746527085fb29df7ede8eb85113d475 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Fri, 26 Jun 2026 12:15:17 +0530 Subject: [PATCH 09/12] Authors widget: split presentation from data, drop client aggregation Extract AuthorsLeaderboard as an exported presentational component taking pre-built rows, and rename the data-connected wrapper to AuthorsReport so Storybook can exercise loading/empty/populated states. Trust the Stats API's server-side ranking and limiting: replace summarizeAuthors' cross-interval aggregation and sorting with a flat toAuthorItems, dropping the maxEntries param. Update tests accordingly. --- .../__tests__/build-top-authors-data.test.ts | 76 ++++------- .../widgets/authors/build-top-authors-data.ts | 76 +++++------ .../widgets/authors/render.tsx | 119 +++++++++++++----- 3 files changed, 142 insertions(+), 129 deletions(-) diff --git a/projects/packages/premium-analytics/widgets/authors/__tests__/build-top-authors-data.test.ts b/projects/packages/premium-analytics/widgets/authors/__tests__/build-top-authors-data.test.ts index db118faafa6d..a434168f7c83 100644 --- a/projects/packages/premium-analytics/widgets/authors/__tests__/build-top-authors-data.test.ts +++ b/projects/packages/premium-analytics/widgets/authors/__tests__/build-top-authors-data.test.ts @@ -29,22 +29,24 @@ function makeAuthor( { label = 'Author', views }: AuthorSeed ): StatsTopAuthorsI } /** - * Builds a normalized top-authors report. Each inner array represents the - * authors for one data point (time interval), so multiple data points can be - * passed to exercise cross-interval aggregation. + * Builds a normalized top-authors report. The Stats query layer summarizes + * multi-day ranges server-side, so the report carries a single data point of + * per-author totals — which is what the widget consumes. * - * @param dataPoints - Authors grouped per data point. + * @param authors - The authors for the period, already ranked by the API. * @return A normalized top-authors report. */ -function makeReport( dataPoints: AuthorSeed[][] ): StatsNormalizedReport< StatsTopAuthorsItem > { +function makeReport( authors: AuthorSeed[] ): StatsNormalizedReport< StatsTopAuthorsItem > { return { summary: { date_start: '2024-01-01', date_end: '2024-01-31' }, - data: dataPoints.map( ( authors, index ) => ( { - time_interval: `2024-01-${ String( index + 1 ).padStart( 2, '0' ) }`, - date_start: '2024-01-01', - date_end: '2024-01-31', - items: authors.map( makeAuthor ), - } ) ), + data: [ + { + time_interval: '2024-01-01', + date_start: '2024-01-01', + date_end: '2024-01-31', + items: authors.map( makeAuthor ), + }, + ], }; } @@ -54,12 +56,12 @@ describe( 'buildTopAuthorsData', () => { } ); it( 'returns an empty array when the primary report has no authors', () => { - expect( buildTopAuthorsData( makeReport( [ [] ] ), undefined ) ).toEqual( [] ); + expect( buildTopAuthorsData( makeReport( [] ), undefined ) ).toEqual( [] ); } ); it( 'maps a single author into leaderboard data', () => { const result = buildTopAuthorsData( - makeReport( [ [ { label: 'Alice', views: 10 } ] ] ), + makeReport( [ { label: 'Alice', views: 10 } ] ), undefined ); @@ -76,24 +78,12 @@ describe( 'buildTopAuthorsData', () => { } ); } ); - it( 'aggregates views for the same author across data points', () => { - const result = buildTopAuthorsData( - makeReport( [ [ { label: 'Alice', views: 4 } ], [ { label: 'Alice', views: 6 } ] ] ), - undefined - ); - - expect( result ).toHaveLength( 1 ); - expect( result[ 0 ].currentValue ).toBe( 10 ); - } ); - - it( 'sorts authors by views in descending order', () => { + it( 'preserves the order the API returns authors in', () => { const result = buildTopAuthorsData( makeReport( [ - [ - { label: 'Alice', views: 5 }, - { label: 'Bob', views: 20 }, - { label: 'Carol', views: 12 }, - ], + { label: 'Bob', views: 20 }, + { label: 'Carol', views: 12 }, + { label: 'Alice', views: 5 }, ] ), undefined ); @@ -101,26 +91,10 @@ describe( 'buildTopAuthorsData', () => { expect( result.map( author => author.label ) ).toEqual( [ 'Bob', 'Carol', 'Alice' ] ); } ); - it( 'truncates the leaderboard to maxEntries', () => { - const result = buildTopAuthorsData( - makeReport( [ - [ - { label: 'Alice', views: 50 }, - { label: 'Bob', views: 40 }, - { label: 'Carol', views: 30 }, - ], - ] ), - undefined, - 2 - ); - - expect( result.map( author => author.label ) ).toEqual( [ 'Alice', 'Bob' ] ); - } ); - it( 'aligns comparison values by author label', () => { const result = buildTopAuthorsData( - makeReport( [ [ { label: 'Alice', views: 150 } ] ] ), - makeReport( [ [ { label: 'Alice', views: 100 } ] ] ) + makeReport( [ { label: 'Alice', views: 150 } ] ), + makeReport( [ { label: 'Alice', views: 100 } ] ) ); expect( result[ 0 ] ).toMatchObject( { @@ -133,12 +107,10 @@ describe( 'buildTopAuthorsData', () => { it( 'treats authors missing from the comparison period as zero', () => { const result = buildTopAuthorsData( makeReport( [ - [ - { label: 'Alice', views: 10 }, - { label: 'Bob', views: 8 }, - ], + { label: 'Alice', views: 10 }, + { label: 'Bob', views: 8 }, ] ), - makeReport( [ [ { label: 'Alice', views: 5 } ] ] ) + makeReport( [ { label: 'Alice', views: 5 } ] ) ); const bob = result.find( author => author.label === 'Bob' ); diff --git a/projects/packages/premium-analytics/widgets/authors/build-top-authors-data.ts b/projects/packages/premium-analytics/widgets/authors/build-top-authors-data.ts index ef0e1451dab4..dcbc3be93d3a 100644 --- a/projects/packages/premium-analytics/widgets/authors/build-top-authors-data.ts +++ b/projects/packages/premium-analytics/widgets/authors/build-top-authors-data.ts @@ -8,14 +8,6 @@ import { import { __ } from '@wordpress/i18n'; import type { StatsNormalizedReport, StatsTopAuthorsItem } from '@jetpack-premium-analytics/data'; -type TopAuthorLeaderboardEntry = { - // Display label, also used as the key to dedup and align primary/comparison - // periods. The Stats top-authors response exposes no stable author id, so two - // distinct authors sharing a display name collapse into one row. - label: string; - views: number; -}; - /** * Resolve a display label for an author, falling back to a translated * "Untracked authors" label when the API provides none. @@ -30,30 +22,19 @@ function getAuthorLabel( author: StatsTopAuthorsItem ) { } /** - * Aggregate a top-authors report into per-author view totals, keyed by display - * label, summing across data points and sorting by views descending. + * Flatten a normalized top-authors report into its per-author items. The Stats + * query layer summarizes multi-day ranges server-side and the endpoint returns + * authors already ranked and limited by `max`, so the report carries a single + * data point of per-author totals — mirroring how the Top posts widget reads + * its report. * * @param report - The normalized top-authors report, or undefined while loading. - * @return The aggregated, sorted author entries. + * @return The per-author items for the period. */ -function summarizeAuthors( +function toAuthorItems( report: StatsNormalizedReport< StatsTopAuthorsItem > | undefined -): TopAuthorLeaderboardEntry[] { - const authorViews = new Map< string, TopAuthorLeaderboardEntry >(); - - for ( const dataPoint of report?.data ?? [] ) { - for ( const author of dataPoint.items ) { - const label = getAuthorLabel( author ); - const existing = authorViews.get( label ); - - authorViews.set( label, { - label, - views: ( existing?.views ?? 0 ) + author.views, - } ); - } - } - - return Array.from( authorViews.values() ).sort( ( a, b ) => b.views - a.views ); +): StatsTopAuthorsItem[] { + return report?.data.flatMap( point => point.items ) ?? []; } /** @@ -63,47 +44,46 @@ function summarizeAuthors( * LeaderboardChart, with comparison values aligned by author display label * (authors missing from the comparison period count as zero). * - * @param primary - Primary period top-authors data - * @param comparison - Comparison period top-authors data - * @param maxEntries - Maximum number of entries to include in the leaderboard - * @return Processed data ready for LeaderboardChart component + * @param primary - Primary period top-authors report + * @param comparison - Comparison period top-authors report + * @return Processed data ready for the LeaderboardChart component */ export function buildTopAuthorsData( primary: StatsNormalizedReport< StatsTopAuthorsItem > | undefined, - comparison: StatsNormalizedReport< StatsTopAuthorsItem > | undefined, - maxEntries = 7 + comparison: StatsNormalizedReport< StatsTopAuthorsItem > | undefined ): LeaderboardChartData { - const primaryAuthors = summarizeAuthors( primary ); + const authors = toAuthorItems( primary ); - if ( primaryAuthors.length === 0 ) { + if ( authors.length === 0 ) { return []; } const comparisonViews = new Map( - summarizeAuthors( comparison ).map( author => [ author.label, author.views ] ) + toAuthorItems( comparison ).map( author => [ getAuthorLabel( author ), author.views ] ) ); - const data = primaryAuthors.slice( 0, maxEntries ); - - // Find the max value for share calculation + // Share each value against the largest of either period so the overlay bars + // stay proportional; `1` guards against division by zero. const maxValue = Math.max( - ...data.map( author => Math.max( author.views, comparisonViews.get( author.label ) ?? 0 ) ), - 1 // Prevent division by zero + ...authors.map( author => + Math.max( author.views, comparisonViews.get( getAuthorLabel( author ) ) ?? 0 ) + ), + 1 ); - return data.map( author => { + return authors.map( author => { + const label = getAuthorLabel( author ); const currentValue = author.views; - const previousValue = comparisonViews.get( author.label ) ?? 0; - const delta = calculateDelta( currentValue, previousValue ); + const previousValue = comparisonViews.get( label ) ?? 0; return { - id: author.label, - label: author.label, + id: label, + label, currentValue, previousValue, currentShare: ( currentValue / maxValue ) * 100, previousShare: ( previousValue / maxValue ) * 100, - delta, + delta: calculateDelta( currentValue, previousValue ), }; } ); } diff --git a/projects/packages/premium-analytics/widgets/authors/render.tsx b/projects/packages/premium-analytics/widgets/authors/render.tsx index 5feaa2cdd397..cbb85d86cb8f 100644 --- a/projects/packages/premium-analytics/widgets/authors/render.tsx +++ b/projects/packages/premium-analytics/widgets/authors/render.tsx @@ -9,6 +9,8 @@ import { formatLegendLabels, useWidgetError, useWidgetRootContext, + type LeaderboardChartData, + type LegendLabels, } from '@jetpack-premium-analytics/widgets-toolkit'; import { __ } from '@wordpress/i18n'; import { postAuthor } from '@wordpress/icons'; @@ -58,16 +60,89 @@ const getDefaultReportParams = () => ( { interval: 'day' as const, } ); +export type AuthorsLeaderboardProps = { + /** + * Leaderboard rows to render, already built from the top-authors report. + * When omitted, the empty state is shown (unless `isLoading` is set). + */ + data?: LeaderboardChartData; + /** + * When `true`, the initial loading overlay is rendered instead of the chart. + */ + isLoading?: boolean; + /** + * When `true`, a loading overlay is layered over the chart while data + * refetches in the background. + */ + isRefetching?: boolean; + /** + * When `true`, render each row's previous-period delta next to its value. + */ + withComparison?: boolean; + /** + * Custom legend labels for the current/comparison periods. + */ + legendLabels?: LegendLabels; +}; + +/** + * Presentational leaderboard for the Authors widget. Renders the site's top + * authors by views, and is responsible only for the loading, empty, and + * populated states. + * + * Takes already-built rows via props (and is exported) so Storybook can + * exercise those states with fixture data — there is no Stats backend in + * Storybook, so the data-connected entry point would only ever show chrome. + * + * @param props - Component props. + * @param props.data - Leaderboard rows to render. + * @param props.isLoading - Whether to render the initial loading overlay. + * @param props.isRefetching - Whether to layer a loading overlay over the chart. + * @param props.withComparison - Whether to render previous-period deltas. + * @param props.legendLabels - Custom labels for the current/comparison periods. + * @return The rendered leaderboard. + */ +export function AuthorsLeaderboard( { + data = [], + isLoading = false, + isRefetching = false, + withComparison = false, + legendLabels, +}: AuthorsLeaderboardProps ) { + if ( isLoading ) { + return ; + } + + return ( + <> + + { isRefetching && } + + ); +} + /** - * Authors widget inner component. Reads report params from WidgetRoot context, - * fetches the site's top authors by views from the Jetpack Stats API, and - * renders them as a leaderboard with optional period comparison. + * Fetches the top-authors report through the Jetpack Stats hook, builds the + * leaderboard rows, and hands them to the presentational `AuthorsLeaderboard`. * * @param props - Component props. * @param props.max - Maximum number of authors to display. - * @return The rendered leaderboard content. + * @return The widget content. */ -function AuthorsLeaderboard( { max }: { max: number } ) { +function AuthorsReport( { max }: { max: number } ) { const { reportParams } = useWidgetRootContext(); const statsParams = useMemo( () => ( { ...reportParams, max } ), [ reportParams, max ] ); @@ -91,8 +166,8 @@ function AuthorsLeaderboard( { max }: { max: number } ) { const comparisonData = comparison.data; const chartData = useMemo( - () => buildTopAuthorsData( primaryData, comparisonData, max ), - [ primaryData, comparisonData, max ] + () => buildTopAuthorsData( primaryData, comparisonData ), + [ primaryData, comparisonData ] ); const legendLabels = useMemo( () => formatLegendLabels( reportParams ), [ reportParams ] ); @@ -102,28 +177,14 @@ function AuthorsLeaderboard( { max }: { max: number } ) { return null; } - if ( isInitialLoading ) { - return ; - } - return ( - <> - - { isRefetching && } - + ); } @@ -148,7 +209,7 @@ export default function Authors( { attributes = {}, setError }: AuthorsRenderPro return ( - + ); } From 7ad2ca50f75f1942a5b119cc23a57038b6d1e4f3 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Fri, 26 Jun 2026 12:55:24 +0530 Subject: [PATCH 10/12] Authors widget: use date-fns and localTZDate for report range --- .../premium-analytics/widgets/authors/package.json | 1 + .../premium-analytics/widgets/authors/render.tsx | 11 +++-------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/projects/packages/premium-analytics/widgets/authors/package.json b/projects/packages/premium-analytics/widgets/authors/package.json index 9e43d985e63d..ce8ccf59f4c9 100644 --- a/projects/packages/premium-analytics/widgets/authors/package.json +++ b/projects/packages/premium-analytics/widgets/authors/package.json @@ -9,6 +9,7 @@ "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^13.0.0", "@wordpress/widget-primitives": "next", + "date-fns": "4.1.0", "react": "18.3.1" } } diff --git a/projects/packages/premium-analytics/widgets/authors/render.tsx b/projects/packages/premium-analytics/widgets/authors/render.tsx index cbb85d86cb8f..b851a6566f8b 100644 --- a/projects/packages/premium-analytics/widgets/authors/render.tsx +++ b/projects/packages/premium-analytics/widgets/authors/render.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useStatsTopAuthors } from '@jetpack-premium-analytics/data'; +import { localTZDate, useStatsTopAuthors } from '@jetpack-premium-analytics/data'; import { LeaderboardChart, WidgetLoadingOverlay, @@ -14,6 +14,7 @@ import { } from '@jetpack-premium-analytics/widgets-toolkit'; import { __ } from '@wordpress/i18n'; import { postAuthor } from '@wordpress/icons'; +import { format } from 'date-fns'; import { useMemo } from 'react'; /** * Internal dependencies @@ -38,12 +39,6 @@ const toPositiveInt = ( value: string | number | undefined, fallback: number ) = return Number.isFinite( parsed ) && parsed > 0 ? parsed : fallback; }; -const toDateString = ( date: Date ) => { - const pad = ( part: number ) => String( part ).padStart( 2, '0' ); - - return `${ date.getFullYear() }-${ pad( date.getMonth() + 1 ) }-${ pad( date.getDate() ) }`; -}; - /** * Build a "very long" default report range (all time, through the end of * today) used when the host doesn't pass explicit report params. Explicit @@ -56,7 +51,7 @@ const toDateString = ( date: Date ) => { */ const getDefaultReportParams = () => ( { from: '2000-01-01T00:00:00', - to: `${ toDateString( new Date() ) }T23:59:59`, + to: `${ format( localTZDate(), 'yyyy-MM-dd' ) }T23:59:59`, interval: 'day' as const, } ); From 8933d09b66f875fd598f26aa23fa9a7dab3a4d11 Mon Sep 17 00:00:00 2001 From: Nikhil Date: Mon, 29 Jun 2026 13:12:58 +0530 Subject: [PATCH 11/12] Authors widget: drop duplicate formatLegendLabels export after trunk rebase Trunk now exports formatLegendLabels from the widgets-toolkit barrel, so the copy this branch added is redundant; keep trunk's and remove the duplicate to avoid a TS2300 duplicate-identifier error. --- .../premium-analytics/packages/widgets-toolkit/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts index 3b38578328e4..abf391ef8472 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -54,7 +54,6 @@ export { type TimeSeriesData, calculateDelta, flagUrl, - formatLegendLabels, BOOKINGS_FILTER, PHYSICAL_PRODUCTS_FILTER, FULFILLED_ORDERS_FILTER, From 9be77297867ea529d1eadab41f4f5978f99f398e Mon Sep 17 00:00:00 2001 From: Nikhil Date: Mon, 29 Jun 2026 16:37:30 +0530 Subject: [PATCH 12/12] Authors widget: use dashboard date range via context, drop date-fns Removes the hardcoded all-time default report params and the date-fns dependency, so the widget reads report params from the dashboard date range through WidgetRoot context like the other Stats widgets. --- .../widgets/authors/package.json | 1 - .../widgets/authors/render.tsx | 33 +++---------------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/projects/packages/premium-analytics/widgets/authors/package.json b/projects/packages/premium-analytics/widgets/authors/package.json index ce8ccf59f4c9..9e43d985e63d 100644 --- a/projects/packages/premium-analytics/widgets/authors/package.json +++ b/projects/packages/premium-analytics/widgets/authors/package.json @@ -9,7 +9,6 @@ "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^13.0.0", "@wordpress/widget-primitives": "next", - "date-fns": "4.1.0", "react": "18.3.1" } } diff --git a/projects/packages/premium-analytics/widgets/authors/render.tsx b/projects/packages/premium-analytics/widgets/authors/render.tsx index b851a6566f8b..1994b63af1a6 100644 --- a/projects/packages/premium-analytics/widgets/authors/render.tsx +++ b/projects/packages/premium-analytics/widgets/authors/render.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { localTZDate, useStatsTopAuthors } from '@jetpack-premium-analytics/data'; +import { useStatsTopAuthors } from '@jetpack-premium-analytics/data'; import { LeaderboardChart, WidgetLoadingOverlay, @@ -14,7 +14,6 @@ import { } from '@jetpack-premium-analytics/widgets-toolkit'; import { __ } from '@wordpress/i18n'; import { postAuthor } from '@wordpress/icons'; -import { format } from 'date-fns'; import { useMemo } from 'react'; /** * Internal dependencies @@ -39,22 +38,6 @@ const toPositiveInt = ( value: string | number | undefined, fallback: number ) = return Number.isFinite( parsed ) && parsed > 0 ? parsed : fallback; }; -/** - * Build a "very long" default report range (all time, through the end of - * today) used when the host doesn't pass explicit report params. Explicit - * from/to pass through `normalizeReportParams` untouched, so this wide range - * survives WidgetRoot's normalization instead of the rolling default preset. - * - * TODO: Remove the default range once we have a way to pass the launched date to the widget. - * - * @return The default report params covering an all-time range. - */ -const getDefaultReportParams = () => ( { - from: '2000-01-01T00:00:00', - to: `${ format( localTZDate(), 'yyyy-MM-dd' ) }T23:59:59`, - interval: 'day' as const, -} ); - export type AuthorsLeaderboardProps = { /** * Leaderboard rows to render, already built from the top-authors report. @@ -186,8 +169,9 @@ function AuthorsReport( { max }: { max: number } ) { /** * Authors widget render entry point. * - * WidgetRoot provides the analytics query client, chart theme, and the - * resolved report params consumed by the inner leaderboard. + * WidgetRoot provides the analytics query client, chart theme, and the report + * params consumed by the inner leaderboard — resolved from the dashboard date + * range via context, the same way the other Stats widgets read them. * * @param props - Render props. * @param props.attributes - Widget attributes. @@ -195,15 +179,8 @@ function AuthorsReport( { max }: { max: number } ) { * @return The rendered Authors widget. */ export default function Authors( { attributes = {}, setError }: AuthorsRenderProps ) { - const attributesWithDefaults = useMemo( () => { - const hasReportParams = - !! attributes.reportParams && Object.keys( attributes.reportParams ).length > 0; - - return hasReportParams ? attributes : { ...attributes, reportParams: getDefaultReportParams() }; - }, [ attributes ] ); - return ( - + );