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

Filter by extension

Filter by extension


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

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Internal dependencies
*/
import { buildTopAuthorsData } from '../build-top-authors-data';
import type { StatsNormalizedReport, StatsTopAuthorsItem } from '@jetpack-premium-analytics/data';

type AuthorSeed = {
label?: string;
views: number;
};

/**
* 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 {
label,
views,
icon: null,
iconClassName: 'avatar-user',
className: 'module-content-list-item-large',
children: null,
};
}

/**
* 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 authors - The authors for the period, already ranked by the API.
* @return A normalized top-authors report.
*/
function makeReport( authors: AuthorSeed[] ): StatsNormalizedReport< StatsTopAuthorsItem > {
return {
summary: { date_start: '2024-01-01', date_end: '2024-01-31' },
data: [
{
time_interval: '2024-01-01',
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( [ { label: 'Alice', views: 10 } ] ),
undefined
);

expect( result ).toHaveLength( 1 );
expect( result[ 0 ] ).toMatchObject( {
id: 'Alice',
label: 'Alice',
currentValue: 10,
previousValue: 0,
currentShare: 100,
previousShare: 0,
// No comparison value, so the author reads as newly appeared.
delta: 100,
} );
} );

it( 'preserves the order the API returns authors in', () => {
const result = buildTopAuthorsData(
makeReport( [
{ label: 'Bob', views: 20 },
{ label: 'Carol', views: 12 },
{ label: 'Alice', views: 5 },
] ),
undefined
);

expect( result.map( author => author.label ) ).toEqual( [ 'Bob', 'Carol', 'Alice' ] );
} );

it( 'aligns comparison values by author label', () => {
const result = buildTopAuthorsData(
makeReport( [ { label: 'Alice', views: 150 } ] ),
makeReport( [ { 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( [
{ label: 'Alice', views: 10 },
{ label: 'Bob', views: 8 },
] ),
makeReport( [ { label: 'Alice', views: 5 } ] )
);

const bob = result.find( author => author.label === 'Bob' );
expect( bob ).toMatchObject( { previousValue: 0, delta: 100 } );
} );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe we could coordinate those changes into https://github.com/Automattic/jetpack/blob/trunk/projects/packages/premium-analytics/packages/data/src/processing/stats/top-authors.ts, so that there's no need for more mappings?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I was digging into whether buildTopAuthorsData could fold into the top-authors sanitizer like you suggested, and I'm not sure it can — wanted to run my thinking by you:

  • useStatsReport looks like it runs the primary and comparison as two separate queries, each sanitized on its own (use-report.ts#L64-L67), so the sanitizer only ever sees one period — which seems to mean the period-over-period shares/deltas have to be computed in the widget, where both primary.data and comparison.data are in hand. Does that match how you're reading it?
  • The shaping also pulls LeaderboardChartData/calculateDelta from widgets-toolkit, and since data is the leaf the toolkit depends on, moving it into processing/stats would invert that dependency. Is there a cleaner spot for it I might be missing?

For what it's worth, the current split seems to match the recently-merged widgets — sales-by-utm-channel composes useReportOrderAttribution + buildSalesByUtmData( primary.data ) in its render, and top-posts does the same — so I leaned toward following that pattern here.

That said, I think you're right that there's real duplication: authors, top-posts, locations, and sales-by-utm all hand-roll the rows→LeaderboardChartData step. Would a shared buildLeaderboardData in widgets-toolkit make sense as a follow-up across all of them? I'd lean toward a separate PR rather than scope-creeping this one — but let me know your thoughts, happy to go a different way if you'd prefer.

* External dependencies
*/
import {
calculateDelta,
type LeaderboardChartData,
} from '@jetpack-premium-analytics/widgets-toolkit';
import { __ } from '@wordpress/i18n';
import type { StatsNormalizedReport, StatsTopAuthorsItem } from '@jetpack-premium-analytics/data';

/**
* 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' );
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Claude code found this is a dead code since sanitizeStatsTopAuthorsResponse already substitutes item.name || 'Untracked Authors':

'Untracked Authors'` is untranslated there though.


/**
* 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 per-author items for the period.
*/
function toAuthorItems(
report: StatsNormalizedReport< StatsTopAuthorsItem > | undefined
): StatsTopAuthorsItem[] {
return report?.data.flatMap( point => point.items ) ?? [];
}

/**
* 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 display label
* (authors missing from the comparison period count as zero).
*
* @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
): LeaderboardChartData {
const authors = toAuthorItems( primary );

if ( authors.length === 0 ) {
return [];
}

const comparisonViews = new Map(
toAuthorItems( comparison ).map( author => [ getAuthorLabel( author ), author.views ] )
);

// 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(
...authors.map( author =>
Math.max( author.views, comparisonViews.get( getAuthorLabel( author ) ) ?? 0 )
),
1
);

return authors.map( author => {
const label = getAuthorLabel( author );
const currentValue = author.views;
const previousValue = comparisonViews.get( label ) ?? 0;

return {
id: label,
label,
currentValue,
previousValue,
currentShare: ( currentValue / maxValue ) * 100,
previousShare: ( previousValue / maxValue ) * 100,
delta: calculateDelta( currentValue, previousValue ),
};
} );
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "@automattic/jetpack-premium-analytics-widget-authors",
"version": "0.1.0-alpha",
"private": true,
"type": "module",
"dependencies": {
"@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"
}
}
Loading
Loading