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 (
+
+ );
+}
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 (
-