diff --git a/projects/packages/premium-analytics/changelog/update-post-detail-parity b/projects/packages/premium-analytics/changelog/update-post-detail-parity new file mode 100644 index 000000000000..42d779328303 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/update-post-detail-parity @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Post detail page: apply the video-page parity fixes — inline date filters on the title row with row-width degradation and no comparison, long-title overflow and featured-image sizing fixes, and the post-views day-shift fix. diff --git a/projects/packages/premium-analytics/packages/routing/src/index.ts b/projects/packages/premium-analytics/packages/routing/src/index.ts index cc325b80a492..7bbc52dfcb0e 100644 --- a/projects/packages/premium-analytics/packages/routing/src/index.ts +++ b/projects/packages/premium-analytics/packages/routing/src/index.ts @@ -7,6 +7,7 @@ export { export { deriveComparisonRange } from './search/comparison'; export { REPORT_DATE_PARAM_KEYS, + omitComparisonReportParams, pickReportDateParams, buildDashboardLink, buildReportLink, diff --git a/projects/packages/premium-analytics/packages/routing/src/search/report-params/index.ts b/projects/packages/premium-analytics/packages/routing/src/search/report-params/index.ts index 754315f62c99..27a3ce37734e 100644 --- a/projects/packages/premium-analytics/packages/routing/src/search/report-params/index.ts +++ b/projects/packages/premium-analytics/packages/routing/src/search/report-params/index.ts @@ -1,5 +1,6 @@ export { REPORT_DATE_PARAM_KEYS, + omitComparisonReportParams, pickReportDateParams, buildDashboardLink, buildReportLink, diff --git a/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.test.ts b/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.test.ts index 1a842c21a728..e36fe69d2003 100644 --- a/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.test.ts +++ b/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.test.ts @@ -1,4 +1,9 @@ -import { buildDashboardLink, buildReportLink, pickReportDateParams } from './report-params'; +import { + buildDashboardLink, + buildReportLink, + omitComparisonReportParams, + pickReportDateParams, +} from './report-params'; /** * Read a link's querystring the way the router reads it, so a test asserts the @@ -51,6 +56,47 @@ describe( 'pickReportDateParams', () => { } ); } ); +describe( 'omitComparisonReportParams', () => { + it( 'drops only the comparison params, keeping the window and page scope', () => { + expect( + omitComparisonReportParams( { + from: '2026-01-01', + to: '2026-01-31', + interval: 'day', + preset: 'last-30-days', + date_type: 'created', + post_id: '42', + section: 'email-opens', + comp: '1', + compare_from: '2025-12-02', + compare_to: '2025-12-31', + compare_preset: 'previous-period', + } ) + ).toEqual( { + from: '2026-01-01', + to: '2026-01-31', + interval: 'day', + preset: 'last-30-days', + date_type: 'created', + post_id: '42', + section: 'email-opens', + } ); + } ); + + it( 'returns a copy when no comparison params are present', () => { + const search = { from: '2026-01-01', to: '2026-01-31' }; + + const result = omitComparisonReportParams( search ); + + expect( result ).toEqual( search ); + expect( result ).not.toBe( search ); + } ); + + it( 'returns an empty object for missing search', () => { + expect( omitComparisonReportParams( undefined ) ).toEqual( {} ); + } ); +} ); + describe( 'buildDashboardLink', () => { it( 'returns the bare dashboard path when no report params are set', () => { expect( buildDashboardLink( {} ) ).toBe( '/' ); diff --git a/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts b/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts index b42ba911d58c..63053a9ea5e5 100644 --- a/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts +++ b/projects/packages/premium-analytics/packages/routing/src/search/report-params/report-params.ts @@ -44,6 +44,39 @@ export function pickReportDateParams( return picked; } +/** + * The subset of `REPORT_DATE_PARAM_KEYS` that carries the period-over-period + * comparison. + */ +const COMPARISON_PARAM_KEYS = [ 'comp', 'compare_from', 'compare_to', 'compare_preset' ] as const; + +/** + * Drop the comparison params from a search object, keeping everything else. + * + * Detail pages have no period-over-period comparison by design. The params + * stay in the URL so the breadcrumb round trip preserves the dashboard's + * comparison state, but the page strips them from the `reportParams` it + * injects into its widgets, so no widget can render comparison data — the + * page-wide invariant holds by construction instead of relying on every + * widget to ignore them. + * + * @param search - The current route search params. + * @return A new object without the comparison params. + */ +export function omitComparisonReportParams( + search: Record< string, unknown > | undefined +): Record< string, unknown > { + if ( ! search ) { + return {}; + } + + const stripped: Record< string, unknown > = { ...search }; + for ( const key of COMPARISON_PARAM_KEYS ) { + delete stripped[ key ]; + } + return stripped; +} + /** * Serialize one search value the way the router does. * diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx index f60af7c40970..65fc49ecd60d 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx @@ -111,6 +111,14 @@ export type DateFiltersPanelProps = { * Required for proper date/time handling. */ timeZone: string; + + /** + * Whether to render the period-over-period Compare control. Pages whose + * design has no comparison (the post/email detail page) opt out; their + * widgets ignore comparison params, and hiding the control keeps the UI + * honest about it. + */ + showComparison?: boolean; }; /** @@ -140,6 +148,7 @@ export function DateFiltersPanel( { onCancel, canApply = true, timeZone, + showComparison = true, }: DateFiltersPanelProps ) { /** * Validate and normalize the primary preset ID. @@ -342,20 +351,25 @@ export function DateFiltersPanel( { /> - - - + { showComparison && ( + + + + ) } ); } diff --git a/projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.module.scss b/projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.module.scss index 6fdc5b0240c4..87482e057b67 100644 --- a/projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.module.scss +++ b/projects/packages/premium-analytics/routes/post-detail/components/post-summary-card/post-summary-card.module.scss @@ -45,6 +45,11 @@ .image { object-fit: cover; + // The boot shell resets `.boot-layout img { height: auto; max-width: 100% }` + // with higher specificity than this class, squashing the box to the + // image's natural ratio. With that `height: auto` in force, a square + // aspect ratio restores the 72px box without a specificity contest. + aspect-ratio: 1; } .imagePlaceholder { diff --git a/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tabs.test.tsx b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tabs.test.tsx index 1055e807f5ab..b314899415e1 100644 --- a/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tabs.test.tsx +++ b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tabs.test.tsx @@ -13,6 +13,16 @@ import type { DashboardWidget } from '@wordpress/widget-dashboard'; jest.mock( '@jetpack-premium-analytics/routing', () => ( { useStagedSearch: jest.fn(), + omitComparisonReportParams: jest.requireActual( + '../../../packages/routing/src/search/report-params' + ).omitComparisonReportParams, +} ) ); + +// The hook reads the raw URL search to build each layout entry's stripped +// reportParams; the real useSearch throws outside a matched route. +let mockRouteSearch: Record< string, unknown > = {}; +jest.mock( '@wordpress/route', () => ( { + useSearch: () => mockRouteSearch, } ) ); // The email tabs gate on the per-post opens rate summary; the query itself is @@ -82,6 +92,34 @@ describe( 'usePostDetailTabs', () => { beforeEach( () => { jest.clearAllMocks(); mockEmailSends( 3 ); + mockRouteSearch = {}; + } ); + + it( 'injects comparison-stripped report params into every layout entry', () => { + mockSearch( 'post-traffic' ); + mockRouteSearch = { + from: '2026-07-01', + to: '2026-07-07', + interval: 'day', + post_id: String( POST_ID ), + comp: '1', + compare_from: '2026-06-24', + compare_to: '2026-06-30', + compare_preset: 'previous-period', + }; + + const { result } = renderHook( () => usePostDetailTabs( POST_ID ) ); + + expect( result.current.layout.length ).toBeGreaterThan( 0 ); + for ( const widget of result.current.layout ) { + const attributes = widget.attributes as { reportParams?: unknown } | undefined; + expect( attributes?.reportParams ).toEqual( { + from: '2026-07-01', + to: '2026-07-07', + interval: 'day', + post_id: String( POST_ID ), + } ); + } } ); it( 'falls back from a hidden tab and replaces the URL', async () => { @@ -120,7 +158,17 @@ describe( 'usePostDetailTabs', () => { 'email-clicks', ] ); expect( result.current.activeTab ).toBe( 'email-clicks' ); - expect( result.current.layout ).toEqual( POST_DETAIL_TAB_LAYOUTS[ 'email-clicks' ] ); + // The hook overlays each fixed entry with the comparison-stripped + // reportParams (empty here — the mocked route search is empty). + expect( result.current.layout ).toEqual( + POST_DETAIL_TAB_LAYOUTS[ 'email-clicks' ].map( widget => ( { + ...widget, + attributes: { + ...( widget.attributes as Record< string, unknown > | undefined ), + reportParams: {}, + }, + } ) ) + ); expect( stage ).not.toHaveBeenCalled(); expect( commit ).not.toHaveBeenCalled(); } ); diff --git a/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tabs.ts b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tabs.ts index f0b6701904c1..ac00d224b352 100644 --- a/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tabs.ts +++ b/projects/packages/premium-analytics/routes/post-detail/hooks/use-post-detail-tabs.ts @@ -5,10 +5,12 @@ import { useStatsEmailOpensBreakdown, type StatsEmailBreakdown, } from '@jetpack-premium-analytics/data'; +import { omitComparisonReportParams } from '@jetpack-premium-analytics/routing'; /** * WordPress dependencies */ import { useEffect, useMemo } from '@wordpress/element'; +import { useSearch } from '@wordpress/route'; /** * Internal dependencies */ @@ -78,10 +80,31 @@ export function usePostDetailTabs( postId: number ) { } }, [ canNormalize, storedTab, activeTab, setActiveTab ] ); + /* + * The page has no period-over-period comparison by design, but the + * comparison params stay in the URL so the breadcrumb carries the + * dashboard's state back out. Without explicit `reportParams`, every + * `WidgetRoot` falls back to reading the raw URL search — comparison + * included — so comparison-capable widgets (UTM, highlights) would render + * deltas. Injecting the stripped params into each layout entry makes the + * page-wide no-comparison invariant hold by construction. + */ + const search = useSearch( { strict: false } ) as Record< string, unknown > | undefined; + const layout = useMemo( () => { + const reportParams = omitComparisonReportParams( search ); + return POST_DETAIL_TAB_LAYOUTS[ activeTab ].map( widget => ( { + ...widget, + attributes: { + ...( widget.attributes as Record< string, unknown > | undefined ), + reportParams, + }, + } ) ); + }, [ activeTab, search ] ); + return { tabs, activeTab, setActiveTab, - layout: POST_DETAIL_TAB_LAYOUTS[ activeTab ], + layout, }; } diff --git a/projects/packages/premium-analytics/routes/post-detail/route.ts b/projects/packages/premium-analytics/routes/post-detail/route.ts index 104f02c509a8..ab766fd56fba 100644 --- a/projects/packages/premium-analytics/routes/post-detail/route.ts +++ b/projects/packages/premium-analytics/routes/post-detail/route.ts @@ -104,6 +104,14 @@ export const route = { post_id: postId, }; + /* + * Comparison params ride along untouched: this page renders no + * comparison (its widgets ignore them), but the breadcrumb's + * dashboard link carries the URL state back out, so stripping them + * here would silently lose the user's comparison settings on a + * Dashboard → Post → Dashboard round trip. + */ + throw redirect( { to: '/post/$postId', /* diff --git a/projects/packages/premium-analytics/routes/post-detail/stage.module.scss b/projects/packages/premium-analytics/routes/post-detail/stage.module.scss index faf97184ace1..a8d217ba33b4 100644 --- a/projects/packages/premium-analytics/routes/post-detail/stage.module.scss +++ b/projects/packages/premium-analytics/routes/post-detail/stage.module.scss @@ -2,7 +2,7 @@ min-block-size: 0; } -// The scroll container below the fixed tab bar and date filters: the summary +// The scroll container below the fixed tab bar: the summary // header scrolls away with the widgets instead of permanently occupying the // viewport. .scrollArea { @@ -26,21 +26,52 @@ --wp-grid-gap: var(--wpds-dimension-gap-lg); } -.dateFilters { - // Sits directly below the tab bar, outside the scroll container, so it - // stays fixed exactly like the dashboard's filters. The tab list already - // adds a bottom margin, so only pad below here to keep the spacing even; - // inline padding matches the tabs and the widget grid so it lines up. - padding-block-end: var(--wpds-dimension-gap-lg); +.header { + // The summary and the date filter presets share one header row — title on + // the left, presets on the right and vertically centered, per the design + // mocks. Flexbox breaks lines from unshrunk sizes, and the summary's + // inline-size containment (below) clamps its unshrunk size to the 400px + // floor — so the row wraps only when the panel's current layout no longer + // fits beside that floor (the compact dropdown on a phone), never because + // of a long title. On tight single rows the summary absorbs the squeeze + // (title ellipsis), and the panel's own step-down on narrow rows needs + // external measurement and ships separately (#51088). gap-2xl + // above matches the space the old two-row layout added up to; gap-2xl + // below mirrors it symmetrically. + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--wpds-dimension-gap-lg); + padding-block: var(--wpds-dimension-gap-2xl); padding-inline: var(--wpds-dimension-padding-2xl); + // The boot shell's surfaces element is a flex item with the default + // `min-inline-size: auto`, so it asks the page for its min-content width — + // and a specified min-inline-size can only raise an intrinsic + // contribution, never lower it, so the summary title's nowrap min-content + // would widen the whole page into horizontal scrolling before the title's + // own ellipsis could engage. Inline-size containment excludes the header's + // contents from intrinsic sizing. + contain: inline-size; } -.header { - // The summary header below the filters. The top gap stacks with the - // filters row's bottom padding (gap-lg + gap-lg = gap-2xl of space above - // the heading); gap-2xl below mirrors it symmetrically. - padding-block: var(--wpds-dimension-gap-lg) var(--wpds-dimension-gap-2xl); - padding-inline: var(--wpds-dimension-padding-2xl); +.summary { + // Grows into the row's free space (pushing the presets to the row's end) + // and absorbs the squeeze on tight rows, where the title's ellipsis + // engages. The 400px floor keeps the title readable. Inline-size + // containment zeroes the card's intrinsic contribution: its width comes + // from the row's flex sizing, and the wrap decision above sees the 400px + // floor instead of the title's nowrap max-content. + flex: 1 1 auto; + min-inline-size: min(100%, 400px); + contain: inline-size; +} + +.dateFilters { + // Content-sized at the row's end. The auto margin keeps it at the inline + // end both beside the summary and on its + // own wrapped line. + flex: 0 0 auto; + margin-inline-start: auto; } // The header action is a Button rendered as an anchor, so it can carry a real diff --git a/projects/packages/premium-analytics/routes/post-detail/stage.test.tsx b/projects/packages/premium-analytics/routes/post-detail/stage.test.tsx index 38870c37b8b7..4f5a0d9992c5 100644 --- a/projects/packages/premium-analytics/routes/post-detail/stage.test.tsx +++ b/projects/packages/premium-analytics/routes/post-detail/stage.test.tsx @@ -20,7 +20,9 @@ jest.mock( '@jetpack-premium-analytics/routing', () => ( { // Avoid loading DataViews while keeping the real breadcrumbs for these assertions. jest.mock( '@jetpack-premium-analytics/ui', () => ( { - DateFiltersPanel: () =>
Date filters
, + DateFiltersPanel: ( { showComparison }: { showComparison?: boolean } ) => ( +
{ showComparison === false ? 'Date filters without comparison' : 'Date filters' }
+ ), SectionTabPanel: ( { children }: { children: ReactNode } ) =>
{ children }
, StatsBreadcrumbs: jest.requireActual( '../../packages/ui/src/stats-breadcrumbs' ) .StatsBreadcrumbs, @@ -202,6 +204,14 @@ describe( 'post detail stage', () => { expect( screen.queryByRole( 'link', { name: /^View (post|page)$/ } ) ).not.toBeInTheDocument(); } ); + it( 'renders the date filters without the comparison control', () => { + mockSummary(); + + render( stage() ); + + expect( screen.getByText( 'Date filters without comparison' ) ).toBeInTheDocument(); + } ); + it( 'keeps the two-crumb trail when no report origin is present', () => { mockSummary(); diff --git a/projects/packages/premium-analytics/routes/post-detail/stage.tsx b/projects/packages/premium-analytics/routes/post-detail/stage.tsx index 3be35bc302e7..de35f7b407ad 100644 --- a/projects/packages/premium-analytics/routes/post-detail/stage.tsx +++ b/projects/packages/premium-analytics/routes/post-detail/stage.tsx @@ -138,23 +138,38 @@ function PostDetail(): JSX.Element { className={ styles.page } > - { /* - * The date filters and the summary card are shared by every tab - * (same post, same date range), so they render once below the - * tab bar and above the per-tab widget grid. The tab bar and the - * filters stay fixed outside the scroll container, exactly like - * the dashboard's section tabs; the summary header scrolls away - * inside it with the widgets, giving them the vertical room. - */ } -
- -
+ { /* + * The summary card and the date filter presets share the + * header row — title on the left, presets on the right, per + * the design mocks. Both are shared by every tab (same post, + * same date range), so they render once above the per-tab + * widget grid and scroll away with it. + */ }
- +
+ +
+
+ { /* + * The design has no period-over-period comparison on + * this page, so the Compare control is opted out; + * comparison params stay in the URL (stripped from the + * widgets' injected reportParams) so the breadcrumb + * carries them back to the dashboard. + */ } + { /* + * Known rough edge: in this shrink-to-fit slot the panel's + * self-measurement always sees its own content width, so the + * presets keep their full layout and narrow rows degrade + * poorly. External-measurement wiring ships separately + * (#51088). + */ } + +
{ tabs.map( tab => ( diff --git a/projects/packages/premium-analytics/widgets/email-time-series/__tests__/email-time-series.test.tsx b/projects/packages/premium-analytics/widgets/email-time-series/__tests__/email-time-series.test.tsx index b3c63341608d..aa52a103afd3 100644 --- a/projects/packages/premium-analytics/widgets/email-time-series/__tests__/email-time-series.test.tsx +++ b/projects/packages/premium-analytics/widgets/email-time-series/__tests__/email-time-series.test.tsx @@ -2,8 +2,7 @@ * External dependencies */ import { getDefaultQueryParams, queryClient } from '@jetpack-premium-analytics/data'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies @@ -108,26 +107,8 @@ describe( 'EmailTimeSeriesWidget', () => { expect( requestedPath ).toContain( 'stats/clicks/emails/1234' ); } ); - it( 'fetches the compare window and draws it as a second series when comparison is on', async () => { - // Route by the window start: the primary window gets the real buckets, - // the compare window gets a distinct set. - mockApiFetch.mockImplementation( ( { path }: { path: string } ) => - Promise.resolve( - path.includes( 'date=2026-07-01' ) - ? OPENS_TIMELINE_RESPONSE - : { - timeline: { - unit: 'day', - fields: [ 'date', 'opens_count' ], - data: [ - [ '2026-06-24', 2 ], - [ '2026-06-25', 3 ], - [ '2026-06-26', 4 ], - ], - }, - } - ) - ); + it( 'ignores comparison report params: one request, single series', async () => { + mockApiFetch.mockResolvedValue( OPENS_TIMELINE_RESPONSE ); render( { preset: undefined, from: '2026-07-01T00:00:00.000+08:00', to: '2026-07-07T23:59:59.999+08:00', + // Comparison params pass through the post detail URL untouched + // (dashboard state survives the round trip), so a widget + // receiving them must neither fetch a second window nor draw + // an overlay — the page renders no comparison. comp: '1', compare_from: '2026-06-24T00:00:00.000+08:00', compare_to: '2026-06-30T23:59:59.999+08:00', @@ -148,189 +133,14 @@ describe( 'EmailTimeSeriesWidget', () => { ); const chart = await screen.findByTestId( 'comparative-line-chart' ); - await waitFor( () => expect( chart ).toHaveAttribute( 'data-series-count', '2' ) ); + expect( chart ).toHaveAttribute( 'data-series-count', '1' ); expect( chart ).toHaveAttribute( 'data-values', '10,5,7' ); - expect( chart ).toHaveAttribute( 'data-previous-values', '2,3,4' ); - // One request per window, scoped by each window's start date. + // One request, scoped to the primary window only. const requestedDates = mockApiFetch.mock.calls.map( call => new URLSearchParams( String( call[ 0 ].path ).split( '?' )[ 1 ] ).get( 'date' ) ); - expect( requestedDates ).toHaveLength( 2 ); - expect( requestedDates ).toEqual( expect.arrayContaining( [ '2026-07-01', '2026-06-24' ] ) ); - } ); - - it( 'buckets the compare window relative to the primary layout across month boundaries', async () => { - // Primary March window (one month bucket) vs a compare window crossing - // January into February: the overlay must still be a single bucket. - mockApiFetch.mockImplementation( ( { path }: { path: string } ) => - Promise.resolve( - path.includes( 'date=2026-03-01' ) - ? { - timeline: { - unit: 'day', - fields: [ 'date', 'opens_count' ], - data: [ - [ '2026-03-01', 4 ], - [ '2026-03-31', 5 ], - ], - }, - } - : { - timeline: { - unit: 'day', - fields: [ 'date', 'opens_count' ], - data: [ - [ '2026-01-29', 1 ], - [ '2026-02-28', 2 ], - ], - }, - } - ) - ); - - render( - - ); - - const chart = await screen.findByTestId( 'comparative-line-chart' ); - await waitFor( () => expect( chart ).toHaveAttribute( 'data-series-count', '2' ) ); - expect( chart ).toHaveAttribute( 'data-values', '9' ); - expect( chart ).toHaveAttribute( 'data-previous-values', '3' ); - } ); - - it( 'folds a longer compare window into the last bucket instead of dropping days', async () => { - // previous-month can hand back more days than the primary (a 5-day - // compare window onto a 2-day primary). The overlay must keep the same - // two buckets as primary while summing all five compare days, not just - // the two that pair by index. - mockApiFetch.mockImplementation( ( { path }: { path: string } ) => - Promise.resolve( - path.includes( 'date=2026-02-28' ) - ? { - timeline: { - unit: 'day', - fields: [ 'date', 'opens_count' ], - data: [ - [ '2026-02-28', 10 ], - [ '2026-03-01', 20 ], - ], - }, - } - : { - timeline: { - unit: 'day', - fields: [ 'date', 'opens_count' ], - data: [ - [ '2026-01-28', 1 ], - [ '2026-01-29', 2 ], - [ '2026-01-30', 3 ], - [ '2026-01-31', 4 ], - [ '2026-02-01', 5 ], - ], - }, - } - ) - ); - - render( - - ); - - const chart = await screen.findByTestId( 'comparative-line-chart' ); - await waitFor( () => expect( chart ).toHaveAttribute( 'data-series-count', '2' ) ); - // Primary: Feb=10, Mar=20. Compare: Feb bucket = Jan 28 (1); Mar bucket = - // the four remaining days (2+3+4+5 = 14) folded in, so nothing is lost. - expect( chart ).toHaveAttribute( 'data-values', '10,20' ); - expect( chart ).toHaveAttribute( 'data-previous-values', '1,14' ); - } ); - - it( 'surfaces the error state and retries both windows when the compare request fails', async () => { - // Primary succeeds, comparison fails: the widget must not quietly show a - // lone solid line, so the error state appears and Retry re-runs both. - const compareStart = 'date=2026-06-24'; - mockApiFetch.mockImplementation( ( { path }: { path: string } ) => - path.includes( compareStart ) - ? Promise.reject( { status: 403, message: 'Forbidden' } ) - : Promise.resolve( OPENS_TIMELINE_RESPONSE ) - ); - - render( - - ); - - await expect( - screen.findByText( /couldn't load this email's timeline/ ) - ).resolves.toBeInTheDocument(); - - // Retry re-runs both windows; once the compare window resolves, the - // dashed overlay renders. - mockApiFetch.mockImplementation( ( { path }: { path: string } ) => - Promise.resolve( - path.includes( compareStart ) - ? { - timeline: { - unit: 'day', - fields: [ 'date', 'opens_count' ], - data: [ - [ '2026-06-24', 2 ], - [ '2026-06-25', 3 ], - [ '2026-06-26', 4 ], - ], - }, - } - : OPENS_TIMELINE_RESPONSE - ) - ); - await userEvent.click( screen.getByRole( 'button', { name: 'Retry' } ) ); - - const chart = await screen.findByTestId( 'comparative-line-chart' ); - await waitFor( () => expect( chart ).toHaveAttribute( 'data-series-count', '2' ) ); - expect( chart ).toHaveAttribute( 'data-previous-values', '2,3,4' ); + expect( requestedDates ).toEqual( [ '2026-07-01' ] ); } ); it( 'aggregates the daily buckets into ISO weeks for the weekly granularity', async () => { diff --git a/projects/packages/premium-analytics/widgets/email-time-series/render.tsx b/projects/packages/premium-analytics/widgets/email-time-series/render.tsx index 06ed349bd192..8ebb8a9622ec 100644 --- a/projects/packages/premium-analytics/widgets/email-time-series/render.tsx +++ b/projects/packages/premium-analytics/widgets/email-time-series/render.tsx @@ -3,11 +3,9 @@ */ import { bucketStatsTimeSeries, - getStatsChartBucketKey, toPostId, useStatsEmailClicksTimeSeries, useStatsEmailOpensTimeSeries, - type StatsEmailTimeSeriesDataPoint, type StatsEmailTimeSeriesReport, } from '@jetpack-premium-analytics/data'; import { reports } from '@jetpack-premium-analytics/icons'; @@ -67,12 +65,12 @@ type EmailTimeSeriesReportProps = { /** * Fetches the selected email's opens or clicks timeline over the dashboard - * date range and draws it as a line chart; with the date picker's comparison - * on, the compare window is fetched as a second request and drawn as a dashed - * overlay (legend switches to date-range labels). The endpoint reports daily - * buckets; weekly/monthly granularities aggregate them client-side, with the - * comparison bucketed relative to the primary layout. Only the active - * metric's queries run. + * date range and draws it as a line chart. The endpoint reports daily + * buckets; weekly/monthly granularities aggregate them client-side. Only the + * active metric's query runs. The post detail design has no period-over-period + * comparison, so comparison report params are ignored — they ride along in + * the URL untouched so dashboard state survives the round trip, and every + * widget on this page disregards them. * * @param {EmailTimeSeriesReportProps} props - The component props. * @return The widget content. @@ -81,60 +79,22 @@ function EmailTimeSeriesReport( { metric, granularity }: EmailTimeSeriesReportPr const { reportParams } = useWidgetRootContext(); const postId = toPostId( reportParams.post_id ); const hasSelection = postId > 0; - // Comparison dates only survive the report-param normalizer when the - // comparison toggle is on, so their presence is the comparison signal. - const hasComparison = !! ( reportParams.compare_from && reportParams.compare_to ); - // The endpoint has no comparison mode of its own, but it accepts any - // window (`date` is the window start), so the comparison period is just a - // second request scoped to the compare range. - const comparisonParams = useMemo( - () => ( { - ...reportParams, - from: reportParams.compare_from ?? '', - to: reportParams.compare_to ?? '', - preset: undefined, - comp: undefined, - compare_from: undefined, - compare_to: undefined, - compare_preset: undefined, - } ), - [ reportParams ] - ); - - // All hooks are called every render (hooks rule); only the active - // metric's queries are enabled, and the comparison window only fetches - // while the date picker's comparison is on. + // Both hooks are called every render (hooks rule); only the active + // metric's query is enabled. const opens = useStatsEmailOpensTimeSeries( postId, reportParams, { enabled: hasSelection && metric === 'opens', } ); const clicks = useStatsEmailClicksTimeSeries( postId, reportParams, { enabled: hasSelection && metric === 'clicks', } ); - const opensComparison = useStatsEmailOpensTimeSeries( postId, comparisonParams, { - enabled: hasSelection && hasComparison && metric === 'opens', - } ); - const clicksComparison = useStatsEmailClicksTimeSeries( postId, comparisonParams, { - enabled: hasSelection && hasComparison && metric === 'clicks', - } ); const active = metric === 'clicks' ? clicks : opens; - const activeComparison = metric === 'clicks' ? clicksComparison : opensComparison; - // A comparison failure must not silently drop the overlay while the solid - // line stays: surface the error and retry both windows together. - const isComparisonError = - hasComparison && activeComparison.isError && activeComparison.data === undefined; const retry = useCallback( () => { active.refetch(); - if ( hasComparison ) { - activeComparison.refetch(); - } - }, [ active, activeComparison, hasComparison ] ); + }, [ active ] ); const report = active.data as StatsEmailTimeSeriesReport | undefined; - const comparisonReport = hasComparison - ? ( activeComparison.data as StatsEmailTimeSeriesReport | undefined ) - : undefined; const field = METRIC_FIELDS[ metric ]; const chartReport = useMemo( () => { @@ -154,65 +114,15 @@ function EmailTimeSeriesReport( { metric, granularity }: EmailTimeSeriesReportPr } ); }, [ report, granularity, field ] ); - // The comparison window is the same length as the primary for most presets, - // but previous-month/-year can differ (a 31-day month compared with a - // 28-day one), and either window can sit differently against calendar - // boundaries. So instead of calendar-bucketing the comparison directly — - // which could yield a different bucket count and misalign the overlay — - // each comparison day joins the bucket of the primary day at the same - // index. Comparison days past the primary window (a longer previous period) - // fold into the last bucket, so no comparison data is dropped and the - // overlay always mirrors the primary series' bucket layout. - const comparisonChartReport = useMemo( () => { - if ( ! report || ! comparisonReport ) { - return undefined; - } - - if ( granularity === 'day' ) { - return comparisonReport; - } - - const primaryBucketKeys = report.data.map( primaryPoint => - getStatsChartBucketKey( primaryPoint.time_interval, granularity ) - ); - if ( ! primaryBucketKeys.length ) { - return undefined; - } - - const totals = new Map< string, { start: StatsEmailTimeSeriesDataPoint; value: number } >(); - const order: string[] = []; - comparisonReport.data.forEach( ( comparisonPoint: StatsEmailTimeSeriesDataPoint, index ) => { - const key = primaryBucketKeys[ Math.min( index, primaryBucketKeys.length - 1 ) ]; - const value = Number( comparisonPoint[ field ] ?? 0 ); - const bucket = totals.get( key ); - if ( bucket ) { - bucket.value += value; - } else { - totals.set( key, { start: comparisonPoint, value } ); - order.push( key ); - } - } ); - - return { - ...comparisonReport, - data: order.map( key => { - const bucket = totals.get( key )!; - - return { ...bucket.start, value: bucket.value, [ field ]: bucket.value }; - } ), - }; - }, [ report, comparisonReport, granularity, field ] ); - const series = useMemo( () => chartReport ? buildReportMetricSeries( { primary: chartReport, - comparison: comparisonChartReport, metrics: [ { key: field, label: metricLabel( metric ) } ], } ) : [], - [ chartReport, comparisonChartReport, field, metric ] + [ chartReport, field, metric ] ); const seriesStyles = useSeriesStyles( series ); const hasPoints = ( chartReport?.data?.length ?? 0 ) > 0; @@ -221,8 +131,8 @@ function EmailTimeSeriesReport( { metric, granularity }: EmailTimeSeriesReportPr
( 'granularity' ); /** - * Widget-specific controls: the comparison toggle, the opens/clicks metric, - * and the bucket granularity. + * Widget-specific controls: the opens/clicks metric and the bucket + * granularity. */ interface EmailTimeSeriesStoryControls { - withComparison: boolean; metric: EmailTimeSeriesMetric; granularity: EmailTimeSeriesGranularity; } -function renderEmailTimeSeries( { - withComparison, - metric, - granularity, -}: EmailTimeSeriesStoryControls ) { - return ( - - ); +/** + * Builds the widget attributes. Comparison stays a parameter so the dashboard + * story can pass host comparison params without duplicating the scoping rule. + * + * @param {EmailTimeSeriesStoryControls} controls - The story controls. + * @param {boolean} withComparison - Include previous-period comparison report params. + * @return The widget attributes. + */ +function getEmailTimeSeriesAttributes( + { metric, granularity }: EmailTimeSeriesStoryControls, + withComparison = false +): ComponentProps< typeof EmailTimeSeriesRender >[ 'attributes' ] { + return { + reportParams: { ...getDefaultQueryParams( withComparison ), post_id: MOCK_EMAIL_ID }, + metric, + granularity, + }; +} + +function renderEmailTimeSeries( controls: EmailTimeSeriesStoryControls ) { + return ; } // Renders the widget against a distinct email ID so the forced-state stories @@ -111,7 +118,6 @@ const meta = { component: EmailTimeSeriesRender, tags: [ 'autodocs' ], argTypes: { - withComparison: { control: 'boolean' }, metric: { control: 'select', options: METRIC_OPTIONS }, granularity: { control: 'select', options: GRANULARITY_OPTIONS }, }, @@ -119,7 +125,7 @@ const meta = { docs: { description: { component: - "The \"Email performance\" widget. Draws a single sent email's opens or clicks per day as a line chart, spanning the dashboard date range — the chart section of the legacy email detail page. The `granularity` attribute (`relevance: 'high'`) is exposed as a control by the widget host; weekly grouping aggregates the daily buckets client-side because the endpoint only reports hourly/daily. Scoped to one email via a mocked `reportParams.post_id`. With the date picker's comparison on, the compare window is fetched as a second request and drawn as a dashed overlay with date-range legend labels.", + "The \"Email performance\" widget. Draws a single sent email's opens or clicks per day as a line chart, spanning the dashboard date range — the chart section of the legacy email detail page. The `granularity` attribute (`relevance: 'high'`) is exposed as a control by the widget host; weekly grouping aggregates the daily buckets client-side because the endpoint only reports hourly/daily. Scoped to one email via a mocked `reportParams.post_id`. The post detail page has no comparison control, so comparison report params are ignored.", }, }, }, @@ -135,17 +141,7 @@ type Story = StoryObj< EmailTimeSeriesStoryControls >; */ export const Default: Story = { render: renderEmailTimeSeries, - args: { withComparison: false, metric: 'opens', granularity: 'day' }, - decorators: [ withWidgetCanvas ], -}; - -/** - * Comparison from the date picker: the compare window fetches as a second - * request and draws as a dashed overlay, with date-range legend labels. - */ -export const WithComparison: Story = { - render: renderEmailTimeSeries, - args: { withComparison: true, metric: 'opens', granularity: 'day' }, + args: { metric: 'opens', granularity: 'day' }, decorators: [ withWidgetCanvas ], }; @@ -154,7 +150,7 @@ export const WithComparison: Story = { */ export const Clicks: Story = { render: renderEmailTimeSeries, - args: { withComparison: false, metric: 'clicks', granularity: 'day' }, + args: { metric: 'clicks', granularity: 'day' }, decorators: [ withWidgetCanvas ], }; @@ -163,7 +159,7 @@ export const Clicks: Story = { */ export const ByWeeks: Story = { render: renderEmailTimeSeries, - args: { withComparison: false, metric: 'opens', granularity: 'week' }, + args: { metric: 'opens', granularity: 'week' }, decorators: [ withWidgetCanvas ], }; @@ -226,8 +222,15 @@ interface EmailTimeSeriesDashboardStoryProps extends WidgetDashboardWithWidgetControls, EmailTimeSeriesStoryControls {} +/** + * Mounts the real `WidgetDashboard`. It passes comparison params + * unconditionally, so the widget stays covered against crashing or inventing + * an overlay when a host supplies comparison dates. + * + * @param {EmailTimeSeriesDashboardStoryProps} props - The dashboard story controls. + * @return The widget mounted inside the real dashboard. + */ function EmailTimeSeriesDashboardStory( { - withComparison, metric, granularity, ...dashboardArgs @@ -238,11 +241,7 @@ function EmailTimeSeriesDashboardStory( { widgetType={ createStoryWidgetType( widgetManifest, widgetDefinition ) } renderModule={ EMAIL_TIME_SERIES_RENDER_MODULE } renderComponent={ EmailTimeSeriesRender as ComponentType< WidgetRenderProps< unknown > > } - attributes={ { - reportParams: { ...getDefaultQueryParams( withComparison ), post_id: MOCK_EMAIL_ID }, - metric, - granularity, - } } + attributes={ getEmailTimeSeriesAttributes( { metric, granularity }, true ) } /> ); } @@ -251,13 +250,11 @@ export const WidgetDashboardWithWidget: StoryObj< EmailTimeSeriesDashboardStoryP render: args => , args: { ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS, - withComparison: true, metric: 'opens', granularity: 'day', }, argTypes: { ...widgetDashboardWithWidgetArgTypes, - withComparison: { control: 'boolean' }, metric: { control: 'select', options: METRIC_OPTIONS }, granularity: { control: 'select', options: GRANULARITY_OPTIONS }, }, diff --git a/projects/packages/premium-analytics/widgets/post-views/__tests__/post-views.test.tsx b/projects/packages/premium-analytics/widgets/post-views/__tests__/post-views.test.tsx index 44a1995a2de3..64e973253d72 100644 --- a/projects/packages/premium-analytics/widgets/post-views/__tests__/post-views.test.tsx +++ b/projects/packages/premium-analytics/widgets/post-views/__tests__/post-views.test.tsx @@ -4,6 +4,7 @@ import { getDefaultQueryParams, queryClient } from '@jetpack-premium-analytics/data'; import { render, screen } from '@testing-library/react'; import apiFetch from '@wordpress/api-fetch'; +import { getSettings, setSettings } from '@wordpress/date'; /** * Internal dependencies */ @@ -18,14 +19,14 @@ jest.mock( '@jetpack-premium-analytics/widgets-toolkit', () => ( { ComparativeLineChart: ( { series, }: { - series: { label: string; data: { value: number }[] }[]; + series: { label: string; data: { date: Date; value: number }[] }[]; } ) => (
point.value ).join( ',' ) } - data-previous-values={ series[ 1 ]?.data.map( point => point.value ).join( ',' ) } + data-first-date={ series[ 0 ]?.data[ 0 ]?.date.toISOString() } /> ), } ) ); @@ -80,6 +81,29 @@ describe( 'PostViewsWidget', () => { expect( requestedPath ).toContain( 'stats/post/779' ); } ); + it( 'anchors bucket days at site-local midnight so negative-offset sites keep the calendar day', async () => { + // A UTC-12 site: a date-only bucket key parsed as UTC midnight would + // render as the previous day once formatted in the site timezone. The + // point instant must be the key's site-local midnight instead. + const defaultSettings = getSettings(); + setSettings( { + ...defaultSettings, + timezone: { offset: -12, offsetFormatted: '-12', string: '', abbr: '' }, + } ); + + try { + mockApiFetch.mockResolvedValue( STATS_POST_RESPONSE ); + + render( ); + + const chart = await screen.findByTestId( 'comparative-line-chart' ); + // 2026-07-01 site-local midnight at UTC-12 is 2026-07-01T12:00:00Z. + expect( chart ).toHaveAttribute( 'data-first-date', '2026-07-01T12:00:00.000Z' ); + } finally { + setSettings( defaultSettings ); + } + } ); + it( 'buckets views into ISO weeks for the week granularity', async () => { mockApiFetch.mockResolvedValue( STATS_POST_RESPONSE ); @@ -93,7 +117,7 @@ describe( 'PostViewsWidget', () => { expect( chart ).toHaveAttribute( 'data-values', '12,0' ); } ); - it( 'slices the comparison overlay from the same request', async () => { + it( 'ignores comparison report params: one request, single series', async () => { mockApiFetch.mockResolvedValue( STATS_POST_RESPONSE ); render( @@ -101,8 +125,10 @@ describe( 'PostViewsWidget', () => { attributes={ { reportParams: { ...WINDOW_PARAMS, - // `comp: '1'` switches the comparison on; without it the - // param normalizer drops the compare window. + // Comparison params pass through the post detail URL untouched + // (dashboard state survives the round trip), so a widget + // receiving them must neither draw an overlay nor change the + // primary series — the page renders no comparison. comp: '1', compare_from: '2026-06-24T00:00:00.000+08:00', compare_to: '2026-06-30T23:59:59.999+08:00', @@ -112,198 +138,23 @@ describe( 'PostViewsWidget', () => { ); const chart = await screen.findByTestId( 'comparative-line-chart' ); - expect( chart ).toHaveAttribute( 'data-series-count', '2' ); - // The comparison window catches the 6/25 day; both windows zero-fill to - // the same bucket count so the index-aligned overlay can't scrunch. - expect( chart ).toHaveAttribute( 'data-previous-values', '0,9,0,0,0,0,0' ); - // One request serves both windows. + expect( chart ).toHaveAttribute( 'data-series-count', '1' ); + expect( chart ).toHaveAttribute( 'data-series-label', 'Views' ); + expect( chart ).toHaveAttribute( 'data-values', '0,5,0,7,0,0,0' ); expect( mockApiFetch ).toHaveBeenCalledTimes( 1 ); } ); - it( 'uses primary month buckets for a previous period that crosses a month boundary', async () => { - mockApiFetch.mockResolvedValue( { - data: [ - [ '2026-01-29', 1 ], - [ '2026-02-01', 2 ], - [ '2026-02-28', 3 ], - [ '2026-03-01', 4 ], - [ '2026-03-31', 5 ], - ], - } ); - - render( - - ); - - const chart = await screen.findByTestId( 'comparative-line-chart' ); - expect( chart ).toHaveAttribute( 'data-values', '9' ); - // The previous period is one relative monthly bucket, not separate January - // and February points that the comparative chart would collapse onto March. - expect( chart ).toHaveAttribute( 'data-previous-values', '6' ); - } ); - - it( 'clamps a shorter previous-month compare bucket to its own window', async () => { - // Primary March (31 days) vs previous-month February (28 days), monthly. - // The compare bucket must sum only February — a naive relative offset - // would run three days past the compare window and pull March 2 in. - mockApiFetch.mockResolvedValue( { - data: [ - [ '2026-02-10', 5 ], - [ '2026-02-20', 7 ], - [ '2026-03-02', 50 ], - [ '2026-03-10', 100 ], - [ '2026-03-20', 200 ], - ], - } ); - - render( - - ); - - const chart = await screen.findByTestId( 'comparative-line-chart' ); - expect( chart ).toHaveAttribute( 'data-values', '350' ); - // Only February's 5 + 7; March 2's 50 stays out of the compare bucket. - expect( chart ).toHaveAttribute( 'data-previous-values', '12' ); - } ); - - it( 'keeps a longer previous-month compare window from truncating', async () => { - // Primary February (28 days) vs previous-month January (31 days), - // monthly. The compare bucket must sum all of January — the last bucket - // has to extend to the compare window end rather than stopping at the - // primary length. - mockApiFetch.mockResolvedValue( { - data: [ - [ '2026-01-15', 10 ], - [ '2026-01-30', 20 ], - [ '2026-02-15', 100 ], - ], - } ); - - render( - - ); - - const chart = await screen.findByTestId( 'comparative-line-chart' ); - expect( chart ).toHaveAttribute( 'data-values', '100' ); - // Both January days, including Jan 30 which the old offset would drop. - expect( chart ).toHaveAttribute( 'data-previous-values', '30' ); - } ); - - it( 'keeps one comparison point per calendar day when the compare window is longer', async () => { - mockApiFetch.mockResolvedValue( { - data: [ - [ '2026-01-28', 1 ], - [ '2026-01-29', 2 ], - [ '2026-01-30', 3 ], - [ '2026-01-31', 4 ], - [ '2026-02-01', 5 ], - [ '2026-02-28', 10 ], - [ '2026-03-01', 20 ], - ], - } ); - - render( - - ); - - const chart = await screen.findByTestId( 'comparative-line-chart' ); - expect( chart ).toHaveAttribute( 'data-values', '10,20' ); - // Day grouping must not fold Jan 29-Feb 1 into one point merely to - // mirror the shorter primary range. - expect( chart ).toHaveAttribute( 'data-previous-values', '1,2,3,4,5' ); - } ); - - it( 'does not invent trailing comparison days when the compare window is shorter', async () => { - mockApiFetch.mockResolvedValue( { - data: [ - [ '2026-02-28', 5 ], - [ '2026-03-29', 10 ], - [ '2026-03-30', 20 ], - [ '2026-03-31', 30 ], - ], - } ); - - render( - - ); - - const chart = await screen.findByTestId( 'comparative-line-chart' ); - expect( chart ).toHaveAttribute( 'data-values', '10,20,30' ); - expect( chart ).toHaveAttribute( 'data-previous-values', '5' ); - } ); - it( 'renders the scopeless empty state and makes no request without a post scope', async () => { render( ); await expect( screen.findByText( 'Open a post or page report to see its views here.' ) ).resolves.toBeInTheDocument(); - expect( mockApiFetch ).not.toHaveBeenCalled(); + expect( + mockApiFetch.mock.calls.filter( call => + ( call[ 0 ].path as string ).includes( 'stats/post' ) + ) + ).toHaveLength( 0 ); } ); it( 'shows the error state with a Retry action when the fetch fails', async () => { diff --git a/projects/packages/premium-analytics/widgets/post-views/package.json b/projects/packages/premium-analytics/widgets/post-views/package.json index 7a1cece700ad..d8dcce8049d2 100644 --- a/projects/packages/premium-analytics/widgets/post-views/package.json +++ b/projects/packages/premium-analytics/widgets/post-views/package.json @@ -5,8 +5,8 @@ "type": "module", "dependencies": { "@jetpack-premium-analytics/data": "link:../../packages/data", + "@jetpack-premium-analytics/datetime": "link:../../packages/datetime", "@jetpack-premium-analytics/fields": "link:../../packages/fields", - "@jetpack-premium-analytics/formatters": "link:../../packages/formatters", "@jetpack-premium-analytics/icons": "link:../../packages/icons", "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit", "@wordpress/element": "8.2.0", diff --git a/projects/packages/premium-analytics/widgets/post-views/render.tsx b/projects/packages/premium-analytics/widgets/post-views/render.tsx index ba83e08f30b5..6e0c7a7bad39 100644 --- a/projects/packages/premium-analytics/widgets/post-views/render.tsx +++ b/projects/packages/premium-analytics/widgets/post-views/render.tsx @@ -2,7 +2,6 @@ * External dependencies */ import { toPostId } from '@jetpack-premium-analytics/data'; -import { formatDateRange } from '@jetpack-premium-analytics/formatters'; import { reports } from '@jetpack-premium-analytics/icons'; import { ComparativeLineChart, @@ -19,7 +18,7 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import styles from './style.module.css'; -import usePostViews, { type PostViewsPoint } from './use-post-views'; +import usePostViews from './use-post-views'; import type { PostViewsAttributes, PostViewsGranularity } from './widget'; import type { WidgetRenderProps } from '@wordpress/widget-primitives'; @@ -31,20 +30,6 @@ const DATA_FORMAT = { options: { useMultipliers: true, decimals: 0 }, }; -/** - * A series' legend label as its date range (first to last point), consistent - * with the other comparative charts — used only when a comparison overlay - * makes the plain "Views" label ambiguous. - * - * @param points - The series points, oldest first. - * @return The formatted date range, or '' when empty. - */ -function rangeLabel( points: PostViewsPoint[] ): string { - const first = points[ 0 ]; - const last = points[ points.length - 1 ]; - return first && last ? formatDateRange( { from: first.date, to: last.date } ) : ''; -} - type PostViewsInnerProps = { /** The granularity attribute: the chart's bucket size. */ granularity: PostViewsGranularity; @@ -63,43 +48,27 @@ function PostViewsInner( { granularity }: PostViewsInnerProps ) { const { reportParams } = useWidgetRootContext(); const postId = toPostId( reportParams.post_id ); - const { current, previous, isLoading, isFetching, isError, hasData, refetch } = usePostViews( + const { current, isLoading, isFetching, isError, hasData, refetch } = usePostViews( postId, reportParams, granularity ); + // The post detail page has no comparison control, so the chart always + // draws the single "Views" series. const series = useMemo< ComparativeLineChartSeries[] >( () => { if ( ! current.length ) { return []; } - if ( ! previous?.length ) { - return [ - { - label: __( 'Views', 'jetpack-premium-analytics-pkg' ), - group: 'views', - data: current, - }, - ]; - } - - // With a comparison overlay both series are labelled by date range, so - // the legend distinguishes the periods; the previous period draws as a - // same-colour dashed line with no fill. return [ - { label: rangeLabel( current ), group: 'views', data: current }, { - label: rangeLabel( previous ), + label: __( 'Views', 'jetpack-premium-analytics-pkg' ), group: 'views', - data: previous, - options: { - type: 'comparison', - gradient: { from: 'transparent', to: 'transparent', fromOpacity: 0, toOpacity: 0 }, - }, + data: current, }, ]; - }, [ current, previous ] ); + }, [ current ] ); const seriesStyles = useSeriesStyles( series ); return ( @@ -137,10 +106,10 @@ function PostViewsInner( { granularity }: PostViewsInnerProps ) { /** * Post views widget: the scoped post's view trend over the dashboard date - * range as a comparative line chart — the legacy Calypso post summary chart + * range as a line chart — the legacy Calypso post summary chart * (`stats-post-summary`). The view series comes from `stats/post`'s full * daily history, zero-filled and bucketed client-side per the granularity - * attribute, with the comparison window sliced from the same request. + * attribute. * * @param {PostViewsWidgetProps} props - The widget render props. * @return The rendered widget. diff --git a/projects/packages/premium-analytics/widgets/post-views/stories/post-views-widget.stories.tsx b/projects/packages/premium-analytics/widgets/post-views/stories/post-views-widget.stories.tsx index 3b21d2ba0ffe..096e5481b2bd 100644 --- a/projects/packages/premium-analytics/widgets/post-views/stories/post-views-widget.stories.tsx +++ b/projects/packages/premium-analytics/widgets/post-views/stories/post-views-widget.stories.tsx @@ -1,14 +1,17 @@ /** * The Post views widget is the post detail Traffic view's view-trend card: - * the scoped post's views over the dashboard date range as a comparative - * line chart. The post scope arrives through `reportParams.post_id` (seeded - * from the detail page URL in product); the `hasPostScope` control toggles it - * to exercise the scopeless empty state. + * the scoped post's views over the dashboard date range as a line chart. The + * post scope arrives through `reportParams.post_id` (seeded from the detail + * page URL in product); the `hasPostScope` control toggles it to exercise the + * scopeless empty state. * * Data comes from the proxied `stats/post/{id}` endpoint, covered by the * shared report mocks' `stats-post` fixture (a deterministic daily series - * ending today, so relative date presets always intersect it). The comparison - * window is sliced client-side from the same request. + * ending today, so relative date presets always intersect it). The post + * detail design has no period-over-period comparison, so the widget maps no + * comparison rows; the dashboard story still passes comparison params so the + * widget stays covered against crashing or inventing an overlay when a host + * supplies them. */ /** * External dependencies @@ -42,7 +45,6 @@ const MOCK_POST_ID = 779; const POST_VIEWS_RENDER_MODULE = 'storybook/post-views'; interface PostViewsStoryControls { - withComparison: boolean; hasPostScope: boolean; granularity: PostViewsGranularity; } @@ -50,16 +52,17 @@ interface PostViewsStoryControls { /** * Builds the widget attributes: the granularity attribute plus report params * with the post scope the detail page seeds from its URL when `hasPostScope` - * is on. + * is on. Comparison stays a parameter so the dashboard story can pass host + * comparison params without duplicating the scoping rule. * - * @param {PostViewsStoryControls} controls - The story controls. + * @param {PostViewsStoryControls} controls - The story controls. + * @param {boolean} withComparison - Include previous-period comparison report params. * @return The widget attributes. */ -function getPostViewsAttributes( { - withComparison, - hasPostScope, - granularity, -}: PostViewsStoryControls ): ComponentProps< typeof PostViewsRender >[ 'attributes' ] { +function getPostViewsAttributes( + { hasPostScope, granularity }: PostViewsStoryControls, + withComparison = false +): ComponentProps< typeof PostViewsRender >[ 'attributes' ] { return { granularity, reportParams: { @@ -84,10 +87,6 @@ const meta = { component: PostViewsRender, tags: [ 'autodocs' ], argTypes: { - withComparison: { - control: 'boolean', - description: 'Include previous-period comparison report params.', - }, hasPostScope: { control: 'boolean', description: 'Include the `post_id` report param the post detail page seeds from its URL.', @@ -102,7 +101,7 @@ const meta = { docs: { description: { component: - 'The "Post views" widget: the scoped post\'s view trend over the dashboard date range as a comparative line chart — the legacy Calypso post summary chart. The view series comes from `stats/post`\'s full daily history, zero-filled and bucketed client-side per the host-rendered "Group by" control, with the comparison window sliced from the same request. Without a post scope the widget renders a scopeless empty state.', + 'The "Post views" widget: the scoped post\'s view trend over the dashboard date range as a line chart — the legacy Calypso post summary chart. The view series comes from `stats/post`\'s full daily history, zero-filled and bucketed client-side per the host-rendered "Group by" control. The post detail page has no comparison control, so comparison report params are ignored. Without a post scope the widget renders a scopeless empty state.', }, }, }, @@ -113,23 +112,12 @@ export default meta; type Story = StoryObj< PostViewsStoryControls >; /** - * Default — the scoped post's views for the primary period only: a single - * "Views" line with no overlay. + * Default — the scoped post's views for the selected period: a single + * "Views" line. */ export const Default: Story = { render: renderPostViews, - args: { withComparison: false, hasPostScope: true, granularity: 'day' }, - decorators: [ withWidgetCanvas ], -}; - -/** - * WithComparison — the previous-period comparison from the date range picker; - * the chart adds a dashed previous-period overlay and the legend switches to - * date-range labels. - */ -export const WithComparison: Story = { - render: renderPostViews, - args: { withComparison: true, hasPostScope: true, granularity: 'day' }, + args: { hasPostScope: true, granularity: 'day' }, decorators: [ withWidgetCanvas ], }; @@ -140,7 +128,7 @@ export const WithComparison: Story = { */ export const NoPostScope: Story = { render: renderPostViews, - args: { withComparison: false, hasPostScope: false, granularity: 'day' }, + args: { hasPostScope: false, granularity: 'day' }, decorators: [ withWidgetCanvas ], }; @@ -151,13 +139,14 @@ interface PostViewsDashboardStoryProps /** * Mounts the real `WidgetDashboard` with this single widget so it renders * exactly as it does in product (framed card, host "Group by" toolbar - * control, sizing, edit mode). + * control, sizing, edit mode). It passes comparison params unconditionally, + * so the widget stays covered against crashing or inventing an overlay when + * a host supplies comparison dates. * * @param {PostViewsDashboardStoryProps} props - The dashboard story controls. * @return The widget mounted inside the real dashboard. */ function PostViewsDashboardStory( { - withComparison, hasPostScope, granularity, ...dashboardArgs @@ -168,7 +157,7 @@ function PostViewsDashboardStory( { widgetType={ createStoryWidgetType( widgetManifest, widgetDefinition ) } renderModule={ POST_VIEWS_RENDER_MODULE } renderComponent={ PostViewsRender as ComponentType< WidgetRenderProps< unknown > > } - attributes={ getPostViewsAttributes( { withComparison, hasPostScope, granularity } ) } + attributes={ getPostViewsAttributes( { hasPostScope, granularity }, true ) } /> ); } @@ -179,16 +168,11 @@ export const WidgetDashboardWithWidget: StoryObj< PostViewsDashboardStoryProps > ...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS, widgetWidth: 2, widgetHeight: 2, - withComparison: true, hasPostScope: true, granularity: 'day', }, argTypes: { ...widgetDashboardWithWidgetArgTypes, - withComparison: { - control: 'boolean', - description: 'Include previous-period comparison report params.', - }, hasPostScope: { control: 'boolean', description: 'Include the `post_id` report param the post detail page seeds from its URL.', diff --git a/projects/packages/premium-analytics/widgets/post-views/style.module.css b/projects/packages/premium-analytics/widgets/post-views/style.module.css index 92f9a6746349..9ec53ce77712 100644 --- a/projects/packages/premium-analytics/widgets/post-views/style.module.css +++ b/projects/packages/premium-analytics/widgets/post-views/style.module.css @@ -4,7 +4,7 @@ position: relative; display: flex; flex-direction: column; - height: 100%; + block-size: 100%; min-block-size: 0; overflow: hidden; } diff --git a/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts b/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts index 0b7255509a9a..f1cdeb8e001a 100644 --- a/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts +++ b/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts @@ -2,15 +2,14 @@ * External dependencies */ import { - localTZDate, useStatsPost, type ReportParams, type StatsPostDay, } from '@jetpack-premium-analytics/data'; +import { parseSiteDateTime } from '@jetpack-premium-analytics/datetime'; import { useMemo } from '@wordpress/element'; import { addDays, - differenceInCalendarDays, eachDayOfInterval, eachMonthOfInterval, eachWeekOfInterval, @@ -31,13 +30,11 @@ export type PostViewsPoint = { }; /** - * Normalized Post views state: the primary (and optional comparison) series - * plus the request's load/error flags. `hasData` distinguishes the first load - * from refetches. + * Normalized Post views state: the view series plus the request's load/error + * flags. `hasData` distinguishes the first load from refetches. */ export interface PostViewsState { current: PostViewsPoint[]; - previous?: PostViewsPoint[]; isLoading: boolean; isFetching: boolean; isError: boolean; @@ -103,24 +100,24 @@ function toDayWindow( from?: string, to?: string ): DayWindow | undefined { } /** - * Build the primary range's calendar buckets. Each bucket keeps the calendar - * label used by the primary chart while clipping its data bounds to the - * selected range. The clipped bounds can then be applied to the comparison - * range as relative offsets, which preserves the primary series' bucket count - * across calendar boundaries. + * Build the range's calendar buckets. Each bucket keeps the calendar label + * used by the chart while clipping its data bounds to the selected range. * - * @param window - The date-only window to keep. - * @param period - The bucket size. + * @param dayWindow - The date-only window to keep. + * @param period - The bucket size. * @return One bucket per calendar period, oldest first. */ -function calendarBucketWindows( window: DayWindow, period: PostViewsGranularity ): BucketWindow[] { +function calendarBucketWindows( + dayWindow: DayWindow, + period: PostViewsGranularity +): BucketWindow[] { // The URL is user-editable, so an inverted range must not reach // `eachDayOfInterval()` (it throws). - if ( window.from > window.to ) { + if ( dayWindow.from > dayWindow.to ) { return []; } - const interval = { start: parseISO( window.from ), end: parseISO( window.to ) }; + const interval = { start: parseISO( dayWindow.from ), end: parseISO( dayWindow.to ) }; let bucketStarts = eachDayOfInterval( interval ); if ( period === 'week' ) { bucketStarts = eachWeekOfInterval( interval, { weekStartsOn: 1 } ); @@ -131,68 +128,16 @@ function calendarBucketWindows( window: DayWindow, period: PostViewsGranularity return bucketStarts.map( ( start, index ) => { const date = format( start, 'yyyy-MM-dd' ); const nextDate = bucketStarts[ index + 1 ]; - const end = nextDate ? format( addDays( nextDate, -1 ), 'yyyy-MM-dd' ) : window.to; + const end = nextDate ? format( addDays( nextDate, -1 ), 'yyyy-MM-dd' ) : dayWindow.to; return { date, - from: date < window.from ? window.from : date, - to: end > window.to ? window.to : end, + from: date < dayWindow.from ? dayWindow.from : date, + to: end > dayWindow.to ? dayWindow.to : end, }; } ); } -/** - * Map primary bucket boundaries onto the comparison range. For example, a - * primary March 1–31 range has one monthly bucket; its equal-length January - * 29–February 28 comparison range must also have one bucket, even though it - * crosses two calendar months. - * - * Each comparison bucket starts at the same day offset from the comparison - * range's start as its primary bucket does from the primary start, so the - * bucket count always matches. The buckets fully partition the comparison - * range: every bucket's end is the next bucket's start minus a day, and the - * last bucket extends to `comparisonWindow.to`. That keeps the comparison a - * complete, non-overlapping cover of the selected range — a longer previous - * period (previous-month onto a shorter month) folds its tail into the last - * bucket instead of being truncated, and every bound is clamped to - * `comparisonWindow.to` so a shorter one never reaches past the selection. - * - * @param primaryWindow - The selected primary range. - * @param comparisonWindow - The previous-period range. - * @param buckets - Calendar buckets clipped to the primary range. - * @return Comparison buckets with the primary range's relative boundaries. - */ -function relativeBucketWindows( - primaryWindow: DayWindow, - comparisonWindow: DayWindow, - buckets: BucketWindow[] -): BucketWindow[] { - const primaryStart = parseISO( primaryWindow.from ); - const comparisonStart = parseISO( comparisonWindow.from ); - - const froms = buckets.map( bucket => - format( - addDays( comparisonStart, differenceInCalendarDays( parseISO( bucket.from ), primaryStart ) ), - 'yyyy-MM-dd' - ) - ); - - return froms.map( ( from, index ) => { - // Each bucket runs up to the next bucket's start; the last one absorbs - // any remaining comparison days. Clamp the end to the selected window so - // a longer primary offset can't pull in out-of-range days. `from` is left - // unclamped so a shorter comparison keeps distinct (empty) trailing - // buckets rather than collapsing several onto the same key. - const rawTo = - index < froms.length - 1 - ? format( addDays( parseISO( froms[ index + 1 ] ), -1 ), 'yyyy-MM-dd' ) - : comparisonWindow.to; - const to = rawTo > comparisonWindow.to ? comparisonWindow.to : rawTo; - - return { date: from, from, to }; - } ); -} - /** * Sum the post's daily view history into zero-filled buckets. The endpoint * may omit zero-view days and the history only starts at publication, but @@ -216,20 +161,31 @@ function bucketDays( days: StatsPostDay[], buckets: BucketWindow[] ): PostViewsP } } - return buckets.map( bucket => ( { - date: localTZDate( bucket.date ), - value: totals.get( bucket.date ) ?? 0, - } ) ); + // The endpoint's day keys are plain site-local calendar dates, so each + // point's instant must be that day's site-local midnight. `parseSiteDateTime` + // anchors the offset-less key in the site timezone; the chart's `formatDate` + // labels render in the same zone, so the calendar day round-trips without a + // TZ-induced day shift (a date-only string fed to `localTZDate` would parse + // as UTC midnight and read as the previous day on negative-offset sites). + // `bucket.date` comes from `format( start, 'yyyy-MM-dd' )`, so the parse + // cannot fail in practice; if it ever does, drop the point rather than + // fall back to a browser-local instant that reintroduces the day shift. + return buckets.flatMap( bucket => { + const date = parseSiteDateTime( bucket.date ); + return date ? [ { date, value: totals.get( bucket.date ) ?? 0 } ] : []; + } ); } /** * Fetch the scoped post's view trend for the dashboard's report params. One - * `stats/post` request carries the full daily view history; the primary and - * comparison windows are sliced from it client-side, so comparison needs no - * second request. + * `stats/post` request carries the full daily view history; the selected + * window is sliced from it client-side. The post detail design has no + * period-over-period comparison, so comparison report params are ignored — + * they ride along in the URL untouched so dashboard state survives the round + * trip, and every widget on this page disregards them. * * @param postId - The scoped post ID (0 disables the request). - * @param reportParams - The dashboard date range + comparison state. + * @param reportParams - The dashboard date range. * @param period - The selected bucket granularity (day/week/month). * @return The view series and load/error state. */ @@ -243,41 +199,16 @@ export default function usePostViews( fields: [ 'data' ], } ); - const { current, previous } = useMemo( () => { + const current = useMemo( () => { const days = data?.data ?? []; - const window = toDayWindow( reportParams.from, reportParams.to ); - const compareWindow = toDayWindow( reportParams.compare_from, reportParams.compare_to ); - const buckets = window ? calendarBucketWindows( window, period ) : []; - const currentPoints = bucketDays( days, buckets ); - let comparisonBuckets: BucketWindow[] | undefined; - if ( window && compareWindow ) { - // Day grouping must remain one point per actual calendar day. Relative - // bucketing is only needed for coarser periods, where matching the - // primary layout prevents partial week/month boundaries from scrunching - // the comparison overlay. - comparisonBuckets = - period === 'day' - ? calendarBucketWindows( compareWindow, period ) - : relativeBucketWindows( window, compareWindow, buckets ); - } - const previousPoints = comparisonBuckets ? bucketDays( days, comparisonBuckets ) : undefined; + const dayWindow = toDayWindow( reportParams.from, reportParams.to ); + const buckets = dayWindow ? calendarBucketWindows( dayWindow, period ) : []; - return { - current: currentPoints, - previous: previousPoints?.length ? previousPoints : undefined, - }; - }, [ - data, - period, - reportParams.from, - reportParams.to, - reportParams.compare_from, - reportParams.compare_to, - ] ); + return bucketDays( days, buckets ); + }, [ data, period, reportParams.from, reportParams.to ] ); return { current, - previous, isLoading, isFetching, isError, diff --git a/projects/packages/premium-analytics/widgets/post-views/widget.ts b/projects/packages/premium-analytics/widgets/post-views/widget.ts index 4ff2a7a9d8ab..77a12b60d900 100644 --- a/projects/packages/premium-analytics/widgets/post-views/widget.ts +++ b/projects/packages/premium-analytics/widgets/post-views/widget.ts @@ -31,7 +31,7 @@ export type PostViewsAttributes = { * * The post detail Traffic view's view-trend card, the legacy Calypso post * summary chart (`stats-post-summary`): the scoped post's views over the - * dashboard date range as a comparative line chart. The series comes from + * dashboard date range as a line chart. The series comes from * the `stats/post/{id}` daily history, bucketed client-side; the * `granularity` attribute (`relevance: 'high'`) chooses the bucket size. */