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..a97280e079ca --- /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 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/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/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/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..1415f9b7b7d5 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/__tests__/post-highlight-card.test.tsx @@ -0,0 +1,172 @@ +/** + * 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 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', () => { + 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..921214ef8f03 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.module.scss @@ -0,0 +1,230 @@ +/* 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 + * + * 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: + * + * 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; + gap: var(--wpds-dimension-gap-lg); + 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; + 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-inline-size: 0; + min-block-size: 0; + overflow: hidden; +} + +.titleLink { + color: var(--wpds-color-foreground-content-neutral); + text-decoration: none; +} + +.titleLink:hover { + text-decoration: underline; +} + +.title { + display: -webkit-box; + margin: 0; + overflow: hidden; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; +} + +/* `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-shrink: 0; + gap: var(--wpds-dimension-gap-lg); +} + +.metric { + display: flex; + flex: 1; + flex-direction: column; + gap: 0; + min-inline-size: 0; +} + +.metricLabel { + overflow: hidden; + color: var(--wpds-color-foreground-content-neutral); + font-weight: var(--wpds-typography-font-weight-emphasis); + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 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-emphasis); + line-height: var(--wpds-typography-line-height-md); +} + +/* 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; + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +/* Tall cells: the roomier type scale. */ +@container post-highlight (min-height: 300px) { + + .header .title { + font-size: var(--wpds-typography-font-size-2xl); + line-height: var(--wpds-typography-line-height-2xl); + } + + .metric { + gap: var(--wpds-dimension-gap-xs); + } + + .metric .metricValue { + font-size: var(--wpds-typography-font-size-2xl); + font-weight: var(--wpds-typography-font-weight-default); + line-height: var(--wpds-typography-line-height-2xl); + } +} + +/* 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) { + + .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; + } +} + +/* Wide and tall: the image becomes a full-height trailing panel. */ +@container post-highlight (min-width: 520px) and (min-height: 300px) { + + .media { + align-self: stretch; + inline-size: 44%; + block-size: auto; + max-block-size: none; + aspect-ratio: auto; + } +} + +/* 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; + } +} + +/* 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/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..6409893f22d7 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/post-highlight-card/post-highlight-card.tsx @@ -0,0 +1,221 @@ +/** + * External dependencies + */ +import { Text, VisuallyHidden } from '@jetpack-premium-analytics/externals'; +import { __, sprintf } from '@wordpress/i18n'; +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, or `undefined` when unavailable — rendered as a dash, so a + * failed request is not shown as a real count of zero. + */ + value: number | undefined; + + /** + * 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 } } + { metric.value === undefined ? ( + + { /* Spelled out below, since a screen reader may skip the dash. */ } + + + { __( 'Not available', 'jetpack-premium-analytics-pkg' ) } + + + ) : ( + + ) } +
+ ); +} + +/** + * 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..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 @@ -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', @@ -1009,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). @@ -1336,6 +1348,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/__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/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/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..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 @@ -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 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 = { render: renderLatestPost, @@ -214,6 +219,10 @@ 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 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. */ @@ -241,3 +250,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/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/latest-post/use-latest-post.ts b/projects/packages/premium-analytics/widgets/latest-post/use-latest-post.ts index 24ba00679f93..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 @@ -10,9 +10,10 @@ import { } from '@jetpack-premium-analytics/data'; export type LatestPostWithMetrics = LatestPost & { - views: number; - likeCount: number; - commentCount: number; + /** All-time totals from the Stats post endpoint; undefined when unknown. */ + views: number | undefined; + likeCount: number | undefined; + commentCount: number | undefined; }; export type UseLatestPostResult = { @@ -30,9 +31,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 zeroed 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. */ @@ -46,10 +46,9 @@ 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; + // 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 = () => { void latestPostResult.refetch(); @@ -63,9 +62,10 @@ 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, + // Undefined rather than zeroed, so a 403 cannot read as "Likes 0". + views: postStatsResult.data?.views, + likeCount: postStatsResult.data?.like_count, + commentCount: 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 new file mode 100644 index 000000000000..edcd76a7fc8c --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/__tests__/use-popular-post.test.tsx @@ -0,0 +1,299 @@ +/** + * External dependencies + */ +import { queryClient, type ReportParams } from '@jetpack-premium-analytics/data'; +import { act, 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' } ], + }, + }, +]; + +// `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 }; + +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/' ) ) { + // 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( { + error: 'unauthorized', + message: 'User cannot access this private blog.', + status: 403, + } ) + : 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 and shows all-time metrics for it', 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', + // 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, + } ) + ); + 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 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 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 ).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 ); + } ); + + 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(); + } ); + + 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/package.json b/projects/packages/premium-analytics/widgets/popular-post/package.json new file mode 100644 index 000000000000..6e6a3db18c53 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/package.json @@ -0,0 +1,16 @@ +{ + "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/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", + "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..c3c89e83b07c --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/render.tsx @@ -0,0 +1,113 @@ +/** + * External dependencies + */ +import { pickReportDateParams } from '@jetpack-premium-analytics/routing'; +import { + PostHighlightCard, + WidgetRoot, + WidgetState, + describeError, + useWidgetRootContext, + type PostHighlightCardMetric, + type ReportParamsFieldAttributes, +} from '@jetpack-premium-analytics/widgets-toolkit'; +import { useMemo } from '@wordpress/element'; +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 ``. + * + * 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. + */ +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 + ? [ + { 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 && ( + + ) } + + ); +} + +/** + * 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..875fbaf58af4 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/stories/popular-post-widget.stories.tsx @@ -0,0 +1,218 @@ +/** + * 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 all three of its all-time metrics 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 { withStoryRouter } from '../../stories/with-story-router'; +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 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.', + }, + }, + }, +} satisfies Meta< typeof PopularPostRender >; + +export default meta; + +type Story = StoryObj< Partial< ComponentProps< typeof PopularPostRender > > >; + +/** + * 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 + * 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, withStoryRouter ], +}; + +/** + * 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, withStoryRouter ], + 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, withStoryRouter ], + 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, withStoryRouter ], + 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 = { + // 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, withStoryRouter ], + 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 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. + * + * @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, + }, +}; + +/** + * 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/use-popular-post.ts b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts new file mode 100644 index 000000000000..6475510ba74e --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/use-popular-post.ts @@ -0,0 +1,142 @@ +/** + * 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' ]; + +// 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; + +export type PopularPostWithMetrics = { + id: number; + title: string; + url: string; + /** + * The post's publish timestamp. + */ + date: string; + imageUrl: string; + imageAlt: string; + /** All-time totals from the Stats post endpoint; undefined when unknown. */ + views: number | undefined; + likeCount: number | undefined; + commentCount: number | undefined; +}; + +export type UsePopularPostResult = { + post: PopularPostWithMetrics | null; + isLoading: boolean; + isFetching: boolean; + isError: boolean; + error: unknown; + refetch: () => void; +}; + +/** + * 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. + * + * 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( () => { + // 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; + 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. + 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: [ 'views', 'like_count', 'post' ] } ); + + /* + * 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; + const metrics = + statsPostData && ( statsPostId === undefined || statsPostId === postId ) + ? statsPostData + : undefined; + + // 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 + // count towards the widget's loading state once there is a post to load. + const isLoading = + topPostsResult.isLoading || + ( postId > 0 && ( contentResult.isLoading || postStatsResult.isLoading || isMetricsPending ) ); + const isFetching = + topPostsResult.isFetching || contentResult.isFetching || postStatsResult.isFetching; + // 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 = () => { + 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 ?? '', + // Undefined rather than zeroed, so a 403 cannot read as "Likes 0". + views: metrics?.views, + likeCount: metrics?.like_count, + commentCount: metrics?.post?.comment_count, + } + : 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..499fb6190789 --- /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 all-time stats.", + "help": { + "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" +} 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..10154b64a1de --- /dev/null +++ b/projects/packages/premium-analytics/widgets/popular-post/widget.ts @@ -0,0 +1,28 @@ +/** + * 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. The range only picks the winner — the views, + * likes, and comments shown for it are all-time totals. + */ +export default { + icon: trendingUp, + attributes: [] as WidgetAttributeField< PopularPostAttributes >[], + example: { + attributes: {}, + }, +}; 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, };