diff --git a/AGENTS.md b/AGENTS.md index 736e1bea1d4d..df17b9484308 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -368,9 +368,6 @@ An inline `@phan-suppress-next-line -- ` is acceptable ONLY as a - The "phan: Update wpcom stubs" PR is machine-generated and gets rebased/recreated on every job run — never hand-edit it, your changes will be overwritten. - `.phan/stubs/wpcom-stubs.php` is likewise generated (its header says so). Never edit it directly to add a symbol — add it to `stub-defs.php` in wpcom instead. -## Maintaining this file +## Maintaining This File -Keep this file for knowledge useful to almost every future agent session in this project. -Do not repeat what the codebase already shows; point to the authoritative file or command instead. -Prefer rewriting or pruning existing entries over appending new ones. -When updating this file, preserve this bar for all agents and keep entries concise. +If you discover a pattern or pitfall not covered here, mention it to the developer so they can decide whether to update this file. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a9d4d2694af2..000000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,2 +0,0 @@ - -@AGENTS.md 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..19b1c964a989 --- /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 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. diff --git a/projects/packages/premium-analytics/packages/datetime/README.md b/projects/packages/premium-analytics/packages/datetime/README.md index 0df9f7b24a91..8ee42d98d482 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 @@ -221,11 +221,14 @@ twelve-month window as 12 months. ```typescript type DateRange = { - from?: Date; - to?: Date; + from?: TZDate; + to?: TZDate; }; ``` +Both bounds stay optional: `resolveBucketStamp` returns `undefined` for a bound +it cannot resolve, and the chart passes that straight through. + ### `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..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 @@ -3,6 +3,37 @@ */ 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 +41,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 +73,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 +96,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 +110,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 +230,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..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 @@ -2,11 +2,17 @@ * Internal dependencies */ import { getDateRangeSpan } from '../date-range-span'; +import { createTZDateFromParts } from '../tz'; +import type { TZDate } from '@date-fns/tz'; /** - * 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. + * 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. * * @param year - Full year. * @param month - 1-indexed month. @@ -15,8 +21,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 +33,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', () => { @@ -38,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', @@ -61,7 +71,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 } ); } ); 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 08b0bfe1518b..09d47bc31fb6 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 @@ -8,32 +8,62 @@ import { differenceInDays } from 'date-fns'; import { getDateRangeSpan } from '../date-range-span'; import { COMPARISON_PRESETS, getComparisonRangeFromPreset } from '../get-comparison-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 ), } ); } ); } ); @@ -42,14 +72,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 ), } ); } ); @@ -63,27 +93,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 ), } ); } ); } ); @@ -92,8 +122,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 => { @@ -105,32 +135,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 ), } ); } ); } ); @@ -139,38 +169,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', @@ -184,13 +214,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 ), } ); } ); @@ -198,8 +228,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 ); @@ -211,37 +241,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 ), } ); } ); @@ -267,13 +297,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 ), } ); } ); @@ -283,13 +313,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 ), } ); } ); @@ -300,13 +330,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 ), } ); } ); } ); @@ -314,25 +344,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 ), } ); } ); @@ -342,8 +372,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( @@ -359,14 +389,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 ), } ); } ); @@ -374,14 +404,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 ), } ); } ); @@ -390,12 +420,12 @@ describe( 'getComparisonRangeFromPreset', () => { // back from 31 January clamps to 30 November: 62 days against the // reference's 59, so the comparison counts days instead. 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 ); @@ -407,14 +437,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 ), } ); } ); } ); @@ -422,8 +452,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', () => { @@ -433,7 +463,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', () => { @@ -444,7 +474,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 ) ); @@ -458,16 +488,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 ), } ); } ); @@ -476,8 +506,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 ), } ); } ); @@ -485,13 +515,13 @@ describe( 'getComparisonRangeFromPreset', () => { // read on 8 March came back three days short, and year to date read on // 1 January 2028 came back inverted. it.each( [ - [ 'March, after a shorter February', new Date( 2026, 2, 1 ), new Date( 2026, 2, 8 ) ], - [ 'February, after a longer January', new Date( 2026, 1, 1 ), new Date( 2026, 1, 15 ) ], - [ 'September, after a longer August', new Date( 2026, 8, 1 ), new Date( 2026, 8, 8 ) ], + [ 'March, after a shorter February', siteDate( 2026, 2, 1 ), siteDate( 2026, 2, 8 ) ], + [ 'February, after a longer January', siteDate( 2026, 1, 1 ), siteDate( 2026, 1, 15 ) ], + [ 'September, after a longer August', siteDate( 2026, 8, 1 ), siteDate( 2026, 8, 8 ) ], ] )( 'mirrors month to date with an equal-length window in %s', ( _label, first, last ) => { const monthToDate = { - from: new Date( first.getFullYear(), first.getMonth(), first.getDate(), 0, 0, 0, 0 ), - to: new Date( last.getFullYear(), last.getMonth(), last.getDate(), 23, 59, 59, 999 ), + from: siteDate( first.getFullYear(), first.getMonth(), first.getDate(), 0, 0, 0, 0 ), + to: siteDate( last.getFullYear(), last.getMonth(), last.getDate(), 23, 59, 59, 999 ), }; const comparison = getComparisonRangeFromPreset( monthToDate, 'previous-period', { @@ -505,13 +535,13 @@ describe( 'getComparisonRangeFromPreset', () => { } ); it.each( [ - [ 'the first day of a leap year', new Date( 2028, 0, 1 ), new Date( 2028, 0, 1 ) ], - [ 'the first day after one', new Date( 2025, 0, 1 ), new Date( 2025, 0, 1 ) ], - [ 'a day well into a leap year', new Date( 2028, 0, 1 ), new Date( 2028, 2, 5 ) ], + [ 'the first day of a leap year', siteDate( 2028, 0, 1 ), siteDate( 2028, 0, 1 ) ], + [ 'the first day after one', siteDate( 2025, 0, 1 ), siteDate( 2025, 0, 1 ) ], + [ 'a day well into a leap year', siteDate( 2028, 0, 1 ), siteDate( 2028, 2, 5 ) ], ] )( 'mirrors year to date with an equal-length window on %s', ( _label, first, last ) => { const yearToDate = { - from: new Date( first.getFullYear(), first.getMonth(), first.getDate(), 0, 0, 0, 0 ), - to: new Date( last.getFullYear(), last.getMonth(), last.getDate(), 23, 59, 59, 999 ), + from: siteDate( first.getFullYear(), first.getMonth(), first.getDate(), 0, 0, 0, 0 ), + to: siteDate( last.getFullYear(), last.getMonth(), last.getDate(), 23, 59, 59, 999 ), }; const comparison = getComparisonRangeFromPreset( yearToDate, 'previous-period', { 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/date-range-span.ts b/projects/packages/premium-analytics/packages/datetime/src/date-range-span.ts index c255619e65d5..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 @@ -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 ); @@ -97,7 +96,7 @@ function getWholeMonths( from: Date, to: Date ): 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; @@ -107,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. 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 6a8d2b78999b..f74771fb1742 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,15 @@ 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` 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 }; export const COMPARISON_PREVIOUS_PERIOD = 'previous-period' as const; export const COMPARISON_PREVIOUS_WEEK = 'previous-week' as const; @@ -62,7 +69,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 +86,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 +152,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 +174,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 388d53a40966..4bd5e13b942f 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/presets/primary.ts b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts index 6b3393e098f7..7a29b3af59f6 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/presets/primary.ts @@ -48,18 +48,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; }; @@ -232,7 +233,9 @@ function buildDateContext( timeZone: string ): DateContext { const nowWithTZ = toLocalTZ( undefined, timeZone ); const initOfToday = startOfDay( nowWithTZ ); const endOfToday = endOfDay( nowWithTZ ); - const endOfYesterday = endOfDay( subDays( initOfToday, 1 ) ); + // 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 ); 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..f4f4848041de 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts @@ -4,6 +4,7 @@ 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. @@ -14,9 +15,11 @@ import { toLocalTZ } from './tz'; * @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 +34,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/tz.ts b/projects/packages/premium-analytics/packages/datetime/src/tz.ts index 3e46ba26c897..93fcdf5e3305 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/tz.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/tz.ts @@ -163,9 +163,9 @@ 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 ): 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 ); @@ -176,9 +176,9 @@ export function startOfDayTZ( date: Date | number, timeZone: string ): Date { * * @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 ): 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/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/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/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/__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 2e7e28e967f1..b70c657760ae 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 @@ -7,6 +7,10 @@ jest.mock( '@jetpack-premium-analytics/datetime', () => ( { ...jest.requireActual( '@jetpack-premium-analytics/datetime' ), reportingTimeZone: () => '+00:00', } ) ); +/** + * External dependencies + */ +import { TZDate } from '@date-fns/tz'; /** * Internal dependencies */ @@ -15,12 +19,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(); @@ -121,7 +125,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 }, @@ -166,8 +170,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' }, @@ -203,8 +207,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 26dfb8f8f329..77683362f9c5 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' ); @@ -385,8 +386,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' ) @@ -433,7 +434,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( { @@ -452,7 +453,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( { @@ -470,7 +471,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( { @@ -488,7 +489,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( { @@ -511,7 +512,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( { @@ -529,7 +532,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(); @@ -543,7 +546,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(); } ); @@ -560,7 +563,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(); } ); @@ -574,7 +577,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( { @@ -593,7 +596,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/use-report-date-filters.tsx b/projects/packages/premium-analytics/packages/routing/src/hooks/use-report-date-filters/use-report-date-filters.tsx index 1346e8a89642..1177133051ee 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,16 +27,14 @@ import type { PrimaryPresetId, } from '@jetpack-premium-analytics/datetime'; -type PickerRange = { from: Date | undefined; to: Date | 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; @@ -261,7 +259,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.test.ts b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.test.ts index 11c353def860..a1622171e434 100644 --- a/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.test.ts +++ b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.test.ts @@ -1,6 +1,7 @@ /** * External dependencies */ +import { TZDate } from '@date-fns/tz'; import { getSettings, setSettings } from '@wordpress/date'; /** * Internal dependencies @@ -96,8 +97,8 @@ describe( 'encodeRangeToSearchParams', () => { } ); // Midnight in the site zone, the shape the calendar inputs stage. - const from = new Date( '2026-06-29T04:00:00.000Z' ); - const to = new Date( '2026-07-09T04:00:00.000Z' ); + const from = new TZDate( '2026-06-29T04:00:00.000Z', 'America/New_York' ); + const to = new TZDate( '2026-07-09T04:00:00.000Z', 'America/New_York' ); it( 'extends a calendar edit to the end of the site day', () => { expect( encodeRangeToSearchParams( { from, to } ) ).toEqual( { 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 cb24456d0c93..f9919e33155e 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 @@ -10,6 +10,7 @@ import { reportingTimeZone, type DateRange, type PrimaryPresetId, + type TZDate, } from '@jetpack-premium-analytics/datetime'; import { isValid } from 'date-fns'; @@ -20,7 +21,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 f8d310fa5b2e..c882ee57503d 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 > > = {} ) { @@ -65,8 +66,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( { @@ -130,8 +131,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', @@ -145,8 +146,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' ); 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 78ad16be6d14..91c7c47abb60 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 6bfb7bf89fee..9f17e3af3ccc 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 516061bc8555..e7d93289c697 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-range-input/date-range-input.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx index 52b5c660f834..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'; @@ -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/__tests__/date-range-filter.test.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/date-range-filter.test.tsx index a18ddb8f72f2..255e9e127b92 100644 --- a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/date-range-filter.test.tsx +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/__tests__/date-range-filter.test.tsx @@ -1,11 +1,13 @@ +import { TZDate } from '@date-fns/tz'; +import { type DateRange } from '@jetpack-premium-analytics/datetime'; import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useState } from 'react'; -import { DateRangePopoverContent, type DateRange } from '../date-range-filter'; +import { DateRangePopoverContent } from '../date-range-filter'; const JULY_2026: DateRange = { - 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' ), }; type Props = Parameters< typeof DateRangePopoverContent >[ 0 ]; 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 8137d053ea23..13e49673f502 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, + toLocalTZ, + type DateRange, + type PrimaryPresetId, +} from '@jetpack-premium-analytics/datetime'; import { Button, RangeCalendar, Stack } from '@jetpack-premium-analytics/externals'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; @@ -13,9 +18,11 @@ import { DateRangeInput } from '../date-range-input'; import './date-range-filter.scss'; /** - * The calendar's own range type, from `@wordpress/ui`. + * The calendar's own range type, from `@wordpress/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. */ -export type DateRange = NonNullable< Parameters< typeof RangeCalendar >[ 0 ][ 'value' ] >; +type CalendarRange = NonNullable< Parameters< typeof RangeCalendar >[ 0 ][ 'value' ] >; type DateRangePopoverContentProps = { range: DateRange; @@ -106,22 +113,27 @@ export function DateRangePopoverContent( { * not the calendar's computed range: `RangeCalendar` never restarts a * complete range on click, it only moves the nearest endpoint. */ - const handleCalendarChange = ( _nextRange: DateRange | null, triggerDate: Date ) => { + const handleCalendarChange = ( _nextRange: CalendarRange | null, triggerDate: Date ) => { + // The picker already builds its days in `timeZone`; this restates that in + // the type, since its `onValueChange` 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 } ); }; - const calendarRange = draftRange ?? range; + // Widened to the calendar's shape, which requires both keys where a + // `DateRange` leaves them optional. + 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; 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..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,7 +1,8 @@ +import { toLocalTZ } from '@jetpack-premium-analytics/datetime'; 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 > = { @@ -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 ); 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 9bb4d25b2e2d..edcb138e21e1 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' ), }; /**