Premium Analytics: add Emails widget#50061
Conversation
Port the Jetpack Stats Emails module into a registered jpa/stats-emails widget: a leaderboard of the latest sent emails with a selector to switch the displayed metric between open rate and click rate. Data comes from the useStatsEmailSummary hook (lifetime summary endpoint, no date range or comparison).
Screenshots (after)New widget — "after" only. Populated (Storybook Live Premium Analytics dashboard (Traffic tab) — mounts cleanly via Customize → Add widget; shows the empty state here because this test blog has no sent newsletters. The |
|
Thank you for your PR! When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:
This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖 Follow this PR Review Process:
If you have questions about anything, reach out in #jetpack-developers for guidance! |
This comment has been minimized.
This comment has been minimized.
Code Coverage SummaryThis PR did not change code coverage! That could be good or bad, depending on the situation. Everything covered before, and still is? Great! Nothing was covered before? Not so great. 🤷 |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…tory pattern - EmailRow.id docblock: fallback is the array index, not the title - Drop redundant data cast (hook already types it as StatsEmailSummary | undefined) - Drop no-op clsx() wrapper and the clsx dependency - Note in the story why it uses fixtures over WidgetDashboardWithWidget (matches top-posts)
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
🤖 Review-cycle summary —
|
| Source | Comment | Resolution |
|---|---|---|
| claude[bot] | Code review (#4838719355) | #1 EmailRow.id docblock fixed (fallback = array index); #2 redundant as StatsEmailSummary cast dropped; #3 no-op clsx() + dependency removed; Storybook deviation documented with a comment pointing at top-posts. All in f293305fa0. |
| claude[bot] | Re-review | LGTM ✅ — confirmed all items resolved, no regressions. |
Deliberately retained (intentional, precedented): defensive double row-count limit; plain <Text> in-body header (matches locations); bare <WidgetRoot> non-time-series pattern (matches top-posts). Unit tests for toEmailRows/buildLeaderboardData noted as an optional follow-up.
Unaddressed (flagged for owner): None.
CI: all required checks pass except PHP tests: PHP 8.4 WP trunk, which fails only at the Composer Install step — https://wordpress.org/nightly-builds/wordpress-latest.zip returns HTTP 404 (a WordPress.org nightly-build outage, unrelated to this frontend-only change; the "WP previous" PHP job passes). The job was rerun twice with the same infra result. It should go green on a re-run once WordPress.org restores the nightly zip.
The selector switches the displayed metric, not the sort order, so "By open rate"/"By click rate" was misleading. Use "Open rate"/"Click rate".
The metric selector changes the displayed value and bar width only; row order stays newest-first (the endpoint's post_date desc default). Reword the comments that implied the leaderboard ranks by the selected rate.
| function buildLeaderboardData( rows: EmailRow[], metric: EmailMetric ): LeaderboardChartData { | ||
| const rateOf = ( row: EmailRow ) => ( metric === 'opens' ? row.opensRate : row.clicksRate ); | ||
| // `1` guards against division by zero when every rate is 0. | ||
| const maxRate = Math.max( ...rows.map( rateOf ), 1 ); |
There was a problem hiding this comment.
Would maxValue > 0 ? (value / maxValue) * 100 : 0 guard fit better here than the floor-at-1?
If every email's click rate is below 1%, maxRate pins to 1 instead of the real max, and all bars compress against a 1% reference so the top row never fills. Non-blocking, but it lands on this widget's headline click-rate view.
| @@ -0,0 +1,189 @@ | |||
| /** | |||
There was a problem hiding this comment.
Should this be emails-widget.stories.tsx to match the convention?
The package AGENTS.md widget contract specifies stories/<widget-name>-widget.stories.tsx, and all four existing widgets follow it (top-posts-widget.stories.tsx, etc.).
| function toEmailRows( report: StatsEmailSummary | undefined, max: number ): EmailRow[] { | ||
| const items = report?.data?.[ 0 ]?.items ?? []; | ||
|
|
||
| return items.slice( 0, max > 0 ? max : undefined ).map( ( item, index ) => ( { |
There was a problem hiding this comment.
It seems that in EmailsReport, we already request Math.min(max, 30) rows. Is there any reason need to slice again here? It's harmless. Just curious.
| return ( | ||
| <Stack className={ styles.root }> | ||
| <Stack direction="row" justify="space-between" align="center" className={ styles.header }> | ||
| <Text>{ __( 'Latest emails sent', 'jetpack-premium-analytics' ) }</Text> |
There was a problem hiding this comment.
Widget also has a widget title. Would this title be redundant?
| <LeaderboardChart | ||
| className={ styles.leaderboard } | ||
| data={ data } | ||
| loading={ isLoading } |
There was a problem hiding this comment.
Should this drive the refetch overlay off isFetching, not the inert loading={ isLoading }?
isLoading is isPending && isFetching — false as soon as data exists — so in this branch (stale rows present) it's always false.


Fixes WOOA7S-1503
Proposed changes
Ports the Jetpack Stats Emails module into a registered Premium Analytics widget (
jpa/stats-emails).projects/packages/premium-analytics/widgets/emails/(package.json, widget.json, widget.ts, render.tsx, CSS module, Storybook story).LeaderboardChartof the latest sent emails (newest-first), with a selector to switch the displayed metric between open rate and click rate — modeled on thelocationswidget's local UI state.useStatsEmailSummaryhook in@jetpack-premium-analytics/data. This is the lifetime email-summary endpoint, so there is no date range and no comparison period — the widget follows the non-time-series prop-drill pattern oftop-posts(wraps<WidgetRoot>, prop-drillsattributes).presentation: full-bleed; the body renders its own header ("Latest emails sent" + the metric selector), mirroringlocations.percentagedata format (signDisplay: 'never').maxattribute controls the row count;max = 0requests the endpoint maximum (30).Story note: like
top-posts(the canonical non-time-series reference), the Storybook story exercises the presentationalEmailsLeaderboardwith fixtures so the populated states render without a backend — the dashboard-harness mocks (registerReportMocks) cover only the WCanalytics/reportsendpoints, not the Stats proxy, so a data-connected dashboard story would only ever show the empty state.Related product discussion/links
stats-emails.tsxin wp-calypsoDoes this pull request change what data or activity we track or use?
No. It reuses the existing
stats/emails/summaryproxy endpoint anduseStatsEmailSummaryhook; no new data is collected.Testing instructions
jp build plugins/premium-analytics --deps./wp-admin/admin.php?page=jetpack-premium-analytics-wp-admin.stats/emails/summaryrequest returns 200 (on a site with sent newsletters the rows render with proportional bars and percentages; on a site without, the empty state shows). No console errors should originate from the widget.Packages/Premium Analytics/Widgets/Emails), check theDefault,ByClickRate,Loading,Empty,ErrorState, andLongLabelsstories.