Skip to content

Premium Analytics: add Emails widget#50061

Open
kangzj wants to merge 4 commits into
trunkfrom
add/wooa7s-1503-emails
Open

Premium Analytics: add Emails widget#50061
kangzj wants to merge 4 commits into
trunkfrom
add/wooa7s-1503-emails

Conversation

@kangzj

@kangzj kangzj commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Fixes WOOA7S-1503

Proposed changes

Ports the Jetpack Stats Emails module into a registered Premium Analytics widget (jpa/stats-emails).

  • New widget at projects/packages/premium-analytics/widgets/emails/ (package.json, widget.json, widget.ts, render.tsx, CSS module, Storybook story).
  • Renders a LeaderboardChart of the latest sent emails (newest-first), with a selector to switch the displayed metric between open rate and click rate — modeled on the locations widget's local UI state.
  • Data comes from the existing useStatsEmailSummary hook 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 of top-posts (wraps <WidgetRoot>, prop-drills attributes).
  • presentation: full-bleed; the body renders its own header ("Latest emails sent" + the metric selector), mirroring locations.
  • Open/click rates (0–100) are formatted as percentages via the toolkit's percentage data format (signDisplay: 'never').
  • max attribute controls the row count; max = 0 requests the endpoint maximum (30).

Story note: like top-posts (the canonical non-time-series reference), the Storybook story exercises the presentational EmailsLeaderboard with fixtures so the populated states render without a backend — the dashboard-harness mocks (registerReportMocks) cover only the WC analytics/reports endpoints, not the Stats proxy, so a data-connected dashboard story would only ever show the empty state.

Related product discussion/links

Does this pull request change what data or activity we track or use?

No. It reuses the existing stats/emails/summary proxy endpoint and useStatsEmailSummary hook; no new data is collected.

Testing instructions

  • Build the dashboard plugin: jp build plugins/premium-analytics --deps.
  • Open the Premium Analytics dashboard: /wp-admin/admin.php?page=jetpack-premium-analytics-wp-admin.
  • Click Customize → Add widget, add Emails, then Done.
  • Confirm the widget mounts with the "Latest emails sent" header and a "By open rate / By click rate" selector, and that the stats/emails/summary request 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.
  • In Storybook (Packages/Premium Analytics/Widgets/Emails), check the Default, ByClickRate, Loading, Empty, ErrorState, and LongLabels stories.

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).
@kangzj

kangzj commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Screenshots (after)

New widget — "after" only.

Populated (Storybook Default story — open-rate view)

Emails widget populated

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 stats/emails/summary request returns 200 ({"posts":[]}) and there are no console errors from the widget.

Emails widget on the live dashboard

@kangzj kangzj marked this pull request as ready for review June 30, 2026 00:13
@kangzj kangzj requested review from a team as code owners June 30, 2026 00:13
@kangzj kangzj self-assigned this Jun 30, 2026
@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

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:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

@github-actions github-actions Bot added the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label Jun 30, 2026
@kangzj kangzj marked this pull request as draft June 30, 2026 00:15
@kangzj

This comment has been minimized.

@jp-launch-control

Copy link
Copy Markdown

Code Coverage Summary

This 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. 🤷

Full summary · PHP report

@kangzj

This comment has been minimized.

@claude

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)
@kangzj

This comment has been minimized.

@kangzj

This comment has been minimized.

@kangzj

This comment has been minimized.

@claude

This comment has been minimized.

@kangzj

kangzj commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Review-cycle summary — 03b026e0bef293305fa0

3 round(s); @claude re-review LGTM; all required checks green except one WordPress.org infra outage (below).

What changed during the cycle

Commits added:

  • f293305fa0 — Address review: drop redundant cast/clsx, fix id docblock, document story pattern

Diff summary: 3 files changed, 12 insertions(+), 9 deletions(-)

Review threads addressed:

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.

@kangzj kangzj added [Status] Needs Team Review Obsolete. Use Needs Review instead. and removed [Status] In Progress labels Jun 30, 2026
@kangzj kangzj marked this pull request as ready for review June 30, 2026 00:47
@kangzj kangzj removed the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label Jun 30, 2026
kangzj added 2 commits June 30, 2026 13:45
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.

@chihsuan chihsuan left a comment

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.

Thanks @kangzj Overall looks good.

Per AGENTS.md, I think we should add a WidgetDashboardWithWidget story with a data mock before merging.

Also left a few comments.

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 );

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.

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 @@
/**

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.

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 ) => ( {

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.

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>

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.

Widget also has a widget title. Would this title be redundant?

<LeaderboardChart
className={ styles.leaderboard }
data={ data }
loading={ isLoading }

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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants