From 5728d4f98e24841f0e7c6d9af482b8c37ed9e408 Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Thu, 30 Jul 2026 15:09:19 +0200 Subject: [PATCH 01/12] Premium Analytics: add a Popular post widget and share the post highlight card Adds the Insights "Most popular post" module as `jpa/popular-post`: the site's most-viewed post for the dashboard's selected date range, with its publish date and the same three metric tiles as Latest post. Unlike Latest post it is period-scoped, reading `reportParams` from the widget root context, so changing the date range changes both the winning post and its view count. Views come from `stats/top-posts` for the period; likes and comments come from `stats/post/{id}`, which takes no date range, so those two tiles carry an all-time aggregation note instead of implying a period number. Latest post's presentational card moves to `PostHighlightCard` in widgets-toolkit so both widgets render one card shape, and the card now adapts to the dashboard cell with a single container-query breakpoint: width-2 and wider cells get the two-column layout from the design (text left, metric row anchored to the bottom, rounded near-square featured image right), while width-1 cells drop the image and wrap the metric row. The publish line copy now matches the prototype ("Post published on "). WOOA7S-1787 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../changelog/wooa7s-1787-post-widgets | 4 + .../packages/data/src/index.ts | 2 +- .../data/src/queries/latest-post-query.ts | 42 +++- .../widgets-toolkit/src/components/index.ts | 5 + .../__tests__/post-highlight-card.test.tsx | 151 +++++++++++++ .../components/post-highlight-card/index.ts | 5 + .../post-highlight-card.module.scss | 156 +++++++++++++ .../post-highlight-card.tsx | 210 ++++++++++++++++++ .../packages/widgets-toolkit/src/index.ts | 3 + .../src/stories/mocks/data/index.ts | 2 + .../src/stories/mocks/data/post-content.ts | 71 ++++++ .../stories/mocks/register-report-mocks.ts | 17 ++ .../__tests__/latest-post-card.test.tsx | 96 -------- .../widgets/latest-post/render.tsx | 174 ++++----------- .../stories/latest-post-widget.stories.tsx | 8 + .../widgets/latest-post/style.module.css | 134 ----------- .../__tests__/use-popular-post.test.tsx | 163 ++++++++++++++ .../widgets/popular-post/package.json | 14 ++ .../widgets/popular-post/render.tsx | 119 ++++++++++ .../stories/popular-post-widget.stories.tsx | 175 +++++++++++++++ .../widgets/popular-post/use-popular-post.ts | 138 ++++++++++++ .../widgets/popular-post/widget.json | 10 + .../widgets/popular-post/widget.ts | 29 +++ 23 files changed, 1360 insertions(+), 368 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/wooa7s-1787-post-widgets create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/__tests__/post-highlight-card.test.tsx create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/index.ts create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx create mode 100644 projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/post-content.ts delete mode 100644 projects/packages/premium-analytics/widgets/latest-post/__tests__/latest-post-card.test.tsx delete mode 100644 projects/packages/premium-analytics/widgets/latest-post/style.module.css create mode 100644 projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx create mode 100644 projects/packages/premium-analytics/widgets/popular-post/package.json create mode 100644 projects/packages/premium-analytics/widgets/popular-post/render.tsx create mode 100644 projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx create mode 100644 projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts create mode 100644 projects/packages/premium-analytics/widgets/popular-post/widget.json create mode 100644 projects/packages/premium-analytics/widgets/popular-post/widget.ts diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1787-post-widgets b/projects/packages/premium-analytics/changelog/wooa7s-1787-post-widgets new file mode 100644 index 000000000000..936570c281a7 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1787-post-widgets @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Insights: add a Popular post widget and make the single-post highlight card responsive. diff --git a/projects/packages/premium-analytics/packages/data/src/index.ts b/projects/packages/premium-analytics/packages/data/src/index.ts index f0b965c7b191..2f0a1b33d278 100644 --- a/projects/packages/premium-analytics/packages/data/src/index.ts +++ b/projects/packages/premium-analytics/packages/data/src/index.ts @@ -25,7 +25,7 @@ export type { export { useStatsPostLikes } from './hooks/use-stats-post-likes'; export type { StatsPostLikesParams, StatsPostLikesResponse } from './hooks/use-stats-post-likes'; export { useStatsQuery } from './hooks/use-stats-query'; -export { latestPostQuery } from './queries/latest-post-query'; +export { latestPostQuery, postContentQuery } from './queries/latest-post-query'; export type { LatestPost, LatestPostResponse } from './processing/latest-post'; export { useStatsTopPosts } from './hooks/use-stats-top-posts'; export { useStatsReferrers } from './hooks/use-stats-referrers'; diff --git a/projects/packages/premium-analytics/packages/data/src/queries/latest-post-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/latest-post-query.ts index 8a24cd89bb06..deba0b6eafa5 100644 --- a/projects/packages/premium-analytics/packages/data/src/queries/latest-post-query.ts +++ b/projects/packages/premium-analytics/packages/data/src/queries/latest-post-query.ts @@ -12,13 +12,18 @@ import type { UseQueryOptions } from '@tanstack/react-query'; export type { LatestPostResponse }; +// The headline fields a single-post highlight card needs, plus the embedded +// featured media. Shared by both queries below so they stay in one shape. +const POST_CONTENT_FIELDS = + 'id,title,link,date,featured_media,_links.wp:featuredmedia,_embedded.wp:featuredmedia'; + const LATEST_POST_PATH = addQueryArgs( '/wp/v2/posts', { per_page: 1, status: 'publish', orderby: 'date', order: 'desc', _embed: 'wp:featuredmedia', - _fields: 'id,title,link,date,featured_media,_links.wp:featuredmedia,_embedded.wp:featuredmedia', + _fields: POST_CONTENT_FIELDS, } ); /** @@ -36,3 +41,38 @@ export function latestPostQuery(): UseQueryOptions< LatestPostResponse > { placeholderData: previousData => previousData, }; } + +/** + * React Query options for one published post's headline content, read locally + * from the core WordPress posts endpoint. Same source and shape as + * `latestPostQuery()`, addressed by ID: report data identifies a post but carries + * no featured image, so a widget highlighting a reported post reads its content + * on-site in a dependent request. + * + * Disabled until a post ID is known, and deliberately without `placeholderData`: + * the key changes with the post, and carrying the previous post's title and image + * over would briefly mislabel the new one. + * + * @param postId - The post to read. Values <= 0 leave the query disabled. + * @return The query options for the post-content request. + */ +export function postContentQuery( postId: number ): UseQueryOptions< LatestPostResponse > { + return { + queryKey: [ 'post-content', postId ], + // The path is built inside the fetcher so `postId` stays its only input, + // which is already part of the query key above. + queryFn: async () => + sanitizeLatestPostResponse( + await apiFetch( { + path: addQueryArgs( '/wp/v2/posts', { + include: postId, + per_page: 1, + status: 'publish', + _embed: 'wp:featuredmedia', + _fields: POST_CONTENT_FIELDS, + } ), + } ) + ), + enabled: Number.isInteger( postId ) && postId > 0, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts index 00abc68c8966..e95ff8eec9f6 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts @@ -59,6 +59,11 @@ export { type LeaderboardPostLabelProps, type LeaderboardPostLabelVariant, } from './leaderboard-post-label'; +export { + PostHighlightCard, + type PostHighlightCardMetric, + type PostHighlightCardProps, +} from './post-highlight-card'; export { VideoTitleLink, type VideoTitleLinkProps } from './video-title-link'; export { SubscriberList, diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/__tests__/post-highlight-card.test.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/__tests__/post-highlight-card.test.tsx new file mode 100644 index 000000000000..132a591e53aa --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/__tests__/post-highlight-card.test.tsx @@ -0,0 +1,151 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +/** + * Internal dependencies + */ +import { PostHighlightCard } from '../post-highlight-card'; +import type { PostHighlightCardProps } from '../post-highlight-card'; +import type { AnchorHTMLAttributes, ReactNode } from 'react'; + +type MockRouteLinkProps = { + to: string; + params?: Record< string, unknown >; + search?: Record< string, unknown >; + children: ReactNode; +} & Omit< AnchorHTMLAttributes< HTMLAnchorElement >, 'href' >; + +// `forwardRef`, because the design system link that renders this forwards a ref. +jest.mock( '@wordpress/route', () => { + const { forwardRef } = jest.requireActual( 'react' ) as typeof import('react'); + + return { + Link: forwardRef< HTMLAnchorElement, MockRouteLinkProps >( + ( { to, params, search, children, ...props }, ref ) => { + const path = Object.entries( params ?? {} ).reduce( + ( result, [ key, value ] ) => result.replace( `$${ key }`, String( value ) ), + to + ); + const query = new URLSearchParams(); + Object.entries( search ?? {} ).forEach( ( [ key, value ] ) => { + if ( value !== undefined && value !== null ) { + query.set( key, String( value ) ); + } + } ); + const queryString = query.toString(); + + return ( + + { children } + + ); + } + ), + }; +} ); + +const props: PostHighlightCardProps = { + title: 'Quarterly update', + url: 'https://example.com/quarterly-update/', + date: '2026-06-05T00:00:00+00:00', + metrics: [ + { key: 'views', label: 'Views', value: 42 }, + { key: 'likes', label: 'Likes', value: 3, note: 'All-time total.' }, + ], +}; + +describe( 'PostHighlightCard', () => { + it( 'links the title to the detail route and carries the report window', () => { + render( + + ); + + const href = screen.getByRole( 'link', { name: /^Quarterly update/ } ).getAttribute( 'href' ); + const url = new URL( href ?? '', 'http://localhost' ); + + expect( url.pathname ).toBe( '/post/12' ); + expect( url.searchParams.get( 'from' ) ).toBe( '2026-06-01' ); + expect( url.searchParams.get( 'to' ) ).toBe( '2026-06-30' ); + } ); + + it( 'falls back to the published post when there is no post ID', () => { + render( ); + + const link = screen.getByRole( 'link', { name: /^Quarterly update/ } ); + expect( link ).toHaveAttribute( 'href', 'https://example.com/quarterly-update/' ); + expect( link ).toHaveAttribute( 'target', '_blank' ); + } ); + + it( 'keeps the title as plain text when the post URL is unsafe', () => { + render( ); + + expect( screen.getByText( 'Quarterly update' ) ).toBeInTheDocument(); + expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument(); + } ); + + it( 'links the title to the post', () => { + render( ); + + // `openInNewTab` appends a screen-reader hint to the accessible name. + expect( screen.getByRole( 'link', { name: /^Quarterly update/ } ) ).toHaveAttribute( + 'href', + 'https://example.com/quarterly-update/' + ); + } ); + + // The title used to be wrapped in `` unconditionally, so a revert here is plausible. + it( 'keeps the title readable as plain text when the post URL is unsafe', () => { + render( ); + + expect( screen.getByText( 'Quarterly update' ) ).toBeInTheDocument(); + expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument(); + } ); + + it( 'renders the publish line and the metric tiles', () => { + render( ); + + expect( screen.getByText( 'Post published on Jun 5, 2026' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Views' ) ).toBeInTheDocument(); + expect( screen.getByText( '42' ) ).toBeInTheDocument(); + } ); + + it( 'omits the publish line when the post has no date', () => { + render( ); + + expect( screen.queryByText( /^Post published on/ ) ).not.toBeInTheDocument(); + } ); + + // A lifetime metric shown next to a period-scoped one must say so, and the + // `title` tooltip alone is invisible to assistive technology. + it( 'exposes a metric note as both a tooltip and visually hidden text', () => { + render( ); + + expect( screen.getByTitle( 'All-time total.' ) ).toBeInTheDocument(); + expect( screen.getByText( 'All-time total.' ) ).toBeInTheDocument(); + } ); + + it( 'renders the featured image only when one is present', () => { + const { rerender } = render( ); + + // Scoped by name: `openInNewTab` renders its own `role="img"` link glyph. + expect( screen.queryByRole( 'img', { name: 'Hero image' } ) ).not.toBeInTheDocument(); + + rerender( + + ); + + expect( screen.getByRole( 'img', { name: 'Hero image' } ) ).toHaveAttribute( + 'src', + 'https://example.com/hero.jpg' + ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/index.ts new file mode 100644 index 000000000000..ce6b772fb1d1 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/index.ts @@ -0,0 +1,5 @@ +export { + PostHighlightCard, + type PostHighlightCardMetric, + type PostHighlightCardProps, +} from './post-highlight-card'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss new file mode 100644 index 000000000000..fb49c0ed2efb --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss @@ -0,0 +1,156 @@ +/* Shared single-post highlight card. The widget host owns the card + * chrome, so this only lays out the body: title, publish line, metric + * tiles, and the featured image. + * + * Sizing follows the package's container-query convention — `WidgetRoot` + * names the widget's container `widget`, so the ladder below tracks the + * dashboard cell rather than the viewport. There is one breakpoint: + * + * >= 432px width-2 and wider cells, including the default placement: + * two columns, text left with the metric row anchored to the + * bottom, and a rounded, near-square featured image filling + * the trailing column's full height. + * < 432px width-1 cells: one column, the featured image is dropped — + * it would eat most of a small cell — and the metric row + * wraps with tighter gaps. The primary metric is first, so it + * stays on the first wrapped row. + * + * The 432px threshold matches the Top locations widget: it sits just + * below a width-2 cell at the minimum four-column dashboard width, so + * every width-2 cell gets the two-column layout the design specifies. + */ +.root { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-lg); + height: 100%; + overflow: hidden; +} + +.content { + display: flex; + flex: 1; + flex-direction: column; + + /* Title block at the top, metric row anchored to the bottom. */ + justify-content: space-between; + gap: var(--wpds-dimension-gap-lg); + min-width: 0; + min-height: 0; +} + +.header { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-xs); + min-width: 0; +} + +.titleLink { + color: var(--wpds-color-foreground-content-neutral); + text-decoration: none; +} + +.titleLink:hover { + text-decoration: underline; +} + +.title { + display: -webkit-box; + margin: 0; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; +} + +.date { + color: var(--wpds-color-foreground-content-neutral-weak); +} + +.metrics { + display: flex; + flex-flow: row wrap; + gap: var(--wpds-dimension-gap-lg); +} + +.metric { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-xs); + min-width: 0; +} + +.metricLabel { + font-weight: var(--wpds-typography-font-weight-medium); + color: var(--wpds-color-foreground-content-neutral); +} + +.metric .metricValue { + font-size: var(--wpds-typography-font-size-lg); +} + +/* Featured image: inset with rounded corners, covering its box at any + * aspect ratio. */ +.media { + overflow: hidden; + border-radius: var(--wpds-border-radius-lg); +} + +.image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Narrow cells: drop the featured image, scale the headline down, and + * tighten the metric row so it wraps without overflowing. */ +@container widget (max-width: 431px) { + + .root { + gap: var(--wpds-dimension-gap-md); + } + + .content { + gap: var(--wpds-dimension-gap-md); + } + + .media { + display: none; + } + + /* `Text`'s own variant class carries a font-size at the same + * specificity, so the override is scoped through `.header` to win + * deterministically. */ + .header .title { + font-size: var(--wpds-typography-font-size-xl); + line-height: var(--wpds-typography-line-height-md); + -webkit-line-clamp: 2; + line-clamp: 2; + } + + .metrics { + gap: var(--wpds-dimension-gap-md); + } +} + +/* Width-2 and wider cells: text left, featured image right at close to + * the body's full height. 44% keeps it near-square at the default + * placement, per the design. */ +@container widget (min-width: 432px) { + + .root { + flex-direction: row; + align-items: stretch; + gap: var(--wpds-dimension-gap-xl); + } + + .media { + flex: 0 0 44%; + } + + .metrics { + gap: var(--wpds-dimension-gap-2xl); + } +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx new file mode 100644 index 000000000000..9cf259b53895 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx @@ -0,0 +1,210 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Text, VisuallyHidden } from '@wordpress/ui'; +import { format, parseISO } from 'date-fns'; +/** + * Internal dependencies + */ +import { MetricValue } from '../metric-value'; +import { PostTitleLink } from '../post-title-link'; +import styles from './post-highlight-card.module.scss'; +import type { DataFormat } from '../../types'; + +/** + * Shortened counts with no decimals, matching the Stats widget convention. + */ +const DEFAULT_METRIC_FORMAT: DataFormat = { + type: 'number', + options: { useMultipliers: true, decimals: 0 }, +}; + +export type PostHighlightCardMetric = { + /** + * Stable identifier for the metric. + */ + key: string; + + /** + * The metric label (e.g. "Views"). + */ + label: string; + + /** + * The metric value. + */ + value: number; + + /** + * Caveat about how the value is aggregated, e.g. that it is an all-time total + * while its neighbours are scoped to the dashboard's date range. Shown as a + * hover tooltip on the tile and mirrored as visually hidden text for + * assistive technology. + */ + note?: string; +}; + +export type PostHighlightCardProps = { + /** + * The post title. Rendered as plain text when `url` is absent or unsafe. + */ + title: string; + + /** + * Public URL of the post. Used as the link when there is no post ID. + */ + url?: string | null; + + /** + * Post ID. When present the title links to the internal detail route. + */ + postId?: number | string; + + /** + * Search params for the detail route, from `pickReportDateParams()`. + */ + detailSearch?: Record< string, unknown >; + + /** + * The post's publish timestamp, as an ISO date string. + */ + date?: string; + + /** + * Featured image URL. Omit for a card with no media. + */ + imageUrl?: string; + + /** + * Alternative text for the featured image. + */ + imageAlt?: string; + + /** + * The metric tiles, primary metric first: on narrow cells the tiles wrap, so + * the first one stays visible alongside the title and publish line. + */ + metrics: PostHighlightCardMetric[]; + + /** + * Format configuration for the metric values. + * @default shortened counts with no decimals + */ + dataFormat?: DataFormat; +}; + +/** + * Formats an ISO date string as a "Post published on " line, falling back + * to the raw string when the date cannot be parsed. + * + * @param date - The post's ISO date string. + * @return The formatted publish line, or an empty string when absent. + */ +function formatPublishDate( date: string ): string { + if ( ! date ) { + return ''; + } + + const parsed = parseISO( date ); + const formatted = Number.isNaN( parsed.getTime() ) ? date : format( parsed, 'PP' ); + + return sprintf( + /* translators: %s: the post's publish date, e.g. "Jun 5, 2026". */ + __( 'Post published on %s', 'jetpack-premium-analytics-pkg' ), + formatted + ); +} + +/** + * A single labelled metric value, with an optional aggregation caveat. + * + * @param props - The component props. + * @param props.metric - The metric to render. + * @param props.dataFormat - Format configuration for the value. + * @return The rendered metric tile. + */ +function PostHighlightMetric( { + metric, + dataFormat, +}: { + metric: PostHighlightCardMetric; + dataFormat: DataFormat; +} ) { + return ( +
+ + { metric.label } + + { /* The `title` tooltip is invisible to keyboard and screen-reader users, + so the caveat is repeated as visually hidden text. */ } + { metric.note && { metric.note } } + +
+ ); +} + +/** + * Presentational card highlighting a single post: its title (linking to the + * published post), its publish date, a row of metric tiles, and its featured + * image when present. + * + * Shared by the "Latest post" and "Popular post" widgets. It renders only the + * populated state — loading, error, and empty belong to the calling widget's + * `` — and adapts to the dashboard cell size through the container + * queries in its stylesheet. + * + * @param {PostHighlightCardProps} props - The component props. + * @return The rendered card. + */ +export function PostHighlightCard( { + title, + url, + postId, + detailSearch, + date = '', + imageUrl = '', + imageAlt = '', + metrics, + dataFormat = DEFAULT_METRIC_FORMAT, +}: PostHighlightCardProps ) { + const publishDate = formatPublishDate( date ); + + return ( +
+
+
+ }> + + + { publishDate && ( + + { publishDate } + + ) } +
+
+ { metrics.map( metric => ( + + ) ) } +
+
+ { imageUrl && ( +
+ { +
+ ) } +
+ ); +} 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 ee076cda631b..b39d6ce23aad 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -60,6 +60,9 @@ export { LeaderboardPostLabel, type LeaderboardPostLabelProps, type LeaderboardPostLabelVariant, + PostHighlightCard, + type PostHighlightCardMetric, + type PostHighlightCardProps, VideoTitleLink, type VideoTitleLinkProps, SubscriberList, diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/index.ts index 87e174fea75e..20fb78158019 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/index.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/index.ts @@ -64,6 +64,8 @@ export { export { mockCommentsData } from './comments'; export { mockSearchTermsData, mockSearchTermsComparisonData } from './search-terms'; + +export { buildPostContentResponse } from './post-content'; export { mockSingleVideoData } from './single-video'; export { mockTagsData } from './tags'; export { mockTopAuthorsData, mockTopAuthorsComparisonData } from './top-authors'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/post-content.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/post-content.ts new file mode 100644 index 000000000000..6b2eb7692035 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/post-content.ts @@ -0,0 +1,71 @@ +/** + * Fixture for the core `/wp/v2/posts` endpoint, addressed by ID (`include=`). + * + * The single-post highlight widgets read a reported post's title, permalink, + * publish date, and featured image from the local core endpoint, because Stats + * report rows carry no featured image. Only the `include=` form is served here: + * the Latest post stories mock the unfiltered "newest post" form themselves. + * + * The IDs match the `stats/top-posts` fixture in `register-stats-mocks.ts`, so + * the Popular post widget resolves the winning row to real content. + */ + +// A neutral gradient stands in for a featured image, inline so Storybook needs +// no network request or bundled asset. +const FEATURED_IMAGE_URL = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='600'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%231d4ed8'/%3E%3Cstop offset='1' stop-color='%2393c5fd'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='800' height='600' fill='url(%23g)'/%3E%3C/svg%3E"; + +type PostContentFixture = { + title: string; + link: string; + date: string; +}; + +const POST_CONTENT_BY_ID: Record< string, PostContentFixture > = { + 1: { + title: 'Hello World Post', + link: 'https://example.com/hello-world/', + date: '2026-06-01T09:30:00', + }, + 4: { + title: 'How we cut our build times in half', + link: 'https://example.com/build-times/', + date: '2026-06-18T14:05:00', + }, +}; + +const FALLBACK_POST_CONTENT: PostContentFixture = { + title: 'A post from the archives', + link: 'https://example.com/from-the-archives/', + date: '2026-05-14T11:20:00', +}; + +/** + * Builds a core posts response for a single post ID, in the shape + * `sanitizeLatestPostResponse()` reads (a one-item array with embedded featured + * media). + * + * @param postId - The requested post ID, from the request's `include` param. + * @return The raw core posts response. + */ +export function buildPostContentResponse( postId: string ) { + const fixture = POST_CONTENT_BY_ID[ postId ] ?? FALLBACK_POST_CONTENT; + + return [ + { + id: Number( postId ), + title: { rendered: fixture.title }, + link: fixture.link, + date: fixture.date, + featured_media: 42, + _embedded: { + 'wp:featuredmedia': [ + { + source_url: FEATURED_IMAGE_URL, + alt_text: 'Featured image', + }, + ], + }, + }, + ]; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts index 39ced35eee9c..f85fb601012b 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts @@ -69,6 +69,7 @@ import { mockEmailClientBreakdown, mockEmailInternalLinkBreakdown, mockEmailUserContentLinkBreakdown, + buildPostContentResponse, } from './data'; import { getMockParamsFromPreset } from './presets'; import type { APIFetchMiddleware, APIFetchOptions } from '@wordpress/api-fetch'; @@ -99,6 +100,12 @@ const POST_LIKES_PATH_PATTERN = const POST_COMMENTS_PATH_PATTERN = /^\/jetpack-premium-analytics\/v1\/proxy\/v1\.1\/posts\/\d+\/replies(?:\?|$)/; const WP_SETTINGS_PATH = '/wp/v2/settings'; +// Core posts endpoint, addressed by ID. The single-post highlight widgets read a +// reported post's content (title, permalink, publish date, featured image) from +// core, because Stats report rows carry no featured image. Only the `include=` +// form is handled here — the Latest post stories mock the unfiltered "newest +// post" form themselves. +const WP_POSTS_PATH = '/wp/v2/posts'; const coreSettingsMock = { timezone: 'UTC', @@ -1336,6 +1343,16 @@ const reportMocksMiddleware: APIFetchMiddleware = async ( options: APIFetchOptio return coreSettingsMock; } + if ( requestPath.startsWith( WP_POSTS_PATH ) ) { + const includeParam = getQueryParam( requestPath, 'include' ); + + if ( includeParam ) { + return buildPostContentResponse( includeParam ); + } + + return next( options ); + } + if ( requestPath.startsWith( STATS_FOLLOWERS_PATH ) ) { return buildFollowersResponse(); } diff --git a/projects/packages/premium-analytics/widgets/latest-post/__tests__/latest-post-card.test.tsx b/projects/packages/premium-analytics/widgets/latest-post/__tests__/latest-post-card.test.tsx deleted file mode 100644 index feb6374fa350..000000000000 --- a/projects/packages/premium-analytics/widgets/latest-post/__tests__/latest-post-card.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/** - * External dependencies - */ -import { render, screen } from '@testing-library/react'; -/** - * Internal dependencies - */ -import LatestPost, { LatestPostCard } from '../render'; -import { useLatestPost, type LatestPostWithMetrics } from '../use-latest-post'; - -jest.mock( '@wordpress/route', () => jest.requireActual( '../../test-utils' ).mockWordPressRoute ); - -jest.mock( '../use-latest-post', () => ( { - ...jest.requireActual( '../use-latest-post' ), - useLatestPost: jest.fn(), -} ) ); - -const mockUseLatestPost = useLatestPost as jest.MockedFunction< typeof useLatestPost >; - -const post = { - id: 12, - title: 'Quarterly update', - url: 'https://example.com/quarterly-update/', - date: '2026-06-05T00:00:00+00:00', - views: 42, - likeCount: 3, - commentCount: 1, -} as unknown as LatestPostWithMetrics; - -describe( 'LatestPostCard', () => { - beforeEach( () => { - mockUseLatestPost.mockReset(); - } ); - - it( 'carries the widget report window into the detail link', () => { - mockUseLatestPost.mockReturnValue( { - post, - isLoading: false, - isFetching: false, - isError: false, - refetch: jest.fn(), - } ); - - render( - - ); - - const link = screen.getByRole( 'link', { name: 'Quarterly update' } ); - const url = new URL( link.getAttribute( 'href' ) ?? '', 'https://example.com' ); - - expect( url.pathname ).toBe( '/post/12' ); - expect( url.searchParams.get( 'from' ) ).toBe( '2026-06-01' ); - expect( url.searchParams.get( 'to' ) ).toBe( '2026-06-30' ); - } ); - - it( 'links the title to the post detail page and carries the report window', () => { - render( ); - - const link = screen.getByRole( 'link', { name: 'Quarterly update' } ); - const url = new URL( link.getAttribute( 'href' ) ?? '', 'https://example.com' ); - - expect( url.pathname ).toBe( '/post/12' ); - expect( url.searchParams.get( 'from' ) ).toBe( '2026-06-01' ); - expect( url.searchParams.get( 'post_url' ) ).toBe( 'https://example.com/quarterly-update/' ); - } ); - - it( 'falls back to the published post when there is no post ID', () => { - render( ); - - const link = screen.getByRole( 'link', { name: /Quarterly update/ } ); - expect( link ).toHaveAttribute( 'href', 'https://example.com/quarterly-update/' ); - expect( link ).toHaveAttribute( 'target', '_blank' ); - } ); - - // The title used to be wrapped in `` unconditionally, so a revert here is plausible. - it( 'keeps the title readable as plain text when the post URL is unsafe', () => { - render( - - ); - - expect( screen.getByText( 'Quarterly update' ) ).toBeInTheDocument(); - expect( screen.queryByRole( 'link' ) ).not.toBeInTheDocument(); - } ); -} ); diff --git a/projects/packages/premium-analytics/widgets/latest-post/render.tsx b/projects/packages/premium-analytics/widgets/latest-post/render.tsx index 72da981fd9a7..59b6b668c46a 100644 --- a/projects/packages/premium-analytics/widgets/latest-post/render.tsx +++ b/projects/packages/premium-analytics/widgets/latest-post/render.tsx @@ -3,24 +3,20 @@ */ import { pickReportDateParams } from '@jetpack-premium-analytics/routing'; import { - MetricValue, - PostTitleLink, + PostHighlightCard, WidgetRoot, WidgetState, useWidgetRootContext, - type DataFormat, + type PostHighlightCardMetric, type ReportParamsFieldAttributes, } from '@jetpack-premium-analytics/widgets-toolkit'; import { useMemo } from '@wordpress/element'; -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { postList } from '@wordpress/icons'; -import { Text } from '@jetpack-premium-analytics/externals'; -import { format, parseISO } from 'date-fns'; /** * Internal dependencies */ -import styles from './style.module.css'; -import { useLatestPost, type LatestPostWithMetrics } from './use-latest-post'; +import { useLatestPost } from './use-latest-post'; import type { LatestPostAttributes } from './widget'; import type { WidgetRenderProps } from '@wordpress/widget-primitives'; @@ -29,142 +25,37 @@ import type { WidgetRenderProps } from '@wordpress/widget-primitives'; type LatestPostRenderAttributes = LatestPostAttributes & Partial< ReportParamsFieldAttributes >; type LatestPostWidgetProps = WidgetRenderProps< LatestPostRenderAttributes >; -const METRIC_FORMAT: DataFormat = { - type: 'number', - options: { useMultipliers: true, decimals: 0 }, -}; - -type LatestPostCardProps = { - /** - * The resolved latest post. - */ - post: LatestPostWithMetrics; - /** - * Search parameters carried into the post detail route. - */ - detailSearch?: Record< string, unknown >; -}; - /** - * Formats an ISO date string as a "Published " line, falling back to the - * raw string when the date cannot be parsed. + * Fetches the site's latest post (with its metrics) through `useLatestPost` and + * hands it to the shared `PostHighlightCard`, with loading, error, and empty + * states handled by ``. * - * @param date - The post's ISO date string. - * @return The formatted publish line, or an empty string when absent. - */ -function formatPublishDate( date: string ): string { - if ( ! date ) { - return ''; - } - - const parsed = parseISO( date ); - const formatted = Number.isNaN( parsed.getTime() ) ? date : format( parsed, 'PP' ); - - return sprintf( - /* translators: %s: the post's publish date, e.g. "Jun 5, 2026". */ - __( 'Published %s', 'jetpack-premium-analytics-pkg' ), - formatted - ); -} - -type MetricTileProps = { - label: string; - value: number; -}; - -/** - * A single labelled metric value. This module reports lifetime totals with no - * comparison period, so it renders the value directly with `MetricValue`. - * - * @param {MetricTileProps} props - The tile props. - * @return The rendered metric tile. - */ -function MetricTile( { label, value }: MetricTileProps ) { - return ( -
- - { label } - - -
- ); -} - -/** - * Presentational card for the "Latest post" widget: the post title (linking to - * its analytics detail page), its publish date, three lifetime metric tiles - * (views, likes, comments), and the post's featured image when present. - * - * Renders only the populated state; loading, error, and empty are handled by - * `` in `LatestPostReport`. Exported so Storybook can exercise the - * card with fixtures. - * - * @param {LatestPostCardProps} props - The component props. - * @return The rendered card. - */ -export const LatestPostCard = ( { post, detailSearch = {} }: LatestPostCardProps ) => { - const publishDate = formatPublishDate( post.date ); - - return ( -
-
-
- }> - - - { publishDate && ( - - { publishDate } - - ) } -
-
- - - -
-
- { post.imageUrl && ( -
- { -
- ) } -
- ); -}; - -/** - * Fetches the site's latest post (with its metrics) through `useLatestPost` - * and hands it to the presentational `LatestPostCard`, with loading, error, - * and empty states handled by ``. + * Every tile is a lifetime total, so no tile carries an aggregation note. * * @return The widget content. */ function LatestPostReport() { - const { reportParams } = useWidgetRootContext(); const { post, isLoading, isFetching, isError, refetch } = useLatestPost(); - + const { reportParams } = useWidgetRootContext(); + // The detail page opens on the dashboard's current window. const detailSearch = useMemo( () => pickReportDateParams( reportParams ), [ reportParams ] ); + const metrics: PostHighlightCardMetric[] = post + ? [ + { key: 'views', label: __( 'Views', 'jetpack-premium-analytics-pkg' ), value: post.views }, + { + key: 'likes', + label: __( 'Likes', 'jetpack-premium-analytics-pkg' ), + value: post.likeCount, + }, + { + key: 'comments', + label: __( 'Comments', 'jetpack-premium-analytics-pkg' ), + value: post.commentCount, + }, + ] + : []; + return ( - { post && } + { post && ( + + ) } ); } diff --git a/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx b/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx index bd8c71f5d259..995dbc58bc04 100644 --- a/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx @@ -169,6 +169,11 @@ type Story = StoryObj< Partial< ComponentProps< typeof LatestPostRender > > >; /** * Default — the latest post with its lifetime views, likes, and comments. + * + * The shared close-up canvas is the width of a width-1 dashboard cell, which is + * below the card's 432px breakpoint: the featured image is dropped and the metric + * row wraps. `WidgetDashboardWithWidget` below shows the default width-2 + * placement, where the image sits in a trailing column. */ export const Default: Story = { render: renderLatestPost, @@ -214,6 +219,9 @@ export const Empty: Story = { * Mounts the real `WidgetDashboard` with this single widget so it renders * exactly as it does in product (framed card, sizing, edit mode). * + * Drop `widgetWidth` to 1 to walk the shared card's size ladder: below 432px the + * featured image drops out and the metric row wraps. + * * @param {WidgetDashboardWithWidgetControls} dashboardArgs - The dashboard story controls. * @return The widget mounted inside the real dashboard. */ diff --git a/projects/packages/premium-analytics/widgets/latest-post/style.module.css b/projects/packages/premium-analytics/widgets/latest-post/style.module.css deleted file mode 100644 index 41414fba622a..000000000000 --- a/projects/packages/premium-analytics/widgets/latest-post/style.module.css +++ /dev/null @@ -1,134 +0,0 @@ -.root { - display: flex; - flex-direction: column; - gap: var(--wpds-dimension-gap-lg); - height: 100%; - overflow: hidden; - - /* Offset the host card's body padding so the featured image can bleed to the - * card edges. Tuned to the framed widget padding. */ - --latest-post-frame-pad: var(--wpds-dimension-padding-2xl); -} - -.content { - display: flex; - flex: 1; - flex-direction: column; - justify-content: space-between; - gap: var(--wpds-dimension-gap-lg); - min-width: 0; - min-height: 0; -} - -.header { - display: flex; - flex-direction: column; - gap: var(--wpds-dimension-gap-xs); - min-width: 0; -} - -/* `:any-link` so this beats `PostTitleLink`'s own `color: inherit` default - deterministically — as plain classes the two would tie and the winner would - depend on compiled stylesheet order. Same idiom as the post detail header. */ -.titleLink:any-link { - color: var(--wpds-color-foreground-content-neutral); - text-decoration: none; -} - -.titleLink:hover { - text-decoration: underline; -} - -.title { - display: -webkit-box; - margin: 0; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - line-clamp: 3; -} - -.date { - color: var(--wpds-color-foreground-content-neutral-weak); -} - -.metrics { - display: flex; - flex-flow: row wrap; - gap: var(--wpds-dimension-gap-2xl); -} - -.metric { - display: flex; - flex-direction: column; - gap: var(--wpds-dimension-gap-xs); - min-width: 0; -} - -.metricLabel { - font-weight: var(--wpds-typography-font-weight-emphasis); - color: var(--wpds-color-foreground-content-neutral); -} - -.metric .metricValue { - font-size: var(--wpds-typography-font-size-lg); -} - -/* Featured image. Full-bleeds to the top and sides of the card by offsetting - * the host body padding; the image covers its box at any aspect ratio. */ -.media { - overflow: hidden; -} - -.image { - display: block; - width: 100%; - height: 100%; - object-fit: cover; -} - -/* Narrow tiles (single-column grid cells): stack the image as a top banner and - * scale the headline, metrics, and spacing down so the content fits without - * overflowing a small tile. */ -@container widget (max-width: 599px) { - - .root { - gap: var(--wpds-dimension-gap-md); - } - - .content { - gap: var(--wpds-dimension-gap-md); - } - - .media { - order: -1; - height: 140px; - margin-inline: calc(-1 * var(--latest-post-frame-pad)); - } - - .header .title { - font-size: var(--wpds-typography-font-size-xl); - line-height: var(--wpds-typography-line-height-md); - -webkit-line-clamp: 2; - line-clamp: 2; - } - - .metrics { - gap: var(--wpds-dimension-gap-lg); - } -} - -/* Wide tiles: content left, image bleeds to the right and bottom card edges. */ -@container widget (min-width: 600px) { - - .root { - flex-direction: row; - align-items: stretch; - } - - .media { - flex: 0 0 42%; - margin-block-end: calc(-1 * var(--latest-post-frame-pad)); - margin-inline-end: calc(-1 * var(--latest-post-frame-pad)); - } -} diff --git a/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx b/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx new file mode 100644 index 000000000000..a92cf12e0b79 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx @@ -0,0 +1,163 @@ +/** + * External dependencies + */ +import { queryClient, type ReportParams } from '@jetpack-premium-analytics/data'; +import { renderHook, waitFor } from '@testing-library/react'; +import apiFetch from '@wordpress/api-fetch'; +/** + * Internal dependencies + */ +import { queryClientWrapper as wrapper } from '../../test-utils'; +import { usePopularPost } from '../use-popular-post'; + +jest.mock( '@wordpress/api-fetch' ); + +const mockApiFetch = apiFetch as jest.MockedFunction< typeof apiFetch >; + +const reportParams = { from: '2026-06-01', to: '2026-06-30' } as ReportParams; + +// A multi-day range is requested with `summarize=1`, so the rows live under +// `summary.postviews`. +const topPostsResponse = { + date: '2026-06-30', + period: 'day', + days: {}, + summary: { + postviews: [ + // A page outranks the top post: the hook must skip it. + { id: 9, title: 'About', type: 'page', href: 'https://example.com/about/', views: 900 }, + { + id: 7, + title: 'Winning post', + type: 'post', + href: 'https://example.com/winning-post/', + date: '2026-06-02', + views: 420, + }, + { + id: 8, + title: 'Runner up', + type: 'post', + href: 'https://example.com/runner-up/', + date: '2026-06-03', + views: 120, + }, + ], + total_views: 1440, + }, +}; + +const postContentResponse = [ + { + id: 7, + title: { rendered: 'Winning & popular post' }, + link: 'https://example.com/winning-post/', + date: '2026-06-02T08:00:00', + _embedded: { + 'wp:featuredmedia': [ { source_url: 'https://example.com/hero.jpg', alt_text: 'Hero' } ], + }, + }, +]; + +const postStatsResponse = { views: 9999, like_count: 12, post: { comment_count: 4 } }; + +type MockedFetchArgs = { path?: string; url?: string }; + +function mockEndpoints( { failPostStats = false }: { failPostStats?: boolean } = {} ) { + mockApiFetch.mockImplementation( ( { path = '', url = '' }: MockedFetchArgs ) => { + const target = path || url; + + if ( target.includes( 'stats/top-posts' ) ) { + return Promise.resolve( topPostsResponse ); + } + + if ( target.includes( 'stats/post/' ) ) { + return failPostStats + ? Promise.reject( new Error( 'User cannot access this private blog.' ) ) + : Promise.resolve( postStatsResponse ); + } + + if ( target.startsWith( '/wp/v2/posts' ) ) { + return Promise.resolve( postContentResponse ); + } + + return Promise.resolve( {} ); + } ); +} + +describe( 'usePopularPost', () => { + beforeEach( () => { + queryClient.clear(); + mockApiFetch.mockReset(); + } ); + + it( 'picks the most-viewed post, with period views and all-time engagement', async () => { + mockEndpoints(); + + const { result } = renderHook( () => usePopularPost( reportParams ), { wrapper } ); + + await waitFor( () => + expect( result.current.post ).toEqual( { + id: 7, + // Core content wins over the report row: it is entity-decoded. + title: 'Winning & popular post', + url: 'https://example.com/winning-post/', + date: '2026-06-02T08:00:00', + imageUrl: 'https://example.com/hero.jpg', + imageAlt: 'Hero', + // Period views come from the report, not from the all-time `stats/post` value. + views: 420, + likeCount: 12, + commentCount: 4, + } ) + ); + expect( result.current.isError ).toBe( false ); + } ); + + it( 'requests the resolved post from the core posts endpoint', async () => { + mockEndpoints(); + + renderHook( () => usePopularPost( reportParams ), { wrapper } ); + + await waitFor( () => { + const contentPath = mockApiFetch.mock.calls + .map( ( [ options ] ) => ( options as MockedFetchArgs ).path ?? '' ) + .find( path => path.startsWith( '/wp/v2/posts' ) ); + + expect( contentPath ).toContain( 'include=7' ); + } ); + } ); + + it( 'still renders the post with zeroed engagement when stats/post fails', async () => { + mockEndpoints( { failPostStats: true } ); + + const { result } = renderHook( () => usePopularPost( reportParams ), { wrapper } ); + + await waitFor( () => expect( result.current.post?.views ).toBe( 420 ) ); + expect( result.current.post?.likeCount ).toBe( 0 ); + expect( result.current.post?.commentCount ).toBe( 0 ); + expect( result.current.isError ).toBe( false ); + } ); + + it( 'returns a null post when the period has no post views', async () => { + mockApiFetch.mockImplementation( ( { path = '', url = '' }: MockedFetchArgs ) => { + const target = path || url; + + if ( target.includes( 'stats/top-posts' ) ) { + return Promise.resolve( { + date: '2026-06-30', + period: 'day', + days: {}, + summary: { postviews: [] }, + } ); + } + + return Promise.resolve( {} ); + } ); + + const { result } = renderHook( () => usePopularPost( reportParams ), { wrapper } ); + + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.post ).toBeNull(); + } ); +} ); diff --git a/projects/packages/premium-analytics/widgets/popular-post/package.json b/projects/packages/premium-analytics/widgets/popular-post/package.json new file mode 100644 index 000000000000..68a61f9a6fd5 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/package.json @@ -0,0 +1,14 @@ +{ + "name": "@automattic/jetpack-premium-analytics-widget-popular-post", + "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": "^15.0.0", + "@wordpress/widget-primitives": "0.2.0", + "react": "18.3.1" + } +} diff --git a/projects/packages/premium-analytics/widgets/popular-post/render.tsx b/projects/packages/premium-analytics/widgets/popular-post/render.tsx new file mode 100644 index 000000000000..14dc1e99841b --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/render.tsx @@ -0,0 +1,119 @@ +/** + * External dependencies + */ +import { + PostHighlightCard, + WidgetRoot, + WidgetState, + describeError, + useWidgetRootContext, + type PostHighlightCardMetric, + type ReportParamsFieldAttributes, +} from '@jetpack-premium-analytics/widgets-toolkit'; +import { __ } from '@wordpress/i18n'; +import { trendingUp } from '@wordpress/icons'; +/** + * Internal dependencies + */ +import { usePopularPost } from './use-popular-post'; +import type { PopularPostAttributes } from './widget'; +import type { WidgetRenderProps } from '@wordpress/widget-primitives'; + +// Unlike Latest post, this widget is period-scoped: the host injects the +// dashboard date range through `reportParams`. +type PopularPostRenderAttributes = PopularPostAttributes & Partial< ReportParamsFieldAttributes >; +type PopularPostWidgetProps = WidgetRenderProps< PopularPostRenderAttributes >; + +/** + * Fetches the period's most-viewed post through `usePopularPost` and hands it to + * the shared `PostHighlightCard`, with loading, error, and empty states handled + * by ``. + * + * Views are scoped to the dashboard's date range; likes and comments are all-time + * totals (the Stats post endpoint takes no date range), so those two tiles carry + * an aggregation note the card surfaces as a tooltip and as visually hidden text. + * + * @return The widget content. + */ +function PopularPostReport() { + const { reportParams } = useWidgetRootContext(); + const { post, isLoading, isFetching, isError, error, refetch } = usePopularPost( reportParams ); + + const metrics: PostHighlightCardMetric[] = post + ? [ + { + key: 'views', + label: __( 'Views', 'jetpack-premium-analytics-pkg' ), + value: post.views, + note: __( 'Views in the selected date range.', 'jetpack-premium-analytics-pkg' ), + }, + { + key: 'likes', + label: __( 'Likes', 'jetpack-premium-analytics-pkg' ), + value: post.likeCount, + note: __( + 'All-time likes. Stats does not report likes per date range.', + 'jetpack-premium-analytics-pkg' + ), + }, + { + key: 'comments', + label: __( 'Comments', 'jetpack-premium-analytics-pkg' ), + value: post.commentCount, + note: __( + 'All-time comments. Stats does not report comments per date range.', + 'jetpack-premium-analytics-pkg' + ), + }, + ] + : []; + + return ( + + { post && ( + + ) } + + ); +} + +/** + * Widget render entry point. + * + * WidgetRoot provides the analytics query client, the chart theme, and the + * dashboard's `reportParams` that the inner report reads through + * `useWidgetRootContext()`. + * + * @param {PopularPostWidgetProps} props - The widget render props. + * @return The rendered widget. + */ +export default function PopularPost( { attributes = {} }: PopularPostWidgetProps ) { + return ( + + + + ); +} diff --git a/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx new file mode 100644 index 000000000000..431950d3e4d9 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx @@ -0,0 +1,175 @@ +/** + * The widget ranks posts with the proxied `stats/top-posts` endpoint (covered by + * the legacy stats mocks), then reads the winning post's content from the local + * `/wp/v2/posts` endpoint and its all-time engagement from `stats/post/{id}` + * (both covered by the shared report mocks). + */ +/** + * External dependencies + */ +import { getDefaultQueryParams, type PresetType } from '@jetpack-premium-analytics/data'; +/** + * Internal dependencies + */ +import { registerReportMocks } from '../../../packages/widgets-toolkit/src/stories/mocks/register-report-mocks'; +import { registerStatsMocks } from '../../../packages/widgets-toolkit/src/stories/mocks/register-stats-mocks'; +import { createStoryWidgetType } from '../../stories/create-story-widget-type'; +import { forceStatsMockState } from '../../stories/force-stats-mock-state'; +import { + DEFAULT_WIDGET_DASHBOARD_STORY_ARGS, + WidgetDashboardWithWidget as WidgetDashboardWithWidgetStory, + widgetDashboardWithWidgetArgTypes, + type WidgetDashboardWithWidgetControls, +} from '../../stories/widget-dashboard-with-widget'; +import { withWidgetCanvas } from '../../stories/with-widget-canvas'; +import PopularPostRender from '../render'; +import widgetDefinition from '../widget'; +import widgetManifest from '../widget.json'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { WidgetRenderProps } from '@wordpress/widget-primitives'; +import type { ComponentProps, ComponentType } from 'react'; + +registerReportMocks(); +registerStatsMocks(); + +const POPULAR_POST_RENDER_MODULE = 'storybook/popular-post'; + +/** + * Renders the data-connected widget with report params from the date range + * picker. + * @return The rendered widget. + */ +function renderPopularPost() { + return ; +} + +// Distinct preset → own query-cache entry; see forceStatsMockState. +function renderPopularPostOnPreset( preset: PresetType ) { + return ( + + ); +} + +const meta = { + title: 'Packages/Premium Analytics/Widgets/PopularPost', + component: PopularPostRender, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'The "Popular post" widget shows the site\'s most-viewed post for the dashboard\'s date range, with its publish date, the views it collected in that period, and its all-time likes and comments. Changing the date range changes both the winning post and its view count. There is no `WithComparison` story: the card shows no period-over-period delta, so the dashboard story below carries the comparison report params instead.', + }, + }, + }, +} satisfies Meta< typeof PopularPostRender >; + +export default meta; + +type Story = StoryObj< Partial< ComponentProps< typeof PopularPostRender > > >; + +/** + * Default — the period's most-viewed post with its views, likes, and comments. + * + * The shared close-up canvas is the width of a width-1 dashboard cell, which is + * below the card's 432px breakpoint: the featured image is dropped and the metric + * row wraps. `WidgetDashboardWithWidget` below shows the default width-2 + * placement, where the image sits in a trailing column. + */ +export const Default: Story = { + render: renderPopularPost, + decorators: [ withWidgetCanvas ], +}; + +/** + * First load: the ranking request is in flight, so the widget shows its loading + * state. The mock is forced to never resolve for the duration of this story. + */ +export const Loading: Story = { + render: () => renderPopularPostOnPreset( 'last-90-days' ), + // Off the shared autodocs page — path-keyed override; see forceStatsMockState. + tags: [ '!autodocs' ], + decorators: [ withWidgetCanvas ], + beforeEach: () => { + forceStatsMockState( 'stats/top-posts', 'loading' ); + return () => forceStatsMockState( 'stats/top-posts', null ); + }, +}; + +/** + * A permission-gated 403: `describeError` maps it to neutral copy with no Retry + * action, because the failure is deterministic. + */ +export const Error: Story = { + render: () => renderPopularPostOnPreset( 'last-7-days' ), + tags: [ '!autodocs' ], + decorators: [ withWidgetCanvas ], + beforeEach: () => { + forceStatsMockState( 'stats/top-posts', 'error' ); + return () => forceStatsMockState( 'stats/top-posts', null ); + }, +}; + +/** + * The proxy's `no_connection` 403: a broken Jetpack connection can heal, so + * `describeError` keeps this one retryable. + */ +export const ErrorRetryable: Story = { + render: () => renderPopularPostOnPreset( 'last-12-months' ), + tags: [ '!autodocs' ], + decorators: [ withWidgetCanvas ], + beforeEach: () => { + forceStatsMockState( 'stats/top-posts', 'error-retryable' ); + return () => forceStatsMockState( 'stats/top-posts', null ); + }, +}; + +/** + * Resolved with no rows: the widget shows its empty state. + */ +export const Empty: Story = { + render: () => renderPopularPostOnPreset( 'last-365-days' ), + tags: [ '!autodocs' ], + decorators: [ withWidgetCanvas ], + beforeEach: () => { + forceStatsMockState( 'stats/top-posts', 'empty' ); + return () => forceStatsMockState( 'stats/top-posts', null ); + }, +}; + +/** + * Mounts the real `WidgetDashboard` with this single widget so it renders exactly + * as it does in product (framed card, sizing, edit mode). Drop `widgetWidth` to 1 + * to walk the card's size ladder: below 432px the featured image drops out and the + * metric row wraps. + * + * Comparison report params are passed unconditionally, so the widget stays covered + * against crashing or inventing deltas when the host supplies comparison dates. + * + * @param {WidgetDashboardWithWidgetControls} dashboardArgs - The dashboard story controls. + * @return The widget mounted inside the real dashboard. + */ +function PopularPostDashboardStory( dashboardArgs: WidgetDashboardWithWidgetControls ) { + return ( + > } + attributes={ { reportParams: getDefaultQueryParams( true ) } } + /> + ); +} + +export const WidgetDashboardWithWidget: StoryObj< WidgetDashboardWithWidgetControls > = { + render: args => , + args: { + ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS, + // Popular post is a landscape widget: content left, featured image right. + widgetWidth: 2, + widgetHeight: 2, + }, + argTypes: { + ...widgetDashboardWithWidgetArgTypes, + }, +}; diff --git a/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts new file mode 100644 index 000000000000..6788adad29b4 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts @@ -0,0 +1,138 @@ +/** + * External dependencies + */ +import { + postContentQuery, + useStatsPost, + useStatsQuery, + useStatsTopPosts, + type LatestPostResponse, + type ReportParams, +} from '@jetpack-premium-analytics/data'; +import { useMemo } from 'react'; + +// Only regular posts qualify as a "Popular post": the Stats top-posts report +// also ranks pages and the URL-less homepage entry. Declared at module level so +// the reference stays stable — `useStatsTopPosts` memoizes its comparison mapper +// on this option. +const POPULAR_POST_TYPES = [ 'post' ]; + +// The API caps the ranked rows it returns at `max`, so ask for a page of them: +// filtering down to post-type rows still needs a winner to pick. +const POPULAR_POST_REQUEST_MAX = 20; + +export type PopularPostWithMetrics = { + id: number; + title: string; + url: string; + /** + * The post's publish timestamp. + */ + date: string; + imageUrl: string; + imageAlt: string; + /** + * Views in the dashboard's selected date range. + */ + views: number; + /** + * All-time likes. The Stats post endpoint takes no date range, so this is a + * lifetime total even though `views` above is period-scoped. + */ + likeCount: number; + /** + * All-time comments, read from the post row on the Stats post endpoint — also + * a lifetime total. + */ + commentCount: number; +}; + +export type UsePopularPostResult = { + post: PopularPostWithMetrics | null; + isLoading: boolean; + isFetching: boolean; + isError: boolean; + error: unknown; + refetch: () => void; +}; + +/** + * The site's most-viewed post for the dashboard's selected date range, with the + * metrics a single-post highlight card shows. + * + * Three requests compose the card, mirroring `useLatestPost`'s split: + * + * 1. `stats/top-posts` for the period ranking — this is the widget's report, so + * its date range comes from `reportParams` and its `views` are period-scoped. + * 2. the local core posts endpoint for the winning post's content, because the + * report carries no featured image (and reading content on-site keeps it + * resolvable on private/unlaunched sites). + * 3. `stats/post/{id}` for likes and comments. That endpoint has no date range, + * so both are **all-time** totals; the widget labels them as such. + * + * Only a report failure surfaces as an error — the ranking is the widget. A + * failing content or metrics request degrades to no image and zeroed engagement + * counts rather than blanking the card. + * + * @param reportParams - The dashboard's report params (date range, comparison). + * @return The popular post with its metrics, plus combined loading/error state. + */ +export function usePopularPost( reportParams: ReportParams ): UsePopularPostResult { + const statsParams = useMemo( + () => ( { ...reportParams, max: POPULAR_POST_REQUEST_MAX } ), + [ reportParams ] + ); + + // Ranking, post-type filtering, and the single-row cap all live in the data + // layer's merge helper (see AGENTS.md), so the widget just takes the winner. + const topPostsResult = useStatsTopPosts( statsParams, { + maxRows: 1, + postTypes: POPULAR_POST_TYPES, + } ); + const topRow = topPostsResult.comparisonRows?.rows[ 0 ]; + const postId = Number( topRow?.id ?? 0 ) || 0; + + const contentResult = useStatsQuery< LatestPostResponse >( postContentQuery( postId ) ); + const postStatsResult = useStatsPost( { postId, fields: [ 'like_count', 'post' ] } ); + + // Both dependent queries are disabled until a post ID resolves, so they only + // count towards the widget's loading state once there is a post to load. + const isLoading = + topPostsResult.isLoading || + ( postId > 0 && ( contentResult.isLoading || postStatsResult.isLoading ) ); + const isFetching = + topPostsResult.isFetching || contentResult.isFetching || postStatsResult.isFetching; + // The Stats queries keep the previous range's rows via `placeholderData`, so a + // failed range change keeps the post visible; only surface the error when there + // is nothing to show. + const isError = topPostsResult.isError && ! topRow; + + const refetch = () => { + void topPostsResult.refetch(); + // The dependent queries are disabled until a post ID resolves; refetching + // them while disabled would force a request for post 0. + if ( postId > 0 ) { + void contentResult.refetch(); + void postStatsResult.refetch(); + } + }; + + const content = contentResult.data ?? null; + const post = topRow + ? { + id: postId, + // The report row is the fallback for the fields core also returns: its + // title comes from WPCOM and can lag a rename, and it is not entity-decoded. + title: content?.title || String( topRow.label ?? '' ), + url: content?.url || topRow.link || '', + date: content?.date || ( typeof topRow.date === 'string' ? topRow.date : '' ), + imageUrl: content?.imageUrl ?? '', + imageAlt: content?.imageAlt ?? '', + views: topRow.views, + likeCount: postStatsResult.data?.like_count ?? 0, + commentCount: Number( postStatsResult.data?.post?.comment_count ) || 0, + } + : null; + + return { post, isLoading, isFetching, isError, error: topPostsResult.error, refetch }; +} diff --git a/projects/packages/premium-analytics/widgets/popular-post/widget.json b/projects/packages/premium-analytics/widgets/popular-post/widget.json new file mode 100644 index 000000000000..1d1e2dad0409 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/widget.json @@ -0,0 +1,10 @@ +{ + "name": "jpa/popular-post", + "title": "Popular post", + "description": "Your most-viewed post for the selected date range, with its views for that period.", + "help": { + "content": "The post with the most views in the dashboard's date range, with its publish date and engagement. Views cover the selected date range. Likes and comments are all-time totals, because the Stats API reports them per post rather than per period." + }, + "category": "stats", + "presentation": "framed" +} diff --git a/projects/packages/premium-analytics/widgets/popular-post/widget.ts b/projects/packages/premium-analytics/widgets/popular-post/widget.ts new file mode 100644 index 000000000000..294d899c0cbb --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/widget.ts @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { trendingUp } from '@wordpress/icons'; +import type { WidgetAttributeField } from '@wordpress/widget-primitives'; + +/** + * The Popular post widget has no configurable attributes: it always shows the + * single most-viewed post for the dashboard's date range. `Record< never, never >` + * (not `Record< string, never >`) so the render-only type can compose host fields + * such as `reportParams` without collapsing them to `never`. + */ +export type PopularPostAttributes = Record< never, never >; + +/** + * Widget type definition. + * + * The Insights "Most popular post" module: the site's most-viewed post for the + * dashboard's selected date range. Unlike the sibling Latest post widget, this + * one is period-scoped — changing the date range changes which post wins and the + * view count shown for it. + */ +export default { + icon: trendingUp, + attributes: [] as WidgetAttributeField< PopularPostAttributes >[], + example: { + attributes: {}, + }, +}; From 52bd19b18fbac0145a722e9feae0960d7ba1d4ab Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Thu, 30 Jul 2026 18:43:33 +0200 Subject: [PATCH 02/12] Premium Analytics: make the post highlight card height-aware The card's size ladder branched on width only, so a cell that was wide enough for the two-column layout but short could not fit its content. The metric row was pinned to the end of an over-tall column and `overflow: hidden` on the root silently clipped it: at a 736x86 body the row's values sat 38px below the clip boundary, leaving the labels cut mid-line, and at 331x86 the labels rendered cleanly with no values under them at all. Rebuilt against the design prototype, which resolves `wide = inline-size >= 520px` and `tall = block-size >= 300px` and ships `--wide-tall` / `--wide-short` / `--narrow-short` treatments. The card now declares its own named size container -- the shared `widget` container is `container-type: inline-size` and cannot answer block-axis queries -- and queries both axes, following the precedent in `widget-state.module.scss`. The clipping is fixed structurally rather than by moving a breakpoint: `.metrics` is `flex-shrink: 0` and `.header` is the flexible, clipping box, so the headline gives up space while the metric row keeps its full size. A label can no longer be shown without its value at any geometry. Below the height where the publish line also fits, it is dropped outright instead of being clipped to a sliver. Both widgets gain ShortCell and ShortNarrowCell dashboard stories so a height regression is visible in review. WOOA7S-1787 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../changelog/wooa7s-1787-post-widgets | 2 +- .../post-highlight-card.module.scss | 206 ++++++++++++------ .../stories/latest-post-widget.stories.tsx | 38 ++++ .../stories/popular-post-widget.stories.tsx | 38 ++++ 4 files changed, 217 insertions(+), 67 deletions(-) diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1787-post-widgets b/projects/packages/premium-analytics/changelog/wooa7s-1787-post-widgets index 936570c281a7..a97280e079ca 100644 --- a/projects/packages/premium-analytics/changelog/wooa7s-1787-post-widgets +++ b/projects/packages/premium-analytics/changelog/wooa7s-1787-post-widgets @@ -1,4 +1,4 @@ Significance: minor Type: added -Insights: add a Popular post widget and make the single-post highlight card responsive. +Insights: add a Popular post widget, and adapt the single-post highlight card to both the width and the height of its dashboard cell. diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss index fb49c0ed2efb..05a0aae8d9a6 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss @@ -2,48 +2,75 @@ * chrome, so this only lays out the body: title, publish line, metric * tiles, and the featured image. * - * Sizing follows the package's container-query convention — `WidgetRoot` - * names the widget's container `widget`, so the ladder below tracks the - * dashboard cell rather than the viewport. There is one breakpoint: + * # Sizing * - * >= 432px width-2 and wider cells, including the default placement: - * two columns, text left with the metric row anchored to the - * bottom, and a rounded, near-square featured image filling - * the trailing column's full height. - * < 432px width-1 cells: one column, the featured image is dropped — - * it would eat most of a small cell — and the metric row - * wraps with tighter gaps. The primary metric is first, so it - * stays on the first wrapped row. + * The card adapts on BOTH axes, because a dashboard cell can be short as + * well as narrow. The thresholds and the per-mode treatments come from + * the design prototype, whose card resolves `wide = inline-size >= 520px` + * and `tall = block-size >= 300px` and exposes them as `--wide-tall` / + * `--wide-short` / `--narrow-short` modifiers: * - * The 432px threshold matches the Top locations widget: it sits just - * below a width-2 cell at the minimum four-column dashboard width, so - * every width-2 cell gets the two-column layout the design specifies. + * wide + tall featured image stretched to the body height at 44% + * inline size; 32/40 title and metric values. + * wide + short featured image becomes a 116px square, centred on the + * block axis; 20/24 title and metric values. + * narrow (either) no featured image — it would eat most of a small + * cell; type follows the tall/short rule above. + * + * Rules are written short-first, so the compact treatment is the base and + * the roomier one is additive: a cell too small for any query to match + * still gets the treatment that needs the least space. + * + * # Why this element is a size container + * + * The shared `widget` container is `container-type: inline-size`, so it + * cannot answer block-axis queries. The card therefore declares its own + * named size container, the same way `widget-state.module.scss` does for + * its short-tile icon rule. Size containment means the card takes no + * block size from its content, so it must inherit one: ``'s + * `.ready` wrapper is `block-size: 100%` and the dashboard body has a + * definite height, which satisfies that. + * + * # Structural guarantee: a metric label is never orphaned + * + * `.metrics` is `flex-shrink: 0` and `.header` is the flexible, clipping + * box. Under block-axis pressure the headline gives up space (it is line + * clamped) while the metric row keeps its full size, so a label can never + * be left visible with its value pushed outside the card. The clip on + * `.root` is only a backstop against spilling over a neighbouring widget + * — it is deliberately not what keeps the layout honest. */ .root { display: flex; - flex-direction: column; gap: var(--wpds-dimension-gap-lg); - height: 100%; + block-size: 100%; + min-block-size: 0; + + /* Backstop only — see the note above. */ overflow: hidden; + container-type: size; + container-name: post-highlight; } .content { display: flex; flex: 1; flex-direction: column; - - /* Title block at the top, metric row anchored to the bottom. */ - justify-content: space-between; - gap: var(--wpds-dimension-gap-lg); - min-width: 0; - min-height: 0; + gap: var(--wpds-dimension-gap-sm); + min-inline-size: 0; + min-block-size: 0; } +/* Takes the free space so the metric row sits at the bottom, and gives it + * back first when there is not enough: this is the box that shrinks. */ .header { display: flex; + flex: 1; flex-direction: column; gap: var(--wpds-dimension-gap-xs); - min-width: 0; + min-inline-size: 0; + min-block-size: 0; + overflow: hidden; } .titleLink { @@ -59,98 +86,145 @@ display: -webkit-box; margin: 0; overflow: hidden; + overflow-wrap: anywhere; -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - line-clamp: 3; +} + +/* `Text`'s own variant class carries a font-size at the same specificity, + * so the size is pinned through `.header` to win deterministically. */ +.header .title { + font-size: var(--wpds-typography-font-size-xl); + line-height: var(--wpds-typography-line-height-md); + -webkit-line-clamp: 2; + line-clamp: 2; } .date { + flex-shrink: 0; + overflow: hidden; color: var(--wpds-color-foreground-content-neutral-weak); + text-overflow: ellipsis; + white-space: nowrap; } +/* Never shrinks: label and value stay together, whatever the cell does. */ .metrics { display: flex; - flex-flow: row wrap; + flex-shrink: 0; gap: var(--wpds-dimension-gap-lg); } .metric { display: flex; + flex: 1; flex-direction: column; - gap: var(--wpds-dimension-gap-xs); - min-width: 0; + gap: 0; + min-inline-size: 0; } .metricLabel { - font-weight: var(--wpds-typography-font-weight-medium); + overflow: hidden; color: var(--wpds-color-foreground-content-neutral); + font-weight: var(--wpds-typography-font-weight-medium); + text-overflow: ellipsis; + white-space: nowrap; } +/* The design weights the compact value up to 600; the token scale tops out + * at `medium` (499), which is the closest on-token match. */ .metric .metricValue { - font-size: var(--wpds-typography-font-size-lg); + font-size: var(--wpds-typography-font-size-xl); + font-weight: var(--wpds-typography-font-weight-medium); + line-height: var(--wpds-typography-line-height-md); } -/* Featured image: inset with rounded corners, covering its box at any - * aspect ratio. */ +/* Featured image. Hidden by default: only the wide modes below show it. */ .media { + display: none; + flex-shrink: 0; overflow: hidden; border-radius: var(--wpds-border-radius-lg); } .image { display: block; - width: 100%; - height: 100%; + inline-size: 100%; + block-size: 100%; object-fit: cover; } -/* Narrow cells: drop the featured image, scale the headline down, and - * tighten the metric row so it wraps without overflowing. */ -@container widget (max-width: 431px) { +/* Tall cells: the roomier type scale. */ +@container post-highlight (min-height: 300px) { - .root { - gap: var(--wpds-dimension-gap-md); + .header .title { + font-size: var(--wpds-typography-font-size-2xl); + line-height: var(--wpds-typography-line-height-2xl); } - .content { - gap: var(--wpds-dimension-gap-md); + .metric { + gap: var(--wpds-dimension-gap-xs); } - .media { - display: none; + .metric .metricValue { + font-size: var(--wpds-typography-font-size-2xl); + font-weight: var(--wpds-typography-font-weight-regular); + line-height: var(--wpds-typography-line-height-2xl); } +} - /* `Text`'s own variant class carries a font-size at the same - * specificity, so the override is scoped through `.header` to win - * deterministically. */ - .header .title { - font-size: var(--wpds-typography-font-size-xl); - line-height: var(--wpds-typography-line-height-md); - -webkit-line-clamp: 2; - line-clamp: 2; - } +/* Wide cells: the featured image appears. On short cells it is a square + * thumbnail centred against the text column. */ +@container post-highlight (min-width: 520px) { - .metrics { - gap: var(--wpds-dimension-gap-md); + .media { + display: block; + align-self: center; + + /* Square, but never taller than the card: on the shortest cells the + * block size caps and `aspect-ratio` pulls the inline size in with + * it, so the thumbnail shrinks instead of overflowing. */ + inline-size: auto; + block-size: 116px; + max-block-size: 100%; + aspect-ratio: 1; } } -/* Width-2 and wider cells: text left, featured image right at close to - * the body's full height. 44% keeps it near-square at the default - * placement, per the design. */ -@container widget (min-width: 432px) { +/* Wide and tall: the image becomes a full-height trailing panel. */ +@container post-highlight (min-width: 520px) and (min-height: 300px) { - .root { - flex-direction: row; - align-items: stretch; - gap: var(--wpds-dimension-gap-xl); + .media { + align-self: stretch; + inline-size: 44%; + block-size: auto; + max-block-size: none; + aspect-ratio: auto; } +} - .media { - flex: 0 0 44%; +/* Very short cells: one headline line, so the publish date survives + * alongside the metric row instead of being clipped out of the header. + * + * The switch sits at 130px because a two-line headline needs 124px of + * card: 2 x 24px of title, the 4px header gap, the 20px publish line, the + * 8px content gap, and the 44px metric row. The few pixels of slack keep + * the rule from depending on that sum exactly. */ +@container post-highlight (max-height: 130px) { + + .header .title { + -webkit-line-clamp: 1; + line-clamp: 1; } +} - .metrics { - gap: var(--wpds-dimension-gap-2xl); +/* Below the height where a one-line headline, the publish line, and the + * metric row all fit (24 + 4 + 20 + 8 + 44 = 100px), drop the publish + * line. The headline identifies the post better than its date, and the + * metric row must stay whole, so this is the piece that gives way — + * dropped outright rather than clipped to a sliver of text. */ +@container post-highlight (max-height: 100px) { + + .date { + display: none; } } diff --git a/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx b/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx index 995dbc58bc04..7393ac40f3c9 100644 --- a/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx @@ -249,3 +249,41 @@ export const WidgetDashboardWithWidget: StoryObj< WidgetDashboardWithWidgetContr ...widgetDashboardWithWidgetArgTypes, }, }; + +/** + * A short cell at the default width. Height, not just width, drives the card: + * below 300px of body the type scale steps down and the featured image becomes a + * centred square instead of a full-height panel. + * + * This geometry regressed once — the metric row was pushed past the card's bottom + * edge and silently clipped, leaving labels with no values — so it is covered + * here to keep a height regression visible in review. + */ +export const ShortCell: StoryObj< WidgetDashboardWithWidgetControls > = { + render: args => , + args: { + ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS, + widgetWidth: 2, + widgetHeight: 1, + }, + argTypes: { + ...widgetDashboardWithWidgetArgTypes, + }, +}; + +/** + * The smallest cell the dashboard grid produces: narrow *and* short. The featured + * image drops out entirely and the headline clamps to one line, but the whole + * metric row — every label with its value — stays inside the card. + */ +export const ShortNarrowCell: StoryObj< WidgetDashboardWithWidgetControls > = { + render: args => , + args: { + ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS, + widgetWidth: 1, + widgetHeight: 1, + }, + argTypes: { + ...widgetDashboardWithWidgetArgTypes, + }, +}; diff --git a/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx index 431950d3e4d9..9a4293ecbae0 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx @@ -173,3 +173,41 @@ export const WidgetDashboardWithWidget: StoryObj< WidgetDashboardWithWidgetContr ...widgetDashboardWithWidgetArgTypes, }, }; + +/** + * A short cell at the default width. Height, not just width, drives the card: + * below 300px of body the type scale steps down and the featured image becomes a + * centred square instead of a full-height panel. + * + * This geometry regressed once — the metric row was pushed past the card's bottom + * edge and silently clipped, leaving labels with no values — so it is covered + * here to keep a height regression visible in review. + */ +export const ShortCell: StoryObj< WidgetDashboardWithWidgetControls > = { + render: args => , + args: { + ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS, + widgetWidth: 2, + widgetHeight: 1, + }, + argTypes: { + ...widgetDashboardWithWidgetArgTypes, + }, +}; + +/** + * The smallest cell the dashboard grid produces: narrow *and* short. The featured + * image drops out entirely and the headline clamps to one line, but the whole + * metric row — every label with its value — stays inside the card. + */ +export const ShortNarrowCell: StoryObj< WidgetDashboardWithWidgetControls > = { + render: args => , + args: { + ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS, + widgetWidth: 1, + widgetHeight: 1, + }, + argTypes: { + ...widgetDashboardWithWidgetArgTypes, + }, +}; From 33dbd1a5a129106785f0a99290f0ac98dbaa0a6d Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Fri, 31 Jul 2026 17:43:51 +0200 Subject: [PATCH 03/12] Premium Analytics: address Popular post review feedback Read every displayed metric from `stats/post`, so the three tiles share one all-time window instead of pairing range-scoped views with lifetime likes and comments. The date range's only job is now picking the winner, which also matches the sibling Latest post widget that shares this card. Consume metrics only from a response that identifies the current post. The Stats query carries the previous key's payload over through `placeholderData` while the content query deliberately does not, so a winner change could render the new post's title beside the previous post's engagement. Drop the comparison fields from the top-posts request: they triggered a second report fetch for a window this widget never renders. Document the `max` trade-off that can hide a qualifying post on a page-heavy site. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../__tests__/use-popular-post.test.tsx | 150 +++++++++++++++++- .../widgets/popular-post/render.tsx | 22 +-- .../widgets/popular-post/use-popular-post.ts | 82 +++++++--- .../widgets/popular-post/widget.json | 4 +- 4 files changed, 211 insertions(+), 47 deletions(-) diff --git a/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx b/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx index a92cf12e0b79..715f4a51b47e 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx +++ b/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx @@ -2,7 +2,7 @@ * External dependencies */ import { queryClient, type ReportParams } from '@jetpack-premium-analytics/data'; -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies @@ -59,7 +59,19 @@ const postContentResponse = [ }, ]; -const postStatsResponse = { views: 9999, like_count: 12, post: { comment_count: 4 } }; +// `post.ID` is what ties a metrics response to the post the card is showing — +// the hook refuses to display metrics it cannot attribute to the current winner. +const postStatsResponse = { + views: 9999, + like_count: 12, + post: { ID: 7, comment_count: 4 }, +}; + +const runnerUpStatsResponse = { + views: 3333, + like_count: 34, + post: { ID: 8, comment_count: 5 }, +}; type MockedFetchArgs = { path?: string; url?: string }; @@ -72,8 +84,15 @@ function mockEndpoints( { failPostStats = false }: { failPostStats?: boolean } = } if ( target.includes( 'stats/post/' ) ) { + // A 403 rather than a bare Error: the shared retry policy retries with + // exponential backoff, so a retryable rejection would leave the card in + // its skeleton for the whole backoff window instead of settling. return failPostStats - ? Promise.reject( new Error( 'User cannot access this private blog.' ) ) + ? Promise.reject( { + error: 'unauthorized', + message: 'User cannot access this private blog.', + status: 403, + } ) : Promise.resolve( postStatsResponse ); } @@ -91,7 +110,7 @@ describe( 'usePopularPost', () => { mockApiFetch.mockReset(); } ); - it( 'picks the most-viewed post, with period views and all-time engagement', async () => { + it( 'picks the most-viewed post and shows all-time metrics for it', async () => { mockEndpoints(); const { result } = renderHook( () => usePopularPost( reportParams ), { wrapper } ); @@ -105,8 +124,9 @@ describe( 'usePopularPost', () => { date: '2026-06-02T08:00:00', imageUrl: 'https://example.com/hero.jpg', imageAlt: 'Hero', - // Period views come from the report, not from the all-time `stats/post` value. - views: 420, + // All three tiles share one window: the all-time `stats/post` response. + // The report row's period views (420) deliberately do not surface. + views: 9999, likeCount: 12, commentCount: 4, } ) @@ -128,12 +148,18 @@ describe( 'usePopularPost', () => { } ); } ); - it( 'still renders the post with zeroed engagement when stats/post fails', async () => { + it( 'still renders the post with zeroed metrics when stats/post fails', async () => { mockEndpoints( { failPostStats: true } ); const { result } = renderHook( () => usePopularPost( reportParams ), { wrapper } ); - await waitFor( () => expect( result.current.post?.views ).toBe( 420 ) ); + // The ranking still resolved, so the card keeps its title and image; every + // metric now comes from the failed request, so all three read zero. The + // loading state must settle: a failed metrics request is the one case where + // the identity guard could otherwise hold the card in a skeleton forever. + await waitFor( () => expect( result.current.isLoading ).toBe( false ), { timeout: 5000 } ); + expect( result.current.post?.title ).toBe( 'Winning & popular post' ); + expect( result.current.post?.views ).toBe( 0 ); expect( result.current.post?.likeCount ).toBe( 0 ); expect( result.current.post?.commentCount ).toBe( 0 ); expect( result.current.isError ).toBe( false ); @@ -160,4 +186,112 @@ describe( 'usePopularPost', () => { await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); expect( result.current.post ).toBeNull(); } ); + + it( 'never attributes the previous winner’s metrics to a new one', async () => { + let releaseRunnerUpStats: () => void = () => {}; + const runnerUpStats = new Promise( resolve => { + releaseRunnerUpStats = () => resolve( runnerUpStatsResponse ); + } ); + + mockApiFetch.mockImplementation( ( { path = '', url = '' }: MockedFetchArgs ) => { + const target = path || url; + + if ( target.includes( 'stats/top-posts' ) ) { + // The July range promotes the runner up to the winning post. + return Promise.resolve( + target.includes( '2026-07' ) + ? { + ...topPostsResponse, + summary: { + ...topPostsResponse.summary, + postviews: [ + { + id: 8, + title: 'Runner up', + type: 'post', + href: 'https://example.com/runner-up/', + date: '2026-06-03', + views: 700, + }, + ], + }, + } + : topPostsResponse + ); + } + + // Held open so the assertions run while the new winner's metrics are + // still in flight — the window the bug lived in. + if ( target.includes( 'stats/post/8' ) ) { + return runnerUpStats; + } + + if ( target.includes( 'stats/post/' ) ) { + return Promise.resolve( postStatsResponse ); + } + + if ( target.startsWith( '/wp/v2/posts' ) ) { + return Promise.resolve( postContentResponse ); + } + + return Promise.resolve( {} ); + } ); + + const { result, rerender } = renderHook( + ( { params }: { params: ReportParams } ) => usePopularPost( params ), + { wrapper, initialProps: { params: reportParams } } + ); + + await waitFor( () => expect( result.current.post?.likeCount ).toBe( 12 ) ); + + rerender( { params: { from: '2026-07-01', to: '2026-07-31' } as ReportParams } ); + + await waitFor( () => expect( result.current.post?.id ).toBe( 8 ) ); + + // The winner changed while its metrics are still loading. The Stats query + // keeps the previous key's payload through `placeholderData`, so this is + // exactly where post 7's engagement used to leak onto post 8. + expect( result.current.post?.views ).not.toBe( 9999 ); + expect( result.current.post?.likeCount ).not.toBe( 12 ); + expect( result.current.post?.commentCount ).not.toBe( 4 ); + expect( result.current.isLoading ).toBe( true ); + + // Resolving the held request updates state, so let React flush it here + // rather than leaking the update into a later test. + await act( async () => { + releaseRunnerUpStats(); + await runnerUpStats; + } ); + + await waitFor( () => expect( result.current.post?.likeCount ).toBe( 34 ) ); + expect( result.current.post?.views ).toBe( 3333 ); + expect( result.current.post?.commentCount ).toBe( 5 ); + } ); + + it( 'does not request the comparison window it never renders', async () => { + mockEndpoints(); + + const { result } = renderHook( + () => + usePopularPost( { + ...reportParams, + comp: '1', + compare_from: '2026-05-01', + compare_to: '2026-05-31', + } as ReportParams ), + { wrapper } + ); + + await waitFor( () => expect( result.current.post?.id ).toBe( 7 ) ); + + const topPostsPaths = mockApiFetch.mock.calls + .map( ( [ options ] ) => { + const { path, url } = options as MockedFetchArgs; + return path || url || ''; + } ) + .filter( target => target.includes( 'stats/top-posts' ) ); + + expect( topPostsPaths ).toHaveLength( 1 ); + expect( topPostsPaths.every( target => ! target.includes( '2026-05' ) ) ).toBe( true ); + } ); } ); diff --git a/projects/packages/premium-analytics/widgets/popular-post/render.tsx b/projects/packages/premium-analytics/widgets/popular-post/render.tsx index 14dc1e99841b..05ee89c36e92 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/render.tsx +++ b/projects/packages/premium-analytics/widgets/popular-post/render.tsx @@ -29,9 +29,10 @@ type PopularPostWidgetProps = WidgetRenderProps< PopularPostRenderAttributes >; * the shared `PostHighlightCard`, with loading, error, and empty states handled * by ``. * - * Views are scoped to the dashboard's date range; likes and comments are all-time - * totals (the Stats post endpoint takes no date range), so those two tiles carry - * an aggregation note the card surfaces as a tooltip and as visually hidden text. + * The dashboard's date range picks which post is shown; all three tiles are + * all-time totals from the Stats post endpoint, so they share one window and + * need no per-tile aggregation note — the same treatment as `Latest post`, + * which shares this card. * * @return The widget content. */ @@ -41,29 +42,16 @@ function PopularPostReport() { const metrics: PostHighlightCardMetric[] = post ? [ - { - key: 'views', - label: __( 'Views', 'jetpack-premium-analytics-pkg' ), - value: post.views, - note: __( 'Views in the selected date range.', 'jetpack-premium-analytics-pkg' ), - }, + { key: 'views', label: __( 'Views', 'jetpack-premium-analytics-pkg' ), value: post.views }, { key: 'likes', label: __( 'Likes', 'jetpack-premium-analytics-pkg' ), value: post.likeCount, - note: __( - 'All-time likes. Stats does not report likes per date range.', - 'jetpack-premium-analytics-pkg' - ), }, { key: 'comments', label: __( 'Comments', 'jetpack-premium-analytics-pkg' ), value: post.commentCount, - note: __( - 'All-time comments. Stats does not report comments per date range.', - 'jetpack-premium-analytics-pkg' - ), }, ] : []; diff --git a/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts index 6788adad29b4..85fa72a3cd0b 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts +++ b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts @@ -19,6 +19,12 @@ const POPULAR_POST_TYPES = [ 'post' ]; // The API caps the ranked rows it returns at `max`, so ask for a page of them: // filtering down to post-type rows still needs a winner to pick. +// +// Trade-off: on a page-heavy site the top 20 ranked rows can all be pages, and +// the widget then shows its empty state even though a qualifying post ranks +// lower. Raising this only moves the boundary rather than removing it; a +// `post_type`-filtered top-posts endpoint would, and would also let the ranking +// and the metrics come from one window. const POPULAR_POST_REQUEST_MAX = 20; export type PopularPostWithMetrics = { @@ -32,17 +38,15 @@ export type PopularPostWithMetrics = { imageUrl: string; imageAlt: string; /** - * Views in the dashboard's selected date range. + * All-time views, read from the Stats post endpoint. */ views: number; /** - * All-time likes. The Stats post endpoint takes no date range, so this is a - * lifetime total even though `views` above is period-scoped. + * All-time likes, read from the Stats post endpoint. */ likeCount: number; /** - * All-time comments, read from the post row on the Stats post endpoint — also - * a lifetime total. + * All-time comments, read from the post row on the Stats post endpoint. */ commentCount: number; }; @@ -62,26 +66,43 @@ export type UsePopularPostResult = { * * Three requests compose the card, mirroring `useLatestPost`'s split: * - * 1. `stats/top-posts` for the period ranking — this is the widget's report, so - * its date range comes from `reportParams` and its `views` are period-scoped. + * 1. `stats/top-posts` for the period ranking. The date range's only job is to + * pick the winner; the row's period views are not what the card displays. * 2. the local core posts endpoint for the winning post's content, because the * report carries no featured image (and reading content on-site keeps it * resolvable on private/unlaunched sites). - * 3. `stats/post/{id}` for likes and comments. That endpoint has no date range, - * so both are **all-time** totals; the widget labels them as such. + * 3. `stats/post/{id}` for every displayed metric — views, likes and comments. + * + * All three tiles therefore share one window: **all-time**. The Stats post + * endpoint takes no date range, so likes and comments can only be lifetime + * totals; reading views from the same response keeps three tiles that sit side + * by side from silently measuring two different periods. It also matches the + * sibling `Latest post` widget, which shares this card and is already all-time. * * Only a report failure surfaces as an error — the ranking is the widget. A - * failing content or metrics request degrades to no image and zeroed engagement - * counts rather than blanking the card. + * failing content or metrics request degrades to no image and zeroed counts + * rather than blanking the card. * * @param reportParams - The dashboard's report params (date range, comparison). * @return The popular post with its metrics, plus combined loading/error state. */ export function usePopularPost( reportParams: ReportParams ): UsePopularPostResult { - const statsParams = useMemo( - () => ( { ...reportParams, max: POPULAR_POST_REQUEST_MAX } ), - [ reportParams ] - ); + const statsParams = useMemo( () => { + /* + * The comparison window drives a second `stats/top-posts` request, but this + * widget renders a single winner and no period-over-period delta anywhere, + * so that response would be fetched and discarded. Drop the comparison + * fields, the way `video-detail-highlights` does for params its endpoint + * cannot use. + */ + const primaryParams = { ...reportParams, max: POPULAR_POST_REQUEST_MAX }; + delete primaryParams.comp; + delete primaryParams.compare_from; + delete primaryParams.compare_to; + delete primaryParams.compare_preset; + + return primaryParams; + }, [ reportParams ] ); // Ranking, post-type filtering, and the single-row cap all live in the data // layer's merge helper (see AGENTS.md), so the widget just takes the winner. @@ -93,13 +114,34 @@ export function usePopularPost( reportParams: ReportParams ): UsePopularPostResu const postId = Number( topRow?.id ?? 0 ) || 0; const contentResult = useStatsQuery< LatestPostResponse >( postContentQuery( postId ) ); - const postStatsResult = useStatsPost( { postId, fields: [ 'like_count', 'post' ] } ); + const postStatsResult = useStatsPost( { postId, fields: [ 'views', 'like_count', 'post' ] } ); + + /* + * `statsProxyQuery` carries the previous key's payload over through + * `placeholderData`, while the content query deliberately does not. On a + * winner change that pairing would render the new post's title beside the + * previous post's engagement until the dependent request resolves. + * + * So consume metrics only from a response that identifies the post we asked + * for. When the endpoint omits the identifier there is nothing to match on, + * and holding the card in a skeleton forever would be worse than trusting it. + */ + const statsPostData = postStatsResult.data; + const statsPostId = statsPostData?.post?.ID; + const metrics = + statsPostData && ( statsPostId === undefined || statsPostId === postId ) + ? statsPostData + : undefined; + + // A failed metrics request degrades to zeroed counts, so it stops counting as + // pending — otherwise the card would skeleton indefinitely on a 403. + const isMetricsPending = postId > 0 && ! metrics && ! postStatsResult.isError; // Both dependent queries are disabled until a post ID resolves, so they only // count towards the widget's loading state once there is a post to load. const isLoading = topPostsResult.isLoading || - ( postId > 0 && ( contentResult.isLoading || postStatsResult.isLoading ) ); + ( postId > 0 && ( contentResult.isLoading || postStatsResult.isLoading || isMetricsPending ) ); const isFetching = topPostsResult.isFetching || contentResult.isFetching || postStatsResult.isFetching; // The Stats queries keep the previous range's rows via `placeholderData`, so a @@ -128,9 +170,9 @@ export function usePopularPost( reportParams: ReportParams ): UsePopularPostResu date: content?.date || ( typeof topRow.date === 'string' ? topRow.date : '' ), imageUrl: content?.imageUrl ?? '', imageAlt: content?.imageAlt ?? '', - views: topRow.views, - likeCount: postStatsResult.data?.like_count ?? 0, - commentCount: Number( postStatsResult.data?.post?.comment_count ) || 0, + views: metrics?.views ?? 0, + likeCount: metrics?.like_count ?? 0, + commentCount: Number( metrics?.post?.comment_count ) || 0, } : null; diff --git a/projects/packages/premium-analytics/widgets/popular-post/widget.json b/projects/packages/premium-analytics/widgets/popular-post/widget.json index 1d1e2dad0409..499fb6190789 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/widget.json +++ b/projects/packages/premium-analytics/widgets/popular-post/widget.json @@ -1,9 +1,9 @@ { "name": "jpa/popular-post", "title": "Popular post", - "description": "Your most-viewed post for the selected date range, with its views for that period.", + "description": "Your most-viewed post for the selected date range, with its all-time stats.", "help": { - "content": "The post with the most views in the dashboard's date range, with its publish date and engagement. Views cover the selected date range. Likes and comments are all-time totals, because the Stats API reports them per post rather than per period." + "content": "The post with the most views in the dashboard's date range, with its publish date and engagement. The date range selects which post is shown; views, likes, and comments are all-time totals, because the Stats API reports them per post rather than per period." }, "category": "stats", "presentation": "framed" From 8808330f7b58d32f73b44e58e79637d0d14b7734 Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Mon, 3 Aug 2026 09:50:39 +0200 Subject: [PATCH 04/12] Premium Analytics: address Chi's post widget review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show an unread metric as a dash rather than as zero. The Stats post endpoint can fail on its own — it 403s on a private site — and zeroing there rendered a post with 12 likes as "Likes 0", which is a wrong number rather than a missing one. Applies to Latest post too, which shares the card and had the same gap. Stop hiding a report failure behind stale rows. `placeholderData` only applies while a query is pending, so rows that survive an error are the last successful data — a failed background refetch. Gating on `! topRow` / `! latestPost` hid exactly that: stale numbers, no error, no Retry. Correct the story docblocks that still described a 432px breakpoint; the card resolves wide at 520px and tall at 300px. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../__tests__/post-highlight-card.test.tsx | 21 +++++++++ .../post-highlight-card.tsx | 28 +++++++++--- .../__tests__/use-latest-post.test.tsx | 12 +++-- .../stories/latest-post-widget.stories.tsx | 9 ++-- .../widgets/latest-post/use-latest-post.ts | 44 +++++++++++++----- .../__tests__/use-popular-post.test.tsx | 18 ++++---- .../stories/popular-post-widget.stories.tsx | 9 ++-- .../widgets/popular-post/use-popular-post.ts | 45 ++++++++++++++----- 8 files changed, 136 insertions(+), 50 deletions(-) diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/__tests__/post-highlight-card.test.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/__tests__/post-highlight-card.test.tsx index 132a591e53aa..1415f9b7b7d5 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/__tests__/post-highlight-card.test.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/__tests__/post-highlight-card.test.tsx @@ -120,6 +120,27 @@ describe( 'PostHighlightCard', () => { expect( screen.queryByText( /^Post published on/ ) ).not.toBeInTheDocument(); } ); + // A metric whose request failed must not be shown as a real count: on a + // private site the Stats endpoint 403s, and "Likes 0" would be a wrong number + // rather than a missing one. + it( 'renders an unavailable metric as a dash, not as zero', () => { + render( + + ); + + expect( screen.getByText( '—' ) ).toBeInTheDocument(); + // Spelled out for assistive tech, which may skip the dash entirely. + expect( screen.getByText( 'Not available' ) ).toBeInTheDocument(); + // A genuine zero still renders as a number. + expect( screen.getByText( '0' ) ).toBeInTheDocument(); + } ); + // A lifetime metric shown next to a period-scoped one must say so, and the // `title` tooltip alone is invisible to assistive technology. it( 'exposes a metric note as both a tooltip and visually hidden text', () => { diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx index 9cf259b53895..b067ebfebbc1 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx @@ -32,9 +32,12 @@ export type PostHighlightCardMetric = { label: string; /** - * The metric value. + * The metric value, or `undefined` when it could not be read. An unavailable + * metric renders as a dash rather than as `0`: the endpoint that supplies + * these counts can fail on its own (a private site 403s it), and a zero there + * is indistinguishable from a post that genuinely has no likes. */ - value: number; + value: number | undefined; /** * Caveat about how the value is aggregated, e.g. that it is an all-time total @@ -139,11 +142,22 @@ function PostHighlightMetric( { { /* The `title` tooltip is invisible to keyboard and screen-reader users, so the caveat is repeated as visually hidden text. */ } { metric.note && { metric.note } } - + { metric.value === undefined ? ( + + { /* An em dash reads as "—" to a screen reader, or is skipped + entirely, so the state is also spelled out for assistive tech. */ } + + + { __( 'Not available', 'jetpack-premium-analytics-pkg' ) } + + + ) : ( + + ) } ); } diff --git a/projects/packages/premium-analytics/widgets/latest-post/__tests__/use-latest-post.test.tsx b/projects/packages/premium-analytics/widgets/latest-post/__tests__/use-latest-post.test.tsx index 71758ddbe859..51f099327775 100644 --- a/projects/packages/premium-analytics/widgets/latest-post/__tests__/use-latest-post.test.tsx +++ b/projects/packages/premium-analytics/widgets/latest-post/__tests__/use-latest-post.test.tsx @@ -85,7 +85,7 @@ describe( 'useLatestPost', () => { expect( result.current.post ).toBeNull(); } ); - it( 'still renders content with zeroed metrics when stats/post fails (private site)', async () => { + it( 'still renders content, with metrics unknown, when stats/post fails (private site)', async () => { mockApiFetch.mockImplementation( ( { path = '', url = '' }: { path?: string; url?: string } ) => { const target = path || url; @@ -115,11 +115,15 @@ describe( 'useLatestPost', () => { date: '2026-06-22T10:00:00', imageUrl: '', imageAlt: '', - views: 0, - likeCount: 0, - commentCount: 0, + // Unknown rather than zero: the content request succeeded, so the post + // renders, but a 403 on the metrics endpoint must not be shown as a + // real count of zero. + views: undefined, + likeCount: undefined, + commentCount: undefined, } ) ); + // The content request — the widget's own report — succeeded. expect( result.current.isError ).toBe( false ); } ); } ); diff --git a/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx b/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx index 7393ac40f3c9..440359f2985a 100644 --- a/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/latest-post/stories/latest-post-widget.stories.tsx @@ -171,8 +171,8 @@ type Story = StoryObj< Partial< ComponentProps< typeof LatestPostRender > > >; * Default — the latest post with its lifetime views, likes, and comments. * * The shared close-up canvas is the width of a width-1 dashboard cell, which is - * below the card's 432px breakpoint: the featured image is dropped and the metric - * row wraps. `WidgetDashboardWithWidget` below shows the default width-2 + * below the card's 520px wide breakpoint: the featured image is dropped and the + * metric row wraps. `WidgetDashboardWithWidget` below shows the default width-2 * placement, where the image sits in a trailing column. */ export const Default: Story = { @@ -219,8 +219,9 @@ export const Empty: Story = { * Mounts the real `WidgetDashboard` with this single widget so it renders * exactly as it does in product (framed card, sizing, edit mode). * - * Drop `widgetWidth` to 1 to walk the shared card's size ladder: below 432px the - * featured image drops out and the metric row wraps. + * Drop `widgetWidth` to 1 to walk the shared card's size ladder: below 520px wide + * the featured image drops out and the metric row wraps. Shortening the cell below + * 300px also switches the card to its compact type scale. * * @param {WidgetDashboardWithWidgetControls} dashboardArgs - The dashboard story controls. * @return The widget mounted inside the real dashboard. diff --git a/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts b/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts index 24ba00679f93..0e2baaf73268 100644 --- a/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts +++ b/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts @@ -9,10 +9,26 @@ import { type LatestPostResponse, } from '@jetpack-premium-analytics/data'; +/** + * The Stats post row reports `comment_count` as either a string or a number. + * An absent count stays absent, so it can render as "not available" instead of + * being flattened into a real zero. + * + * @param value - The raw `comment_count` from the Stats post row. + * @return The parsed count, or undefined when the row carries none. + */ +function toCommentCount( value: string | number | undefined ): number | undefined { + return value === undefined ? undefined : Number( value ) || 0; +} + export type LatestPostWithMetrics = LatestPost & { - views: number; - likeCount: number; - commentCount: number; + /** + * All-time metrics from the Stats post endpoint. Undefined when that request + * failed, so the card can distinguish "unknown" from a genuine zero. + */ + views: number | undefined; + likeCount: number | undefined; + commentCount: number | undefined; }; export type UseLatestPostResult = { @@ -32,7 +48,8 @@ export type UseLatestPostResult = { * * Only a content failure surfaces as an error — content is the widget. When the * Stats request fails (e.g. a private Simple site where stats/post 403s), the - * post still renders with its metrics zeroed rather than blanking the widget. + * post still renders, with its metrics marked unavailable rather than zeroed and + * rather than blanking the widget. * * @return The latest post with its metrics, plus combined loading/error state. */ @@ -46,10 +63,13 @@ export function useLatestPost(): UseLatestPostResult { const isLoading = latestPostResult.isLoading || ( postId > 0 && postStatsResult.isLoading ); const isFetching = latestPostResult.isFetching || postStatsResult.isFetching; - // The content query keeps prior data via `placeholderData`, so a transient - // refetch failure keeps the post visible; only surface the error when there - // is nothing to show. - const isError = latestPostResult.isError && ! latestPost; + /* + * Surface a content failure even when a post is still on screen. `placeholderData` + * only applies while a query is pending, so a post that survives an error is + * React Query's last successful data — a failed background refetch. Gating the + * error on `! latestPost` hid exactly that case: stale content, no error, no Retry. + */ + const isError = latestPostResult.isError; const refetch = () => { void latestPostResult.refetch(); @@ -63,9 +83,11 @@ export function useLatestPost(): UseLatestPostResult { const post = latestPost ? { ...latestPost, - views: postStatsResult.data?.views ?? 0, - likeCount: postStatsResult.data?.like_count ?? 0, - commentCount: Number( postStatsResult.data?.post?.comment_count ) || 0, + // Left undefined rather than zeroed when the metrics request fails, so + // the card shows a dash instead of claiming zero likes. + views: postStatsResult.data?.views, + likeCount: postStatsResult.data?.like_count, + commentCount: toCommentCount( postStatsResult.data?.post?.comment_count ), } : null; diff --git a/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx b/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx index 715f4a51b47e..edcd76a7fc8c 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx +++ b/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx @@ -148,20 +148,22 @@ describe( 'usePopularPost', () => { } ); } ); - it( 'still renders the post with zeroed metrics when stats/post fails', async () => { + it( 'still renders the post, with metrics unknown, when stats/post fails', async () => { mockEndpoints( { failPostStats: true } ); const { result } = renderHook( () => usePopularPost( reportParams ), { wrapper } ); - // The ranking still resolved, so the card keeps its title and image; every - // metric now comes from the failed request, so all three read zero. The - // loading state must settle: a failed metrics request is the one case where - // the identity guard could otherwise hold the card in a skeleton forever. + // The ranking still resolved, so the card keeps its title and image. Every + // metric comes from the failed request, so all three are left unknown rather + // than zeroed — a 403 on a private site must not read as "0 likes". The + // loading state must also settle: a failed metrics request is the one case + // where the identity guard could otherwise skeleton the card forever. await waitFor( () => expect( result.current.isLoading ).toBe( false ), { timeout: 5000 } ); expect( result.current.post?.title ).toBe( 'Winning & popular post' ); - expect( result.current.post?.views ).toBe( 0 ); - expect( result.current.post?.likeCount ).toBe( 0 ); - expect( result.current.post?.commentCount ).toBe( 0 ); + expect( result.current.post?.views ).toBeUndefined(); + expect( result.current.post?.likeCount ).toBeUndefined(); + expect( result.current.post?.commentCount ).toBeUndefined(); + // The ranking request succeeded, so the widget itself is not in error. expect( result.current.isError ).toBe( false ); } ); diff --git a/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx index 9a4293ecbae0..33dfb0fc9fc7 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx @@ -72,8 +72,8 @@ type Story = StoryObj< Partial< ComponentProps< typeof PopularPostRender > > >; * Default — the period's most-viewed post with its views, likes, and comments. * * The shared close-up canvas is the width of a width-1 dashboard cell, which is - * below the card's 432px breakpoint: the featured image is dropped and the metric - * row wraps. `WidgetDashboardWithWidget` below shows the default width-2 + * below the card's 520px wide breakpoint: the featured image is dropped and the + * metric row wraps. `WidgetDashboardWithWidget` below shows the default width-2 * placement, where the image sits in a trailing column. */ export const Default: Story = { @@ -140,8 +140,9 @@ export const Empty: Story = { /** * Mounts the real `WidgetDashboard` with this single widget so it renders exactly * as it does in product (framed card, sizing, edit mode). Drop `widgetWidth` to 1 - * to walk the card's size ladder: below 432px the featured image drops out and the - * metric row wraps. + * to walk the card's size ladder: below 520px wide the featured image drops out and + * the metric row wraps. Shortening the cell below 300px also switches the card to + * its compact type scale. * * Comparison report params are passed unconditionally, so the widget stays covered * against crashing or inventing deltas when the host supplies comparison dates. diff --git a/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts index 85fa72a3cd0b..5533b44ff384 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts +++ b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts @@ -27,6 +27,18 @@ const POPULAR_POST_TYPES = [ 'post' ]; // and the metrics come from one window. const POPULAR_POST_REQUEST_MAX = 20; +/** + * The Stats post row reports `comment_count` as either a string or a number. + * An absent count stays absent, so it can render as "not available" instead of + * being flattened into a real zero. + * + * @param value - The raw `comment_count` from the Stats post row. + * @return The parsed count, or undefined when the row carries none. + */ +function toCommentCount( value: string | number | undefined ): number | undefined { + return value === undefined ? undefined : Number( value ) || 0; +} + export type PopularPostWithMetrics = { id: number; title: string; @@ -38,17 +50,19 @@ export type PopularPostWithMetrics = { imageUrl: string; imageAlt: string; /** - * All-time views, read from the Stats post endpoint. + * All-time views, read from the Stats post endpoint. Undefined when that + * request failed, so the card can distinguish "unknown" from zero. */ - views: number; + views: number | undefined; /** - * All-time likes, read from the Stats post endpoint. + * All-time likes, read from the Stats post endpoint. Undefined when unknown. */ - likeCount: number; + likeCount: number | undefined; /** * All-time comments, read from the post row on the Stats post endpoint. + * Undefined when unknown. */ - commentCount: number; + commentCount: number | undefined; }; export type UsePopularPostResult = { @@ -144,10 +158,14 @@ export function usePopularPost( reportParams: ReportParams ): UsePopularPostResu ( postId > 0 && ( contentResult.isLoading || postStatsResult.isLoading || isMetricsPending ) ); const isFetching = topPostsResult.isFetching || contentResult.isFetching || postStatsResult.isFetching; - // The Stats queries keep the previous range's rows via `placeholderData`, so a - // failed range change keeps the post visible; only surface the error when there - // is nothing to show. - const isError = topPostsResult.isError && ! topRow; + /* + * Surface a report failure even when rows are still on screen. `placeholderData` + * only applies while a query is pending, so once a range has loaded the rows + * that survive an error are React Query's last successful data — a failed + * background refetch. Gating the error on `! topRow` therefore hid exactly the + * case worth reporting: stale numbers with no error and no Retry. + */ + const isError = topPostsResult.isError; const refetch = () => { void topPostsResult.refetch(); @@ -170,9 +188,12 @@ export function usePopularPost( reportParams: ReportParams ): UsePopularPostResu date: content?.date || ( typeof topRow.date === 'string' ? topRow.date : '' ), imageUrl: content?.imageUrl ?? '', imageAlt: content?.imageAlt ?? '', - views: metrics?.views ?? 0, - likeCount: metrics?.like_count ?? 0, - commentCount: Number( metrics?.post?.comment_count ) || 0, + // Left undefined rather than zeroed when the metrics request fails or + // is still unconfirmed: the card shows a dash, so a private site's + // 403 cannot render a post with 12 likes as "Likes 0". + views: metrics?.views, + likeCount: metrics?.like_count, + commentCount: toCommentCount( metrics?.post?.comment_count ), } : null; From f36d98460340d3affc6ca45dd025393382507ca2 Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Mon, 3 Aug 2026 10:04:36 +0200 Subject: [PATCH 05/12] Premium Analytics: trim the post widget doc blocks Compress the comments added over the review rounds: keep the non-obvious rationale, drop the restated-code prose and the multi-paragraph explanations. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../post-highlight-card.tsx | 9 +- .../widgets/latest-post/use-latest-post.ts | 31 ++---- .../widgets/popular-post/use-popular-post.ts | 94 +++++-------------- 3 files changed, 36 insertions(+), 98 deletions(-) diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx index b067ebfebbc1..e511642ffa7e 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx @@ -32,10 +32,8 @@ export type PostHighlightCardMetric = { label: string; /** - * The metric value, or `undefined` when it could not be read. An unavailable - * metric renders as a dash rather than as `0`: the endpoint that supplies - * these counts can fail on its own (a private site 403s it), and a zero there - * is indistinguishable from a post that genuinely has no likes. + * The metric value, or `undefined` when unavailable — rendered as a dash, so a + * failed request is not shown as a real count of zero. */ value: number | undefined; @@ -144,8 +142,7 @@ function PostHighlightMetric( { { metric.note && { metric.note } } { metric.value === undefined ? ( - { /* An em dash reads as "—" to a screen reader, or is skipped - entirely, so the state is also spelled out for assistive tech. */ } + { /* Spelled out below, since a screen reader may skip the dash. */ } { __( 'Not available', 'jetpack-premium-analytics-pkg' ) } diff --git a/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts b/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts index 0e2baaf73268..c80ff816004c 100644 --- a/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts +++ b/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts @@ -10,22 +10,18 @@ import { } from '@jetpack-premium-analytics/data'; /** - * The Stats post row reports `comment_count` as either a string or a number. - * An absent count stays absent, so it can render as "not available" instead of - * being flattened into a real zero. + * Parses the Stats row's string-or-number `comment_count`, keeping an absent + * count absent so it can render as unknown rather than as a real zero. * - * @param value - The raw `comment_count` from the Stats post row. - * @return The parsed count, or undefined when the row carries none. + * @param value - The raw `comment_count`. + * @return The count, or undefined when absent. */ function toCommentCount( value: string | number | undefined ): number | undefined { return value === undefined ? undefined : Number( value ) || 0; } export type LatestPostWithMetrics = LatestPost & { - /** - * All-time metrics from the Stats post endpoint. Undefined when that request - * failed, so the card can distinguish "unknown" from a genuine zero. - */ + /** All-time totals from the Stats post endpoint; undefined when unknown. */ views: number | undefined; likeCount: number | undefined; commentCount: number | undefined; @@ -46,10 +42,8 @@ export type UseLatestPostResult = { * from the Stats post endpoint in a second, dependent request keyed by the * resolved post ID. * - * Only a content failure surfaces as an error — content is the widget. When the - * Stats request fails (e.g. a private Simple site where stats/post 403s), the - * post still renders, with its metrics marked unavailable rather than zeroed and - * rather than blanking the widget. + * Only a content failure surfaces as an error. When the Stats request fails (a + * private site 403s it), the post still renders with its metrics unknown. * * @return The latest post with its metrics, plus combined loading/error state. */ @@ -63,12 +57,8 @@ export function useLatestPost(): UseLatestPostResult { const isLoading = latestPostResult.isLoading || ( postId > 0 && postStatsResult.isLoading ); const isFetching = latestPostResult.isFetching || postStatsResult.isFetching; - /* - * Surface a content failure even when a post is still on screen. `placeholderData` - * only applies while a query is pending, so a post that survives an error is - * React Query's last successful data — a failed background refetch. Gating the - * error on `! latestPost` hid exactly that case: stale content, no error, no Retry. - */ + // Surfaced even with a post on screen: `placeholderData` only applies while + // pending, so a post surviving an error means a failed background refetch. const isError = latestPostResult.isError; const refetch = () => { @@ -83,8 +73,7 @@ export function useLatestPost(): UseLatestPostResult { const post = latestPost ? { ...latestPost, - // Left undefined rather than zeroed when the metrics request fails, so - // the card shows a dash instead of claiming zero likes. + // Undefined rather than zeroed, so a 403 cannot read as "Likes 0". views: postStatsResult.data?.views, likeCount: postStatsResult.data?.like_count, commentCount: toCommentCount( postStatsResult.data?.post?.comment_count ), diff --git a/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts index 5533b44ff384..8a83c0584b85 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts +++ b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts @@ -17,23 +17,17 @@ import { useMemo } from 'react'; // on this option. const POPULAR_POST_TYPES = [ 'post' ]; -// The API caps the ranked rows it returns at `max`, so ask for a page of them: -// filtering down to post-type rows still needs a winner to pick. -// -// Trade-off: on a page-heavy site the top 20 ranked rows can all be pages, and -// the widget then shows its empty state even though a qualifying post ranks -// lower. Raising this only moves the boundary rather than removing it; a -// `post_type`-filtered top-posts endpoint would, and would also let the ranking -// and the metrics come from one window. +// Ask for a page of ranked rows, since filtering to post-type rows still needs a +// winner. On a page-heavy site all 20 can be pages, leaving the widget empty +// while a qualifying post ranks lower; only a `post_type`-filtered endpoint fixes that. const POPULAR_POST_REQUEST_MAX = 20; /** - * The Stats post row reports `comment_count` as either a string or a number. - * An absent count stays absent, so it can render as "not available" instead of - * being flattened into a real zero. + * Parses the Stats row's string-or-number `comment_count`, keeping an absent + * count absent so it can render as unknown rather than as a real zero. * - * @param value - The raw `comment_count` from the Stats post row. - * @return The parsed count, or undefined when the row carries none. + * @param value - The raw `comment_count`. + * @return The count, or undefined when absent. */ function toCommentCount( value: string | number | undefined ): number | undefined { return value === undefined ? undefined : Number( value ) || 0; @@ -49,19 +43,9 @@ export type PopularPostWithMetrics = { date: string; imageUrl: string; imageAlt: string; - /** - * All-time views, read from the Stats post endpoint. Undefined when that - * request failed, so the card can distinguish "unknown" from zero. - */ + /** All-time totals from the Stats post endpoint; undefined when unknown. */ views: number | undefined; - /** - * All-time likes, read from the Stats post endpoint. Undefined when unknown. - */ likeCount: number | undefined; - /** - * All-time comments, read from the post row on the Stats post endpoint. - * Undefined when unknown. - */ commentCount: number | undefined; }; @@ -75,40 +59,19 @@ export type UsePopularPostResult = { }; /** - * The site's most-viewed post for the dashboard's selected date range, with the - * metrics a single-post highlight card shows. + * The site's most-viewed post for the selected date range. The range only picks + * the winner: every displayed metric is an all-time total from `stats/post`, so + * the three tiles cannot measure different periods. * - * Three requests compose the card, mirroring `useLatestPost`'s split: - * - * 1. `stats/top-posts` for the period ranking. The date range's only job is to - * pick the winner; the row's period views are not what the card displays. - * 2. the local core posts endpoint for the winning post's content, because the - * report carries no featured image (and reading content on-site keeps it - * resolvable on private/unlaunched sites). - * 3. `stats/post/{id}` for every displayed metric — views, likes and comments. - * - * All three tiles therefore share one window: **all-time**. The Stats post - * endpoint takes no date range, so likes and comments can only be lifetime - * totals; reading views from the same response keeps three tiles that sit side - * by side from silently measuring two different periods. It also matches the - * sibling `Latest post` widget, which shares this card and is already all-time. - * - * Only a report failure surfaces as an error — the ranking is the widget. A - * failing content or metrics request degrades to no image and zeroed counts - * rather than blanking the card. + * Only a ranking failure surfaces as an error; a failing content or metrics + * request degrades to no image and unknown counts. * * @param reportParams - The dashboard's report params (date range, comparison). * @return The popular post with its metrics, plus combined loading/error state. */ export function usePopularPost( reportParams: ReportParams ): UsePopularPostResult { const statsParams = useMemo( () => { - /* - * The comparison window drives a second `stats/top-posts` request, but this - * widget renders a single winner and no period-over-period delta anywhere, - * so that response would be fetched and discarded. Drop the comparison - * fields, the way `video-detail-highlights` does for params its endpoint - * cannot use. - */ + // Comparison params would fetch a second report this widget never renders. const primaryParams = { ...reportParams, max: POPULAR_POST_REQUEST_MAX }; delete primaryParams.comp; delete primaryParams.compare_from; @@ -131,14 +94,11 @@ export function usePopularPost( reportParams: ReportParams ): UsePopularPostResu const postStatsResult = useStatsPost( { postId, fields: [ 'views', 'like_count', 'post' ] } ); /* - * `statsProxyQuery` carries the previous key's payload over through - * `placeholderData`, while the content query deliberately does not. On a - * winner change that pairing would render the new post's title beside the - * previous post's engagement until the dependent request resolves. - * - * So consume metrics only from a response that identifies the post we asked - * for. When the endpoint omits the identifier there is nothing to match on, - * and holding the card in a skeleton forever would be worse than trusting it. + * Only consume metrics the response attributes to the current post: the Stats + * query keeps the previous key's payload via `placeholderData` while the + * content query does not, so a winner change could pair a new title with old + * engagement. A response without an identifier is trusted, since there is + * nothing to match on and skeletoning forever would be worse. */ const statsPostData = postStatsResult.data; const statsPostId = statsPostData?.post?.ID; @@ -147,8 +107,7 @@ export function usePopularPost( reportParams: ReportParams ): UsePopularPostResu ? statsPostData : undefined; - // A failed metrics request degrades to zeroed counts, so it stops counting as - // pending — otherwise the card would skeleton indefinitely on a 403. + // A failed request stops counting as pending, or a 403 would skeleton forever. const isMetricsPending = postId > 0 && ! metrics && ! postStatsResult.isError; // Both dependent queries are disabled until a post ID resolves, so they only @@ -158,13 +117,8 @@ export function usePopularPost( reportParams: ReportParams ): UsePopularPostResu ( postId > 0 && ( contentResult.isLoading || postStatsResult.isLoading || isMetricsPending ) ); const isFetching = topPostsResult.isFetching || contentResult.isFetching || postStatsResult.isFetching; - /* - * Surface a report failure even when rows are still on screen. `placeholderData` - * only applies while a query is pending, so once a range has loaded the rows - * that survive an error are React Query's last successful data — a failed - * background refetch. Gating the error on `! topRow` therefore hid exactly the - * case worth reporting: stale numbers with no error and no Retry. - */ + // Surfaced even with rows on screen: `placeholderData` only applies while + // pending, so rows surviving an error mean a failed background refetch. const isError = topPostsResult.isError; const refetch = () => { @@ -188,9 +142,7 @@ export function usePopularPost( reportParams: ReportParams ): UsePopularPostResu date: content?.date || ( typeof topRow.date === 'string' ? topRow.date : '' ), imageUrl: content?.imageUrl ?? '', imageAlt: content?.imageAlt ?? '', - // Left undefined rather than zeroed when the metrics request fails or - // is still unconfirmed: the card shows a dash, so a private site's - // 403 cannot render a post with 12 likes as "Likes 0". + // Undefined rather than zeroed, so a 403 cannot read as "Likes 0". views: metrics?.views, likeCount: metrics?.like_count, commentCount: toCommentCount( metrics?.post?.comment_count ), From d5457c94da36f1ca04538df66721f39ad9df8ce9 Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Mon, 3 Aug 2026 10:15:52 +0200 Subject: [PATCH 06/12] Premium Analytics: import post highlight card UI from externals Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../src/components/post-highlight-card/post-highlight-card.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx index e511642ffa7e..6409893f22d7 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx @@ -1,8 +1,8 @@ /** * External dependencies */ +import { Text, VisuallyHidden } from '@jetpack-premium-analytics/externals'; import { __, sprintf } from '@wordpress/i18n'; -import { Text, VisuallyHidden } from '@wordpress/ui'; import { format, parseISO } from 'date-fns'; /** * Internal dependencies From 260be57706d6b77c745651ce4806651d0feea4a2 Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Tue, 4 Aug 2026 12:58:22 +0200 Subject: [PATCH 07/12] Premium Analytics: fix the post widget Storybook states The stats/post mock returned a fixture with a hardcoded post ID for every requested post, so the card could never attribute the metrics to the current winner and sat in its skeleton forever. The mock now echoes the ID it was asked for, which is what the real endpoint does. Move the Empty story off `last-365-days`: it resolves to the same dates as ErrorRetryable's `last-12-months` most years, so the two shared a query key and Empty's cached result won. Normalize `comment_count` in `sanitizeStatsPostResponse` instead of in each consumer, removing three copies of the same coercion. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../data/src/processing/stats/post.ts | 22 +++++++++++++++++-- .../stories/mocks/register-report-mocks.ts | 13 +++++++---- .../widgets/latest-post/use-latest-post.ts | 13 +---------- .../stories/popular-post-widget.stories.tsx | 5 ++++- .../widgets/popular-post/use-popular-post.ts | 13 +---------- .../use-post-highlights.ts | 2 +- 6 files changed, 36 insertions(+), 32 deletions(-) diff --git a/projects/packages/premium-analytics/packages/data/src/processing/stats/post.ts b/projects/packages/premium-analytics/packages/data/src/processing/stats/post.ts index 4a984c782458..6f8132f7a316 100644 --- a/projects/packages/premium-analytics/packages/data/src/processing/stats/post.ts +++ b/projects/packages/premium-analytics/packages/data/src/processing/stats/post.ts @@ -65,7 +65,7 @@ export type StatsPostMeta = { post_date?: string; post_date_gmt?: string; post_status?: string; - comment_count?: StatsPostRawNumeric; + comment_count?: number; }; export type StatsPostRawResponse = { @@ -172,6 +172,24 @@ function normalizeStatsPostWeek( value: unknown ): StatsPostWeek { }; } +/** + * Normalizes the post meta, parsing `comment_count` and leaving an absent count + * absent so consumers can tell unknown from a real zero. + * + * @param value - The raw post meta. + * @return The normalized meta. + */ +function normalizeStatsPostMeta( value: unknown ): StatsPostMeta { + const meta = coerceStatsRecord( value ); + + return { + ...( meta as StatsPostMeta ), + ...( meta.comment_count !== undefined + ? { comment_count: safeParseFloat( meta.comment_count ) } + : {} ), + }; +} + export function sanitizeStatsPostResponse( response: unknown ): StatsPostResponse { if ( ! isStatsRecord( response ) ) { return {}; @@ -202,6 +220,6 @@ export function sanitizeStatsPostResponse( response: unknown ): StatsPostRespons ...( payload.highest_week_average !== undefined ? { highest_week_average: safeParseFloat( payload.highest_week_average ) } : {} ), - ...( payload.post !== undefined ? { post: payload.post as StatsPostMeta } : {} ), + ...( payload.post !== undefined ? { post: normalizeStatsPostMeta( payload.post ) } : {} ), }; } diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts index f85fb601012b..d54d54e9329e 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts @@ -1016,10 +1016,15 @@ function buildEmailBreakdownResponse( requestPath: string ): unknown { * @return The mock response body, or `null` if no specific handler matched. */ function routeStatsReport( subPath: string, requestPath: string ): unknown { - // Single-post detail — `stats/post/{id}`. Any post ID resolves to the - // shared fixture so post-scoped widgets render real values. - if ( subPath.startsWith( '/post/' ) ) { - return mockStatsPostData; + // Single-post detail — `stats/post/{id}`. Any post ID resolves to the shared + // fixture, but the fixture must report the ID that was asked for: widgets + // attribute metrics to the current post and skeleton on a mismatch. + const statsPost = subPath.match( /^\/post\/(\d+)/ ); + if ( statsPost ) { + return { + ...mockStatsPostData, + post: { ...mockStatsPostData.post, ID: Number( statsPost[ 1 ] ) }, + }; } // Single-video detail: `/video/{postId}` (drives video detail widgets). diff --git a/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts b/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts index c80ff816004c..47e195aae4b8 100644 --- a/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts +++ b/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts @@ -9,17 +9,6 @@ import { type LatestPostResponse, } from '@jetpack-premium-analytics/data'; -/** - * Parses the Stats row's string-or-number `comment_count`, keeping an absent - * count absent so it can render as unknown rather than as a real zero. - * - * @param value - The raw `comment_count`. - * @return The count, or undefined when absent. - */ -function toCommentCount( value: string | number | undefined ): number | undefined { - return value === undefined ? undefined : Number( value ) || 0; -} - export type LatestPostWithMetrics = LatestPost & { /** All-time totals from the Stats post endpoint; undefined when unknown. */ views: number | undefined; @@ -76,7 +65,7 @@ export function useLatestPost(): UseLatestPostResult { // Undefined rather than zeroed, so a 403 cannot read as "Likes 0". views: postStatsResult.data?.views, likeCount: postStatsResult.data?.like_count, - commentCount: toCommentCount( postStatsResult.data?.post?.comment_count ), + commentCount: postStatsResult.data?.post?.comment_count, } : null; diff --git a/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx index 33dfb0fc9fc7..9cc5a7940663 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx @@ -128,7 +128,10 @@ export const ErrorRetryable: Story = { * Resolved with no rows: the widget shows its empty state. */ export const Empty: Story = { - render: () => renderPopularPostOnPreset( 'last-365-days' ), + // A calendar year, not a rolling window: `last-365-days` and `last-12-months` + // resolve to the same dates most years, which would share ErrorRetryable's + // query key and serve this story's cached empty result there instead. + render: () => renderPopularPostOnPreset( 'last-year' ), tags: [ '!autodocs' ], decorators: [ withWidgetCanvas ], beforeEach: () => { diff --git a/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts index 8a83c0584b85..6475510ba74e 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts +++ b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts @@ -22,17 +22,6 @@ const POPULAR_POST_TYPES = [ 'post' ]; // while a qualifying post ranks lower; only a `post_type`-filtered endpoint fixes that. const POPULAR_POST_REQUEST_MAX = 20; -/** - * Parses the Stats row's string-or-number `comment_count`, keeping an absent - * count absent so it can render as unknown rather than as a real zero. - * - * @param value - The raw `comment_count`. - * @return The count, or undefined when absent. - */ -function toCommentCount( value: string | number | undefined ): number | undefined { - return value === undefined ? undefined : Number( value ) || 0; -} - export type PopularPostWithMetrics = { id: number; title: string; @@ -145,7 +134,7 @@ export function usePopularPost( reportParams: ReportParams ): UsePopularPostResu // Undefined rather than zeroed, so a 403 cannot read as "Likes 0". views: metrics?.views, likeCount: metrics?.like_count, - commentCount: toCommentCount( metrics?.post?.comment_count ), + commentCount: metrics?.post?.comment_count, } : null; diff --git a/projects/packages/premium-analytics/widgets/post-detail-highlights/use-post-highlights.ts b/projects/packages/premium-analytics/widgets/post-detail-highlights/use-post-highlights.ts index 3f08d7609de3..d9113d2495d3 100644 --- a/projects/packages/premium-analytics/widgets/post-detail-highlights/use-post-highlights.ts +++ b/projects/packages/premium-analytics/widgets/post-detail-highlights/use-post-highlights.ts @@ -100,7 +100,7 @@ export default function usePostHighlights( return { views, viewsPrevious, - comments: Number( data?.post?.comment_count ) || 0, + comments: data?.post?.comment_count ?? 0, likes: data?.like_count ?? 0, hasComparison, }; From 48c37bf1e120bd0a1550862d9d9964dea59e842c Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Tue, 4 Aug 2026 13:58:38 +0200 Subject: [PATCH 08/12] Premium Analytics: link the Popular post title to the post detail page Matches Latest post, which trunk moved onto the internal detail route. Both widgets now carry the dashboard's window into the detail page, and the card still falls back to the published post when there is no post ID. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../premium-analytics/widgets/popular-post/render.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/projects/packages/premium-analytics/widgets/popular-post/render.tsx b/projects/packages/premium-analytics/widgets/popular-post/render.tsx index 05ee89c36e92..c3c89e83b07c 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/render.tsx +++ b/projects/packages/premium-analytics/widgets/popular-post/render.tsx @@ -1,6 +1,7 @@ /** * External dependencies */ +import { pickReportDateParams } from '@jetpack-premium-analytics/routing'; import { PostHighlightCard, WidgetRoot, @@ -10,6 +11,7 @@ import { type PostHighlightCardMetric, type ReportParamsFieldAttributes, } from '@jetpack-premium-analytics/widgets-toolkit'; +import { useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { trendingUp } from '@wordpress/icons'; /** @@ -39,6 +41,8 @@ type PopularPostWidgetProps = WidgetRenderProps< PopularPostRenderAttributes >; function PopularPostReport() { const { reportParams } = useWidgetRootContext(); const { post, isLoading, isFetching, isError, error, refetch } = usePopularPost( reportParams ); + // The detail page opens on the dashboard's current window. + const detailSearch = useMemo( () => pickReportDateParams( reportParams ), [ reportParams ] ); const metrics: PostHighlightCardMetric[] = post ? [ @@ -78,6 +82,8 @@ function PopularPostReport() { Date: Tue, 4 Aug 2026 15:09:43 +0200 Subject: [PATCH 09/12] Premium Analytics: give the Popular post stories a router The card's title now renders a router-backed Link when it has a post ID, so the close-up stories need the same `withStoryRouter` decorator the sibling post widgets already use. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../stories/popular-post-widget.stories.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx index 9cc5a7940663..3cb028653ea7 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx @@ -21,6 +21,7 @@ import { widgetDashboardWithWidgetArgTypes, type WidgetDashboardWithWidgetControls, } from '../../stories/widget-dashboard-with-widget'; +import { withStoryRouter } from '../../stories/with-story-router'; import { withWidgetCanvas } from '../../stories/with-widget-canvas'; import PopularPostRender from '../render'; import widgetDefinition from '../widget'; @@ -78,7 +79,7 @@ type Story = StoryObj< Partial< ComponentProps< typeof PopularPostRender > > >; */ export const Default: Story = { render: renderPopularPost, - decorators: [ withWidgetCanvas ], + decorators: [ withWidgetCanvas, withStoryRouter ], }; /** @@ -89,7 +90,7 @@ export const Loading: Story = { render: () => renderPopularPostOnPreset( 'last-90-days' ), // Off the shared autodocs page — path-keyed override; see forceStatsMockState. tags: [ '!autodocs' ], - decorators: [ withWidgetCanvas ], + decorators: [ withWidgetCanvas, withStoryRouter ], beforeEach: () => { forceStatsMockState( 'stats/top-posts', 'loading' ); return () => forceStatsMockState( 'stats/top-posts', null ); @@ -103,7 +104,7 @@ export const Loading: Story = { export const Error: Story = { render: () => renderPopularPostOnPreset( 'last-7-days' ), tags: [ '!autodocs' ], - decorators: [ withWidgetCanvas ], + decorators: [ withWidgetCanvas, withStoryRouter ], beforeEach: () => { forceStatsMockState( 'stats/top-posts', 'error' ); return () => forceStatsMockState( 'stats/top-posts', null ); @@ -117,7 +118,7 @@ export const Error: Story = { export const ErrorRetryable: Story = { render: () => renderPopularPostOnPreset( 'last-12-months' ), tags: [ '!autodocs' ], - decorators: [ withWidgetCanvas ], + decorators: [ withWidgetCanvas, withStoryRouter ], beforeEach: () => { forceStatsMockState( 'stats/top-posts', 'error-retryable' ); return () => forceStatsMockState( 'stats/top-posts', null ); @@ -133,7 +134,7 @@ export const Empty: Story = { // query key and serve this story's cached empty result there instead. render: () => renderPopularPostOnPreset( 'last-year' ), tags: [ '!autodocs' ], - decorators: [ withWidgetCanvas ], + decorators: [ withWidgetCanvas, withStoryRouter ], beforeEach: () => { forceStatsMockState( 'stats/top-posts', 'empty' ); return () => forceStatsMockState( 'stats/top-posts', null ); From 3de5cb7758930ee54300af27961c080a50094642 Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Wed, 5 Aug 2026 11:04:33 +0200 Subject: [PATCH 10/12] Premium Analytics: declare the post widgets' real dependencies Popular post imports `pickReportDateParams` and `useMemo` but declared neither, building only because hoisting happened to provide them. Latest post gained the same `useMemo` import, and no longer uses `date-fns` or `externals` since the shared card took over its rendering. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../premium-analytics/widgets/latest-post/package.json | 5 ++--- .../premium-analytics/widgets/popular-post/package.json | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/projects/packages/premium-analytics/widgets/latest-post/package.json b/projects/packages/premium-analytics/widgets/latest-post/package.json index 0b7e26aa44cb..45d7b196610e 100644 --- a/projects/packages/premium-analytics/widgets/latest-post/package.json +++ b/projects/packages/premium-analytics/widgets/latest-post/package.json @@ -5,12 +5,11 @@ "type": "module", "dependencies": { "@jetpack-premium-analytics/data": "link:../../packages/data", - "@jetpack-premium-analytics/externals": "link:../../packages/externals", "@jetpack-premium-analytics/routing": "link:../../packages/routing", "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit", + "@wordpress/element": "8.3.0", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^15.0.0", - "@wordpress/widget-primitives": "0.4.0", - "date-fns": "4.1.0" + "@wordpress/widget-primitives": "0.4.0" } } diff --git a/projects/packages/premium-analytics/widgets/popular-post/package.json b/projects/packages/premium-analytics/widgets/popular-post/package.json index 68a61f9a6fd5..6e6a3db18c53 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/package.json +++ b/projects/packages/premium-analytics/widgets/popular-post/package.json @@ -5,10 +5,12 @@ "type": "module", "dependencies": { "@jetpack-premium-analytics/data": "link:../../packages/data", + "@jetpack-premium-analytics/routing": "link:../../packages/routing", "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit", + "@wordpress/element": "8.3.0", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^15.0.0", - "@wordpress/widget-primitives": "0.2.0", + "@wordpress/widget-primitives": "0.4.0", "react": "18.3.1" } } From cda4846f86b196da0970d576e67ff071d3e9bbe1 Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Wed, 5 Aug 2026 11:06:50 +0200 Subject: [PATCH 11/12] Premium Analytics: sync the Popular post docs with its behaviour The widget reads every metric from `stats/post`, so the date range picks the winner rather than scoping the view count. The widget definition and the Storybook descriptions still described the earlier period-scoped views. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../popular-post/stories/popular-post-widget.stories.tsx | 6 +++--- .../premium-analytics/widgets/popular-post/widget.ts | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx index 3cb028653ea7..875fbaf58af4 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx @@ -1,7 +1,7 @@ /** * The widget ranks posts with the proxied `stats/top-posts` endpoint (covered by * the legacy stats mocks), then reads the winning post's content from the local - * `/wp/v2/posts` endpoint and its all-time engagement from `stats/post/{id}` + * `/wp/v2/posts` endpoint and all three of its all-time metrics from `stats/post/{id}` * (both covered by the shared report mocks). */ /** @@ -59,7 +59,7 @@ const meta = { docs: { description: { component: - 'The "Popular post" widget shows the site\'s most-viewed post for the dashboard\'s date range, with its publish date, the views it collected in that period, and its all-time likes and comments. Changing the date range changes both the winning post and its view count. There is no `WithComparison` story: the card shows no period-over-period delta, so the dashboard story below carries the comparison report params instead.', + 'The "Popular post" widget shows the site\'s most-viewed post for the dashboard\'s date range, with its publish date and its all-time views, likes, and comments. Changing the date range changes which post wins, not the totals shown for it: every tile comes from the all-time `stats/post` response, so the three cannot measure different periods. There is no `WithComparison` story: the card shows no period-over-period delta, so the dashboard story below carries the comparison report params instead.', }, }, }, @@ -70,7 +70,7 @@ export default meta; type Story = StoryObj< Partial< ComponentProps< typeof PopularPostRender > > >; /** - * Default — the period's most-viewed post with its views, likes, and comments. + * Default — the period's most-viewed post with its all-time views, likes, and comments. * * The shared close-up canvas is the width of a width-1 dashboard cell, which is * below the card's 520px wide breakpoint: the featured image is dropped and the diff --git a/projects/packages/premium-analytics/widgets/popular-post/widget.ts b/projects/packages/premium-analytics/widgets/popular-post/widget.ts index 294d899c0cbb..10154b64a1de 100644 --- a/projects/packages/premium-analytics/widgets/popular-post/widget.ts +++ b/projects/packages/premium-analytics/widgets/popular-post/widget.ts @@ -16,9 +16,8 @@ export type PopularPostAttributes = Record< never, never >; * Widget type definition. * * The Insights "Most popular post" module: the site's most-viewed post for the - * dashboard's selected date range. Unlike the sibling Latest post widget, this - * one is period-scoped — changing the date range changes which post wins and the - * view count shown for it. + * dashboard's selected date range. The range only picks the winner — the views, + * likes, and comments shown for it are all-time totals. */ export default { icon: trendingUp, From eb3ff66d9c13b169de9f93253afe3c6236a87ce6 Mon Sep 17 00:00:00 2001 From: Lourens Schep Date: Wed, 5 Aug 2026 12:10:51 +0200 Subject: [PATCH 12/12] Premium Analytics: move the post highlight card onto the theme 1.0 tokens Trunk's `@wordpress/*` update renamed the font-weight tokens (medium -> emphasis, regular -> default). The shared card still used the old names, which resolve to nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HAX3YkQxDLLXhKXjft6qU2 --- .../post-highlight-card.module.scss | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss index 05a0aae8d9a6..921214ef8f03 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss @@ -125,16 +125,16 @@ .metricLabel { overflow: hidden; color: var(--wpds-color-foreground-content-neutral); - font-weight: var(--wpds-typography-font-weight-medium); + font-weight: var(--wpds-typography-font-weight-emphasis); text-overflow: ellipsis; white-space: nowrap; } -/* The design weights the compact value up to 600; the token scale tops out - * at `medium` (499), which is the closest on-token match. */ +/* The design weights the compact value up to 600; `emphasis` is the closest + * the token scale goes. */ .metric .metricValue { font-size: var(--wpds-typography-font-size-xl); - font-weight: var(--wpds-typography-font-weight-medium); + font-weight: var(--wpds-typography-font-weight-emphasis); line-height: var(--wpds-typography-line-height-md); } @@ -167,7 +167,7 @@ .metric .metricValue { font-size: var(--wpds-typography-font-size-2xl); - font-weight: var(--wpds-typography-font-weight-regular); + font-weight: var(--wpds-typography-font-weight-default); line-height: var(--wpds-typography-line-height-2xl); } }