Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Report date filters: stage a date range through one shared bounds helper in both pickers.
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -119,6 +120,10 @@ describe( 'reportParamsAttributeField', () => {
} );

describe( 'report params field', () => {
afterEach( () => {
jest.useRealTimers();
} );

it( 'offers no bucket control by default', () => {
renderField();

Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
*/
import { resolveIntervalForRange, type ReportQueryParams } from '@jetpack-premium-analytics/data';
import {
endOfDayTZ,
isSelectablePreset,
reportingTimeZone,
type ComparisonPresetId,
type DateRange,
type PrimaryPresetId,
Expand All @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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'
);
} );
} );
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit wider than the rule it replaces: year-surface presets now skip the end-of-day move too. Looks behavior-neutral since those ranges already end at the site's end of day, but it isn't mentioned in the description. Might be worth a sentence?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, that's intentional but undocumented. I checked all five year-surface presets and each already ends at the site's end of day, so it's a no-op. Added a sentence and a test.

exactRange || isSelectablePreset( presetId ) || isYearSurfacePresetId( presetId )
? range.to
: endOfDayTZ( range.to, reportingTimeZone() )
),
};
}
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export { decodeDateSearchParam, encodeDateToSearchParam } from './date-range';
export {
decodeDateSearchParam,
encodeDateToSearchParam,
encodeRangeToSearchParams,
} from './date-range';
Loading