From 9ee2ef7c6414dc1b7d49f626f48c6b3f13e0e5e8 Mon Sep 17 00:00:00 2001 From: dognose24 Date: Tue, 4 Aug 2026 02:51:09 +0800 Subject: [PATCH 1/8] =?UTF-8?q?Premium=20Analytics:=20post=20detail=20pari?= =?UTF-8?q?ty=20=E2=80=94=20overflow=20fixes,=20timezone,=20no=20compariso?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies to the post/email detail page the fixes proven on the video detail page: the breadcrumb/summary overflow bugs (contain: inline-size plus the breadcrumbs-slot shrink shim), aspect-ratio: 1 so the boot shell's img reset cannot squash the featured image, post-views bucket keys parsed as site-local calendar dates (parseSiteDateTime, UTC-12 regression test), and block-size nits. The page's design has no period-over-period comparison: the post-views and email-time-series widgets drop their comparison series, the route normalizes comparison params away, and DateFiltersPanel gains a minimal showComparison prop so the Compare control hides at the existing fixed-bar call site. Moving the panel onto the summary's title row (per the mock) is deferred until the preset measurement rework lands (WOOA7S-1816) — it would collide head-on, the same reason the video-detail PR dropped its filters row. Rebased onto trunk accordingly: nothing here depends on the video-detail branch anymore. Co-Authored-By: Claude Fable 5 --- .../changelog/update-post-detail-parity | 4 + .../date-filters-panel/date-filters-panel.tsx | 42 ++-- .../post-summary-card.module.scss | 5 + .../routes/post-detail/package.json | 2 + .../routes/post-detail/route.ts | 17 +- .../routes/post-detail/stage.module.scss | 23 ++ .../routes/post-detail/stage.test.tsx | 12 +- .../routes/post-detail/stage.tsx | 13 +- .../__tests__/email-time-series.test.tsx | 209 +--------------- .../widgets/email-time-series/render.tsx | 113 +-------- .../email-time-series-widget.stories.tsx | 83 ++++--- .../post-views/__tests__/post-views.test.tsx | 226 +++--------------- .../widgets/post-views/package.json | 2 +- .../widgets/post-views/render.tsx | 49 +--- .../stories/post-views-widget.stories.tsx | 68 ++---- .../widgets/post-views/style.module.css | 2 +- .../widgets/post-views/use-post-views.ts | 139 +++-------- .../widgets/post-views/widget.ts | 2 +- 18 files changed, 270 insertions(+), 741 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/update-post-detail-parity 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/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 8cb71fe7efd8..477b223677c3 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 @@ -107,6 +107,14 @@ export type DateFiltersPanelProps = { * instead of its own wrapper to determine compact/wide layouts. */ containerElement?: HTMLElement | null; + + /** + * Whether to render the period-over-period Compare control. Pages whose + * design has no comparison (the post/email detail page) opt out; their + * routes also normalize comparison params away, so hiding the control keeps + * the UI honest about it. + */ + showComparison?: boolean; }; /** @@ -137,6 +145,7 @@ export function DateFiltersPanel( { canApply = true, timeZone, containerElement, + showComparison = true, }: DateFiltersPanelProps ) { /** * Validate and normalize the primary preset ID. @@ -253,20 +262,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/package.json b/projects/packages/premium-analytics/routes/post-detail/package.json index 2912b1867d97..96564e548895 100644 --- a/projects/packages/premium-analytics/routes/post-detail/package.json +++ b/projects/packages/premium-analytics/routes/post-detail/package.json @@ -13,6 +13,7 @@ "@jetpack-premium-analytics/ui": "workspace:*", "@jetpack-premium-analytics/widgets-toolkit": "workspace:*", "@wordpress/admin-ui": "2.5.0", + "@wordpress/compose": "8.4.0", "@wordpress/core-data": "7.51.0", "@wordpress/data": "10.51.0", "@wordpress/element": "8.3.0", @@ -23,6 +24,7 @@ "@wordpress/ui": "0.17.0", "@wordpress/widget-dashboard": "0.2.0", "@wordpress/widget-primitives": "0.2.0", + "clsx": "2.1.1", "date-fns": "4.1.0", "fast-deep-equal": "^3.1.3", "react": "18.3.1" diff --git a/projects/packages/premium-analytics/routes/post-detail/route.ts b/projects/packages/premium-analytics/routes/post-detail/route.ts index 49ff0b7e720d..9dbd5dc78b5c 100644 --- a/projects/packages/premium-analytics/routes/post-detail/route.ts +++ b/projects/packages/premium-analytics/routes/post-detail/route.ts @@ -23,6 +23,11 @@ import { resolveTabId } from './config'; type PostDetailParams = { postId?: string }; type PostDetailSearch = Record< string, string | undefined >; +// The post detail design has no period-over-period comparison, so these +// params are normalized out of the URL — whether hand-edited in, carried over +// from another report, or added by the default date seed. +const COMPARISON_SEARCH_PARAMS = [ 'comp', 'compare_from', 'compare_to', 'compare_preset' ]; + /** * Whether a raw path param is a valid single-post scope (a positive integer). * @@ -77,8 +82,11 @@ export const route = { const needsDateSeed = needsReportDateParamsSeed( currentSearch ); const needsPostSeed = currentSearch.post_id !== postId; const needsSectionSeed = !! currentSearch.section && resolvedSection !== currentSearch.section; + const hasComparisonParams = COMPARISON_SEARCH_PARAMS.some( + param => currentSearch[ param ] !== undefined + ); - if ( needsDateSeed || needsPostSeed || needsSectionSeed ) { + if ( needsDateSeed || needsPostSeed || needsSectionSeed || hasComparisonParams ) { /* * Seed dates in the site timezone, not the browser's, by waiting for * core `site` settings. A rejection here shouldn't error the whole @@ -103,6 +111,13 @@ export const route = { post_id: postId, }; + // `normalizeReportParams` carries incoming comparison params through + // (and adds the default comparison on a fresh load); this page has no + // comparison, so drop them before they reach the URL and the widgets. + for ( const param of COMPARISON_SEARCH_PARAMS ) { + delete seeded[ param ]; + } + 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..e7c4e6921856 100644 --- a/projects/packages/premium-analytics/routes/post-detail/stage.module.scss +++ b/projects/packages/premium-analytics/routes/post-detail/stage.module.scss @@ -2,6 +2,21 @@ min-block-size: 0; } +// A long unbroken post title in the breadcrumb refuses to shrink: admin-ui's +// Page header renders the breadcrumbs slot inside a flex chain (inner header +// Stack → the Breadcrumbs `nav`) whose items keep the flexbox default +// `min-inline-size: auto`, so the crumb's nowrap min-content propagates and +// drags the whole page into horizontal scrolling before the crumb's own +// ellipsis can engage. Let both links shrink until admin-ui fixes the slot; +// the `nav`'s parent has no stable class, hence the structural `:has()`. +.page :has(> nav[aria-label]) { + min-inline-size: 0; +} + +.page nav[aria-label] { + min-inline-size: 0; +} + // The scroll container below the fixed tab bar and date filters: the summary // header scrolls away with the widgets instead of permanently occupying the // viewport. @@ -41,6 +56,14 @@ // 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); + // 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; } // 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 aae5d0d13fe3..a190fb3aa302 100644 --- a/projects/packages/premium-analytics/routes/post-detail/stage.test.tsx +++ b/projects/packages/premium-analytics/routes/post-detail/stage.test.tsx @@ -14,7 +14,9 @@ jest.mock( '@jetpack-premium-analytics/routing', () => ( { } ) ); 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 }
, // The real guard is covered in the ui package; keep the scheme check here so // the header still refuses a non-http URL. @@ -150,6 +152,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( 'renders the breadcrumb trail with the resolved title', () => { mockSummary(); diff --git a/projects/packages/premium-analytics/routes/post-detail/stage.tsx b/projects/packages/premium-analytics/routes/post-detail/stage.tsx index f86ff02ea9cd..3071573e1e2c 100644 --- a/projects/packages/premium-analytics/routes/post-detail/stage.tsx +++ b/projects/packages/premium-analytics/routes/post-detail/stage.tsx @@ -155,7 +155,18 @@ function PostDetail(): JSX.Element { * layouts instead of relying on the viewport. */ }
- + { /* + * The design has no period-over-period comparison on this + * page, so the Compare control is opted out; the route also + * normalizes comparison params away. Moving the panel onto + * the summary's title row (per the mock) is deferred until + * the preset measurement rework lands (WOOA7S-1816). + */ } +
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..9460b9c7712d 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', + // The post detail route normalizes comparison params away, but + // a widget receiving them anyway must neither fetch a second + // window nor draw an overlay — the page has 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 +132,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..c65ac4e9c532 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,11 @@ 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 (the route normalizes + * them out of the URL). * * @param {EmailTimeSeriesReportProps} props - The component props. * @return The widget content. @@ -81,60 +78,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 +113,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 +130,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..895044ab6c84 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,9 @@ describe( 'PostViewsWidget', () => { attributes={ { reportParams: { ...WINDOW_PARAMS, - // `comp: '1'` switches the comparison on; without it the - // param normalizer drops the compare window. + // The post detail route normalizes comparison params away, but + // a widget receiving them anyway must neither draw an overlay + // nor change the primary series — the page has 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 +137,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 e4d15e138f78..d6f019544f41 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..ae69564d304d 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,27 @@ function bucketDays( days: StatsPostDay[], buckets: BucketWindow[] ): PostViewsP } } + // 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). return buckets.map( bucket => ( { - date: localTZDate( bucket.date ), + date: parseSiteDateTime( bucket.date ) ?? parseISO( bucket.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 (the + * route normalizes them out of the URL). * * @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 +195,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. */ From 38a00f1a7c77403ef310ccff77c5fa4699e71492 Mon Sep 17 00:00:00 2001 From: dognose24 Date: Tue, 4 Aug 2026 15:22:13 +0800 Subject: [PATCH 2/8] Pass comparison params through the post-detail route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the video-detail review outcome (#50970): the page renders no comparison and its widgets ignore the params, but the breadcrumb's dashboard link reads the URL state back out, so stripping them lost the user's comparison settings on a Dashboard → Post → Dashboard round trip. Co-Authored-By: Claude Fable 5 --- .../date-filters-panel/date-filters-panel.tsx | 4 ++-- .../routes/post-detail/route.ts | 23 +++++++------------ .../routes/post-detail/stage.tsx | 10 ++++---- 3 files changed, 16 insertions(+), 21 deletions(-) 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 477b223677c3..2f7674a85795 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,8 +111,8 @@ export type DateFiltersPanelProps = { /** * Whether to render the period-over-period Compare control. Pages whose * design has no comparison (the post/email detail page) opt out; their - * routes also normalize comparison params away, so hiding the control keeps - * the UI honest about it. + * widgets ignore comparison params, and hiding the control keeps the UI + * honest about it. */ showComparison?: boolean; }; diff --git a/projects/packages/premium-analytics/routes/post-detail/route.ts b/projects/packages/premium-analytics/routes/post-detail/route.ts index 9dbd5dc78b5c..a338d75ff244 100644 --- a/projects/packages/premium-analytics/routes/post-detail/route.ts +++ b/projects/packages/premium-analytics/routes/post-detail/route.ts @@ -23,11 +23,6 @@ import { resolveTabId } from './config'; type PostDetailParams = { postId?: string }; type PostDetailSearch = Record< string, string | undefined >; -// The post detail design has no period-over-period comparison, so these -// params are normalized out of the URL — whether hand-edited in, carried over -// from another report, or added by the default date seed. -const COMPARISON_SEARCH_PARAMS = [ 'comp', 'compare_from', 'compare_to', 'compare_preset' ]; - /** * Whether a raw path param is a valid single-post scope (a positive integer). * @@ -82,11 +77,8 @@ export const route = { const needsDateSeed = needsReportDateParamsSeed( currentSearch ); const needsPostSeed = currentSearch.post_id !== postId; const needsSectionSeed = !! currentSearch.section && resolvedSection !== currentSearch.section; - const hasComparisonParams = COMPARISON_SEARCH_PARAMS.some( - param => currentSearch[ param ] !== undefined - ); - if ( needsDateSeed || needsPostSeed || needsSectionSeed || hasComparisonParams ) { + if ( needsDateSeed || needsPostSeed || needsSectionSeed ) { /* * Seed dates in the site timezone, not the browser's, by waiting for * core `site` settings. A rejection here shouldn't error the whole @@ -111,12 +103,13 @@ export const route = { post_id: postId, }; - // `normalizeReportParams` carries incoming comparison params through - // (and adds the default comparison on a fresh load); this page has no - // comparison, so drop them before they reach the URL and the widgets. - for ( const param of COMPARISON_SEARCH_PARAMS ) { - delete seeded[ param ]; - } + /* + * 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.tsx b/projects/packages/premium-analytics/routes/post-detail/stage.tsx index 3071573e1e2c..3b62fa7147b4 100644 --- a/projects/packages/premium-analytics/routes/post-detail/stage.tsx +++ b/projects/packages/premium-analytics/routes/post-detail/stage.tsx @@ -157,10 +157,12 @@ function PostDetail(): JSX.Element {
{ /* * The design has no period-over-period comparison on this - * page, so the Compare control is opted out; the route also - * normalizes comparison params away. Moving the panel onto - * the summary's title row (per the mock) is deferred until - * the preset measurement rework lands (WOOA7S-1816). + * page, so the Compare control is opted out; comparison + * params stay in the URL (the widgets ignore them) so the + * breadcrumb carries them back to the dashboard. Moving the + * panel onto the summary's title row (per the mock) is + * deferred until the preset measurement rework lands + * (WOOA7S-1816). */ } Date: Tue, 4 Aug 2026 22:35:39 +0800 Subject: [PATCH 3/8] Premium Analytics: align comparison pass-through comments with the route contract --- .../email-time-series/__tests__/email-time-series.test.tsx | 7 ++++--- .../premium-analytics/widgets/email-time-series/render.tsx | 5 +++-- .../widgets/post-views/__tests__/post-views.test.tsx | 7 ++++--- .../premium-analytics/widgets/post-views/use-post-views.ts | 5 +++-- 4 files changed, 14 insertions(+), 10 deletions(-) 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 9460b9c7712d..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 @@ -118,9 +118,10 @@ describe( 'EmailTimeSeriesWidget', () => { preset: undefined, from: '2026-07-01T00:00:00.000+08:00', to: '2026-07-07T23:59:59.999+08:00', - // The post detail route normalizes comparison params away, but - // a widget receiving them anyway must neither fetch a second - // window nor draw an overlay — the page has no comparison. + // 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', 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 c65ac4e9c532..8ebb8a9622ec 100644 --- a/projects/packages/premium-analytics/widgets/email-time-series/render.tsx +++ b/projects/packages/premium-analytics/widgets/email-time-series/render.tsx @@ -68,8 +68,9 @@ type EmailTimeSeriesReportProps = { * 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 (the route normalizes - * them out of the URL). + * 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. 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 895044ab6c84..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 @@ -125,9 +125,10 @@ describe( 'PostViewsWidget', () => { attributes={ { reportParams: { ...WINDOW_PARAMS, - // The post detail route normalizes comparison params away, but - // a widget receiving them anyway must neither draw an overlay - // nor change the primary series — the page has no comparison. + // 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', 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 ae69564d304d..0618c96d0524 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 @@ -177,8 +177,9 @@ function bucketDays( days: StatsPostDay[], buckets: BucketWindow[] ): PostViewsP * Fetch the scoped post's view trend for the dashboard's report params. One * `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 (the - * route normalizes them out of the URL). + * 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. From 10fc2e2aef31984551f89381e8c12b365aa6905b Mon Sep 17 00:00:00 2001 From: dognose24 Date: Thu, 6 Aug 2026 13:04:06 +0800 Subject: [PATCH 4/8] Strip comparison params from injected widget reportParams and drop the parseISO fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the page-wide no-comparison invariant now holds by construction — usePostDetailTabs injects comparison-stripped reportParams (via the new omitComparisonReportParams helper) into every layout entry, so comparison-capable widgets like UTM insights and highlights can no longer render deltas from URL state. The URL keeps the comparison params for the breadcrumb round trip. Also remove the parseISO fallback in use-post-views: bucket dates come from format(start, 'yyyy-MM-dd') so parseSiteDateTime cannot fail, and the fallback would silently reintroduce the browser-local day shift; an unparseable bucket now drops the point instead. Co-Authored-By: Claude Fable 5 --- .../packages/routing/src/index.ts | 1 + .../routing/src/search/report-params/index.ts | 1 + .../report-params/report-params.test.ts | 48 +++++++++++++++++- .../src/search/report-params/report-params.ts | 33 ++++++++++++ .../hooks/use-post-detail-tabs.test.tsx | 50 ++++++++++++++++++- .../post-detail/hooks/use-post-detail-tabs.ts | 25 +++++++++- .../widgets/post-views/use-post-views.ts | 11 ++-- 7 files changed, 162 insertions(+), 7 deletions(-) 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/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/widgets/post-views/use-post-views.ts b/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts index 0618c96d0524..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 @@ -167,10 +167,13 @@ function bucketDays( days: StatsPostDay[], buckets: BucketWindow[] ): PostViewsP // 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). - return buckets.map( bucket => ( { - date: parseSiteDateTime( bucket.date ) ?? parseISO( bucket.date ), - value: totals.get( bucket.date ) ?? 0, - } ) ); + // `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 } ] : []; + } ); } /** From 72de229375e7705973009e1a3411aee736818315 Mon Sep 17 00:00:00 2001 From: dognose24 Date: Thu, 6 Aug 2026 13:51:03 +0800 Subject: [PATCH 5/8] Record the crypto-js deprecation in pnpm-lock.yaml The npm registry marked crypto-js@4.2.0 deprecated after this lock was written, so the lock check's fresh resolution now expects the deprecation note. Registry-metadata drift only; no dependency change. Co-Authored-By: Claude Fable 5 --- pnpm-lock.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9862d22ff06..9600f828115b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12904,6 +12904,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-declaration-sorter@7.4.0: resolution: {integrity: sha512-LTuzjPoyA2vMGKKcaOqKSp7Ub2eGrNfKiZH4LpezxpNrsICGCSFvsQOI29psISxNZtaXibkC2CXzrQ5enMeGGw==} From b67f72b2dc3f7a4aef7dab79d6825911c6ce03c7 Mon Sep 17 00:00:00 2001 From: dognose24 Date: Fri, 7 Aug 2026 03:18:38 +0800 Subject: [PATCH 6/8] Put the date filter presets on the summary title row per the mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One header row per the design mocks: title left, presets right, vertically centered. The summary grows into free space and absorbs the squeeze down to a 400px floor (title ellipsis); its inline-size containment keeps a long title from wrapping the row. Known rough edge, deferred to #51088: the panel self-measures its root, which in this shrink-to-fit slot always sees its own content width — so the presets keep their full layout and narrow rows degrade poorly. The external-measurement wiring (containerElement / reservedInlineSize) ships there to keep this PR free of shared-component changes beyond the already-reviewed showComparison prop. Co-Authored-By: Claude Fable 5 --- .../routes/post-detail/stage.module.scss | 49 +++++++++++++----- .../routes/post-detail/stage.tsx | 51 +++++++++++-------- 2 files changed, 65 insertions(+), 35 deletions(-) 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 e7c4e6921856..222f5bb2bc90 100644 --- a/projects/packages/premium-analytics/routes/post-detail/stage.module.scss +++ b/projects/packages/premium-analytics/routes/post-detail/stage.module.scss @@ -41,20 +41,23 @@ --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); - padding-inline: var(--wpds-dimension-padding-2xl); -} - .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); + // 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 — @@ -66,6 +69,26 @@ contain: inline-size; } +.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 // href to the live post. @wordpress/ui styles the button inside `@layer wp-ui`, // and wp-admin colours bare anchors outside any cascade layer — unlayered wins diff --git a/projects/packages/premium-analytics/routes/post-detail/stage.tsx b/projects/packages/premium-analytics/routes/post-detail/stage.tsx index 6f54086bfc52..de35f7b407ad 100644 --- a/projects/packages/premium-analytics/routes/post-detail/stage.tsx +++ b/projects/packages/premium-analytics/routes/post-detail/stage.tsx @@ -138,31 +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 design has no period-over-period comparison on this - * page, so the Compare control is opted out; comparison - * params stay in the URL (the widgets ignore them) so the - * breadcrumb carries them back to the dashboard. Moving the - * panel onto the summary's title row (per the mock) ships - * separately (WOOA7S-1816). + * 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 => ( From 3e9bc4f8abd07c90dad722b1893e501d36f847f2 Mon Sep 17 00:00:00 2001 From: dognose24 Date: Fri, 7 Aug 2026 03:50:17 +0800 Subject: [PATCH 7/8] Drop the route-level breadcrumb shim in favor of the component-owned fix Superseded by #51085: the ':has(> nav)' rule targets StatsBreadcrumbs' display:contents trail wrapper (no box for min-inline-size to act on) and the nav rule is covered by the component stylesheet. Tracked upstream as WordPress/gutenberg#81297. Co-Authored-By: Claude Fable 5 --- .../routes/post-detail/stage.module.scss | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) 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 222f5bb2bc90..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,22 +2,7 @@ min-block-size: 0; } -// A long unbroken post title in the breadcrumb refuses to shrink: admin-ui's -// Page header renders the breadcrumbs slot inside a flex chain (inner header -// Stack → the Breadcrumbs `nav`) whose items keep the flexbox default -// `min-inline-size: auto`, so the crumb's nowrap min-content propagates and -// drags the whole page into horizontal scrolling before the crumb's own -// ellipsis can engage. Let both links shrink until admin-ui fixes the slot; -// the `nav`'s parent has no stable class, hence the structural `:has()`. -.page :has(> nav[aria-label]) { - min-inline-size: 0; -} - -.page nav[aria-label] { - min-inline-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 { From 14e7faf409ddc293a8b3064f052e75ed0eb678cb Mon Sep 17 00:00:00 2001 From: dognose24 Date: Fri, 7 Aug 2026 13:50:59 +0800 Subject: [PATCH 8/8] =?UTF-8?q?ci:=20retrigger=20checks=20=E2=80=94=20work?= =?UTF-8?q?flow=20runs=20were=20never=20created=20for=20the=20previous=20p?= =?UTF-8?q?ush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit