From 690ceaf9173faefa59b038d345387710b5c672fc Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 15:11:49 +0800 Subject: [PATCH 1/8] refactor: type DateRange as a zoned instant (WOOA7S-2100) --- .../change-wooa7s-2100-daterange-zoned | 4 + .../packages/datetime/README.md | 17 ++ .../src/__tests__/comparison-presets.test.ts | 56 +++- .../src/__tests__/date-range-span.test.ts | 43 ++- .../__tests__/get-comparison-range.test.ts | 264 ++++++++++-------- .../src/__tests__/site-datetime.test.ts | 9 +- .../src/__tests__/step-date-range.test.ts | 50 +++- .../packages/datetime/src/date-range-span.ts | 9 +- .../packages/datetime/src/drill-date-range.ts | 9 +- .../datetime/src/get-comparison-range.ts | 30 +- .../packages/datetime/src/index.ts | 1 + .../packages/datetime/src/presets/primary.ts | 18 +- .../packages/datetime/src/site-datetime.ts | 16 +- .../packages/datetime/src/step-date-range.ts | 6 +- .../packages/datetime/src/to-date-range.ts | 3 +- .../packages/datetime/src/tz.ts | 4 +- .../src/date/__fixtures__/wp-date-settings.ts | 5 +- .../date/__tests__/format-date-range.test.ts | 3 +- .../__tests__/build-range-patch.test.ts | 22 +- .../use-report-date-filters.test.tsx | 63 +++-- .../build-range-patch.ts | 4 +- .../use-report-date-filters.tsx | 14 +- .../src/search/date-range/date-range.ts | 3 +- .../date-comparison-dropdown.test.tsx | 5 +- .../date-comparison-dropdown.stories.tsx | 3 +- .../__tests__/date-filters-panel.test.tsx | 25 +- .../__tests__/date-period-dropdown.test.tsx | 5 +- .../date-period-dropdown.tsx | 3 +- .../stories/date-period-dropdown.stories.tsx | 7 +- .../date-period-navigation.stories.tsx | 19 +- .../date-range-popover/date-range-filter.tsx | 27 +- .../ui/src/date-range-popover/index.ts | 1 - .../stories/date-range-popover.stories.tsx | 2 +- .../src/date-year-filter/date-year-filter.tsx | 2 +- .../use-comparison-date-presets.ts | 5 +- .../__tests__/report-page-layout.test.tsx | 9 +- .../routes/detail-header.test.ts | 3 +- .../post-header-slots.test.tsx | 5 +- .../video-header-slots.test.tsx | 5 +- 39 files changed, 496 insertions(+), 283 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned diff --git a/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned b/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned new file mode 100644 index 000000000000..c1f6dc47be45 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Date range: Type the range bounds as zoned instants, so a span cannot be measured in the browser's timezone. diff --git a/projects/packages/premium-analytics/packages/datetime/README.md b/projects/packages/premium-analytics/packages/datetime/README.md index fe4dc2a7e988..22491d4e7580 100644 --- a/projects/packages/premium-analytics/packages/datetime/README.md +++ b/projects/packages/premium-analytics/packages/datetime/README.md @@ -237,11 +237,28 @@ not the browser's. ```typescript type DateRange = { + from?: TZDate; + to?: TZDate; +}; +``` + +Zoned, so `getDateRangeSpan` and the steppers cut day boundaries on the site's +clock. A plain `Date` names the same instant but reads its day in the browser's +zone, which measures a 30 day window as 31. + +### `EditedDateRange` + +```typescript +type EditedDateRange = { from?: Date; to?: Date; }; ``` +What a date picker hands back, before anything anchors it: a calendar click +reports the day in the browser's zone. `buildRangePatch` anchors these; nothing +measures a span on one. + ### `ComparisonPresetId` ```typescript diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/comparison-presets.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/comparison-presets.test.ts index 6fedf0120a63..d71866dc6038 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/comparison-presets.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/comparison-presets.test.ts @@ -1,8 +1,42 @@ +/** + * External dependencies + */ /** * Internal dependencies */ import { COMPARISON_PRESETS, isComparisonPresetId } from '../get-comparison-range'; import { getComparisonOptions } from '../presets'; +import { createTZDateFromParts } from '../tz'; +import type { TZDate } from '@date-fns/tz'; +/** + * A site timezone with a fixed offset, so every expectation below holds + * whatever timezone the machine running the suite is in. + */ +const SITE_ZONE = 'Asia/Taipei'; + +/** + * Build a site-local date from the parts `new Date()` takes. + * + * @param year - Full year. + * @param month - 0-indexed month. + * @param day - Day of month. + * @param hours - Hour of day. + * @param minutes - Minute of hour. + * @param seconds - Second of minute. + * @param ms - Millisecond of second. + * @return The date. + */ +function siteDate( + year: number, + month: number, + day: number, + hours = 0, + minutes = 0, + seconds = 0, + ms = 0 +): TZDate { + return createTZDateFromParts( [ year, month, day, hours, minutes, seconds, ms ], SITE_ZONE ); +} /** * A day-aligned range, inclusive on both ends. Months are 0-based. @@ -10,8 +44,8 @@ import { getComparisonOptions } from '../presets'; * @param to */ const daysRange = ( from: [ number, number, number ], to: [ number, number, number ] ) => ( { - from: new Date( from[ 0 ], from[ 1 ], from[ 2 ], 0, 0, 0, 0 ), - to: new Date( to[ 0 ], to[ 1 ], to[ 2 ], 23, 59, 59, 999 ), + from: siteDate( from[ 0 ], from[ 1 ], from[ 2 ], 0, 0, 0, 0 ), + to: siteDate( to[ 0 ], to[ 1 ], to[ 2 ], 23, 59, 59, 999 ), } ); const ids = ( range: Parameters< typeof getComparisonOptions >[ 0 ] ) => @@ -42,7 +76,7 @@ describe( 'comparison options', () => { it( 'returns nothing for an incomplete or inverted range', () => { expect( getComparisonOptions( {} ) ).toEqual( [] ); - expect( getComparisonOptions( { from: new Date( 2026, 7, 30 ) } ) ).toEqual( [] ); + expect( getComparisonOptions( { from: siteDate( 2026, 7, 30 ) } ) ).toEqual( [] ); expect( getComparisonOptions( daysRange( [ 2026, 7, 30 ], [ 2026, 7, 29 ] ) ) ).toEqual( [] ); } ); @@ -65,8 +99,8 @@ describe( 'comparison options', () => { it( 'reads a rolling 24-hour window in hours', () => { const last24Hours = { - from: new Date( 2026, 7, 30, 15, 0, 0, 0 ), - to: new Date( 2026, 7, 31, 14, 59, 59, 999 ), + from: siteDate( 2026, 7, 30, 15, 0, 0, 0 ), + to: siteDate( 2026, 7, 31, 14, 59, 59, 999 ), }; const options = getComparisonOptions( last24Hours ); @@ -79,8 +113,8 @@ describe( 'comparison options', () => { ] ); expect( options[ 0 ].label ).toBe( 'Previous 24 hours' ); expect( options[ 1 ].range ).toEqual( { - from: new Date( 2026, 7, 23, 15, 0, 0, 0 ), - to: new Date( 2026, 7, 24, 14, 59, 59, 999 ), + from: siteDate( 2026, 7, 23, 15, 0, 0, 0 ), + to: siteDate( 2026, 7, 24, 14, 59, 59, 999 ), } ); } ); @@ -199,16 +233,16 @@ describe( 'comparison options', () => { it( 'reads a drilled single hour as the previous hour', () => { const hour = { - from: new Date( 2026, 7, 31, 14, 0, 0, 0 ), - to: new Date( 2026, 7, 31, 14, 59, 59, 999 ), + from: siteDate( 2026, 7, 31, 14, 0, 0, 0 ), + to: siteDate( 2026, 7, 31, 14, 59, 59, 999 ), }; const options = getComparisonOptions( hour ); expect( options[ 0 ].label ).toBe( 'Previous hour' ); expect( options[ 0 ].range ).toEqual( { - from: new Date( 2026, 7, 31, 13, 0, 0, 0 ), - to: new Date( 2026, 7, 31, 13, 59, 59, 999 ), + from: siteDate( 2026, 7, 31, 13, 0, 0, 0 ), + to: siteDate( 2026, 7, 31, 13, 59, 59, 999 ), } ); } ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts index 77bb76ae6686..58dc4f072cab 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts @@ -1,12 +1,21 @@ +/** + * External dependencies + */ /** * Internal dependencies */ import { getDateRangeSpan } from '../date-range-span'; +import { createTZDateFromParts } from '../tz'; +import type { TZDate } from '@date-fns/tz'; + +/** + * A site timezone with a fixed offset, so every expectation below holds + * whatever timezone the machine running the suite is in. + */ +const SITE_ZONE = 'Asia/Taipei'; /** - * Build a local-time date. Dates are constructed from parts rather than parsed - * from ISO strings so the day boundaries land in the machine's timezone, which - * is the frame `date-fns` reads. + * Build a site-local date. * * @param year - Full year. * @param month - 1-indexed month. @@ -15,8 +24,8 @@ import { getDateRangeSpan } from '../date-range-span'; * @param minutes - Minute of hour. * @return The date. */ -function at( year: number, month: number, day: number, hours = 0, minutes = 0 ): Date { - return new Date( year, month - 1, day, hours, minutes, 0, 0 ); +function at( year: number, month: number, day: number, hours = 0, minutes = 0 ): TZDate { + return createTZDateFromParts( [ year, month - 1, day, hours, minutes, 0, 0 ], SITE_ZONE ); } /** @@ -27,8 +36,8 @@ function at( year: number, month: number, day: number, hours = 0, minutes = 0 ): * @param day - Day of month. * @return The end of that day. */ -function endOf( year: number, month: number, day: number ): Date { - return new Date( year, month - 1, day, 23, 59, 59, 999 ); +function endOf( year: number, month: number, day: number ): TZDate { + return createTZDateFromParts( [ year, month - 1, day, 23, 59, 59, 999 ], SITE_ZONE ); } describe( 'getDateRangeSpan', () => { @@ -61,7 +70,7 @@ describe( 'getDateRangeSpan', () => { expect( getDateRangeSpan( { from: at( 2026, 7, 28, 19 ), - to: new Date( 2026, 6, 29, 18, 59, 59, 999 ), + to: createTZDateFromParts( [ 2026, 6, 29, 18, 59, 59, 999 ], SITE_ZONE ), } ) ).toEqual( { unit: 'hour', value: 24 } ); } ); @@ -119,6 +128,24 @@ describe( 'getDateRangeSpan', () => { } ); } ); + it( "measures a 30 day window in the site zone, not the browser's", () => { + const from = at( 2026, 6, 29 ); + const to = endOf( 2026, 7, 28 ); + + expect( getDateRangeSpan( { from, to } ) ).toEqual( { unit: 'day', value: 30 } ); + + // The same instants read without the zone: `coversWholeDays` no longer + // recognises the site's midnight, and the fallback counts a 31st day. + expect( + getDateRangeSpan( { + // @ts-expect-error -- `DateRange` rejects a zone-naive bound; this is what it prevents. + from: new Date( from.getTime() ), + // @ts-expect-error -- as above. + to: new Date( to.getTime() ), + } ) + ).toEqual( { unit: 'day', value: 31 } ); + } ); + it( 'falls back to days when the range does not divide into months', () => { expect( getDateRangeSpan( { from: at( 2026, 4, 30 ), to: endOf( 2026, 7, 28 ) } ) ).toEqual( { unit: 'day', diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/get-comparison-range.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/get-comparison-range.test.ts index 5b668b729484..6f3d8e7e7b4a 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/get-comparison-range.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/get-comparison-range.test.ts @@ -9,32 +9,62 @@ import { getDateRangeSpan } from '../date-range-span'; import { COMPARISON_PRESETS, getComparisonRangeFromPreset } from '../get-comparison-range'; import { stepDateRange } from '../step-date-range'; import { createTZDateFromParts } from '../tz'; +import type { TZDate } from '@date-fns/tz'; +/** + * A site timezone with a fixed offset, so every expectation below holds + * whatever timezone the machine running the suite is in. + */ +const SITE_ZONE = 'Asia/Taipei'; + +/** + * Build a site-local date from the parts `new Date()` takes. + * + * @param year - Full year. + * @param month - 0-indexed month. + * @param day - Day of month. + * @param hours - Hour of day. + * @param minutes - Minute of hour. + * @param seconds - Second of minute. + * @param ms - Millisecond of second. + * @return The date. + */ +function siteDate( + year: number, + month: number, + day: number, + hours = 0, + minutes = 0, + seconds = 0, + ms = 0 +): TZDate { + return createTZDateFromParts( [ year, month, day, hours, minutes, seconds, ms ], SITE_ZONE ); +} describe( 'getComparisonRangeFromPreset', () => { it( 'returns undefined when the reference range is incomplete', () => { expect( - getComparisonRangeFromPreset( { from: new Date( 2026, 6, 1 ) }, 'previous-period' ) + getComparisonRangeFromPreset( { from: siteDate( 2026, 6, 1 ) }, 'previous-period' ) ).toBeUndefined(); expect( getComparisonRangeFromPreset( {}, 'previous-period' ) ).toBeUndefined(); } ); describe( 'day-aligned references', () => { const reference = { - from: new Date( 2026, 5, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 5, 7, 23, 59, 59, 999 ), + from: siteDate( 2026, 5, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 5, 7, 23, 59, 59, 999 ), }; it( 'mirrors the previous period on day bounds', () => { expect( getComparisonRangeFromPreset( reference, 'previous-period' ) ).toEqual( { - from: new Date( 2026, 4, 25, 0, 0, 0, 0 ), - to: new Date( 2026, 4, 31, 23, 59, 59, 999 ), + from: siteDate( 2026, 4, 25, 0, 0, 0, 0 ), + to: siteDate( 2026, 4, 31, 23, 59, 59, 999 ), } ); } ); it( 'shifts the previous month, clamping to day bounds', () => { expect( getComparisonRangeFromPreset( reference, 'previous-month' ) ).toEqual( { - from: new Date( 2026, 4, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 4, 7, 23, 59, 59, 999 ), + from: siteDate( 2026, 4, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 4, 7, 23, 59, 59, 999 ), } ); } ); } ); @@ -43,14 +73,14 @@ describe( 'getComparisonRangeFromPreset', () => { // A rolling 24-hour window ending mid-afternoon, ends inclusive as the // presets build them (`endOfHour`). const reference = { - from: new Date( 2026, 6, 9, 14, 30, 0, 0 ), - to: new Date( 2026, 6, 10, 14, 29, 59, 999 ), + from: siteDate( 2026, 6, 9, 14, 30, 0, 0 ), + to: siteDate( 2026, 6, 10, 14, 29, 59, 999 ), }; it( 'mirrors the exact previous window for previous-period', () => { expect( getComparisonRangeFromPreset( reference, 'previous-period' ) ).toEqual( { - from: new Date( 2026, 6, 8, 14, 30, 0, 0 ), - to: new Date( 2026, 6, 9, 14, 29, 59, 999 ), + from: siteDate( 2026, 6, 8, 14, 30, 0, 0 ), + to: siteDate( 2026, 6, 9, 14, 29, 59, 999 ), } ); } ); @@ -64,27 +94,27 @@ describe( 'getComparisonRangeFromPreset', () => { // The `last-24-hours` shape: shifting by the exclusive span landed `to` on // the reference's own `from`, pulling hourly buckets one hour late. const last24Hours = { - from: new Date( 2026, 7, 17, 15, 0, 0, 0 ), - to: new Date( 2026, 7, 18, 14, 59, 59, 999 ), + from: siteDate( 2026, 7, 17, 15, 0, 0, 0 ), + to: siteDate( 2026, 7, 18, 14, 59, 59, 999 ), }; expect( getComparisonRangeFromPreset( last24Hours, 'previous-period' ) ).toEqual( { - from: new Date( 2026, 7, 16, 15, 0, 0, 0 ), - to: new Date( 2026, 7, 17, 14, 59, 59, 999 ), + from: siteDate( 2026, 7, 16, 15, 0, 0, 0 ), + to: siteDate( 2026, 7, 17, 14, 59, 59, 999 ), } ); } ); it( 'keeps the time of day for previous-month', () => { expect( getComparisonRangeFromPreset( reference, 'previous-month' ) ).toEqual( { - from: new Date( 2026, 5, 9, 14, 30, 0, 0 ), - to: new Date( 2026, 5, 10, 14, 29, 59, 999 ), + from: siteDate( 2026, 5, 9, 14, 30, 0, 0 ), + to: siteDate( 2026, 5, 10, 14, 29, 59, 999 ), } ); } ); it( 'keeps the time of day for previous-year', () => { expect( getComparisonRangeFromPreset( reference, 'previous-year' ) ).toEqual( { - from: new Date( 2025, 6, 9, 14, 30, 0, 0 ), - to: new Date( 2025, 6, 10, 14, 29, 59, 999 ), + from: siteDate( 2025, 6, 9, 14, 30, 0, 0 ), + to: siteDate( 2025, 6, 10, 14, 29, 59, 999 ), } ); } ); } ); @@ -93,8 +123,8 @@ describe( 'getComparisonRangeFromPreset', () => { // A rolling 24-hour window at the end of March; February is shorter, // so a plain calendar shift would collapse both endpoints onto Feb 28. const endOfMarch = { - from: new Date( 2026, 2, 30, 14, 0, 0, 0 ), - to: new Date( 2026, 2, 31, 14, 0, 0, 0 ), + from: siteDate( 2026, 2, 30, 14, 0, 0, 0 ), + to: siteDate( 2026, 2, 31, 14, 0, 0, 0 ), }; it.each( COMPARISON_PRESETS )( 'preserves the window duration for %s', presetId => { @@ -106,32 +136,32 @@ describe( 'getComparisonRangeFromPreset', () => { it( 'keeps a 24h window for previous-month when both endpoints would clamp', () => { expect( getComparisonRangeFromPreset( endOfMarch, 'previous-month' ) ).toEqual( { - from: new Date( 2026, 1, 27, 14, 0, 0, 0 ), - to: new Date( 2026, 1, 28, 14, 0, 0, 0 ), + from: siteDate( 2026, 1, 27, 14, 0, 0, 0 ), + to: siteDate( 2026, 1, 28, 14, 0, 0, 0 ), } ); } ); it( 'keeps a 48h window for previous-month when one endpoint would clamp', () => { const rolling48h = { - from: new Date( 2026, 2, 30, 14, 0, 0, 0 ), - to: new Date( 2026, 3, 1, 14, 0, 0, 0 ), + from: siteDate( 2026, 2, 30, 14, 0, 0, 0 ), + to: siteDate( 2026, 3, 1, 14, 0, 0, 0 ), }; expect( getComparisonRangeFromPreset( rolling48h, 'previous-month' ) ).toEqual( { - from: new Date( 2026, 1, 27, 14, 0, 0, 0 ), - to: new Date( 2026, 2, 1, 14, 0, 0, 0 ), + from: siteDate( 2026, 1, 27, 14, 0, 0, 0 ), + to: siteDate( 2026, 2, 1, 14, 0, 0, 0 ), } ); } ); it( 'keeps a 24h window for previous-year across leap day', () => { const leapDay = { - from: new Date( 2028, 1, 28, 14, 0, 0, 0 ), - to: new Date( 2028, 1, 29, 14, 0, 0, 0 ), + from: siteDate( 2028, 1, 28, 14, 0, 0, 0 ), + to: siteDate( 2028, 1, 29, 14, 0, 0, 0 ), }; expect( getComparisonRangeFromPreset( leapDay, 'previous-year' ) ).toEqual( { - from: new Date( 2027, 1, 27, 14, 0, 0, 0 ), - to: new Date( 2027, 1, 28, 14, 0, 0, 0 ), + from: siteDate( 2027, 1, 27, 14, 0, 0, 0 ), + to: siteDate( 2027, 1, 28, 14, 0, 0, 0 ), } ); } ); } ); @@ -140,38 +170,38 @@ describe( 'getComparisonRangeFromPreset', () => { it.each( [ [ 'a rolling 30-day window', - new Date( 2026, 6, 21, 0, 0, 0, 0 ), - new Date( 2026, 7, 19, 23, 59, 59, 999 ), - new Date( 2026, 5, 20, 0, 0, 0, 0 ), - new Date( 2026, 6, 19, 23, 59, 59, 999 ), + siteDate( 2026, 6, 21, 0, 0, 0, 0 ), + siteDate( 2026, 7, 19, 23, 59, 59, 999 ), + siteDate( 2026, 5, 20, 0, 0, 0, 0 ), + siteDate( 2026, 6, 19, 23, 59, 59, 999 ), ], [ 'a window whose end clamps in a shorter month', - new Date( 2026, 2, 2, 0, 0, 0, 0 ), - new Date( 2026, 2, 31, 23, 59, 59, 999 ), - new Date( 2026, 0, 30, 0, 0, 0, 0 ), - new Date( 2026, 1, 28, 23, 59, 59, 999 ), + siteDate( 2026, 2, 2, 0, 0, 0, 0 ), + siteDate( 2026, 2, 31, 23, 59, 59, 999 ), + siteDate( 2026, 0, 30, 0, 0, 0, 0 ), + siteDate( 2026, 1, 28, 23, 59, 59, 999 ), ], [ 'a week spanning a year boundary', - new Date( 2025, 11, 29, 0, 0, 0, 0 ), - new Date( 2026, 0, 4, 23, 59, 59, 999 ), - new Date( 2025, 10, 28, 0, 0, 0, 0 ), - new Date( 2025, 11, 4, 23, 59, 59, 999 ), + siteDate( 2025, 11, 29, 0, 0, 0, 0 ), + siteDate( 2026, 0, 4, 23, 59, 59, 999 ), + siteDate( 2025, 10, 28, 0, 0, 0, 0 ), + siteDate( 2025, 11, 4, 23, 59, 59, 999 ), ], [ 'a window that starts on the 1st but stops short of the month end', - new Date( 2026, 2, 1, 0, 0, 0, 0 ), - new Date( 2026, 2, 15, 23, 59, 59, 999 ), - new Date( 2026, 1, 1, 0, 0, 0, 0 ), - new Date( 2026, 1, 15, 23, 59, 59, 999 ), + siteDate( 2026, 2, 1, 0, 0, 0, 0 ), + siteDate( 2026, 2, 15, 23, 59, 59, 999 ), + siteDate( 2026, 1, 1, 0, 0, 0, 0 ), + siteDate( 2026, 1, 15, 23, 59, 59, 999 ), ], [ 'a window whose start has no counterpart a month back', - new Date( 2026, 0, 31, 0, 0, 0, 0 ), - new Date( 2026, 2, 1, 23, 59, 59, 999 ), - new Date( 2026, 0, 3, 0, 0, 0, 0 ), - new Date( 2026, 1, 1, 23, 59, 59, 999 ), + siteDate( 2026, 0, 31, 0, 0, 0, 0 ), + siteDate( 2026, 2, 1, 23, 59, 59, 999 ), + siteDate( 2026, 0, 3, 0, 0, 0, 0 ), + siteDate( 2026, 1, 1, 23, 59, 59, 999 ), ], ] )( 'keeps the reference length for previous-month with %s', @@ -185,13 +215,13 @@ describe( 'getComparisonRangeFromPreset', () => { it( 'keeps the reference length for previous-year across a leap day', () => { const reference = { - from: new Date( 2028, 1, 20, 0, 0, 0, 0 ), - to: new Date( 2028, 2, 5, 23, 59, 59, 999 ), + from: siteDate( 2028, 1, 20, 0, 0, 0, 0 ), + to: siteDate( 2028, 2, 5, 23, 59, 59, 999 ), }; expect( getComparisonRangeFromPreset( reference, 'previous-year' ) ).toEqual( { - from: new Date( 2027, 1, 19, 0, 0, 0, 0 ), - to: new Date( 2027, 2, 5, 23, 59, 59, 999 ), + from: siteDate( 2027, 1, 19, 0, 0, 0, 0 ), + to: siteDate( 2027, 2, 5, 23, 59, 59, 999 ), } ); } ); @@ -199,8 +229,8 @@ describe( 'getComparisonRangeFromPreset', () => { 'covers the same number of days as the reference for %s', presetId => { const reference = { - from: new Date( 2026, 0, 31, 0, 0, 0, 0 ), - to: new Date( 2026, 2, 1, 23, 59, 59, 999 ), + from: siteDate( 2026, 0, 31, 0, 0, 0, 0 ), + to: siteDate( 2026, 2, 1, 23, 59, 59, 999 ), }; const comparison = getComparisonRangeFromPreset( reference, presetId ); @@ -212,37 +242,37 @@ describe( 'getComparisonRangeFromPreset', () => { describe( 'whole calendar months', () => { it( 'sets a whole month against the whole month before it', () => { const march = { - from: new Date( 2026, 2, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 2, 31, 23, 59, 59, 999 ), + from: siteDate( 2026, 2, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 2, 31, 23, 59, 59, 999 ), }; expect( getComparisonRangeFromPreset( march, 'previous-month' ) ).toEqual( { - from: new Date( 2026, 1, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 1, 28, 23, 59, 59, 999 ), + from: siteDate( 2026, 1, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 1, 28, 23, 59, 59, 999 ), } ); } ); it( 'keeps a multi-month window on month bounds', () => { const janToFeb = { - from: new Date( 2026, 0, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 1, 28, 23, 59, 59, 999 ), + from: siteDate( 2026, 0, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 1, 28, 23, 59, 59, 999 ), }; expect( getComparisonRangeFromPreset( janToFeb, 'previous-month' ) ).toEqual( { - from: new Date( 2025, 11, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 0, 31, 23, 59, 59, 999 ), + from: siteDate( 2025, 11, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 0, 31, 23, 59, 59, 999 ), } ); } ); it( 'sets a leap February against the shorter one a year back', () => { const february2028 = { - from: new Date( 2028, 1, 1, 0, 0, 0, 0 ), - to: new Date( 2028, 1, 29, 23, 59, 59, 999 ), + from: siteDate( 2028, 1, 1, 0, 0, 0, 0 ), + to: siteDate( 2028, 1, 29, 23, 59, 59, 999 ), }; expect( getComparisonRangeFromPreset( february2028, 'previous-year' ) ).toEqual( { - from: new Date( 2027, 1, 1, 0, 0, 0, 0 ), - to: new Date( 2027, 1, 28, 23, 59, 59, 999 ), + from: siteDate( 2027, 1, 1, 0, 0, 0, 0 ), + to: siteDate( 2027, 1, 28, 23, 59, 59, 999 ), } ); } ); @@ -268,13 +298,13 @@ describe( 'getComparisonRangeFromPreset', () => { */ it( 'sets a whole month against the whole month before it for previous-period', () => { const march = { - from: new Date( 2026, 2, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 2, 31, 23, 59, 59, 999 ), + from: siteDate( 2026, 2, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 2, 31, 23, 59, 59, 999 ), }; expect( getComparisonRangeFromPreset( march, 'previous-period' ) ).toEqual( { - from: new Date( 2026, 1, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 1, 28, 23, 59, 59, 999 ), + from: siteDate( 2026, 1, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 1, 28, 23, 59, 59, 999 ), } ); } ); @@ -284,13 +314,13 @@ describe( 'getComparisonRangeFromPreset', () => { */ it( 'sets a calendar year against the previous calendar year for previous-period', () => { const year2025 = { - from: new Date( 2025, 0, 1, 0, 0, 0, 0 ), - to: new Date( 2025, 11, 31, 23, 59, 59, 999 ), + from: siteDate( 2025, 0, 1, 0, 0, 0, 0 ), + to: siteDate( 2025, 11, 31, 23, 59, 59, 999 ), }; expect( getComparisonRangeFromPreset( year2025, 'previous-period' ) ).toEqual( { - from: new Date( 2024, 0, 1, 0, 0, 0, 0 ), - to: new Date( 2024, 11, 31, 23, 59, 59, 999 ), + from: siteDate( 2024, 0, 1, 0, 0, 0, 0 ), + to: siteDate( 2024, 11, 31, 23, 59, 59, 999 ), } ); } ); @@ -301,13 +331,13 @@ describe( 'getComparisonRangeFromPreset', () => { */ it( 'steps a rolling 12-month window back by its month count for previous-period', () => { const last12Months = { - from: new Date( 2025, 7, 31, 0, 0, 0, 0 ), - to: new Date( 2026, 7, 30, 23, 59, 59, 999 ), + from: siteDate( 2025, 7, 31, 0, 0, 0, 0 ), + to: siteDate( 2026, 7, 30, 23, 59, 59, 999 ), }; expect( getComparisonRangeFromPreset( last12Months, 'previous-period' ) ).toEqual( { - from: new Date( 2024, 7, 31, 0, 0, 0, 0 ), - to: new Date( 2025, 7, 30, 23, 59, 59, 999 ), + from: siteDate( 2024, 7, 31, 0, 0, 0, 0 ), + to: siteDate( 2025, 7, 30, 23, 59, 59, 999 ), } ); } ); } ); @@ -315,25 +345,25 @@ describe( 'getComparisonRangeFromPreset', () => { describe( 'previous-week', () => { it( 'shifts a day-aligned range back seven days on day bounds', () => { const yesterday = { - from: new Date( 2026, 7, 30, 0, 0, 0, 0 ), - to: new Date( 2026, 7, 30, 23, 59, 59, 999 ), + from: siteDate( 2026, 7, 30, 0, 0, 0, 0 ), + to: siteDate( 2026, 7, 30, 23, 59, 59, 999 ), }; expect( getComparisonRangeFromPreset( yesterday, 'previous-week' ) ).toEqual( { - from: new Date( 2026, 7, 23, 0, 0, 0, 0 ), - to: new Date( 2026, 7, 23, 23, 59, 59, 999 ), + from: siteDate( 2026, 7, 23, 0, 0, 0, 0 ), + to: siteDate( 2026, 7, 23, 23, 59, 59, 999 ), } ); } ); it( 'keeps the time of day for a rolling window', () => { const rolling = { - from: new Date( 2026, 6, 9, 14, 30, 0, 0 ), - to: new Date( 2026, 6, 10, 14, 29, 59, 999 ), + from: siteDate( 2026, 6, 9, 14, 30, 0, 0 ), + to: siteDate( 2026, 6, 10, 14, 29, 59, 999 ), }; expect( getComparisonRangeFromPreset( rolling, 'previous-week' ) ).toEqual( { - from: new Date( 2026, 6, 2, 14, 30, 0, 0 ), - to: new Date( 2026, 6, 3, 14, 29, 59, 999 ), + from: siteDate( 2026, 6, 2, 14, 30, 0, 0 ), + to: siteDate( 2026, 6, 3, 14, 29, 59, 999 ), } ); } ); @@ -343,8 +373,8 @@ describe( 'getComparisonRangeFromPreset', () => { */ it( 'matches the previous period exactly at seven days', () => { const week = { - from: new Date( 2026, 5, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 5, 7, 23, 59, 59, 999 ), + from: siteDate( 2026, 5, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 5, 7, 23, 59, 59, 999 ), }; expect( getComparisonRangeFromPreset( week, 'previous-week' ) ).toEqual( @@ -360,14 +390,14 @@ describe( 'getComparisonRangeFromPreset', () => { expect( getComparisonRangeFromPreset( { - from: new Date( 2025, 0, 1, 0, 0, 0, 0 ), - to: new Date( 2025, 11, 31, 23, 59, 59, 999 ), + from: siteDate( 2025, 0, 1, 0, 0, 0, 0 ), + to: siteDate( 2025, 11, 31, 23, 59, 59, 999 ), }, 'previous-period' ) ).toEqual( { - from: new Date( 2024, 0, 1, 0, 0, 0, 0 ), - to: new Date( 2024, 11, 31, 23, 59, 59, 999 ), + from: siteDate( 2024, 0, 1, 0, 0, 0, 0 ), + to: siteDate( 2024, 11, 31, 23, 59, 59, 999 ), } ); } ); @@ -375,14 +405,14 @@ describe( 'getComparisonRangeFromPreset', () => { expect( getComparisonRangeFromPreset( { - from: new Date( 2025, 7, 20, 0, 0, 0, 0 ), - to: new Date( 2026, 7, 19, 23, 59, 59, 999 ), + from: siteDate( 2025, 7, 20, 0, 0, 0, 0 ), + to: siteDate( 2026, 7, 19, 23, 59, 59, 999 ), }, 'previous-period' ) ).toEqual( { - from: new Date( 2024, 7, 20, 0, 0, 0, 0 ), - to: new Date( 2025, 7, 19, 23, 59, 59, 999 ), + from: siteDate( 2024, 7, 20, 0, 0, 0, 0 ), + to: siteDate( 2025, 7, 19, 23, 59, 59, 999 ), } ); } ); @@ -392,12 +422,12 @@ describe( 'getComparisonRangeFromPreset', () => { // reference's 59. The step arrows count days there, and a comparison // naming a different window than the arrow would is a defect. const clamping = { - from: new Date( 2026, 0, 31, 0, 0, 0, 0 ), - to: new Date( 2026, 2, 30, 23, 59, 59, 999 ), + from: siteDate( 2026, 0, 31, 0, 0, 0, 0 ), + to: siteDate( 2026, 2, 30, 23, 59, 59, 999 ), }; const expected = { - from: new Date( 2025, 11, 3, 0, 0, 0, 0 ), - to: new Date( 2026, 0, 30, 23, 59, 59, 999 ), + from: siteDate( 2025, 11, 3, 0, 0, 0, 0 ), + to: siteDate( 2026, 0, 30, 23, 59, 59, 999 ), }; expect( getComparisonRangeFromPreset( clamping, 'previous-period' ) ).toEqual( expected ); @@ -410,14 +440,14 @@ describe( 'getComparisonRangeFromPreset', () => { expect( getComparisonRangeFromPreset( { - from: new Date( 2026, 0, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 1, 28, 23, 59, 59, 999 ), + from: siteDate( 2026, 0, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 1, 28, 23, 59, 59, 999 ), }, 'previous-period' ) ).toEqual( { - from: new Date( 2025, 10, 1, 0, 0, 0, 0 ), - to: new Date( 2025, 11, 31, 23, 59, 59, 999 ), + from: siteDate( 2025, 10, 1, 0, 0, 0, 0 ), + to: siteDate( 2025, 11, 31, 23, 59, 59, 999 ), } ); } ); } ); @@ -425,8 +455,8 @@ describe( 'getComparisonRangeFromPreset', () => { describe( 'to-date presets', () => { // `last-12-months` as read on 20 August 2026. const reference = { - from: new Date( 2025, 8, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 7, 20, 23, 59, 59, 999 ), + from: siteDate( 2025, 8, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 7, 20, 23, 59, 59, 999 ), }; it( 'steps the previous period back by the completed window', () => { @@ -436,7 +466,7 @@ describe( 'getComparisonRangeFromPreset', () => { getComparisonRangeFromPreset( reference, 'previous-period', { primaryPresetId: 'last-12-months', } )?.from - ).toEqual( new Date( 2024, 8, 1, 0, 0, 0, 0 ) ); + ).toEqual( siteDate( 2024, 8, 1, 0, 0, 0, 0 ) ); } ); it( 'stops the previous period as many days short as the reference does', () => { @@ -447,7 +477,7 @@ describe( 'getComparisonRangeFromPreset', () => { primaryPresetId: 'last-12-months', } ); - expect( comparison?.to ).toEqual( new Date( 2025, 7, 20, 23, 59, 59, 999 ) ); + expect( comparison?.to ).toEqual( siteDate( 2025, 7, 20, 23, 59, 59, 999 ) ); expect( differenceInDays( comparison!.to!, comparison!.from! ) ).toBe( differenceInDays( reference.to, reference.from ) ); @@ -461,16 +491,16 @@ describe( 'getComparisonRangeFromPreset', () => { primaryPresetId: 'last-12-months', } ) ).toEqual( { - from: new Date( 2024, 8, 1, 0, 0, 0, 0 ), - to: new Date( 2025, 7, 20, 23, 59, 59, 999 ), + from: siteDate( 2024, 8, 1, 0, 0, 0, 0 ), + to: siteDate( 2025, 7, 20, 23, 59, 59, 999 ), } ); expect( getComparisonRangeFromPreset( reference, 'previous-month', { primaryPresetId: 'last-12-months', } ) ).toEqual( { - from: new Date( 2025, 7, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 6, 20, 23, 59, 59, 999 ), + from: siteDate( 2025, 7, 1, 0, 0, 0, 0 ), + to: siteDate( 2026, 6, 20, 23, 59, 59, 999 ), } ); } ); @@ -479,8 +509,8 @@ describe( 'getComparisonRangeFromPreset', () => { expect( getComparisonRangeFromPreset( reference, 'previous-period', { primaryPresetId: 'custom' } ) ).toEqual( { - from: new Date( 2024, 8, 12, 0, 0, 0, 0 ), - to: new Date( 2025, 7, 31, 23, 59, 59, 999 ), + from: siteDate( 2024, 8, 12, 0, 0, 0, 0 ), + to: siteDate( 2025, 7, 31, 23, 59, 59, 999 ), } ); } ); } ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/site-datetime.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/site-datetime.test.ts index 1de69eac4339..bc4155fb0935 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/site-datetime.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/site-datetime.test.ts @@ -53,10 +53,15 @@ describe( 'parseSiteDateTime', () => { expect( date?.toISOString() ).toBe( '2026-07-09T04:12:57.000Z' ); } ); - it( 'accepts a Date instance unchanged', () => { + it( 'anchors a Date instance to the site timezone, keeping its instant', () => { const source = new Date( '2026-06-29T12:00:00.000Z' ); + const date = parseSiteDateTime( source ); - expect( parseSiteDateTime( source ) ).toBe( source ); + expect( date?.getTime() ).toBe( source.getTime() ); + expect( date?.timeZone ).toBe( 'Europe/Amsterdam' ); + // The zone is what the plain input could not carry: 12:00 UTC is 14:00 + // on the site's clock, and a browser-zone read would name another hour. + expect( date?.getHours() ).toBe( 14 ); } ); it( 'returns undefined for a malformed value', () => { diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/step-date-range.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/step-date-range.test.ts index e08e6339436b..5cafbdba0d76 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/step-date-range.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/step-date-range.test.ts @@ -7,11 +7,41 @@ import { differenceInCalendarDays } from 'date-fns'; * Internal dependencies */ import { canStepForward, stepDateRange } from '../step-date-range'; +import { createTZDateFromParts } from '../tz'; import type { DateRange } from '../get-comparison-range'; +/** + * A site timezone with a fixed offset, so every expectation below holds + * whatever timezone the machine running the suite is in. + */ +const SITE_ZONE = 'Asia/Taipei'; + +/** + * Build a site-local date from the parts `new Date()` takes. + * + * @param year - Full year. + * @param month - 0-indexed month. + * @param day - Day of month. + * @param hours - Hour of day. + * @param minutes - Minute of hour. + * @param seconds - Second of minute. + * @param ms - Millisecond of second. + * @return The date. + */ +function siteDate( + year: number, + month: number, + day: number, + hours = 0, + minutes = 0, + seconds = 0, + ms = 0 +): TZDate { + return createTZDateFromParts( [ year, month, day, hours, minutes, seconds, ms ], SITE_ZONE ); +} /** - * Build a local-time date, so day boundaries land in the machine's timezone the - * way the span helper reads them. + * Build a site-local date, so day boundaries land in the zone the span helper + * reads them in. * * @param year - Full year. * @param month - 1-based month. @@ -20,8 +50,8 @@ import type { DateRange } from '../get-comparison-range'; * @param minute - Minute of the hour. * @return The date. */ -function at( year: number, month: number, day: number, hour = 0, minute = 0 ): Date { - return new Date( year, month - 1, day, hour, minute ); +function at( year: number, month: number, day: number, hour = 0, minute = 0 ): TZDate { + return siteDate( year, month - 1, day, hour, minute ); } /** @@ -31,10 +61,10 @@ function at( year: number, month: number, day: number, hour = 0, minute = 0 ): D * @param to - Last day. * @return The inclusive whole-day range. */ -function wholeDays( from: Date, to: Date ) { +function wholeDays( from: TZDate, to: TZDate ) { return { - from: new Date( from.getFullYear(), from.getMonth(), from.getDate(), 0, 0, 0, 0 ), - to: new Date( to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59, 999 ), + from: siteDate( from.getFullYear(), from.getMonth(), from.getDate(), 0, 0, 0, 0 ), + to: siteDate( to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59, 999 ), }; } @@ -52,13 +82,13 @@ describe( 'stepDateRange', () => { // `last-24-hours`: hour boundaries, a millisecond short of 24 hours. const range = { from: at( 2026, 7, 9, 19 ), - to: new Date( 2026, 6, 10, 18, 59, 59, 999 ), + to: siteDate( 2026, 6, 10, 18, 59, 59, 999 ), }; const previous = stepDateRange( range, 'previous' ); expect( previous?.from ).toEqual( at( 2026, 7, 8, 19 ) ); - expect( previous?.to ).toEqual( new Date( 2026, 6, 9, 18, 59, 59, 999 ) ); + expect( previous?.to ).toEqual( siteDate( 2026, 6, 9, 18, 59, 59, 999 ) ); } ); it( 'moves a month-scale window by calendar months, not by days', () => { @@ -160,7 +190,7 @@ describe( 'canStepForward', () => { it( 'is true on the window right behind a live hour-snapped one', () => { const live = { from: at( 2026, 7, 26, 13 ), - to: new Date( 2026, 6, 27, 12, 59, 59, 999 ), + to: siteDate( 2026, 6, 27, 12, 59, 59, 999 ), }; const back = stepDateRange( live, 'previous' ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/date-range-span.ts b/projects/packages/premium-analytics/packages/datetime/src/date-range-span.ts index c255619e65d5..6be8f2111fa4 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/date-range-span.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/date-range-span.ts @@ -12,10 +12,11 @@ import { isSameDay, startOfDay, } from 'date-fns'; +import type { DateRange } from './get-comparison-range'; +import type { TZDate } from '@date-fns/tz'; /** * Internal dependencies */ -import type { DateRange } from './get-comparison-range'; /** * The unit a range's length is best described in. @@ -58,7 +59,7 @@ const MONTHS_PER_YEAR = 12; * @param to - Range end. * @return Whether both ends sit on a day boundary. */ -function coversWholeDays( from: Date, to: Date ): boolean { +function coversWholeDays( from: TZDate, to: TZDate ): boolean { return isEqual( from, startOfDay( from ) ) && isEqual( to, endOfDay( to ) ); } @@ -72,9 +73,7 @@ function coversWholeDays( from: Date, to: Date ): boolean { * @param to - Range end. * @return The month count, or null when the range is not a whole number of months. */ -function getWholeMonths( from: Date, to: Date ): number | null { - // `addDays` keeps the input's `Date` subclass, so a site-timezone `TZDate` - // stays anchored to that zone rather than the browser's. +function getWholeMonths( from: TZDate, to: TZDate ): number | null { const dayAfterTo = startOfDay( addDays( to, 1 ) ); const months = differenceInCalendarMonths( dayAfterTo, from ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/drill-date-range.ts b/projects/packages/premium-analytics/packages/datetime/src/drill-date-range.ts index 0c40e6ee79c0..de3211919b10 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/drill-date-range.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/drill-date-range.ts @@ -16,6 +16,7 @@ import { */ import type { DateRange } from './get-comparison-range'; import type { IntervalType } from './interval'; +import type { TZDate } from '@date-fns/tz'; /** * Bucket boundaries per interval a chart can draw. @@ -24,7 +25,7 @@ import type { IntervalType } from './interval'; * drawn in hours has nothing below it to open. */ const BUCKET_BOUNDS: Partial< - Record< IntervalType, { start: ( date: Date ) => Date; end: ( date: Date ) => Date } > + Record< IntervalType, { start: ( date: TZDate ) => TZDate; end: ( date: TZDate ) => TZDate } > > = { day: { start: startOfDay, end: endOfDay }, // ISO weeks, matching how the report's own week buckets are cut. @@ -44,7 +45,11 @@ const BUCKET_BOUNDS: Partial< * @param now - The current instant, for the clamp. * @return The bucket's range, or null when the interval has nothing below it. */ -export function drillDateRange( date: Date, interval: IntervalType, now: Date ): DateRange | null { +export function drillDateRange( + date: TZDate, + interval: IntervalType, + now: TZDate +): DateRange | null { const bounds = BUCKET_BOUNDS[ interval ]; if ( ! bounds ) { diff --git a/projects/packages/premium-analytics/packages/datetime/src/get-comparison-range.ts b/projects/packages/premium-analytics/packages/datetime/src/get-comparison-range.ts index b57b68a0e818..f87808fa0fc8 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/get-comparison-range.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/get-comparison-range.ts @@ -25,8 +25,26 @@ import { */ import { completeToDateRange } from './to-date-range'; import type { PrimaryPresetId } from './presets/types'; +import type { TZDate } from '@date-fns/tz'; -export type DateRange = { from?: Date; to?: Date }; +/** + * An inclusive range of instants, each anchored to the zone it was read in. + * + * Zoned rather than plain, so `getDateRangeSpan` measures day boundaries in the + * site's zone; a plain `Date` reads them in the browser's and lands a day out. + * Both bounds stay optional: `resolveBucketStamp` returns `undefined` for one it + * cannot resolve, and the chart passes that straight through. + */ +export type DateRange = { from?: TZDate; to?: TZDate }; + +/** + * A range as a date picker hands it back, before anything anchors it. + * + * A calendar click reports the day in the browser's zone, so these bounds name + * an instant the site may read as a different day. `buildRangePatch` is what + * anchors them; nothing measures a span on one. + */ +export type EditedDateRange = { from?: Date; to?: Date }; export const COMPARISON_PREVIOUS_PERIOD = 'previous-period' as const; export const COMPARISON_PREVIOUS_WEEK = 'previous-week' as const; @@ -62,7 +80,7 @@ export function isComparisonPresetId( value: unknown ): value is ComparisonPrese * @param to - Range end. * @return The inclusive day count. */ -function getInclusiveDayCount( from: Date, to: Date ): number { +function getInclusiveDayCount( from: TZDate, to: TZDate ): number { return differenceInDays( to, from ) + 1; } @@ -79,7 +97,7 @@ function getInclusiveDayCount( from: Date, to: Date ): number { * @param to - Range end. * @return The month count, or null. */ -export function getWholeMonthCount( from: Date, to: Date ): number | null { +export function getWholeMonthCount( from: TZDate, to: TZDate ): number | null { const isDayAligned = from.getTime() === startOfDay( from ).getTime() && to.getTime() === endOfDay( to ).getTime(); @@ -145,7 +163,7 @@ export function getComparisonRangeFromPreset( // duration: a calendar shift clamps day-of-month and would collapse the window. if ( ! isDayAligned ) { const windowMs = differenceInMilliseconds( refTo, refFrom ); - let to: Date; + let to: TZDate; if ( presetId === COMPARISON_PREVIOUS_PERIOD ) { // Both ends are inclusive, so the window lasts `windowMs + 1`; shifting @@ -167,7 +185,9 @@ export function getComparisonRangeFromPreset( }; } - const clampDayBound = ( date: Date, bound: 0 | 1 ) => + // Annotated: a nested `date-fns` call has no contextual type to infer the + // zoned subclass from, and would widen the result back to a plain `Date`. + const clampDayBound = ( date: TZDate, bound: 0 | 1 ): TZDate => bound === 1 ? endOfDay( startOfDay( date ) ) : startOfDay( date ); if ( presetId === COMPARISON_PREVIOUS_PERIOD ) { diff --git a/projects/packages/premium-analytics/packages/datetime/src/index.ts b/projects/packages/premium-analytics/packages/datetime/src/index.ts index d29ae84a9ebf..dcea8fe99f45 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/index.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/index.ts @@ -2,6 +2,7 @@ export { getComparisonRangeFromPreset, isComparisonPresetId, type DateRange, + type EditedDateRange, type ComparisonPresetId, } from './get-comparison-range'; export type { ComparisonRangeOptions } from './get-comparison-range'; diff --git a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts index 6abeb30b4e31..d0bc157ca098 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts @@ -46,18 +46,19 @@ import { type YearSurfacePresetId, } from './types'; import type { DateRange } from '../get-comparison-range'; +import type { TZDate } from '@date-fns/tz'; /** * Shared date calculations used by multiple presets. */ type DateContext = { - now: Date; - initOfToday: Date; - endOfToday: Date; - endOfYesterday: Date; - lastMonth: Date; - endOfLastMonth: Date; - lastYear: Date; + now: TZDate; + initOfToday: TZDate; + endOfToday: TZDate; + endOfYesterday: TZDate; + lastMonth: TZDate; + endOfLastMonth: TZDate; + lastYear: TZDate; timeZone: string; }; @@ -214,7 +215,8 @@ function buildDateContext( timeZone: string ): DateContext { const nowWithTZ = toLocalTZ( undefined, timeZone ); const initOfToday = startOfDay( nowWithTZ ); const endOfToday = endOfDay( nowWithTZ ); - const endOfYesterday = endOfDay( subDays( initOfToday, 1 ) ); + const initOfYesterday = subDays( initOfToday, 1 ); + const endOfYesterday = endOfDay( initOfYesterday ); const lastMonth = subMonths( initOfToday, 1 ); const endOfLastMonth = endOfMonth( lastMonth ); const lastYear = subYears( initOfToday, 1 ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts b/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts index aeaa95dfe552..a8b8f50def9d 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts @@ -1,9 +1,13 @@ +/** + * External dependencies + */ /** * Internal dependencies */ import { reportingTimeZone } from './reporting-time-zone'; import { readSiteTimestamp } from './site-timestamp'; import { toLocalTZ } from './tz'; +import type { TZDate } from '@date-fns/tz'; /** * Parse a timestamp in the timezone reports are read in. @@ -11,12 +15,17 @@ import { toLocalTZ } from './tz'; * Offset-less Stats API values are anchored to that zone; offset-bearing ones * already name an instant and keep it. * + * Anchored to that zone rather than left plain, so date arithmetic on the result + * takes its day boundaries there instead of in the browser's zone. + * * @param value - The raw timestamp, or a `Date`. * @return The instant, or `undefined` when the value is missing or malformed. */ -export function parseSiteDateTime( value: unknown ): Date | undefined { +export function parseSiteDateTime( value: unknown ): TZDate | undefined { if ( value instanceof Date ) { - return isNaN( value.getTime() ) ? undefined : value; + // Re-anchored, not returned as-is: the same instant, read in the reporting + // zone whatever zone the caller's `Date` carried. + return isNaN( value.getTime() ) ? undefined : toLocalTZ( value.getTime(), reportingTimeZone() ); } if ( typeof value !== 'string' ) { @@ -31,6 +40,5 @@ export function parseSiteDateTime( value: unknown ): Date | undefined { const parsed = toLocalTZ( timestamp.value, reportingTimeZone() ); - // A plain `Date`, so callers keep reading its parts in their own zone as they did. - return isNaN( parsed.getTime() ) ? undefined : new Date( parsed.getTime() ); + return isNaN( parsed.getTime() ) ? undefined : parsed; } diff --git a/projects/packages/premium-analytics/packages/datetime/src/step-date-range.ts b/projects/packages/premium-analytics/packages/datetime/src/step-date-range.ts index 456037eb6c66..e2e2834948ac 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/step-date-range.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/step-date-range.ts @@ -29,7 +29,7 @@ export type StepDirection = 'previous' | 'next'; * land on the same days of the month, and a step across a DST boundary has to * keep the wall clock. */ -const ADD_BY_UNIT: Record< DateRangeSpanUnit, ( date: Date, amount: number ) => Date > = { +const ADD_BY_UNIT: Record< DateRangeSpanUnit, ( date: TZDate, amount: number ) => TZDate > = { hour: addHours, day: addDays, month: addMonths, @@ -45,7 +45,7 @@ const ADD_BY_UNIT: Record< DateRangeSpanUnit, ( date: Date, amount: number ) => * @param amount - How far, signed. * @return The shifted range. */ -function shift( from: Date, to: Date, unit: DateRangeSpanUnit, amount: number ): DateRange { +function shift( from: TZDate, to: TZDate, unit: DateRangeSpanUnit, amount: number ): DateRange { const add = ADD_BY_UNIT[ unit ]; return { from: add( from, amount ), to: add( to, amount ) }; @@ -113,7 +113,7 @@ export function canStepForward( range: DateRange, now: Date ): boolean { // Anchored to the window's own timezone, so a site offset from the browser // closes its buckets on its own clock. - const timeZone = range.to && 'timeZone' in range.to ? ( range.to as TZDate ).timeZone : undefined; + const timeZone = range.to?.timeZone; const horizon = END_OF_BUCKET[ span.unit ]( timeZone ? new TZDate( now.getTime(), timeZone ) : now ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/to-date-range.ts b/projects/packages/premium-analytics/packages/datetime/src/to-date-range.ts index 37c05b688312..991a804eae1c 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/to-date-range.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/to-date-range.ts @@ -62,8 +62,7 @@ export function clampRangeEndToToday< T extends DateRange >( range: T, now: Date // Anchored to the window's own timezone, so a site offset from the browser // closes the day on its own clock. - const timeZone = 'timeZone' in range.to ? ( range.to as TZDate ).timeZone : undefined; - const endOfToday = endOfDay( timeZone ? new TZDate( now.getTime(), timeZone ) : now ); + const endOfToday = endOfDay( new TZDate( now.getTime(), range.to.timeZone ) ); return range.to.getTime() > endOfToday.getTime() ? { ...range, to: endOfToday } : range; } diff --git a/projects/packages/premium-analytics/packages/datetime/src/tz.ts b/projects/packages/premium-analytics/packages/datetime/src/tz.ts index 3e46ba26c897..ad1ef5ed1bff 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/tz.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/tz.ts @@ -165,7 +165,7 @@ export function dateToISOStringWithTZ( date: Date, timezone: string ): string { * @param timeZone - Timezone string (e.g., 'America/New_York', 'UTC', '+08:00') * @return A Date object representing midnight in the specified timezone */ -export function startOfDayTZ( date: Date | number, timeZone: string ): Date { +export function startOfDayTZ( date: Date | number, timeZone: string ): TZDate { const tzDate = new TZDateMini( new Date( date ).getTime(), timeZone ); // startOfDay from date-fns respects the timezone context in TZDate return startOfDay( tzDate ); @@ -178,7 +178,7 @@ export function startOfDayTZ( date: Date | number, timeZone: string ): Date { * @param timeZone - Timezone string (e.g., 'America/New_York', 'UTC', '+08:00') * @return A Date object representing the last millisecond of the day in the specified timezone */ -export function endOfDayTZ( date: Date | number, timeZone: string ): Date { +export function endOfDayTZ( date: Date | number, timeZone: string ): TZDate { const tzDate = new TZDateMini( new Date( date ).getTime(), timeZone ); // endOfDay from date-fns respects the timezone context in TZDate return endOfDay( tzDate ); diff --git a/projects/packages/premium-analytics/packages/formatters/src/date/__fixtures__/wp-date-settings.ts b/projects/packages/premium-analytics/packages/formatters/src/date/__fixtures__/wp-date-settings.ts index d9a1333ef916..20c2db3f40ea 100644 --- a/projects/packages/premium-analytics/packages/formatters/src/date/__fixtures__/wp-date-settings.ts +++ b/projects/packages/premium-analytics/packages/formatters/src/date/__fixtures__/wp-date-settings.ts @@ -7,6 +7,7 @@ /** * External dependencies */ +import { TZDate } from '@date-fns/tz'; import { getSettings, type DateSettings } from '@wordpress/date'; /** @@ -99,5 +100,5 @@ export const ES_ES_SETTINGS = settingsFor( * starts. * @return The date. */ -export const utcDate = ( year: number, month: number, day: number, hour: number = 0 ): Date => - new Date( Date.UTC( year, month - 1, day, hour ) ); +export const utcDate = ( year: number, month: number, day: number, hour: number = 0 ): TZDate => + new TZDate( Date.UTC( year, month - 1, day, hour ), 'UTC' ); diff --git a/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date-range.test.ts b/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date-range.test.ts index e578916ebd8d..ff288341f59e 100644 --- a/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date-range.test.ts +++ b/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date-range.test.ts @@ -1,6 +1,7 @@ /** * External dependencies */ +import { TZDate } from '@date-fns/tz'; import { setSettings } from '@wordpress/date'; import { resetLocaleData, setLocaleData } from '@wordpress/i18n'; /** @@ -89,7 +90,7 @@ describe( 'formatDateRange', () => { it( 'falls back instead of throwing when one date is invalid', () => { expect( - formatDateRange( { from: new Date( Number.NaN ), to: utcDate( 2025, 6, 21 ) } ) + formatDateRange( { from: new TZDate( Number.NaN, 'UTC' ), to: utcDate( 2025, 6, 21 ) } ) ).toBe( `Invalid date${ FALLBACK_SEP }June 21, 2025` ); } ); } ); diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/__tests__/build-range-patch.test.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/__tests__/build-range-patch.test.ts index 00918dfa1f68..ef470028d9fb 100644 --- a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/__tests__/build-range-patch.test.ts +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/__tests__/build-range-patch.test.ts @@ -10,6 +10,7 @@ jest.mock( '@jetpack-premium-analytics/datetime', () => ( { /** * External dependencies */ +import { TZDate } from '@date-fns/tz'; import { canStepForward, stepDateRange } from '@jetpack-premium-analytics/datetime'; /** * Internal dependencies @@ -19,12 +20,12 @@ import { buildRangePatch } from '../build-range-patch'; describe( 'buildRangePatch', () => { // A rolling sub-day window: `to` sits mid-day, exactly where end-of-day // rounding would corrupt it. - const from = new Date( '2026-07-09T14:30:00.000+00:00' ); - const to = new Date( '2026-07-10T14:30:00.000+00:00' ); + const from = new TZDate( '2026-07-09T14:30:00.000+00:00', 'UTC' ); + const to = new TZDate( '2026-07-10T14:30:00.000+00:00', 'UTC' ); // A window long enough to allow day buckets, for the cases about carrying a // selection rather than about coercing it. - const wideTo = new Date( '2026-07-19T14:30:00.000+00:00' ); + const wideTo = new TZDate( '2026-07-19T14:30:00.000+00:00', 'UTC' ); it( 'returns null when there is nothing to stage', () => { expect( buildRangePatch( { effective: {} } ) ).toBeNull(); @@ -125,7 +126,7 @@ describe( 'buildRangePatch', () => { it( 'extends calendar and manual edits to the end of the day', () => { // The end of the *site's* day (pinned to UTC above), whatever the host. // A literal instant, so the expectation cannot drift with `endOfDayTZ`. - const expected = new Date( '2026-07-10T23:59:59.999+00:00' ).getTime(); + const expected = new TZDate( '2026-07-10T23:59:59.999+00:00', 'UTC' ).getTime(); const custom = buildRangePatch( { nextRange: { from, to }, @@ -172,7 +173,10 @@ describe( 'buildRangePatch', () => { preset: 'custom', } ); - const backRange = { from: new Date( back?.from ?? '' ), to: new Date( back?.to ?? '' ) }; + const backRange = { + from: new TZDate( back?.from ?? '', 'UTC' ), + to: new TZDate( back?.to ?? '', 'UTC' ), + }; expect( canStepForward( backRange, to ) ).toBe( true ); const returned = stepDateRange( backRange, 'next' ); @@ -199,8 +203,8 @@ describe( 'buildRangePatch', () => { it( 'falls back to the previous period when the new range drops the preset', () => { const patch = buildRangePatch( { nextRange: { - from: new Date( '2026-08-01T00:00:00.000+00:00' ), - to: new Date( '2026-08-30T23:59:59.999+00:00' ), + from: new TZDate( '2026-08-01T00:00:00.000+00:00', 'UTC' ), + to: new TZDate( '2026-08-30T23:59:59.999+00:00', 'UTC' ), }, nextPresetId: 'last-30-days', effective: { comp: '1', compare_preset: 'previous-month' }, @@ -236,8 +240,8 @@ describe( 'buildRangePatch', () => { // 12 September; as the to-date preset, twelve months back on the first. const patch = buildRangePatch( { nextRange: { - from: new Date( '2025-09-01T00:00:00.000Z' ), - to: new Date( '2026-08-20T23:59:59.999Z' ), + from: new TZDate( '2025-09-01T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-08-20T23:59:59.999Z', 'UTC' ), }, nextPresetId: 'last-12-months', effective: { preset: 'last-7-days', comp: '1', compare_preset: 'previous-period' }, diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/__tests__/use-report-date-filters.test.tsx b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/__tests__/use-report-date-filters.test.tsx index 64e705fbd7f4..eca65596bcb5 100644 --- a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/__tests__/use-report-date-filters.test.tsx +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/__tests__/use-report-date-filters.test.tsx @@ -18,6 +18,7 @@ jest.mock( '@wordpress/route', () => ( { /** * External dependencies */ +import { TZDate } from '@date-fns/tz'; import { act, renderHook } from '@testing-library/react'; import { getSettings, setSettings } from '@wordpress/date'; /** @@ -102,8 +103,8 @@ describe( 'useReportDateFilters', () => { act( () => { result.current.onChange( { - from: new Date( '2026-07-24T00:00:00.000Z' ), - to: new Date( '2026-07-30T23:59:59.999Z' ), + from: new TZDate( '2026-07-24T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-07-30T23:59:59.999Z', 'UTC' ), }, 'last-7-days' ); @@ -138,8 +139,8 @@ describe( 'useReportDateFilters', () => { act( () => { result.current.onChange( { - from: new Date( '2026-07-24T00:00:00.000Z' ), - to: new Date( '2026-07-30T23:59:59.999Z' ), + from: new TZDate( '2026-07-24T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-07-30T23:59:59.999Z', 'UTC' ), }, 'last-7-days' ); @@ -162,8 +163,8 @@ describe( 'useReportDateFilters', () => { act( () => { result.current.onComparisonChange( { - from: new Date( '2026-06-01T00:00:00.000Z' ), - to: new Date( '2026-06-30T23:59:59.999Z' ), + from: new TZDate( '2026-06-01T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-06-30T23:59:59.999Z', 'UTC' ), }, 'previous-period' ); @@ -255,8 +256,8 @@ describe( 'useReportDateFilters', () => { act( () => result.current.onChange( { - from: new Date( '2026-07-10T00:00:00.000Z' ), - to: new Date( '2026-07-30T23:59:59.999Z' ), + from: new TZDate( '2026-07-10T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-07-30T23:59:59.999Z', 'UTC' ), }, 'custom' ) @@ -264,8 +265,8 @@ describe( 'useReportDateFilters', () => { act( () => result.current.onComparisonChange( { - from: new Date( '2026-06-01T00:00:00.000Z' ), - to: new Date( '2026-06-30T23:59:59.999Z' ), + from: new TZDate( '2026-06-01T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-06-30T23:59:59.999Z', 'UTC' ), }, 'previous-period' ) @@ -274,8 +275,8 @@ describe( 'useReportDateFilters', () => { act( () => result.current.onChange( { - from: new Date( '2026-07-01T00:00:00.000Z' ), - to: new Date( '2026-07-30T23:59:59.999Z' ), + from: new TZDate( '2026-07-01T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-07-30T23:59:59.999Z', 'UTC' ), }, 'custom' ) @@ -319,8 +320,8 @@ describe( 'useReportDateFilters', () => { act( () => { result.current.onChange( { - from: new Date( '2026-07-28T00:00:00.000Z' ), - to: new Date( '2026-07-30T23:59:59.999Z' ), + from: new TZDate( '2026-07-28T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-07-30T23:59:59.999Z', 'UTC' ), }, 'custom' ); @@ -345,8 +346,8 @@ describe( 'useReportDateFilters', () => { act( () => { result.current.onChange( { - from: new Date( '2026-07-24T00:00:00.000Z' ), - to: new Date( '2026-07-30T23:59:59.999Z' ), + from: new TZDate( '2026-07-24T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-07-30T23:59:59.999Z', 'UTC' ), }, 'last-7-days' ); @@ -354,8 +355,8 @@ describe( 'useReportDateFilters', () => { act( () => { result.current.onComparisonChange( { - from: new Date( '2026-06-01T00:00:00.000Z' ), - to: new Date( '2026-06-30T23:59:59.999Z' ), + from: new TZDate( '2026-06-01T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-06-30T23:59:59.999Z', 'UTC' ), }, 'previous-period' ); @@ -418,8 +419,8 @@ describe( 'useReportDateFilters', () => { act( () => result.current.replaceRange( { - from: new Date( '2026-07-24T00:00:00.000Z' ), - to: new Date( '2026-07-30T23:59:59.999Z' ), + from: new TZDate( '2026-07-24T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-07-30T23:59:59.999Z', 'UTC' ), }, 'last-7-days' ) @@ -466,7 +467,7 @@ describe( 'useReportDateFilters', () => { interval: 'day', } ); - act( () => result.current.drillDown( new Date( '2026-07-21T13:45:00.000Z' ) ) ); + act( () => result.current.drillDown( new TZDate( '2026-07-21T13:45:00.000Z', 'UTC' ) ) ); rerender(); expect( mockSearch ).toMatchObject( { @@ -485,7 +486,7 @@ describe( 'useReportDateFilters', () => { interval: 'week', } ); - act( () => result.current.drillDown( new Date( '2026-07-22T00:00:00.000Z' ) ) ); + act( () => result.current.drillDown( new TZDate( '2026-07-22T00:00:00.000Z', 'UTC' ) ) ); rerender(); expect( mockSearch ).toMatchObject( { @@ -503,7 +504,7 @@ describe( 'useReportDateFilters', () => { interval: 'month', } ); - act( () => result.current.drillDown( new Date( '2026-02-14T00:00:00.000Z' ) ) ); + act( () => result.current.drillDown( new TZDate( '2026-02-14T00:00:00.000Z', 'UTC' ) ) ); rerender(); expect( mockSearch ).toMatchObject( { @@ -521,7 +522,7 @@ describe( 'useReportDateFilters', () => { interval: 'year', } ); - act( () => result.current.drillDown( new Date( '2024-05-09T00:00:00.000Z' ) ) ); + act( () => result.current.drillDown( new TZDate( '2024-05-09T00:00:00.000Z', 'UTC' ) ) ); rerender(); expect( mockSearch ).toMatchObject( { @@ -544,7 +545,9 @@ describe( 'useReportDateFilters', () => { interval: 'year', } ); - act( () => result.current.drillDown( new Date( '2026-02-14T00:00:00.000Z' ), 'month' ) ); + act( () => + result.current.drillDown( new TZDate( '2026-02-14T00:00:00.000Z', 'UTC' ), 'month' ) + ); rerender(); expect( mockSearch ).toMatchObject( { @@ -562,7 +565,7 @@ describe( 'useReportDateFilters', () => { interval: 'day', } ); - act( () => result.current.drillDown( new Date( '2026-07-21T13:45:00.000Z' ) ) ); + act( () => result.current.drillDown( new TZDate( '2026-07-21T13:45:00.000Z', 'UTC' ) ) ); expect( mockNavigate ).toHaveBeenCalledTimes( 1 ); expect( mockNavigate.mock.calls[ 0 ][ 0 ].replace ).toBeFalsy(); @@ -576,7 +579,7 @@ describe( 'useReportDateFilters', () => { interval: 'hour', } ); - act( () => result.current.drillDown( new Date( '2026-07-21T13:00:00.000Z' ) ) ); + act( () => result.current.drillDown( new TZDate( '2026-07-21T13:00:00.000Z', 'UTC' ) ) ); expect( mockNavigate ).not.toHaveBeenCalled(); } ); @@ -593,7 +596,7 @@ describe( 'useReportDateFilters', () => { interval: 'day', } ); - act( () => result.current.drillDown( new Date( '2026-09-05T00:00:00.000Z' ) ) ); + act( () => result.current.drillDown( new TZDate( '2026-09-05T00:00:00.000Z', 'UTC' ) ) ); expect( mockNavigate ).not.toHaveBeenCalled(); } ); @@ -607,7 +610,7 @@ describe( 'useReportDateFilters', () => { interval: 'week', } ); - act( () => result.current.drillDown( new Date( '2026-07-23T00:00:00.000Z' ) ) ); + act( () => result.current.drillDown( new TZDate( '2026-07-23T00:00:00.000Z', 'UTC' ) ) ); rerender(); expect( mockSearch ).toMatchObject( { @@ -626,7 +629,7 @@ describe( 'useReportDateFilters', () => { const cancelBeforeDrill = result.current.onCancel; - act( () => result.current.drillDown( new Date( '2026-07-21T13:45:00.000Z' ) ) ); + act( () => result.current.drillDown( new TZDate( '2026-07-21T13:45:00.000Z', 'UTC' ) ) ); rerender(); act( () => cancelBeforeDrill() ); 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..a38c5a6622dc 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 @@ -7,7 +7,7 @@ import { isSelectablePreset, reportingTimeZone, type ComparisonPresetId, - type DateRange, + type EditedDateRange, type PrimaryPresetId, } from '@jetpack-premium-analytics/datetime'; /** @@ -28,7 +28,7 @@ export type ReportQuerySearchParams = Partial< >; type BuildRangePatchArgs = { - nextRange?: DateRange; + nextRange?: EditedDateRange; /** * The preset that produced `nextRange`, or 'custom' for manual edits. diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx index 097f85148e3b..aa9f6d9815f5 100644 --- a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx @@ -23,15 +23,17 @@ import { decodeDateSearchParam, encodeDateToSearchParam } from '../../search/dat import { hasPrimaryDateDraft } from '../../search/report-params'; import { useStagedSearch } from '../use-staged-search'; import { buildRangePatch, type ReportQuerySearchParams } from './build-range-patch'; +import type { TZDate } from '@date-fns/tz'; import type { ComparisonPresetId, DateRange, + EditedDateRange, IntervalType, PrimaryPresetId, StepDirection, } from '@jetpack-premium-analytics/datetime'; -type PickerRange = { from: Date | undefined; to: Date | undefined }; +type PickerRange = { from: TZDate | undefined; to: TZDate | undefined }; /** * The values and callbacks that drive `DateFiltersPanel`. @@ -66,7 +68,7 @@ export type ReportDateFilters = { */ intervalOptions: IntervalType[]; - onChange: ( range?: DateRange, presetId?: PrimaryPresetId ) => void; + onChange: ( range?: EditedDateRange, presetId?: PrimaryPresetId ) => void; onComparisonChange: ( range: DateRange | undefined, presetId?: ComparisonPresetId ) => void; onIntervalChange: ( interval: IntervalType ) => void; @@ -150,7 +152,7 @@ export function useReportDateFilters< TFrom extends string >( from?: TFrom ): Re ); const onChange = useCallback( - ( nextRange?: DateRange, nextPresetId?: PrimaryPresetId ) => { + ( nextRange?: EditedDateRange, nextPresetId?: PrimaryPresetId ) => { const patch = buildRangePatch( { nextRange, nextPresetId, effective } ); if ( patch ) { @@ -307,7 +309,11 @@ export function useReportDateFilters< TFrom extends string >( from?: TFrom ): Re * on the clock of the date passed in, and a plain instant would cut it * on the browser's clock instead. */ - const drilled = drillDateRange( toLocalTZ( date, timeZone ), bucketInterval, new Date() ); + const drilled = drillDateRange( + toLocalTZ( date, timeZone ), + bucketInterval, + toLocalTZ( undefined, timeZone ) + ); if ( ! drilled?.from || ! drilled.to ) { return; 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..c4b3a507772a 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 @@ -3,6 +3,7 @@ */ import { dateToISOStringWithLocalTZ, localTZDate } from '@jetpack-premium-analytics/datetime'; import { isValid } from 'date-fns'; +import type { TZDate } from '@date-fns/tz'; /** * Parse a stored report-param date for the picker. @@ -11,7 +12,7 @@ import { isValid } from 'date-fns'; * @param timezone - The timezone used by the picker. * @return The parsed date, or undefined when it is missing or malformed. */ -export function decodeDateSearchParam( value?: string, timezone?: string ): Date | undefined { +export function decodeDateSearchParam( value?: string, timezone?: string ): TZDate | undefined { if ( ! value ) { return undefined; } diff --git a/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/__tests__/date-comparison-dropdown.test.tsx b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/__tests__/date-comparison-dropdown.test.tsx index 436e97563624..f5c83614fded 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/__tests__/date-comparison-dropdown.test.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/__tests__/date-comparison-dropdown.test.tsx @@ -1,3 +1,4 @@ +import { TZDate } from '@date-fns/tz'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { DateComparisonDropdown } from '../date-comparison-dropdown'; @@ -8,13 +9,13 @@ const presets: ComparisonDateRangePreset[] = [ id: 'previous-period', label: 'Previous period', shortLabel: 'Prev. period', - range: { from: new Date( '2026-06-01' ), to: new Date( '2026-06-30' ) }, + range: { from: new TZDate( '2026-06-01', 'UTC' ), to: new TZDate( '2026-06-30', 'UTC' ) }, }, { id: 'previous-month', label: 'Previous month', shortLabel: 'Prev. month', - range: { from: new Date( '2026-05-01' ), to: new Date( '2026-05-31' ) }, + range: { from: new TZDate( '2026-05-01', 'UTC' ), to: new TZDate( '2026-05-31', 'UTC' ) }, }, ]; diff --git a/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/stories/date-comparison-dropdown.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/stories/date-comparison-dropdown.stories.tsx index ed2640f092c5..98ad8db2edfd 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/stories/date-comparison-dropdown.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/stories/date-comparison-dropdown.stories.tsx @@ -2,8 +2,7 @@ import { subDays, startOfDay, endOfDay } from 'date-fns'; import { useState } from 'react'; import { useComparisonDatePresets } from '../../use-comparison-date-presets'; import { DateComparisonDropdown } from '../date-comparison-dropdown'; -import type { DateRange } from '../../date-range-popover'; -import type { ComparisonPresetId } from '@jetpack-premium-analytics/datetime'; +import type { DateRange, ComparisonPresetId } from '@jetpack-premium-analytics/datetime'; import type { Meta, StoryObj } from '@storybook/react'; const meta: Meta< typeof DateComparisonDropdown > = { diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/__tests__/date-filters-panel.test.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/__tests__/date-filters-panel.test.tsx index f0b933ef7c86..808891e77c55 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/__tests__/date-filters-panel.test.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/__tests__/date-filters-panel.test.tsx @@ -1,3 +1,4 @@ +import { TZDate } from '@date-fns/tz'; import { ReportScopeProvider } from '@jetpack-premium-analytics/data'; import { DETAIL_SURFACE_PRESETS } from '@jetpack-premium-analytics/datetime'; import { render, screen, within } from '@testing-library/react'; @@ -6,8 +7,8 @@ import { DateFiltersPanel } from '../date-filters-panel'; import type { ComponentProps } from 'react'; const PRESET_RANGE = { - from: new Date( '2026-07-01T00:00:00.000Z' ), - to: new Date( '2026-07-30T23:59:59.999Z' ), + from: new TZDate( '2026-07-01T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-07-30T23:59:59.999Z', 'UTC' ), }; function panel( props: Partial< ComponentProps< typeof DateFiltersPanel > > = {} ) { @@ -51,8 +52,8 @@ describe( 'DateFiltersPanel', () => { renderPanel( { onStep, appliedRange: { - from: new Date( '2020-07-01T00:00:00.000Z' ), - to: new Date( '2020-07-30T23:59:59.999Z' ), + from: new TZDate( '2020-07-01T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2020-07-30T23:59:59.999Z', 'UTC' ), }, } ); @@ -91,8 +92,8 @@ describe( 'DateFiltersPanel', () => { // different ranges on screen at once (WOOA7S-1936). it( 'names the applied preset on the trigger once one takes over', () => { const customRange = { - from: new Date( '2026-01-30T00:00:00.000Z' ), - to: new Date( '2026-08-05T23:59:59.999Z' ), + from: new TZDate( '2026-01-30T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-08-05T23:59:59.999Z', 'UTC' ), }; const { rerender } = renderPanel( { @@ -142,8 +143,8 @@ describe( 'DateFiltersPanel', () => { // `last-12-months` as read on 20 August 2026. Measured by the day, the // previous period would start on 12 September 2024. const toDateRange = { - from: new Date( '2025-09-01T00:00:00.000Z' ), - to: new Date( '2026-08-20T23:59:59.999Z' ), + from: new TZDate( '2025-09-01T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2026-08-20T23:59:59.999Z', 'UTC' ), }; renderPanel( { appliedPresetId: 'last-12-months', @@ -157,8 +158,8 @@ describe( 'DateFiltersPanel', () => { expect( onComparisonChange ).toHaveBeenCalledWith( { - from: new Date( '2024-09-01T00:00:00.000Z' ), - to: new Date( '2025-08-20T23:59:59.999Z' ), + from: new TZDate( '2024-09-01T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2025-08-20T23:59:59.999Z', 'UTC' ), }, 'previous-period' ); @@ -169,8 +170,8 @@ describe( 'DateFiltersPanel', () => { disabled: true, onStep: jest.fn(), appliedRange: { - from: new Date( '2020-07-01T00:00:00.000Z' ), - to: new Date( '2020-07-30T23:59:59.999Z' ), + from: new TZDate( '2020-07-01T00:00:00.000Z', 'UTC' ), + to: new TZDate( '2020-07-30T23:59:59.999Z', 'UTC' ), }, withIntervalControl: true, intervalOptions: [ 'day', 'week' ], diff --git a/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/__tests__/date-period-dropdown.test.tsx b/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/__tests__/date-period-dropdown.test.tsx index de6363761944..b6c8d40ab42c 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/__tests__/date-period-dropdown.test.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/__tests__/date-period-dropdown.test.tsx @@ -3,14 +3,15 @@ jest.mock( '@wordpress/compose', () => ( { useMediaQuery: jest.fn( () => false ), } ) ); +import { TZDate } from '@date-fns/tz'; import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useMediaQuery } from '@wordpress/compose'; import { DatePeriodDropdown } from '../date-period-dropdown'; const JULY_2026 = { - from: new Date( 2026, 6, 1, 0, 0, 0, 0 ), - to: new Date( 2026, 6, 31, 23, 59, 59, 999 ), + from: new TZDate( 2026, 6, 1, 0, 0, 0, 0, 'UTC' ), + to: new TZDate( 2026, 6, 31, 23, 59, 59, 999, 'UTC' ), }; function renderDropdown( overrides: Partial< Parameters< typeof DatePeriodDropdown >[ 0 ] > = {} ) { diff --git a/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/date-period-dropdown.tsx b/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/date-period-dropdown.tsx index 7293f1d6e941..67ac0e530614 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/date-period-dropdown.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/date-period-dropdown.tsx @@ -5,6 +5,7 @@ import { computePrimaryRange, getMenuSurfacePresetGroups, PRESET_CUSTOM, + type DateRange, type PrimaryPresetId, type QuickSurfacePresetId, } from '@jetpack-premium-analytics/datetime'; @@ -18,7 +19,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; /** * Internal dependencies */ -import { DateRangePopoverContent, type DateRange } from '../date-range-popover'; +import { DateRangePopoverContent } from '../date-range-popover'; import './date-period-dropdown.scss'; /** diff --git a/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/stories/date-period-dropdown.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/stories/date-period-dropdown.stories.tsx index 255fad817234..e5eac1dd82a6 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/stories/date-period-dropdown.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-period-dropdown/stories/date-period-dropdown.stories.tsx @@ -1,8 +1,11 @@ import { DETAIL_SURFACE_PRESETS, computePrimaryRange } from '@jetpack-premium-analytics/datetime'; import { useState } from 'react'; import { DatePeriodDropdown } from '../date-period-dropdown'; -import type { DateRange } from '../../date-range-popover'; -import type { PrimaryPresetId, QuickSurfacePresetId } from '@jetpack-premium-analytics/datetime'; +import type { + DateRange, + PrimaryPresetId, + QuickSurfacePresetId, +} from '@jetpack-premium-analytics/datetime'; import type { Meta, StoryObj } from '@storybook/react'; const meta: Meta< typeof DatePeriodDropdown > = { diff --git a/projects/packages/premium-analytics/packages/ui/src/date-period-navigation/stories/date-period-navigation.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-period-navigation/stories/date-period-navigation.stories.tsx index 944e2dba3ade..fe8008215c41 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-period-navigation/stories/date-period-navigation.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-period-navigation/stories/date-period-navigation.stories.tsx @@ -1,4 +1,5 @@ -import { canStepForward, stepDateRange } from '@jetpack-premium-analytics/datetime'; +import { canStepForward, stepDateRange, toLocalTZ } from '@jetpack-premium-analytics/datetime'; +import { endOfDay, startOfDay, subDays } from 'date-fns'; import { useState } from 'react'; import { DatePeriodNavigation } from '../date-period-navigation'; import type { DateRange, StepDirection } from '@jetpack-premium-analytics/datetime'; @@ -29,6 +30,9 @@ export default meta; type Story = StoryObj< typeof DatePeriodNavigation >; +/** The zone the story's windows are cut in. */ +const TIME_ZONE = 'UTC'; + /** * Seven whole days ending at the given day. * @@ -36,15 +40,12 @@ type Story = StoryObj< typeof DatePeriodNavigation >; * @return The window. */ function weekEnding( endingDaysAgo: number ): DateRange { - const to = new Date(); - to.setDate( to.getDate() - endingDaysAgo ); - to.setHours( 23, 59, 59, 999 ); - - const from = new Date( to ); - from.setDate( from.getDate() - 6 ); - from.setHours( 0, 0, 0, 0 ); + const today = toLocalTZ( undefined, TIME_ZONE ); - return { from, to }; + return { + from: startOfDay( subDays( today, endingDaysAgo + 6 ) ), + to: endOfDay( subDays( today, endingDaysAgo ) ), + }; } /** diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx index 6171768da995..fc73ade508cc 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx @@ -1,7 +1,12 @@ /** * External dependencies */ -import { PRESET_CUSTOM, type PrimaryPresetId } from '@jetpack-premium-analytics/datetime'; +import { + PRESET_CUSTOM, + type DateRange, + type EditedDateRange, + type PrimaryPresetId, +} from '@jetpack-premium-analytics/datetime'; import { Button, DateRangeCalendar, Stack } from '@jetpack-premium-analytics/externals'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; @@ -13,14 +18,16 @@ import { DateRangeInput } from '../date-range-input'; import './date-range-filter.scss'; /** - * The calendar's own range type, from `@automattic/ui`. + * The calendar's own range type, from `@automattic/ui`. Zone-naive: a click + * reports the day in the browser's zone, so these bounds only ever leave here + * as an `EditedDateRange`. */ -export type DateRange = NonNullable< Parameters< typeof DateRangeCalendar >[ 0 ][ 'selected' ] >; +type CalendarRange = NonNullable< Parameters< typeof DateRangeCalendar >[ 0 ][ 'selected' ] >; type DateRangePopoverContentProps = { range: DateRange; - onChange: ( range?: DateRange, preset?: PrimaryPresetId ) => void; + onChange: ( range?: EditedDateRange, preset?: PrimaryPresetId ) => void; onApply: () => void; @@ -40,7 +47,7 @@ type DateRangePopoverContentProps = { timeZone: string; }; -function getDisplayedMonth( range: DateRange ): Date { +function getDisplayedMonth( range: EditedDateRange ): Date { return range?.from ?? new Date(); } @@ -86,9 +93,9 @@ export function DateRangePopoverContent( { * Half-open calendar selection (`from` picked, `to` pending). Kept local: * consumers only receive complete ranges. */ - const [ draftRange, setDraftRange ] = useState< DateRange | null >( null ); + const [ draftRange, setDraftRange ] = useState< CalendarRange | null >( null ); - const handleChange = ( nextRange?: DateRange, nextPrimaryPresetId?: PrimaryPresetId ) => { + const handleChange = ( nextRange?: EditedDateRange, nextPrimaryPresetId?: PrimaryPresetId ) => { setDraftRange( null ); if ( nextRange ) { @@ -106,7 +113,7 @@ export function DateRangePopoverContent( { * not `onSelect`'s computed range: react-day-picker never restarts a * complete range on click, it only moves the nearest endpoint. */ - const handleCalendarSelect = ( _nextRange: DateRange | undefined, triggerDate: Date ) => { + const handleCalendarSelect = ( _nextRange: CalendarRange | undefined, triggerDate: Date ) => { if ( draftRange?.from && ! draftRange.to ) { const [ from, to ] = triggerDate < draftRange.from @@ -121,7 +128,9 @@ export function DateRangePopoverContent( { setDraftRange( { from: triggerDate, to: undefined } ); }; - const calendarRange = draftRange ?? range; + // Widened to the calendar's shape, which requires both keys where a + // `DateRange` leaves them optional. + const calendarRange: CalendarRange = draftRange ?? { from: range.from, to: range.to }; // Apply commits the staged range, not the draft: disable it mid-selection. const effectiveCanApply = canApply && ! draftRange; diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/index.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/index.ts index 2e9543dd47b5..70e732905219 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/index.ts +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/index.ts @@ -1,2 +1 @@ export { DateRangePopoverContent } from './date-range-filter'; -export type { DateRange } from './date-range-filter'; diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/stories/date-range-popover.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/stories/date-range-popover.stories.tsx index d5c309492045..95afed8e8968 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/stories/date-range-popover.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/stories/date-range-popover.stories.tsx @@ -1,7 +1,7 @@ import { subDays, startOfDay, endOfDay } from 'date-fns'; import { useState } from 'react'; import { DateRangePopoverContent } from '../date-range-filter'; -import type { DateRange } from '../date-range-filter'; +import type { DateRange } from '@jetpack-premium-analytics/datetime'; import type { Meta, StoryObj } from '@storybook/react'; const meta: Meta< typeof DateRangePopoverContent > = { diff --git a/projects/packages/premium-analytics/packages/ui/src/date-year-filter/date-year-filter.tsx b/projects/packages/premium-analytics/packages/ui/src/date-year-filter/date-year-filter.tsx index b93e8198995a..242677a80530 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-year-filter/date-year-filter.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-year-filter/date-year-filter.tsx @@ -4,6 +4,7 @@ import { computePrimaryRange, getYearSurfacePresets, + type DateRange, type DateRangePreset, type PrimaryPresetId, type YearSurfacePresetId, @@ -17,7 +18,6 @@ import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; /** * Internal dependencies */ -import type { DateRange } from '../date-range-popover'; import './date-year-filter.scss'; export type DateYearFilterProps = { diff --git a/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/use-comparison-date-presets.ts b/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/use-comparison-date-presets.ts index aff114f74d58..8368f998e2a4 100644 --- a/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/use-comparison-date-presets.ts +++ b/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/use-comparison-date-presets.ts @@ -4,13 +4,10 @@ import { getComparisonOptions, type ComparisonOption, + type DateRange, type PrimaryPresetId, } from '@jetpack-premium-analytics/datetime'; import { useMemo } from 'react'; -/** - * Internal dependencies - */ -import type { DateRange } from '../date-range-popover/date-range-filter'; /** * A comparison option offered for the primary range, as the dropdown consumes diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-page/__tests__/report-page-layout.test.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-page/__tests__/report-page-layout.test.tsx index bba0fab005eb..dfb0fd9dcdf8 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-page/__tests__/report-page-layout.test.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-page/__tests__/report-page-layout.test.tsx @@ -1,6 +1,7 @@ /** * External dependencies */ +import { toLocalTZ } from '@jetpack-premium-analytics/datetime'; import { DateFiltersPanel } from '@jetpack-premium-analytics/ui'; import { render, screen } from '@testing-library/react'; /** @@ -18,14 +19,14 @@ jest.mock( '@jetpack-premium-analytics/ui', () => ( { const dateFiltersPanelMock = jest.mocked( DateFiltersPanel ); const APPLIED_RANGE = { - from: new Date( Date.UTC( 2024, 0, 8 ) ), - to: new Date( Date.UTC( 2024, 0, 14, 23, 59, 59, 999 ) ), + from: toLocalTZ( Date.UTC( 2024, 0, 8 ), 'UTC' ), + to: toLocalTZ( Date.UTC( 2024, 0, 14, 23, 59, 59, 999 ), 'UTC' ), }; // A draft over the applied window, so the controller reaches the panel mid-edit. const STAGED_RANGE = { - from: new Date( Date.UTC( 2019, 0, 7 ) ), - to: new Date( Date.UTC( 2019, 0, 13, 23, 59, 59, 999 ) ), + from: toLocalTZ( Date.UTC( 2019, 0, 7 ), 'UTC' ), + to: toLocalTZ( Date.UTC( 2019, 0, 13, 23, 59, 59, 999 ), 'UTC' ), }; /** A controller mid-edit: a staged range and comparison over an applied window. */ diff --git a/projects/packages/premium-analytics/routes/detail-header.test.ts b/projects/packages/premium-analytics/routes/detail-header.test.ts index ab79e75a9386..6d9f4d3412a5 100644 --- a/projects/packages/premium-analytics/routes/detail-header.test.ts +++ b/projects/packages/premium-analytics/routes/detail-header.test.ts @@ -1,6 +1,7 @@ /** * External dependencies */ +import { TZDate } from '@date-fns/tz'; import { getSettings, setSettings } from '@wordpress/date'; /** * Internal dependencies @@ -58,7 +59,7 @@ describe( 'performanceSentence', () => { [ 'no range', undefined ], [ 'an open start', { from: undefined, to: utcDate( 2026, 7, 15 ) } ], [ 'an open end', { from: utcDate( 2026, 7, 9 ), to: undefined } ], - [ 'an unparseable bound', { from: new Date( 'nope' ), to: utcDate( 2026, 7, 15 ) } ], + [ 'an unparseable bound', { from: new TZDate( 'nope', 'UTC' ), to: utcDate( 2026, 7, 15 ) } ], ] )( 'states nothing for %s', ( _label, range ) => { expect( performanceSentence( range ) ).toBeUndefined(); } ); diff --git a/projects/packages/premium-analytics/routes/post-detail/components/post-header-slots/post-header-slots.test.tsx b/projects/packages/premium-analytics/routes/post-detail/components/post-header-slots/post-header-slots.test.tsx index 386136ea2e47..fbef30eaa8fd 100644 --- a/projects/packages/premium-analytics/routes/post-detail/components/post-header-slots/post-header-slots.test.tsx +++ b/projects/packages/premium-analytics/routes/post-detail/components/post-header-slots/post-header-slots.test.tsx @@ -1,3 +1,4 @@ +import { toLocalTZ } from '@jetpack-premium-analytics/datetime'; import { SectionHeader } from '@jetpack-premium-analytics/ui'; import { render, screen } from '@testing-library/react'; import { postHeaderSlots } from './post-header-slots'; @@ -16,8 +17,8 @@ const SUMMARY: PostSummary = { // UTC-anchored: the sentence renders in the site zone, so a browser-local // `Date` would name the previous day in zones west of it. const PERFORMANCE_RANGE = { - from: new Date( Date.UTC( 2026, 6, 9 ) ), - to: new Date( Date.UTC( 2026, 6, 15 ) ), + from: toLocalTZ( Date.UTC( 2026, 6, 9 ), 'UTC' ), + to: toLocalTZ( Date.UTC( 2026, 6, 15 ), 'UTC' ), }; /** diff --git a/projects/packages/premium-analytics/routes/video-detail/components/video-header-slots/video-header-slots.test.tsx b/projects/packages/premium-analytics/routes/video-detail/components/video-header-slots/video-header-slots.test.tsx index b36d786527b6..235d0c55ac16 100644 --- a/projects/packages/premium-analytics/routes/video-detail/components/video-header-slots/video-header-slots.test.tsx +++ b/projects/packages/premium-analytics/routes/video-detail/components/video-header-slots/video-header-slots.test.tsx @@ -1,3 +1,4 @@ +import { toLocalTZ } from '@jetpack-premium-analytics/datetime'; import { SectionHeader } from '@jetpack-premium-analytics/ui'; import { render, screen } from '@testing-library/react'; import { videoHeaderSlots } from './video-header-slots'; @@ -16,8 +17,8 @@ const SUMMARY: VideoSummary = { // UTC-anchored: the sentence renders in the site zone, so a browser-local // `Date` would name the previous day in zones west of it. const PERFORMANCE_RANGE = { - from: new Date( Date.UTC( 2026, 6, 9 ) ), - to: new Date( Date.UTC( 2026, 6, 15 ) ), + from: toLocalTZ( Date.UTC( 2026, 6, 9 ), 'UTC' ), + to: toLocalTZ( Date.UTC( 2026, 6, 15 ), 'UTC' ), }; /** From d5cc617c66d4722cc1957774ea295ba0abc7d650 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 15:54:04 +0800 Subject: [PATCH 2/8] refactor: anchor the picker's day at the calendar boundary, drop EditedDateRange --- .../packages/datetime/README.md | 22 +++----------- .../src/__tests__/comparison-presets.test.ts | 3 -- .../src/__tests__/date-range-span.test.ts | 29 ++++++++----------- .../datetime/src/get-comparison-range.ts | 15 ++-------- .../packages/datetime/src/index.ts | 1 - .../packages/datetime/src/site-datetime.ts | 3 -- .../build-range-patch.ts | 4 +-- .../use-report-date-filters.tsx | 5 ++-- .../src/date-range-input/date-range-input.tsx | 5 ++-- .../date-range-popover/date-range-filter.tsx | 29 ++++++++++--------- 10 files changed, 41 insertions(+), 75 deletions(-) diff --git a/projects/packages/premium-analytics/packages/datetime/README.md b/projects/packages/premium-analytics/packages/datetime/README.md index 22491d4e7580..442146be30de 100644 --- a/projects/packages/premium-analytics/packages/datetime/README.md +++ b/projects/packages/premium-analytics/packages/datetime/README.md @@ -141,8 +141,8 @@ Calculates comparison date ranges based on predefined presets. ```typescript const reference = { - from: new Date( '2024-01-15' ), - to: new Date( '2024-01-21' ), + from: localTZDate( '2024-01-15', 'America/New_York' ), + to: localTZDate( '2024-01-21', 'America/New_York' ), }; const comparison = getComparisonRangeFromPreset( reference, 'previous-period' ); // Returns dates for Jan 8-14, 2024 @@ -242,22 +242,8 @@ type DateRange = { }; ``` -Zoned, so `getDateRangeSpan` and the steppers cut day boundaries on the site's -clock. A plain `Date` names the same instant but reads its day in the browser's -zone, which measures a 30 day window as 31. - -### `EditedDateRange` - -```typescript -type EditedDateRange = { - from?: Date; - to?: Date; -}; -``` - -What a date picker hands back, before anything anchors it: a calendar click -reports the day in the browser's zone. `buildRangePatch` anchors these; nothing -measures a span on one. +Both bounds stay optional: `resolveBucketStamp` returns `undefined` for a bound +it cannot resolve, and the chart passes that straight through. ### `ComparisonPresetId` diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/comparison-presets.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/comparison-presets.test.ts index d71866dc6038..079fc0e3a6f1 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/comparison-presets.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/comparison-presets.test.ts @@ -1,6 +1,3 @@ -/** - * External dependencies - */ /** * Internal dependencies */ diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts index 58dc4f072cab..b11c4ec92a0b 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts @@ -1,11 +1,9 @@ -/** - * External dependencies - */ /** * Internal dependencies */ import { getDateRangeSpan } from '../date-range-span'; import { createTZDateFromParts } from '../tz'; +import type { DateRange } from '../get-comparison-range'; import type { TZDate } from '@date-fns/tz'; /** @@ -128,22 +126,19 @@ describe( 'getDateRangeSpan', () => { } ); } ); - it( "measures a 30 day window in the site zone, not the browser's", () => { - const from = at( 2026, 6, 29 ); - const to = endOf( 2026, 7, 28 ); + it( "measures a 30 day window on the site's clock, not the machine's", () => { + expect( getDateRangeSpan( { from: at( 2026, 6, 29 ), to: endOf( 2026, 7, 28 ) } ) ).toEqual( { + unit: 'day', + value: 30, + } ); + } ); - expect( getDateRangeSpan( { from, to } ) ).toEqual( { unit: 'day', value: 30 } ); + // Babel strips types, so this is checked by `pnpm run typecheck`, not by jest. + it( 'rejects a zone-naive bound at the type level', () => { + // @ts-expect-error -- a plain `Date` names an instant but no zone to cut days in. + const naive: DateRange = { from: new Date( 0 ), to: new Date( 0 ) }; - // The same instants read without the zone: `coversWholeDays` no longer - // recognises the site's midnight, and the fallback counts a 31st day. - expect( - getDateRangeSpan( { - // @ts-expect-error -- `DateRange` rejects a zone-naive bound; this is what it prevents. - from: new Date( from.getTime() ), - // @ts-expect-error -- as above. - to: new Date( to.getTime() ), - } ) - ).toEqual( { unit: 'day', value: 31 } ); + expect( naive ).toBeDefined(); } ); it( 'falls back to days when the range does not divide into months', () => { diff --git a/projects/packages/premium-analytics/packages/datetime/src/get-comparison-range.ts b/projects/packages/premium-analytics/packages/datetime/src/get-comparison-range.ts index f87808fa0fc8..a8529c6153d6 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/get-comparison-range.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/get-comparison-range.ts @@ -30,22 +30,11 @@ import type { TZDate } from '@date-fns/tz'; /** * An inclusive range of instants, each anchored to the zone it was read in. * - * Zoned rather than plain, so `getDateRangeSpan` measures day boundaries in the - * site's zone; a plain `Date` reads them in the browser's and lands a day out. - * Both bounds stay optional: `resolveBucketStamp` returns `undefined` for one it - * cannot resolve, and the chart passes that straight through. + * Zoned rather than plain, so `getDateRangeSpan` cuts day boundaries on the + * site's clock; a plain `Date` cuts them on the browser's and lands a day out. */ export type DateRange = { from?: TZDate; to?: TZDate }; -/** - * A range as a date picker hands it back, before anything anchors it. - * - * A calendar click reports the day in the browser's zone, so these bounds name - * an instant the site may read as a different day. `buildRangePatch` is what - * anchors them; nothing measures a span on one. - */ -export type EditedDateRange = { from?: Date; to?: Date }; - export const COMPARISON_PREVIOUS_PERIOD = 'previous-period' as const; export const COMPARISON_PREVIOUS_WEEK = 'previous-week' as const; export const COMPARISON_PREVIOUS_MONTH = 'previous-month' as const; diff --git a/projects/packages/premium-analytics/packages/datetime/src/index.ts b/projects/packages/premium-analytics/packages/datetime/src/index.ts index dcea8fe99f45..d29ae84a9ebf 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/index.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/index.ts @@ -2,7 +2,6 @@ export { getComparisonRangeFromPreset, isComparisonPresetId, type DateRange, - type EditedDateRange, type ComparisonPresetId, } from './get-comparison-range'; export type { ComparisonRangeOptions } from './get-comparison-range'; diff --git a/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts b/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts index a8b8f50def9d..da981c2f995d 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts @@ -1,6 +1,3 @@ -/** - * External dependencies - */ /** * Internal dependencies */ 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 a38c5a6622dc..112a030de72f 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 @@ -7,7 +7,7 @@ import { isSelectablePreset, reportingTimeZone, type ComparisonPresetId, - type EditedDateRange, + type DateRange, type PrimaryPresetId, } from '@jetpack-premium-analytics/datetime'; /** @@ -28,7 +28,7 @@ export type ReportQuerySearchParams = Partial< >; type BuildRangePatchArgs = { - nextRange?: EditedDateRange; + nextRange?: DateRange; /** * The preset that produced `nextRange`, or 'custom' for manual edits. diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx index aa9f6d9815f5..7fd703073a36 100644 --- a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx @@ -27,7 +27,6 @@ import type { TZDate } from '@date-fns/tz'; import type { ComparisonPresetId, DateRange, - EditedDateRange, IntervalType, PrimaryPresetId, StepDirection, @@ -68,7 +67,7 @@ export type ReportDateFilters = { */ intervalOptions: IntervalType[]; - onChange: ( range?: EditedDateRange, presetId?: PrimaryPresetId ) => void; + onChange: ( range?: DateRange, presetId?: PrimaryPresetId ) => void; onComparisonChange: ( range: DateRange | undefined, presetId?: ComparisonPresetId ) => void; onIntervalChange: ( interval: IntervalType ) => void; @@ -152,7 +151,7 @@ export function useReportDateFilters< TFrom extends string >( from?: TFrom ): Re ); const onChange = useCallback( - ( nextRange?: EditedDateRange, nextPresetId?: PrimaryPresetId ) => { + ( nextRange?: DateRange, nextPresetId?: PrimaryPresetId ) => { const patch = buildRangePatch( { nextRange, nextPresetId, effective } ); if ( patch ) { diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx index 52b5c660f834..3c9e245b1f29 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx @@ -13,6 +13,7 @@ import { useCallback, useEffect, useState } from 'react'; * Internal dependencies */ import { DateRangePopoverContent } from '../date-range-popover/date-range-filter'; +import type { TZDate } from '@date-fns/tz'; import './date-range-input.scss'; type DateRangeInputProps = Pick< @@ -24,8 +25,8 @@ type DateRangeInputProps = Pick< type DateInputProps = Pick< DateRangeInputProps, 'timeZone' > & { label: string; - date?: Date; - onChange: ( date?: Date ) => void; + date?: TZDate; + onChange: ( date?: TZDate ) => void; }; const formatToString = ( date: Date | undefined, timeZone: string ) => diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx index fc73ade508cc..d2eb51d04278 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx @@ -3,8 +3,8 @@ */ import { PRESET_CUSTOM, + toLocalTZ, type DateRange, - type EditedDateRange, type PrimaryPresetId, } from '@jetpack-premium-analytics/datetime'; import { Button, DateRangeCalendar, Stack } from '@jetpack-premium-analytics/externals'; @@ -18,16 +18,16 @@ import { DateRangeInput } from '../date-range-input'; import './date-range-filter.scss'; /** - * The calendar's own range type, from `@automattic/ui`. Zone-naive: a click - * reports the day in the browser's zone, so these bounds only ever leave here - * as an `EditedDateRange`. + * The calendar's own range type, from `@automattic/ui`. Its bounds are typed as + * plain `Date`, so a day leaves here through `toLocalTZ` to say in the type what + * the picker already does at runtime. */ type CalendarRange = NonNullable< Parameters< typeof DateRangeCalendar >[ 0 ][ 'selected' ] >; type DateRangePopoverContentProps = { range: DateRange; - onChange: ( range?: EditedDateRange, preset?: PrimaryPresetId ) => void; + onChange: ( range?: DateRange, preset?: PrimaryPresetId ) => void; onApply: () => void; @@ -47,7 +47,7 @@ type DateRangePopoverContentProps = { timeZone: string; }; -function getDisplayedMonth( range: EditedDateRange ): Date { +function getDisplayedMonth( range: DateRange ): Date { return range?.from ?? new Date(); } @@ -93,9 +93,9 @@ export function DateRangePopoverContent( { * Half-open calendar selection (`from` picked, `to` pending). Kept local: * consumers only receive complete ranges. */ - const [ draftRange, setDraftRange ] = useState< CalendarRange | null >( null ); + const [ draftRange, setDraftRange ] = useState< DateRange | null >( null ); - const handleChange = ( nextRange?: EditedDateRange, nextPrimaryPresetId?: PrimaryPresetId ) => { + const handleChange = ( nextRange?: DateRange, nextPrimaryPresetId?: PrimaryPresetId ) => { setDraftRange( null ); if ( nextRange ) { @@ -114,23 +114,26 @@ export function DateRangePopoverContent( { * complete range on click, it only moves the nearest endpoint. */ const handleCalendarSelect = ( _nextRange: CalendarRange | undefined, triggerDate: Date ) => { + // The picker already builds its days in `timeZone`; this restates that in + // the type, since its `onSelect` signature says plain `Date`. + const day = toLocalTZ( triggerDate, timeZone ); + if ( draftRange?.from && ! draftRange.to ) { const [ from, to ] = - triggerDate < draftRange.from - ? [ triggerDate, draftRange.from ] - : [ draftRange.from, triggerDate ]; + day < draftRange.from ? [ day, draftRange.from ] : [ draftRange.from, day ]; setDraftRange( null ); onChange( { from, to }, PRESET_CUSTOM ); return; } - setDraftRange( { from: triggerDate, to: undefined } ); + setDraftRange( { from: day, to: undefined } ); }; // Widened to the calendar's shape, which requires both keys where a // `DateRange` leaves them optional. - const calendarRange: CalendarRange = draftRange ?? { from: range.from, to: range.to }; + const selected = draftRange ?? range; + const calendarRange: CalendarRange = { from: selected.from, to: selected.to }; // Apply commits the staged range, not the draft: disable it mid-selection. const effectiveCanApply = canApply && ! draftRange; From 056a617a3bee8e2a9bc5e37af814f9aa5fb091db Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 16:09:32 +0800 Subject: [PATCH 3/8] fix: build the popover story range in the story timezone --- .../changelog/change-wooa7s-2100-daterange-zoned | 2 +- .../stories/date-range-popover.stories.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned b/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned index c1f6dc47be45..d3032e79ed97 100644 --- a/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned +++ b/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned @@ -1,4 +1,4 @@ Significance: patch Type: changed -Date range: Type the range bounds as zoned instants, so a span cannot be measured in the browser's timezone. +Date range: Type the range bounds as zoned instants, so day boundaries are measured on the site's clock. diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/stories/date-range-popover.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/stories/date-range-popover.stories.tsx index 95afed8e8968..d004483b2066 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/stories/date-range-popover.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/stories/date-range-popover.stories.tsx @@ -1,3 +1,4 @@ +import { toLocalTZ } from '@jetpack-premium-analytics/datetime'; import { subDays, startOfDay, endOfDay } from 'date-fns'; import { useState } from 'react'; import { DateRangePopoverContent } from '../date-range-filter'; @@ -20,15 +21,15 @@ export default meta; type Story = StoryObj< typeof DateRangePopoverContent >; -const today = new Date(); +// Default timezone for Storybook - avoids dependency on WordPress stores +const STORYBOOK_TIMEZONE = 'America/New_York'; + +const today = toLocalTZ( undefined, STORYBOOK_TIMEZONE ); const defaultRange: DateRange = { from: startOfDay( subDays( today, 7 ) ), to: endOfDay( subDays( today, 1 ) ), }; -// Default timezone for Storybook - avoids dependency on WordPress stores -const STORYBOOK_TIMEZONE = 'America/New_York'; - function PopoverContentWithState( { isWideScreen = false } ) { const [ range, setRange ] = useState< DateRange >( defaultRange ); From 91ed1d7682a0bb7844836cc03002aae2a0b8b43a Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 16:11:05 +0800 Subject: [PATCH 4/8] docs: drop the duplicated rationale from the parseSiteDateTime docblock --- .../premium-analytics/packages/datetime/src/site-datetime.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts b/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts index da981c2f995d..f4f4848041de 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts @@ -12,9 +12,6 @@ import type { TZDate } from '@date-fns/tz'; * Offset-less Stats API values are anchored to that zone; offset-bearing ones * already name an instant and keep it. * - * Anchored to that zone rather than left plain, so date arithmetic on the result - * takes its day boundaries there instead of in the browser's zone. - * * @param value - The raw timestamp, or a `Date`. * @return The instant, or `undefined` when the value is missing or malformed. */ From 46e5a2126ee12cbdbcb226c79eeac7ee05bc5af8 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 17:50:07 +0800 Subject: [PATCH 5/8] refactor: finish the zoned DateRange contract across now params and docs canStepForward and clampRangeEndToToday still took a plain Date for now while drillDateRange required a TZDate, so the same package asked for the site's clock in one signature and accepted the browser's in two others. Narrow both, plus END_OF_BUCKET alongside the ADD_BY_UNIT it parallels, and zone the call sites the narrowing catches. Re-export TZDate from datetime so a consumer naming a DateRange bound does not take its own direct dependency on @date-fns/tz, and point the three production importers at that boundary. Drop PickerRange, which had become a docblock-less twin of DateRange. Sync the docs the retyping left behind: the startOfDayTZ and endOfDayTZ @return lines, the formatters README parameter tables, and the README sentence that asked callers to pass the site's now by hand. --- .../packages/datetime/README.md | 4 ++-- .../src/__tests__/step-date-range.test.ts | 5 +++-- .../src/__tests__/to-date-range.test.ts | 6 +++--- .../packages/datetime/src/index.ts | 6 ++++++ .../packages/datetime/src/step-date-range.ts | 6 +++--- .../packages/datetime/src/to-date-range.ts | 4 ++-- .../packages/datetime/src/tz.ts | 4 ++-- .../packages/formatters/README.md | 18 +++++++++--------- .../packages/routing/package.json | 3 +++ .../use-report-date-filters.tsx | 7 ++----- .../src/search/date-range/date-range.ts | 7 +++++-- .../date-filters-panel/date-filters-panel.tsx | 5 +++-- .../stories/date-period-navigation.stories.tsx | 5 ++++- .../src/date-range-input/date-range-input.tsx | 2 +- 14 files changed, 48 insertions(+), 34 deletions(-) diff --git a/projects/packages/premium-analytics/packages/datetime/README.md b/projects/packages/premium-analytics/packages/datetime/README.md index 442146be30de..8be28d6cee8d 100644 --- a/projects/packages/premium-analytics/packages/datetime/README.md +++ b/projects/packages/premium-analytics/packages/datetime/README.md @@ -228,8 +228,8 @@ stepDateRange( { from, to }, 'previous' ); // Last 7 days -> the 7 days before #### `canStepForward( range, now )` -Whether the next window has already happened in full. Pass the site's `now`, -not the browser's. +Whether the next window has already happened in full. `now` is typed as a +`TZDate`, so the site's clock is what the signature asks for. ## Types diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/step-date-range.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/step-date-range.test.ts index 5cafbdba0d76..54872e21ae21 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/step-date-range.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/step-date-range.test.ts @@ -212,8 +212,9 @@ describe( 'canStepForward', () => { from: new TZDate( 2026, 6, 26, 0, 0, 0, 0, site ), to: new TZDate( 2026, 6, 26, 23, 59, 59, 999, site ), }; - // 02:00 UTC on the next site day, whatever timezone runs the tests. - const nowInstant = new Date( Date.UTC( 2026, 6, 27, 2, 0 ) ); + // 02:00 UTC on the next site day, carried in a zone still on the previous + // one, so a horizon cut on `now`'s own clock would answer false. + const nowInstant = new TZDate( Date.UTC( 2026, 6, 27, 2, 0 ), 'America/Los_Angeles' ); expect( canStepForward( yesterday, nowInstant ) ).toBe( true ); } ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/to-date-range.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/to-date-range.test.ts index 50a920b477fb..ff6d92379e83 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/to-date-range.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/to-date-range.test.ts @@ -107,11 +107,11 @@ describe( 'clampRangeEndToToday', () => { } ); it( 'closes the day on the window’s own clock', () => { - // 02:00 UTC is still the 26th in New York, so a clamp read on the - // browser's clock would leave the window a day long. + // 02:00 UTC is still the 26th in New York, so a clamp read on the zone + // `now` carries rather than the window's would leave it a day long. const clamped = clampRangeEndToToday( steppedForward, - new Date( Date.UTC( 2026, 7, 27, 2, 0 ) ) + new TZDate( Date.UTC( 2026, 7, 27, 2, 0 ), 'UTC' ) ); expect( clamped.to ).toEqual( endOf( 2026, 8, 26 ) ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/index.ts b/projects/packages/premium-analytics/packages/datetime/src/index.ts index d29ae84a9ebf..14f13ba3bbc8 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/index.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/index.ts @@ -6,6 +6,12 @@ export { } from './get-comparison-range'; export type { ComparisonRangeOptions } from './get-comparison-range'; +/** + * Re-exported so a consumer naming a `DateRange` bound does not have to take a + * direct dependency on `@date-fns/tz`. + */ +export type { TZDate } from '@date-fns/tz'; + export { createTZDateFromParts, toLocalTZ, diff --git a/projects/packages/premium-analytics/packages/datetime/src/step-date-range.ts b/projects/packages/premium-analytics/packages/datetime/src/step-date-range.ts index e2e2834948ac..0a661f41898a 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/step-date-range.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/step-date-range.ts @@ -86,7 +86,7 @@ export function stepDateRange( range: DateRange, direction: StepDirection ): Dat * End of the bucket an instant sits in, at each granularity a window measures * in. */ -const END_OF_BUCKET: Record< DateRangeSpanUnit, ( date: Date ) => Date > = { +const END_OF_BUCKET: Record< DateRangeSpanUnit, ( date: TZDate ) => TZDate > = { hour: endOfHour, day: endOfDay, month: endOfMonth, @@ -100,10 +100,10 @@ const END_OF_BUCKET: Record< DateRangeSpanUnit, ( date: Date ) => Date > = { * live preset ending in the running hour or day stays reachable. * * @param range - The window to test. - * @param now - The instant to compare against. + * @param now - The instant to compare against, read in the site's zone. * @return Whether a forward step lands on a window already worth showing. */ -export function canStepForward( range: DateRange, now: Date ): boolean { +export function canStepForward( range: DateRange, now: TZDate ): boolean { const span = getDateRangeSpan( range ); const next = stepDateRange( range, 'next' ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/to-date-range.ts b/projects/packages/premium-analytics/packages/datetime/src/to-date-range.ts index 991a804eae1c..a7575485aa86 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/to-date-range.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/to-date-range.ts @@ -52,10 +52,10 @@ export function completeToDateRange< T extends DateRange >( * the to-date window the reader started from. * * @param range - The window to clamp. - * @param now - The current instant. + * @param now - The current instant, read in the site's zone. * @return The clamped window, or `range` itself when it already ends today or earlier. */ -export function clampRangeEndToToday< T extends DateRange >( range: T, now: Date ): T { +export function clampRangeEndToToday< T extends DateRange >( range: T, now: TZDate ): T { if ( ! range.to ) { return range; } diff --git a/projects/packages/premium-analytics/packages/datetime/src/tz.ts b/projects/packages/premium-analytics/packages/datetime/src/tz.ts index ad1ef5ed1bff..93fcdf5e3305 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/tz.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/tz.ts @@ -163,7 +163,7 @@ export function dateToISOStringWithTZ( date: Date, timezone: string ): string { * * @param date - The date to get the start of day for * @param timeZone - Timezone string (e.g., 'America/New_York', 'UTC', '+08:00') - * @return A Date object representing midnight in the specified timezone + * @return A `TZDate` representing midnight in the specified timezone */ export function startOfDayTZ( date: Date | number, timeZone: string ): TZDate { const tzDate = new TZDateMini( new Date( date ).getTime(), timeZone ); @@ -176,7 +176,7 @@ export function startOfDayTZ( date: Date | number, timeZone: string ): TZDate { * * @param date - The date to get the end of day for * @param timeZone - Timezone string (e.g., 'America/New_York', 'UTC', '+08:00') - * @return A Date object representing the last millisecond of the day in the specified timezone + * @return A `TZDate` representing the last millisecond of the day in the specified timezone */ export function endOfDayTZ( date: Date | number, timeZone: string ): TZDate { const tzDate = new TZDateMini( new Date( date ).getTime(), timeZone ); diff --git a/projects/packages/premium-analytics/packages/formatters/README.md b/projects/packages/premium-analytics/packages/formatters/README.md index a48022d9a891..a2326c9f0f22 100644 --- a/projects/packages/premium-analytics/packages/formatters/README.md +++ b/projects/packages/premium-analytics/packages/formatters/README.md @@ -154,10 +154,10 @@ formatDateRange( { from, to } ); // 'June 20 – 21, 2025' formatDateRange( { from, to }, { collapseSingleDay: true } ); // 'June 21, 2025' ``` -| Parameter | Type | Default | Description | -| --------------------------- | ---------------------------- | ------- | --------------------------------------------- | -| `range` | `{ from?: Date; to?: Date }` | | Date range object | -| `options.collapseSingleDay` | `boolean` | `false` | Name a window of a day or less by its end day | +| Parameter | Type | Default | Description | +| --------------------------- | -------------------------------- | ------- | --------------------------------------------- | +| `range` | `{ from?: TZDate; to?: TZDate }` | | Date range object | +| `options.collapseSingleDay` | `boolean` | `false` | Name a window of a day or less by its end day | ## `formatDateRangeCompact( range? )` and `formatDateRangeMinimal( range? )` @@ -228,11 +228,11 @@ formatDateRangeLong( { from, to }, { calendarScale: true } ); // without the flag, the last of those reads 'Thursday, January 1 – Saturday, January 3' ``` -| Parameter | Type | Default | Description | -| ----------------------- | ---------------------------- | ------------ | --------------------------------------------- | -| `range` | `{ from?: Date; to?: Date }` | | Date range object | -| `options.referenceYear` | `number` | current year | Year against which the year is redundant | -| `options.calendarScale` | `boolean` | `false` | Force the calendar shape whatever it measures | +| Parameter | Type | Default | Description | +| ----------------------- | -------------------------------- | ------------ | --------------------------------------------- | +| `range` | `{ from?: TZDate; to?: TZDate }` | | Date range object | +| `options.referenceYear` | `number` | current year | Year against which the year is redundant | +| `options.calendarScale` | `boolean` | `false` | Force the calendar shape whatever it measures | ## Implementation diff --git a/projects/packages/premium-analytics/packages/routing/package.json b/projects/packages/premium-analytics/packages/routing/package.json index 4bff1b340d51..900298827e12 100644 --- a/projects/packages/premium-analytics/packages/routing/package.json +++ b/projects/packages/premium-analytics/packages/routing/package.json @@ -13,5 +13,8 @@ "@wordpress/route": "0.20.0", "date-fns": "4.1.0", "react": "18.3.1" + }, + "devDependencies": { + "@date-fns/tz": "1.4.1" } } diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx index 7fd703073a36..2bc823b3ae42 100644 --- a/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx @@ -23,7 +23,6 @@ import { decodeDateSearchParam, encodeDateToSearchParam } from '../../search/dat import { hasPrimaryDateDraft } from '../../search/report-params'; import { useStagedSearch } from '../use-staged-search'; import { buildRangePatch, type ReportQuerySearchParams } from './build-range-patch'; -import type { TZDate } from '@date-fns/tz'; import type { ComparisonPresetId, DateRange, @@ -32,16 +31,14 @@ import type { StepDirection, } from '@jetpack-premium-analytics/datetime'; -type PickerRange = { from: TZDate | undefined; to: TZDate | undefined }; - /** * The values and callbacks that drive `DateFiltersPanel`. */ export type ReportDateFilters = { presetId?: PrimaryPresetId; - range: PickerRange; + range: DateRange; appliedPresetId?: PrimaryPresetId; - appliedRange: PickerRange; + appliedRange: DateRange; comparisonPresetId?: ComparisonPresetId; appliedComparisonPresetId?: ComparisonPresetId; 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 c4b3a507772a..ea493fb46213 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,9 +1,12 @@ /** * External dependencies */ -import { dateToISOStringWithLocalTZ, localTZDate } from '@jetpack-premium-analytics/datetime'; +import { + dateToISOStringWithLocalTZ, + localTZDate, + type TZDate, +} from '@jetpack-premium-analytics/datetime'; import { isValid } from 'date-fns'; -import type { TZDate } from '@date-fns/tz'; /** * Parse a stored report-param date for the picker. 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 e83a0bf8b82e..d48eb6bdde75 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 @@ -6,6 +6,7 @@ import { canStepForward, isComparisonPresetId, isPrimaryPreset, + toLocalTZ, type ComparisonPresetId, type IntervalType, type PrimaryPresetId, @@ -259,12 +260,12 @@ export function DateFiltersPanel( { return ( ); - }, [ appliedRange, disabled, onStep, range ] ); + }, [ appliedRange, disabled, onStep, range, timeZone ] ); // Same arrangement as the comparison control: built once, rendered in the // row and in the probe. diff --git a/projects/packages/premium-analytics/packages/ui/src/date-period-navigation/stories/date-period-navigation.stories.tsx b/projects/packages/premium-analytics/packages/ui/src/date-period-navigation/stories/date-period-navigation.stories.tsx index fe8008215c41..0289a793081f 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-period-navigation/stories/date-period-navigation.stories.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-period-navigation/stories/date-period-navigation.stories.tsx @@ -68,7 +68,10 @@ function DatePeriodNavigationWithState( { initialEndsAgo }: { initialEndsAgo: nu }; return ( - + ); } diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx index 3c9e245b1f29..aa56c12bf977 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx @@ -5,6 +5,7 @@ import { createTZDateFromParts, formatToTimezoneNaiveString, getDatePart, + type TZDate, } from '@jetpack-premium-analytics/datetime'; import { FormField, Input, Stack } from '@jetpack-premium-analytics/externals'; import { __ } from '@wordpress/i18n'; @@ -13,7 +14,6 @@ import { useCallback, useEffect, useState } from 'react'; * Internal dependencies */ import { DateRangePopoverContent } from '../date-range-popover/date-range-filter'; -import type { TZDate } from '@date-fns/tz'; import './date-range-input.scss'; type DateRangeInputProps = Pick< From 5765ca6b00303e46af29071edf547200bf986ffd Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 17:50:20 +0800 Subject: [PATCH 6/8] fix: report no span for a date range whose end precedes its start getDateRangeSpan floors every measurement at 1, so a backwards range fell through differenceInHours to a plausible-looking one-hour window rather than to null, and stepDateRange and canStepForward then acted on it. The range params are decoded from the URL independently with no ordering check, so a hand-edited address reaches this. Pre-existing rather than introduced here: the arithmetic is unchanged by the retyping this branch does. Split out if it is better reviewed alone. --- .../changelog/change-wooa7s-2100-daterange-span-guard | 4 ++++ .../packages/datetime/src/date-range-span.ts | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-span-guard diff --git a/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-span-guard b/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-span-guard new file mode 100644 index 000000000000..3caba0fce8d5 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-span-guard @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Date range: Report no span for a range whose end precedes its start, instead of a one-hour window. diff --git a/projects/packages/premium-analytics/packages/datetime/src/date-range-span.ts b/projects/packages/premium-analytics/packages/datetime/src/date-range-span.ts index 6be8f2111fa4..13d0a0f77aba 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/date-range-span.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/date-range-span.ts @@ -96,7 +96,7 @@ function getWholeMonths( from: TZDate, to: TZDate ): number | null { * open-ended range measures by the day it is read on. * * @param range - The range to measure. - * @return The span, or null when the range is missing an end. + * @return The span, or null when the range is missing an end or runs backwards. */ export function getDateRangeSpan( range?: DateRange ): DateRangeSpan | null { const from = range?.from; @@ -106,6 +106,13 @@ export function getDateRangeSpan( range?: DateRange ): DateRangeSpan | null { return null; } + // A hand-edited URL can name an end before its start. Every measurement + // below floors at 1, so a backwards range would otherwise report a + // plausible-looking one-hour window rather than no window at all. + if ( to.getTime() < from.getTime() ) { + return null; + } + if ( ! coversWholeDays( from, to ) ) { // `round`, not the default truncation: an hour-snapped window runs to the // last millisecond of its final hour, and truncation reads it an hour short. From 05419061316d58fb817d2873759617872643e3fe Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 9 Sep 2026 10:38:54 +0800 Subject: [PATCH 7/8] test: cover the reversed-range guard instead of asserting types in jest Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0181D8ao2EdA5EfQMny529pj --- .../src/__tests__/date-range-span.test.ts | 20 ++++--------------- .../packages/datetime/src/presets/primary.ts | 1 + 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts index b11c4ec92a0b..b845d2618150 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/date-range-span.test.ts @@ -3,7 +3,6 @@ */ import { getDateRangeSpan } from '../date-range-span'; import { createTZDateFromParts } from '../tz'; -import type { DateRange } from '../get-comparison-range'; import type { TZDate } from '@date-fns/tz'; /** @@ -45,6 +44,10 @@ describe( 'getDateRangeSpan', () => { expect( getDateRangeSpan( { from: at( 2026, 7, 21 ) } ) ).toBeNull(); } ); + it( 'returns null when the range runs backwards', () => { + expect( getDateRangeSpan( { from: endOf( 2026, 7, 28 ), to: at( 2026, 6, 29 ) } ) ).toBeNull(); + } ); + it( 'counts whole days inclusively', () => { expect( getDateRangeSpan( { from: at( 2026, 7, 21 ), to: endOf( 2026, 7, 27 ) } ) ).toEqual( { unit: 'day', @@ -126,21 +129,6 @@ describe( 'getDateRangeSpan', () => { } ); } ); - it( "measures a 30 day window on the site's clock, not the machine's", () => { - expect( getDateRangeSpan( { from: at( 2026, 6, 29 ), to: endOf( 2026, 7, 28 ) } ) ).toEqual( { - unit: 'day', - value: 30, - } ); - } ); - - // Babel strips types, so this is checked by `pnpm run typecheck`, not by jest. - it( 'rejects a zone-naive bound at the type level', () => { - // @ts-expect-error -- a plain `Date` names an instant but no zone to cut days in. - const naive: DateRange = { from: new Date( 0 ), to: new Date( 0 ) }; - - expect( naive ).toBeDefined(); - } ); - it( 'falls back to days when the range does not divide into months', () => { expect( getDateRangeSpan( { from: at( 2026, 4, 30 ), to: endOf( 2026, 7, 28 ) } ) ).toEqual( { unit: 'day', diff --git a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts index d0bc157ca098..9aa9dd1aba39 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts @@ -215,6 +215,7 @@ function buildDateContext( timeZone: string ): DateContext { const nowWithTZ = toLocalTZ( undefined, timeZone ); const initOfToday = startOfDay( nowWithTZ ); const endOfToday = endOfDay( nowWithTZ ); + // A nested `date-fns` call has no contextual type, and widens the result to `Date`. const initOfYesterday = subDays( initOfToday, 1 ); const endOfYesterday = endOfDay( initOfYesterday ); const lastMonth = subMonths( initOfToday, 1 ); From 59bd206dfc92dc9e7a4137ef0c428255a7e1564b Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 9 Sep 2026 17:02:44 +0800 Subject: [PATCH 8/8] chore: fold the span guard note into the single changelog entry --- .../changelog/change-wooa7s-2100-daterange-span-guard | 4 ---- .../changelog/change-wooa7s-2100-daterange-zoned | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) delete mode 100644 projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-span-guard diff --git a/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-span-guard b/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-span-guard deleted file mode 100644 index 3caba0fce8d5..000000000000 --- a/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-span-guard +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fixed - -Date range: Report no span for a range whose end precedes its start, instead of a one-hour window. diff --git a/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned b/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned index d3032e79ed97..19b1c964a989 100644 --- a/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned +++ b/projects/packages/premium-analytics/changelog/change-wooa7s-2100-daterange-zoned @@ -1,4 +1,4 @@ Significance: patch Type: changed -Date range: Type the range bounds as zoned instants, so day boundaries are measured on the site's clock. +Date range: Type the range bounds as zoned instants, so day boundaries are measured on the site's clock. A range whose end precedes its start now reports no period length instead of a one-hour window.