-
Notifications
You must be signed in to change notification settings - Fork 891
WOOA7S-1495: add Authors widget backed by Jetpack Stats #49570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
08603a8
37c5777
40518d3
a066ea1
33ff800
0bec55e
0ccd41b
5eb60f6
1e41324
7ad2ca5
8933d09
9be7729
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 @@ | ||||
| /** | ||||
| * 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' ); | ||||
| } | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude code found this is a dead code since 'Untracked Authors'` is untranslated there though. jetpack/projects/packages/premium-analytics/packages/data/src/processing/stats/top-authors.ts Line 26 in e4bf1d4
|
||||
|
|
||||
| /** | ||||
| * 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" | ||
| } | ||
| } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
buildTopAuthorsDatacould fold into thetop-authorssanitizer like you suggested, and I'm not sure it can — wanted to run my thinking by you:useStatsReportlooks 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 bothprimary.dataandcomparison.dataare in hand. Does that match how you're reading it?LeaderboardChartData/calculateDeltafromwidgets-toolkit, and sincedatais the leaf the toolkit depends on, moving it intoprocessing/statswould 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→
LeaderboardChartDatastep. Would a sharedbuildLeaderboardDatainwidgets-toolkitmake 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.