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..4defe8ab1553 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-2079-range-patch @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Report date filters: stage a date range through one shared bounds helper in both pickers. 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..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 @@ -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'; @@ -119,6 +120,10 @@ describe( 'reportParamsAttributeField', () => { } ); describe( 'report params field', () => { + afterEach( () => { + jest.useRealTimers(); + } ); + it( 'offers no bucket control by default', () => { renderField(); @@ -339,6 +344,36 @@ 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 () => { + /* + * 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 ) ); + } ); + // 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..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 @@ -13,19 +13,19 @@ import { } from '@jetpack-premium-analytics/data'; import { type ComparisonPresetId, - endOfDayTZ, 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 { decodeDateSearchParam, deriveComparisonRange, encodeDateToSearchParam, + encodeRangeToSearchParams, hasPrimaryDateDraft, useStagedValue, } from '@jetpack-premium-analytics/routing'; @@ -198,19 +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 ) { - 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() ) + Object.assign( + patch, + encodeRangeToSearchParams( + { from: nextRange.from, to: nextRange.to }, + { presetId: nextPresetId } + ) ); } 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/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..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 @@ -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,57 @@ 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' + ); + } ); + + // 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( + '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..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 @@ -1,7 +1,16 @@ /** * External dependencies */ -import { dateToISOStringWithLocalTZ, localTZDate } from '@jetpack-premium-analytics/datetime'; +import { + dateToISOStringWithLocalTZ, + endOfDayTZ, + isSelectablePreset, + isYearSurfacePresetId, + localTZDate, + reportingTimeZone, + type DateRange, + type PrimaryPresetId, +} from '@jetpack-premium-analytics/datetime'; import { isValid } from 'date-fns'; /** @@ -31,3 +40,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?: PrimaryPresetId; 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 ) || isYearSurfacePresetId( 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';