From 9ff2423c2ad3a2de482b3345666f1df2a36455f2 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 15:01:43 +0800 Subject: [PATCH 1/5] fix: converge both date pickers onto one range-patch to-bound rule --- .../changelog/wooa7s-2079-range-patch | 4 ++ .../__tests__/report-params-field.test.tsx | 23 +++++++++ .../report-params-field.tsx | 11 ++-- .../build-range-patch.ts | 18 ++----- .../packages/routing/src/index.ts | 6 ++- .../src/search/date-range/date-range.test.ts | 50 ++++++++++++++++++- .../src/search/date-range/date-range.ts | 37 +++++++++++++- .../routing/src/search/date-range/index.ts | 6 ++- 8 files changed, 132 insertions(+), 23 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/wooa7s-2079-range-patch diff --git a/projects/packages/premium-analytics/changelog/wooa7s-2079-range-patch b/projects/packages/premium-analytics/changelog/wooa7s-2079-range-patch new file mode 100644 index 000000000000..e813eabc1bc3 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-2079-range-patch @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Report date filters: measure a widget-configured comparison against the selected period, not the rest of the day. diff --git a/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx b/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx index 7c2f23cb2831..ab47a6108faa 100644 --- a/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx +++ b/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx @@ -1,6 +1,7 @@ /** * External dependencies */ +import { normalizeReportParams } from '@jetpack-premium-analytics/data'; import { act, fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; @@ -339,6 +340,28 @@ describe( 'report params field', () => { ); } ); + /* + * Read back through `normalizeReportParams`, the way a widget reads it: that + * recomputes the primary window from the preset and so repairs a stretched + * `to`, while passing the comparison it spawned through untouched. + */ + it( 'measures a comparison against the preset window, not the rest of the day', async () => { + const user = userEvent.setup(); + const { latest } = renderField(); + + await user.click( screen.getByRole( 'button', { name: /compare/i } ) ); + await user.click( await screen.findByRole( 'menuitemradio', { name: /^previous /i } ) ); + await pickPeriod( user, 'Last 24 hours' ); + + const params = normalizeReportParams( latest() ); + const span = ( from?: string, to?: string ) => + new Date( String( to ) ).getTime() - new Date( String( from ) ).getTime(); + + expect( span( params.compare_from, params.compare_to ) ).toBe( + span( params.from, params.to ) + ); + } ); + // A widget can carry a preset with no window behind it, and it compares // nothing, so the control has to stay in its additive state. it( 'ignores a saved comparison preset with no window', () => { diff --git a/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx b/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx index e24d5d1c3410..64f4175c319e 100644 --- a/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx +++ b/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx @@ -13,7 +13,6 @@ import { } from '@jetpack-premium-analytics/data'; import { type ComparisonPresetId, - endOfDayTZ, type IntervalType, isPrimaryPreset, QUICK_SURFACE_PRESETS, @@ -26,6 +25,7 @@ import { decodeDateSearchParam, deriveComparisonRange, encodeDateToSearchParam, + encodeRangeToSearchParams, hasPrimaryDateDraft, useStagedValue, } from '@jetpack-premium-analytics/routing'; @@ -202,11 +202,12 @@ function ReportParamsControl( { const patch: Partial< ReportParams > = {}; if ( nextRange?.from && nextRange?.to ) { - patch.from = encodeDateToSearchParam( nextRange.from ); - patch.to = encodeDateToSearchParam( - // The site's day boundary, not the visitor's (see build-range-patch). - endOfDayTZ( nextRange.to, reportingTimeZone() ) + const { from, to } = encodeRangeToSearchParams( + { from: nextRange.from, to: nextRange.to }, + { presetId: nextPresetId } ); + patch.from = from; + patch.to = to; } if ( nextPresetId ) { diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/build-range-patch.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/build-range-patch.ts index 112a030de72f..efd43807044e 100644 --- a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/build-range-patch.ts +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/build-range-patch.ts @@ -3,9 +3,6 @@ */ import { resolveIntervalForRange, type ReportQueryParams } from '@jetpack-premium-analytics/data'; import { - endOfDayTZ, - isSelectablePreset, - reportingTimeZone, type ComparisonPresetId, type DateRange, type PrimaryPresetId, @@ -14,7 +11,7 @@ import { * Internal dependencies */ import { deriveComparisonRange } from '../../search/comparison'; -import { encodeDateToSearchParam } from '../../search/date-range'; +import { encodeRangeToSearchParams } from '../../search/date-range'; /** * The report search params the date filters read and stage. @@ -67,16 +64,9 @@ export function buildRangePatch( { const patch: ReportQuerySearchParams = {}; if ( nextRange?.from && nextRange.to ) { - /* - * Preset/exact ranges are authoritative and skip end-of-day adjustment; - * calendar edits stage midnight `to`, adjusted to the *site's* end of day — - * date-fns' bare `endOfDay` would use the browser's and stretch the range. - */ - const rangeFrom = encodeDateToSearchParam( nextRange.from ); - const rangeTo = encodeDateToSearchParam( - exactRange || isSelectablePreset( nextPresetId ) - ? nextRange.to - : endOfDayTZ( nextRange.to, reportingTimeZone() ) + const { from: rangeFrom, to: rangeTo } = encodeRangeToSearchParams( + { from: nextRange.from, to: nextRange.to }, + { presetId: nextPresetId, exactRange } ); patch.from = rangeFrom; patch.to = rangeTo; diff --git a/projects/packages/premium-analytics/packages/routing/src/index.ts b/projects/packages/premium-analytics/packages/routing/src/index.ts index 77073ba68472..3399b3a3081d 100644 --- a/projects/packages/premium-analytics/packages/routing/src/index.ts +++ b/projects/packages/premium-analytics/packages/routing/src/index.ts @@ -1,4 +1,8 @@ -export { decodeDateSearchParam, encodeDateToSearchParam } from './search/date-range'; +export { + decodeDateSearchParam, + encodeDateToSearchParam, + encodeRangeToSearchParams, +} from './search/date-range'; export { deriveComparisonRange } from './search/comparison'; export { diff --git a/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.test.ts b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.test.ts index 0ca7248b951b..91a12143ab48 100644 --- a/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.test.ts +++ b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.test.ts @@ -5,7 +5,11 @@ import { getSettings, setSettings } from '@wordpress/date'; /** * Internal dependencies */ -import { decodeDateSearchParam, encodeDateToSearchParam } from './date-range'; +import { + decodeDateSearchParam, + encodeDateToSearchParam, + encodeRangeToSearchParams, +} from './date-range'; const DEFAULTS = getSettings(); @@ -77,3 +81,47 @@ describe( 'encodeDateToSearchParam', () => { expect( decodeDateSearchParam( encoded )?.toISOString() ).toBe( '2026-06-29T04:00:00.000Z' ); } ); } ); + +describe( 'encodeRangeToSearchParams', () => { + beforeEach( () => { + setSettings( { + ...DEFAULTS, + timezone: { + offset: -4, + offsetFormatted: '-4', + string: 'America/New_York', + abbr: 'EDT', + }, + } ); + } ); + + // Midnight in the site zone, the shape the calendar inputs stage. + const from = new Date( '2026-06-29T04:00:00.000Z' ); + const to = new Date( '2026-07-09T04:00:00.000Z' ); + + it( 'extends a calendar edit to the end of the site day', () => { + expect( encodeRangeToSearchParams( { from, to } ) ).toEqual( { + from: '2026-06-29T00:00:00.000-04:00', + to: '2026-07-09T23:59:59.999-04:00', + } ); + } ); + + it( 'stores a selectable preset range as given', () => { + expect( encodeRangeToSearchParams( { from, to }, { presetId: 'last-24-hours' } ).to ).toBe( + '2026-07-09T00:00:00.000-04:00' + ); + } ); + + it( 'stores an exact range as given', () => { + expect( encodeRangeToSearchParams( { from, to }, { exactRange: true } ).to ).toBe( + '2026-07-09T00:00:00.000-04:00' + ); + } ); + + // 'custom' marks a manual edit, so it takes the calendar rule, not the preset one. + it( 'extends a custom range', () => { + expect( encodeRangeToSearchParams( { from, to }, { presetId: 'custom' } ).to ).toBe( + '2026-07-09T23:59:59.999-04:00' + ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts index 16e9f42a8883..44fa57faf67b 100644 --- a/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts +++ b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts @@ -1,7 +1,14 @@ /** * External dependencies */ -import { dateToISOStringWithLocalTZ, localTZDate } from '@jetpack-premium-analytics/datetime'; +import { + dateToISOStringWithLocalTZ, + endOfDayTZ, + isSelectablePreset, + localTZDate, + reportingTimeZone, + type DateRange, +} from '@jetpack-premium-analytics/datetime'; import { isValid } from 'date-fns'; /** @@ -31,3 +38,31 @@ export function decodeDateSearchParam( value?: string, timezone?: string ): Date export function encodeDateToSearchParam( date?: Date ): string | undefined { return date ? dateToISOStringWithLocalTZ( date ) : undefined; } + +/** + * Serialize a picker range into the `from`/`to` report params. + * + * @param range - The range to serialize. + * @param options - How the range was produced. + * @param options.presetId - The preset that produced the range, if any. + * @param options.exactRange - Store both bounds as given. + * @return The serialized bounds. + */ +export function encodeRangeToSearchParams( + range: Required< DateRange >, + { presetId, exactRange }: { presetId?: string; exactRange?: boolean } = {} +): { from: string; to: string } { + return { + from: dateToISOStringWithLocalTZ( range.from ), + /* + * Preset and already-normalized ranges carry their own `to` and are stored + * verbatim; a calendar edit stages midnight, moved to the *site's* end of + * day because date-fns' bare `endOfDay` would use the visitor's. + */ + to: dateToISOStringWithLocalTZ( + exactRange || isSelectablePreset( presetId ) + ? range.to + : endOfDayTZ( range.to, reportingTimeZone() ) + ), + }; +} diff --git a/projects/packages/premium-analytics/packages/routing/src/search/date-range/index.ts b/projects/packages/premium-analytics/packages/routing/src/search/date-range/index.ts index cc0329acd0ef..b27df22468e1 100644 --- a/projects/packages/premium-analytics/packages/routing/src/search/date-range/index.ts +++ b/projects/packages/premium-analytics/packages/routing/src/search/date-range/index.ts @@ -1 +1,5 @@ -export { decodeDateSearchParam, encodeDateToSearchParam } from './date-range'; +export { + decodeDateSearchParam, + encodeDateToSearchParam, + encodeRangeToSearchParams, +} from './date-range'; From c087350da4467bb5317bb2a45fb4f9243e6f2bbe Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 15:05:13 +0800 Subject: [PATCH 2/5] changelog: WOOA7S-2079 range patch convergence --- .../premium-analytics/changelog/wooa7s-2079-range-patch | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/packages/premium-analytics/changelog/wooa7s-2079-range-patch b/projects/packages/premium-analytics/changelog/wooa7s-2079-range-patch index e813eabc1bc3..4defe8ab1553 100644 --- a/projects/packages/premium-analytics/changelog/wooa7s-2079-range-patch +++ b/projects/packages/premium-analytics/changelog/wooa7s-2079-range-patch @@ -1,4 +1,4 @@ Significance: patch -Type: fixed +Type: changed -Report date filters: measure a widget-configured comparison against the selected period, not the rest of the day. +Report date filters: stage a date range through one shared bounds helper in both pickers. From f597877a5d9dd5c87acbde3727a36b779f78e441 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 15:13:38 +0800 Subject: [PATCH 3/5] test: pin the clock and assert the staged preset bound directly --- .../__tests__/report-params-field.test.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx b/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx index ab47a6108faa..d96a4c27a240 100644 --- a/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx +++ b/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx @@ -346,20 +346,30 @@ describe( 'report params field', () => { * `to`, while passing the comparison it spawned through untouched. */ it( 'measures a comparison against the preset window, not the rest of the day', async () => { - const user = userEvent.setup(); + /* + * Pinned away from the day's last hour: "Last 24 hours" ends at + * `endOfHour( now )`, which already *is* end of day from 23:00, so the + * stretch this guards against would be a no-op and the test would pass + * on the unfixed code. + */ + jest.useFakeTimers().setSystemTime( new Date( '2026-06-15T12:00:00.000Z' ) ); + const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } ); const { latest } = renderField(); await user.click( screen.getByRole( 'button', { name: /compare/i } ) ); await user.click( await screen.findByRole( 'menuitemradio', { name: /^previous /i } ) ); await pickPeriod( user, 'Last 24 hours' ); + // The preset's own end, not the end of the day it falls in. + expect( latest()?.to ).toBe( '2026-06-15T12:59:59.999+00:00' ); + const params = normalizeReportParams( latest() ); const span = ( from?: string, to?: string ) => new Date( String( to ) ).getTime() - new Date( String( from ) ).getTime(); - expect( span( params.compare_from, params.compare_to ) ).toBe( - span( params.from, params.to ) - ); + expect( span( params.compare_from, params.compare_to ) ).toBe( span( params.from, params.to ) ); + + jest.useRealTimers(); } ); // A widget can carry a preset with no window behind it, and it compares From 3a115f8473c744d66c20b5e014bc8c2ba5c9deda Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 17:52:17 +0800 Subject: [PATCH 4/5] refactor: type the shared range-bounds preset option and cover year-surface presets --- .../report-params-field/report-params-field.tsx | 17 +++++++++-------- .../routing/src/search/date-range/date-range.ts | 6 ++++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx b/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx index 64f4175c319e..fefcd0d5f650 100644 --- a/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx +++ b/projects/packages/premium-analytics/packages/fields/src/report-params-field/report-params-field.tsx @@ -14,11 +14,11 @@ import { import { type ComparisonPresetId, type IntervalType, - isPrimaryPreset, QUICK_SURFACE_PRESETS, type QuickSurfacePresetId, reportingTimeZone, type DateRange, + type PrimaryPresetId, } from '@jetpack-premium-analytics/datetime'; import { Stack } from '@jetpack-premium-analytics/externals'; import { @@ -198,20 +198,21 @@ function ReportParamsControl( { }, [ isUnofferedPreset, fallbackPreset ] ); const stageDateRange = useCallback( - ( nextRange?: DateRange, nextPresetId?: string ) => { + ( nextRange?: DateRange, nextPresetId?: PrimaryPresetId ) => { const patch: Partial< ReportParams > = {}; if ( nextRange?.from && nextRange?.to ) { - const { from, to } = encodeRangeToSearchParams( - { from: nextRange.from, to: nextRange.to }, - { presetId: nextPresetId } + Object.assign( + patch, + encodeRangeToSearchParams( + { from: nextRange.from, to: nextRange.to }, + { presetId: nextPresetId } + ) ); - patch.from = from; - patch.to = to; } if ( nextPresetId ) { - patch.preset = isPrimaryPreset( nextPresetId ) ? nextPresetId : undefined; + patch.preset = nextPresetId; } if ( reportParams.comp === '1' ) { diff --git a/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts index 44fa57faf67b..cb24456d0c93 100644 --- a/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts +++ b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts @@ -5,9 +5,11 @@ import { dateToISOStringWithLocalTZ, endOfDayTZ, isSelectablePreset, + isYearSurfacePresetId, localTZDate, reportingTimeZone, type DateRange, + type PrimaryPresetId, } from '@jetpack-premium-analytics/datetime'; import { isValid } from 'date-fns'; @@ -50,7 +52,7 @@ export function encodeDateToSearchParam( date?: Date ): string | undefined { */ export function encodeRangeToSearchParams( range: Required< DateRange >, - { presetId, exactRange }: { presetId?: string; exactRange?: boolean } = {} + { presetId, exactRange }: { presetId?: PrimaryPresetId; exactRange?: boolean } = {} ): { from: string; to: string } { return { from: dateToISOStringWithLocalTZ( range.from ), @@ -60,7 +62,7 @@ export function encodeRangeToSearchParams( * day because date-fns' bare `endOfDay` would use the visitor's. */ to: dateToISOStringWithLocalTZ( - exactRange || isSelectablePreset( presetId ) + exactRange || isSelectablePreset( presetId ) || isYearSurfacePresetId( presetId ) ? range.to : endOfDayTZ( range.to, reportingTimeZone() ) ), From 0cb2d587f3040543789145f3b4726e6ddc93f87c Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 9 Sep 2026 10:48:32 +0800 Subject: [PATCH 5/5] test: restore real timers in afterEach and pin the year-surface bound Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VubBCJBkJSDBEGD7F42Tbt --- .../__tests__/report-params-field.test.tsx | 6 ++++-- .../routing/src/search/date-range/date-range.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx b/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx index d96a4c27a240..3722a0796588 100644 --- a/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx +++ b/projects/packages/premium-analytics/packages/fields/src/report-params-field/__tests__/report-params-field.test.tsx @@ -120,6 +120,10 @@ describe( 'reportParamsAttributeField', () => { } ); describe( 'report params field', () => { + afterEach( () => { + jest.useRealTimers(); + } ); + it( 'offers no bucket control by default', () => { renderField(); @@ -368,8 +372,6 @@ describe( 'report params field', () => { new Date( String( to ) ).getTime() - new Date( String( from ) ).getTime(); expect( span( params.compare_from, params.compare_to ) ).toBe( span( params.from, params.to ) ); - - jest.useRealTimers(); } ); // A widget can carry a preset with no window behind it, and it compares diff --git a/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.test.ts b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.test.ts index 91a12143ab48..11c353def860 100644 --- a/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.test.ts +++ b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.test.ts @@ -118,6 +118,16 @@ describe( 'encodeRangeToSearchParams', () => { ); } ); + // The year surface computes its own bounds too, so they are stored as given. + it( 'stores a year-surface range as given', () => { + expect( encodeRangeToSearchParams( { from, to }, { presetId: 'year-2025' } ).to ).toBe( + '2026-07-09T00:00:00.000-04:00' + ); + expect( encodeRangeToSearchParams( { from, to }, { presetId: 'all-time' } ).to ).toBe( + '2026-07-09T00:00:00.000-04:00' + ); + } ); + // 'custom' marks a manual edit, so it takes the calendar rule, not the preset one. it( 'extends a custom range', () => { expect( encodeRangeToSearchParams( { from, to }, { presetId: 'custom' } ).to ).toBe(