point.value ).join( ',' ) }
- data-previous-values={ series[ 1 ]?.data.map( point => point.value ).join( ',' ) }
+ data-first-date={ series[ 0 ]?.data[ 0 ]?.date.toISOString() }
/>
),
} ) );
@@ -80,6 +81,29 @@ describe( 'PostViewsWidget', () => {
expect( requestedPath ).toContain( 'stats/post/779' );
} );
+ it( 'anchors bucket days at site-local midnight so negative-offset sites keep the calendar day', async () => {
+ // A UTC-12 site: a date-only bucket key parsed as UTC midnight would
+ // render as the previous day once formatted in the site timezone. The
+ // point instant must be the key's site-local midnight instead.
+ const defaultSettings = getSettings();
+ setSettings( {
+ ...defaultSettings,
+ timezone: { offset: -12, offsetFormatted: '-12', string: '', abbr: '' },
+ } );
+
+ try {
+ mockApiFetch.mockResolvedValue( STATS_POST_RESPONSE );
+
+ render(
);
+
+ const chart = await screen.findByTestId( 'comparative-line-chart' );
+ // 2026-07-01 site-local midnight at UTC-12 is 2026-07-01T12:00:00Z.
+ expect( chart ).toHaveAttribute( 'data-first-date', '2026-07-01T12:00:00.000Z' );
+ } finally {
+ setSettings( defaultSettings );
+ }
+ } );
+
it( 'buckets views into ISO weeks for the week granularity', async () => {
mockApiFetch.mockResolvedValue( STATS_POST_RESPONSE );
@@ -93,7 +117,7 @@ describe( 'PostViewsWidget', () => {
expect( chart ).toHaveAttribute( 'data-values', '12,0' );
} );
- it( 'slices the comparison overlay from the same request', async () => {
+ it( 'ignores comparison report params: one request, single series', async () => {
mockApiFetch.mockResolvedValue( STATS_POST_RESPONSE );
render(
@@ -101,8 +125,10 @@ describe( 'PostViewsWidget', () => {
attributes={ {
reportParams: {
...WINDOW_PARAMS,
- // `comp: '1'` switches the comparison on; without it the
- // param normalizer drops the compare window.
+ // Comparison params pass through the post detail URL untouched
+ // (dashboard state survives the round trip), so a widget
+ // receiving them must neither draw an overlay nor change the
+ // primary series — the page renders no comparison.
comp: '1',
compare_from: '2026-06-24T00:00:00.000+08:00',
compare_to: '2026-06-30T23:59:59.999+08:00',
@@ -112,198 +138,23 @@ describe( 'PostViewsWidget', () => {
);
const chart = await screen.findByTestId( 'comparative-line-chart' );
- expect( chart ).toHaveAttribute( 'data-series-count', '2' );
- // The comparison window catches the 6/25 day; both windows zero-fill to
- // the same bucket count so the index-aligned overlay can't scrunch.
- expect( chart ).toHaveAttribute( 'data-previous-values', '0,9,0,0,0,0,0' );
- // One request serves both windows.
+ expect( chart ).toHaveAttribute( 'data-series-count', '1' );
+ expect( chart ).toHaveAttribute( 'data-series-label', 'Views' );
+ expect( chart ).toHaveAttribute( 'data-values', '0,5,0,7,0,0,0' );
expect( mockApiFetch ).toHaveBeenCalledTimes( 1 );
} );
- it( 'uses primary month buckets for a previous period that crosses a month boundary', async () => {
- mockApiFetch.mockResolvedValue( {
- data: [
- [ '2026-01-29', 1 ],
- [ '2026-02-01', 2 ],
- [ '2026-02-28', 3 ],
- [ '2026-03-01', 4 ],
- [ '2026-03-31', 5 ],
- ],
- } );
-
- render(
-
- );
-
- const chart = await screen.findByTestId( 'comparative-line-chart' );
- expect( chart ).toHaveAttribute( 'data-values', '9' );
- // The previous period is one relative monthly bucket, not separate January
- // and February points that the comparative chart would collapse onto March.
- expect( chart ).toHaveAttribute( 'data-previous-values', '6' );
- } );
-
- it( 'clamps a shorter previous-month compare bucket to its own window', async () => {
- // Primary March (31 days) vs previous-month February (28 days), monthly.
- // The compare bucket must sum only February — a naive relative offset
- // would run three days past the compare window and pull March 2 in.
- mockApiFetch.mockResolvedValue( {
- data: [
- [ '2026-02-10', 5 ],
- [ '2026-02-20', 7 ],
- [ '2026-03-02', 50 ],
- [ '2026-03-10', 100 ],
- [ '2026-03-20', 200 ],
- ],
- } );
-
- render(
-
- );
-
- const chart = await screen.findByTestId( 'comparative-line-chart' );
- expect( chart ).toHaveAttribute( 'data-values', '350' );
- // Only February's 5 + 7; March 2's 50 stays out of the compare bucket.
- expect( chart ).toHaveAttribute( 'data-previous-values', '12' );
- } );
-
- it( 'keeps a longer previous-month compare window from truncating', async () => {
- // Primary February (28 days) vs previous-month January (31 days),
- // monthly. The compare bucket must sum all of January — the last bucket
- // has to extend to the compare window end rather than stopping at the
- // primary length.
- mockApiFetch.mockResolvedValue( {
- data: [
- [ '2026-01-15', 10 ],
- [ '2026-01-30', 20 ],
- [ '2026-02-15', 100 ],
- ],
- } );
-
- render(
-
- );
-
- const chart = await screen.findByTestId( 'comparative-line-chart' );
- expect( chart ).toHaveAttribute( 'data-values', '100' );
- // Both January days, including Jan 30 which the old offset would drop.
- expect( chart ).toHaveAttribute( 'data-previous-values', '30' );
- } );
-
- it( 'keeps one comparison point per calendar day when the compare window is longer', async () => {
- mockApiFetch.mockResolvedValue( {
- data: [
- [ '2026-01-28', 1 ],
- [ '2026-01-29', 2 ],
- [ '2026-01-30', 3 ],
- [ '2026-01-31', 4 ],
- [ '2026-02-01', 5 ],
- [ '2026-02-28', 10 ],
- [ '2026-03-01', 20 ],
- ],
- } );
-
- render(
-
- );
-
- const chart = await screen.findByTestId( 'comparative-line-chart' );
- expect( chart ).toHaveAttribute( 'data-values', '10,20' );
- // Day grouping must not fold Jan 29-Feb 1 into one point merely to
- // mirror the shorter primary range.
- expect( chart ).toHaveAttribute( 'data-previous-values', '1,2,3,4,5' );
- } );
-
- it( 'does not invent trailing comparison days when the compare window is shorter', async () => {
- mockApiFetch.mockResolvedValue( {
- data: [
- [ '2026-02-28', 5 ],
- [ '2026-03-29', 10 ],
- [ '2026-03-30', 20 ],
- [ '2026-03-31', 30 ],
- ],
- } );
-
- render(
-
- );
-
- const chart = await screen.findByTestId( 'comparative-line-chart' );
- expect( chart ).toHaveAttribute( 'data-values', '10,20,30' );
- expect( chart ).toHaveAttribute( 'data-previous-values', '5' );
- } );
-
it( 'renders the scopeless empty state and makes no request without a post scope', async () => {
render(
);
await expect(
screen.findByText( 'Open a post or page report to see its views here.' )
).resolves.toBeInTheDocument();
- expect( mockApiFetch ).not.toHaveBeenCalled();
+ expect(
+ mockApiFetch.mock.calls.filter( call =>
+ ( call[ 0 ].path as string ).includes( 'stats/post' )
+ )
+ ).toHaveLength( 0 );
} );
it( 'shows the error state with a Retry action when the fetch fails', async () => {
diff --git a/projects/packages/premium-analytics/widgets/post-views/package.json b/projects/packages/premium-analytics/widgets/post-views/package.json
index 7a1cece700ad..d8dcce8049d2 100644
--- a/projects/packages/premium-analytics/widgets/post-views/package.json
+++ b/projects/packages/premium-analytics/widgets/post-views/package.json
@@ -5,8 +5,8 @@
"type": "module",
"dependencies": {
"@jetpack-premium-analytics/data": "link:../../packages/data",
+ "@jetpack-premium-analytics/datetime": "link:../../packages/datetime",
"@jetpack-premium-analytics/fields": "link:../../packages/fields",
- "@jetpack-premium-analytics/formatters": "link:../../packages/formatters",
"@jetpack-premium-analytics/icons": "link:../../packages/icons",
"@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit",
"@wordpress/element": "8.2.0",
diff --git a/projects/packages/premium-analytics/widgets/post-views/render.tsx b/projects/packages/premium-analytics/widgets/post-views/render.tsx
index ba83e08f30b5..6e0c7a7bad39 100644
--- a/projects/packages/premium-analytics/widgets/post-views/render.tsx
+++ b/projects/packages/premium-analytics/widgets/post-views/render.tsx
@@ -2,7 +2,6 @@
* External dependencies
*/
import { toPostId } from '@jetpack-premium-analytics/data';
-import { formatDateRange } from '@jetpack-premium-analytics/formatters';
import { reports } from '@jetpack-premium-analytics/icons';
import {
ComparativeLineChart,
@@ -19,7 +18,7 @@ import { __ } from '@wordpress/i18n';
* Internal dependencies
*/
import styles from './style.module.css';
-import usePostViews, { type PostViewsPoint } from './use-post-views';
+import usePostViews from './use-post-views';
import type { PostViewsAttributes, PostViewsGranularity } from './widget';
import type { WidgetRenderProps } from '@wordpress/widget-primitives';
@@ -31,20 +30,6 @@ const DATA_FORMAT = {
options: { useMultipliers: true, decimals: 0 },
};
-/**
- * A series' legend label as its date range (first to last point), consistent
- * with the other comparative charts — used only when a comparison overlay
- * makes the plain "Views" label ambiguous.
- *
- * @param points - The series points, oldest first.
- * @return The formatted date range, or '' when empty.
- */
-function rangeLabel( points: PostViewsPoint[] ): string {
- const first = points[ 0 ];
- const last = points[ points.length - 1 ];
- return first && last ? formatDateRange( { from: first.date, to: last.date } ) : '';
-}
-
type PostViewsInnerProps = {
/** The granularity attribute: the chart's bucket size. */
granularity: PostViewsGranularity;
@@ -63,43 +48,27 @@ function PostViewsInner( { granularity }: PostViewsInnerProps ) {
const { reportParams } = useWidgetRootContext();
const postId = toPostId( reportParams.post_id );
- const { current, previous, isLoading, isFetching, isError, hasData, refetch } = usePostViews(
+ const { current, isLoading, isFetching, isError, hasData, refetch } = usePostViews(
postId,
reportParams,
granularity
);
+ // The post detail page has no comparison control, so the chart always
+ // draws the single "Views" series.
const series = useMemo< ComparativeLineChartSeries[] >( () => {
if ( ! current.length ) {
return [];
}
- if ( ! previous?.length ) {
- return [
- {
- label: __( 'Views', 'jetpack-premium-analytics-pkg' ),
- group: 'views',
- data: current,
- },
- ];
- }
-
- // With a comparison overlay both series are labelled by date range, so
- // the legend distinguishes the periods; the previous period draws as a
- // same-colour dashed line with no fill.
return [
- { label: rangeLabel( current ), group: 'views', data: current },
{
- label: rangeLabel( previous ),
+ label: __( 'Views', 'jetpack-premium-analytics-pkg' ),
group: 'views',
- data: previous,
- options: {
- type: 'comparison',
- gradient: { from: 'transparent', to: 'transparent', fromOpacity: 0, toOpacity: 0 },
- },
+ data: current,
},
];
- }, [ current, previous ] );
+ }, [ current ] );
const seriesStyles = useSeriesStyles( series );
return (
@@ -137,10 +106,10 @@ function PostViewsInner( { granularity }: PostViewsInnerProps ) {
/**
* Post views widget: the scoped post's view trend over the dashboard date
- * range as a comparative line chart — the legacy Calypso post summary chart
+ * range as a line chart — the legacy Calypso post summary chart
* (`stats-post-summary`). The view series comes from `stats/post`'s full
* daily history, zero-filled and bucketed client-side per the granularity
- * attribute, with the comparison window sliced from the same request.
+ * attribute.
*
* @param {PostViewsWidgetProps} props - The widget render props.
* @return The rendered widget.
diff --git a/projects/packages/premium-analytics/widgets/post-views/stories/post-views-widget.stories.tsx b/projects/packages/premium-analytics/widgets/post-views/stories/post-views-widget.stories.tsx
index 3b21d2ba0ffe..096e5481b2bd 100644
--- a/projects/packages/premium-analytics/widgets/post-views/stories/post-views-widget.stories.tsx
+++ b/projects/packages/premium-analytics/widgets/post-views/stories/post-views-widget.stories.tsx
@@ -1,14 +1,17 @@
/**
* The Post views widget is the post detail Traffic view's view-trend card:
- * the scoped post's views over the dashboard date range as a comparative
- * line chart. The post scope arrives through `reportParams.post_id` (seeded
- * from the detail page URL in product); the `hasPostScope` control toggles it
- * to exercise the scopeless empty state.
+ * the scoped post's views over the dashboard date range as a line chart. The
+ * post scope arrives through `reportParams.post_id` (seeded from the detail
+ * page URL in product); the `hasPostScope` control toggles it to exercise the
+ * scopeless empty state.
*
* Data comes from the proxied `stats/post/{id}` endpoint, covered by the
* shared report mocks' `stats-post` fixture (a deterministic daily series
- * ending today, so relative date presets always intersect it). The comparison
- * window is sliced client-side from the same request.
+ * ending today, so relative date presets always intersect it). The post
+ * detail design has no period-over-period comparison, so the widget maps no
+ * comparison rows; the dashboard story still passes comparison params so the
+ * widget stays covered against crashing or inventing an overlay when a host
+ * supplies them.
*/
/**
* External dependencies
@@ -42,7 +45,6 @@ const MOCK_POST_ID = 779;
const POST_VIEWS_RENDER_MODULE = 'storybook/post-views';
interface PostViewsStoryControls {
- withComparison: boolean;
hasPostScope: boolean;
granularity: PostViewsGranularity;
}
@@ -50,16 +52,17 @@ interface PostViewsStoryControls {
/**
* Builds the widget attributes: the granularity attribute plus report params
* with the post scope the detail page seeds from its URL when `hasPostScope`
- * is on.
+ * is on. Comparison stays a parameter so the dashboard story can pass host
+ * comparison params without duplicating the scoping rule.
*
- * @param {PostViewsStoryControls} controls - The story controls.
+ * @param {PostViewsStoryControls} controls - The story controls.
+ * @param {boolean} withComparison - Include previous-period comparison report params.
* @return The widget attributes.
*/
-function getPostViewsAttributes( {
- withComparison,
- hasPostScope,
- granularity,
-}: PostViewsStoryControls ): ComponentProps< typeof PostViewsRender >[ 'attributes' ] {
+function getPostViewsAttributes(
+ { hasPostScope, granularity }: PostViewsStoryControls,
+ withComparison = false
+): ComponentProps< typeof PostViewsRender >[ 'attributes' ] {
return {
granularity,
reportParams: {
@@ -84,10 +87,6 @@ const meta = {
component: PostViewsRender,
tags: [ 'autodocs' ],
argTypes: {
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
hasPostScope: {
control: 'boolean',
description: 'Include the `post_id` report param the post detail page seeds from its URL.',
@@ -102,7 +101,7 @@ const meta = {
docs: {
description: {
component:
- 'The "Post views" widget: the scoped post\'s view trend over the dashboard date range as a comparative line chart — the legacy Calypso post summary chart. The view series comes from `stats/post`\'s full daily history, zero-filled and bucketed client-side per the host-rendered "Group by" control, with the comparison window sliced from the same request. Without a post scope the widget renders a scopeless empty state.',
+ 'The "Post views" widget: the scoped post\'s view trend over the dashboard date range as a line chart — the legacy Calypso post summary chart. The view series comes from `stats/post`\'s full daily history, zero-filled and bucketed client-side per the host-rendered "Group by" control. The post detail page has no comparison control, so comparison report params are ignored. Without a post scope the widget renders a scopeless empty state.',
},
},
},
@@ -113,23 +112,12 @@ export default meta;
type Story = StoryObj< PostViewsStoryControls >;
/**
- * Default — the scoped post's views for the primary period only: a single
- * "Views" line with no overlay.
+ * Default — the scoped post's views for the selected period: a single
+ * "Views" line.
*/
export const Default: Story = {
render: renderPostViews,
- args: { withComparison: false, hasPostScope: true, granularity: 'day' },
- decorators: [ withWidgetCanvas ],
-};
-
-/**
- * WithComparison — the previous-period comparison from the date range picker;
- * the chart adds a dashed previous-period overlay and the legend switches to
- * date-range labels.
- */
-export const WithComparison: Story = {
- render: renderPostViews,
- args: { withComparison: true, hasPostScope: true, granularity: 'day' },
+ args: { hasPostScope: true, granularity: 'day' },
decorators: [ withWidgetCanvas ],
};
@@ -140,7 +128,7 @@ export const WithComparison: Story = {
*/
export const NoPostScope: Story = {
render: renderPostViews,
- args: { withComparison: false, hasPostScope: false, granularity: 'day' },
+ args: { hasPostScope: false, granularity: 'day' },
decorators: [ withWidgetCanvas ],
};
@@ -151,13 +139,14 @@ interface PostViewsDashboardStoryProps
/**
* Mounts the real `WidgetDashboard` with this single widget so it renders
* exactly as it does in product (framed card, host "Group by" toolbar
- * control, sizing, edit mode).
+ * control, sizing, edit mode). It passes comparison params unconditionally,
+ * so the widget stays covered against crashing or inventing an overlay when
+ * a host supplies comparison dates.
*
* @param {PostViewsDashboardStoryProps} props - The dashboard story controls.
* @return The widget mounted inside the real dashboard.
*/
function PostViewsDashboardStory( {
- withComparison,
hasPostScope,
granularity,
...dashboardArgs
@@ -168,7 +157,7 @@ function PostViewsDashboardStory( {
widgetType={ createStoryWidgetType( widgetManifest, widgetDefinition ) }
renderModule={ POST_VIEWS_RENDER_MODULE }
renderComponent={ PostViewsRender as ComponentType< WidgetRenderProps< unknown > > }
- attributes={ getPostViewsAttributes( { withComparison, hasPostScope, granularity } ) }
+ attributes={ getPostViewsAttributes( { hasPostScope, granularity }, true ) }
/>
);
}
@@ -179,16 +168,11 @@ export const WidgetDashboardWithWidget: StoryObj< PostViewsDashboardStoryProps >
...DEFAULT_WIDGET_DASHBOARD_STORY_ARGS,
widgetWidth: 2,
widgetHeight: 2,
- withComparison: true,
hasPostScope: true,
granularity: 'day',
},
argTypes: {
...widgetDashboardWithWidgetArgTypes,
- withComparison: {
- control: 'boolean',
- description: 'Include previous-period comparison report params.',
- },
hasPostScope: {
control: 'boolean',
description: 'Include the `post_id` report param the post detail page seeds from its URL.',
diff --git a/projects/packages/premium-analytics/widgets/post-views/style.module.css b/projects/packages/premium-analytics/widgets/post-views/style.module.css
index 92f9a6746349..9ec53ce77712 100644
--- a/projects/packages/premium-analytics/widgets/post-views/style.module.css
+++ b/projects/packages/premium-analytics/widgets/post-views/style.module.css
@@ -4,7 +4,7 @@
position: relative;
display: flex;
flex-direction: column;
- height: 100%;
+ block-size: 100%;
min-block-size: 0;
overflow: hidden;
}
diff --git a/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts b/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts
index 0b7255509a9a..f1cdeb8e001a 100644
--- a/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts
+++ b/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts
@@ -2,15 +2,14 @@
* External dependencies
*/
import {
- localTZDate,
useStatsPost,
type ReportParams,
type StatsPostDay,
} from '@jetpack-premium-analytics/data';
+import { parseSiteDateTime } from '@jetpack-premium-analytics/datetime';
import { useMemo } from '@wordpress/element';
import {
addDays,
- differenceInCalendarDays,
eachDayOfInterval,
eachMonthOfInterval,
eachWeekOfInterval,
@@ -31,13 +30,11 @@ export type PostViewsPoint = {
};
/**
- * Normalized Post views state: the primary (and optional comparison) series
- * plus the request's load/error flags. `hasData` distinguishes the first load
- * from refetches.
+ * Normalized Post views state: the view series plus the request's load/error
+ * flags. `hasData` distinguishes the first load from refetches.
*/
export interface PostViewsState {
current: PostViewsPoint[];
- previous?: PostViewsPoint[];
isLoading: boolean;
isFetching: boolean;
isError: boolean;
@@ -103,24 +100,24 @@ function toDayWindow( from?: string, to?: string ): DayWindow | undefined {
}
/**
- * Build the primary range's calendar buckets. Each bucket keeps the calendar
- * label used by the primary chart while clipping its data bounds to the
- * selected range. The clipped bounds can then be applied to the comparison
- * range as relative offsets, which preserves the primary series' bucket count
- * across calendar boundaries.
+ * Build the range's calendar buckets. Each bucket keeps the calendar label
+ * used by the chart while clipping its data bounds to the selected range.
*
- * @param window - The date-only window to keep.
- * @param period - The bucket size.
+ * @param dayWindow - The date-only window to keep.
+ * @param period - The bucket size.
* @return One bucket per calendar period, oldest first.
*/
-function calendarBucketWindows( window: DayWindow, period: PostViewsGranularity ): BucketWindow[] {
+function calendarBucketWindows(
+ dayWindow: DayWindow,
+ period: PostViewsGranularity
+): BucketWindow[] {
// The URL is user-editable, so an inverted range must not reach
// `eachDayOfInterval()` (it throws).
- if ( window.from > window.to ) {
+ if ( dayWindow.from > dayWindow.to ) {
return [];
}
- const interval = { start: parseISO( window.from ), end: parseISO( window.to ) };
+ const interval = { start: parseISO( dayWindow.from ), end: parseISO( dayWindow.to ) };
let bucketStarts = eachDayOfInterval( interval );
if ( period === 'week' ) {
bucketStarts = eachWeekOfInterval( interval, { weekStartsOn: 1 } );
@@ -131,68 +128,16 @@ function calendarBucketWindows( window: DayWindow, period: PostViewsGranularity
return bucketStarts.map( ( start, index ) => {
const date = format( start, 'yyyy-MM-dd' );
const nextDate = bucketStarts[ index + 1 ];
- const end = nextDate ? format( addDays( nextDate, -1 ), 'yyyy-MM-dd' ) : window.to;
+ const end = nextDate ? format( addDays( nextDate, -1 ), 'yyyy-MM-dd' ) : dayWindow.to;
return {
date,
- from: date < window.from ? window.from : date,
- to: end > window.to ? window.to : end,
+ from: date < dayWindow.from ? dayWindow.from : date,
+ to: end > dayWindow.to ? dayWindow.to : end,
};
} );
}
-/**
- * Map primary bucket boundaries onto the comparison range. For example, a
- * primary March 1–31 range has one monthly bucket; its equal-length January
- * 29–February 28 comparison range must also have one bucket, even though it
- * crosses two calendar months.
- *
- * Each comparison bucket starts at the same day offset from the comparison
- * range's start as its primary bucket does from the primary start, so the
- * bucket count always matches. The buckets fully partition the comparison
- * range: every bucket's end is the next bucket's start minus a day, and the
- * last bucket extends to `comparisonWindow.to`. That keeps the comparison a
- * complete, non-overlapping cover of the selected range — a longer previous
- * period (previous-month onto a shorter month) folds its tail into the last
- * bucket instead of being truncated, and every bound is clamped to
- * `comparisonWindow.to` so a shorter one never reaches past the selection.
- *
- * @param primaryWindow - The selected primary range.
- * @param comparisonWindow - The previous-period range.
- * @param buckets - Calendar buckets clipped to the primary range.
- * @return Comparison buckets with the primary range's relative boundaries.
- */
-function relativeBucketWindows(
- primaryWindow: DayWindow,
- comparisonWindow: DayWindow,
- buckets: BucketWindow[]
-): BucketWindow[] {
- const primaryStart = parseISO( primaryWindow.from );
- const comparisonStart = parseISO( comparisonWindow.from );
-
- const froms = buckets.map( bucket =>
- format(
- addDays( comparisonStart, differenceInCalendarDays( parseISO( bucket.from ), primaryStart ) ),
- 'yyyy-MM-dd'
- )
- );
-
- return froms.map( ( from, index ) => {
- // Each bucket runs up to the next bucket's start; the last one absorbs
- // any remaining comparison days. Clamp the end to the selected window so
- // a longer primary offset can't pull in out-of-range days. `from` is left
- // unclamped so a shorter comparison keeps distinct (empty) trailing
- // buckets rather than collapsing several onto the same key.
- const rawTo =
- index < froms.length - 1
- ? format( addDays( parseISO( froms[ index + 1 ] ), -1 ), 'yyyy-MM-dd' )
- : comparisonWindow.to;
- const to = rawTo > comparisonWindow.to ? comparisonWindow.to : rawTo;
-
- return { date: from, from, to };
- } );
-}
-
/**
* Sum the post's daily view history into zero-filled buckets. The endpoint
* may omit zero-view days and the history only starts at publication, but
@@ -216,20 +161,31 @@ function bucketDays( days: StatsPostDay[], buckets: BucketWindow[] ): PostViewsP
}
}
- return buckets.map( bucket => ( {
- date: localTZDate( bucket.date ),
- value: totals.get( bucket.date ) ?? 0,
- } ) );
+ // The endpoint's day keys are plain site-local calendar dates, so each
+ // point's instant must be that day's site-local midnight. `parseSiteDateTime`
+ // anchors the offset-less key in the site timezone; the chart's `formatDate`
+ // labels render in the same zone, so the calendar day round-trips without a
+ // TZ-induced day shift (a date-only string fed to `localTZDate` would parse
+ // as UTC midnight and read as the previous day on negative-offset sites).
+ // `bucket.date` comes from `format( start, 'yyyy-MM-dd' )`, so the parse
+ // cannot fail in practice; if it ever does, drop the point rather than
+ // fall back to a browser-local instant that reintroduces the day shift.
+ return buckets.flatMap( bucket => {
+ const date = parseSiteDateTime( bucket.date );
+ return date ? [ { date, value: totals.get( bucket.date ) ?? 0 } ] : [];
+ } );
}
/**
* Fetch the scoped post's view trend for the dashboard's report params. One
- * `stats/post` request carries the full daily view history; the primary and
- * comparison windows are sliced from it client-side, so comparison needs no
- * second request.
+ * `stats/post` request carries the full daily view history; the selected
+ * window is sliced from it client-side. The post detail design has no
+ * period-over-period comparison, so comparison report params are ignored —
+ * they ride along in the URL untouched so dashboard state survives the round
+ * trip, and every widget on this page disregards them.
*
* @param postId - The scoped post ID (0 disables the request).
- * @param reportParams - The dashboard date range + comparison state.
+ * @param reportParams - The dashboard date range.
* @param period - The selected bucket granularity (day/week/month).
* @return The view series and load/error state.
*/
@@ -243,41 +199,16 @@ export default function usePostViews(
fields: [ 'data' ],
} );
- const { current, previous } = useMemo( () => {
+ const current = useMemo( () => {
const days = data?.data ?? [];
- const window = toDayWindow( reportParams.from, reportParams.to );
- const compareWindow = toDayWindow( reportParams.compare_from, reportParams.compare_to );
- const buckets = window ? calendarBucketWindows( window, period ) : [];
- const currentPoints = bucketDays( days, buckets );
- let comparisonBuckets: BucketWindow[] | undefined;
- if ( window && compareWindow ) {
- // Day grouping must remain one point per actual calendar day. Relative
- // bucketing is only needed for coarser periods, where matching the
- // primary layout prevents partial week/month boundaries from scrunching
- // the comparison overlay.
- comparisonBuckets =
- period === 'day'
- ? calendarBucketWindows( compareWindow, period )
- : relativeBucketWindows( window, compareWindow, buckets );
- }
- const previousPoints = comparisonBuckets ? bucketDays( days, comparisonBuckets ) : undefined;
+ const dayWindow = toDayWindow( reportParams.from, reportParams.to );
+ const buckets = dayWindow ? calendarBucketWindows( dayWindow, period ) : [];
- return {
- current: currentPoints,
- previous: previousPoints?.length ? previousPoints : undefined,
- };
- }, [
- data,
- period,
- reportParams.from,
- reportParams.to,
- reportParams.compare_from,
- reportParams.compare_to,
- ] );
+ return bucketDays( days, buckets );
+ }, [ data, period, reportParams.from, reportParams.to ] );
return {
current,
- previous,
isLoading,
isFetching,
isError,
diff --git a/projects/packages/premium-analytics/widgets/post-views/widget.ts b/projects/packages/premium-analytics/widgets/post-views/widget.ts
index 4ff2a7a9d8ab..77a12b60d900 100644
--- a/projects/packages/premium-analytics/widgets/post-views/widget.ts
+++ b/projects/packages/premium-analytics/widgets/post-views/widget.ts
@@ -31,7 +31,7 @@ export type PostViewsAttributes = {
*
* The post detail Traffic view's view-trend card, the legacy Calypso post
* summary chart (`stats-post-summary`): the scoped post's views over the
- * dashboard date range as a comparative line chart. The series comes from
+ * dashboard date range as a line chart. The series comes from
* the `stats/post/{id}` daily history, bucketed client-side; the
* `granularity` attribute (`relevance: 'high'`) chooses the bucket size.
*/