diff --git a/projects/packages/premium-analytics/changelog/wooa7s-2079-dedup b/projects/packages/premium-analytics/changelog/wooa7s-2079-dedup new file mode 100644 index 000000000000..6b63cc2e8dd3 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-2079-dedup @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Report dates: read every round-trip-validated date label and day bound through one helper. diff --git a/projects/packages/premium-analytics/packages/data/src/processing/stats/bucket-window.ts b/projects/packages/premium-analytics/packages/data/src/processing/stats/bucket-window.ts index 365d84fe731d..d283730fae64 100644 --- a/projects/packages/premium-analytics/packages/data/src/processing/stats/bucket-window.ts +++ b/projects/packages/premium-analytics/packages/data/src/processing/stats/bucket-window.ts @@ -16,6 +16,7 @@ import { formatDatePartWithTime, readSiteTimestamp } from '@jetpack-premium-anal /** * Internal dependencies */ +import { DAY_END_TIME, DAY_START_TIME } from './utils'; import type { StatsQueryParams } from '../../utils/stats-params'; // Bare dates and T-separated datetimes only — getDatePart splits on T alone @@ -42,8 +43,8 @@ export function windowEndHour( value?: string ): number { const padTimePart = ( part: number ) => String( part ).padStart( 2, '0' ); const EDGE_FALLBACKS = { - start: { time: '00:00:00', seconds: '00' }, - end: { time: '23:59:59', seconds: '59' }, + start: { time: DAY_START_TIME, seconds: '00' }, + end: { time: DAY_END_TIME, seconds: '59' }, } as const; // A window bound in the same timezone-naive wall-clock shape the bucket labels diff --git a/projects/packages/premium-analytics/packages/data/src/processing/stats/post.ts b/projects/packages/premium-analytics/packages/data/src/processing/stats/post.ts index c51de305fccf..46d0c91e8c93 100644 --- a/projects/packages/premium-analytics/packages/data/src/processing/stats/post.ts +++ b/projects/packages/premium-analytics/packages/data/src/processing/stats/post.ts @@ -1,4 +1,4 @@ -import { format, isValid, parse } from 'date-fns'; +import { parseExactLabel } from '@jetpack-premium-analytics/datetime'; import { safeParseFloat } from '../../utils/parsing'; import { decodeHtmlText } from '../../utils/text'; import { coerceStatsArray, coerceStatsRecord, isStatsRecord } from './utils'; @@ -99,12 +99,7 @@ const STATS_POST_DAY_FORMAT = 'yyyy-MM-dd'; /** A real calendar day in the API's `YYYY-MM-DD` format. */ function isValidStatsPostDay( value: string ): boolean { - if ( ! /^\d{4}-\d{2}-\d{2}$/.test( value ) ) { - return false; - } - - const parsed = parse( value, STATS_POST_DAY_FORMAT, new Date( 0 ) ); - return isValid( parsed ) && format( parsed, STATS_POST_DAY_FORMAT ) === value; + return parseExactLabel( value, STATS_POST_DAY_FORMAT ) !== null; } function normalizeStatsPostYear( value: unknown ): StatsPostYear { diff --git a/projects/packages/premium-analytics/packages/data/src/processing/stats/time-series.ts b/projects/packages/premium-analytics/packages/data/src/processing/stats/time-series.ts index fbb02dc806fc..812790c2495c 100644 --- a/projects/packages/premium-analytics/packages/data/src/processing/stats/time-series.ts +++ b/projects/packages/premium-analytics/packages/data/src/processing/stats/time-series.ts @@ -1,11 +1,13 @@ -import { formatDatePartWithTime, getDatePart } from '@jetpack-premium-analytics/datetime'; +import { + formatDatePartWithTime, + getDatePart, + parseExactLabel, +} from '@jetpack-premium-analytics/datetime'; import { endOfISOWeek, endOfMonth, endOfYear, format, - isValid, - parse, startOfISOWeek, startOfMonth, startOfYear, @@ -15,6 +17,8 @@ import { createStatsBucketWindowFilter, type StatsBucketFilter } from './bucket- import { coerceStatsArray, coerceStatsRecord, + DAY_END_TIME, + DAY_START_TIME, getStatsIntervalFields, normalizeStatsSummary, } from './utils'; @@ -37,7 +41,6 @@ export type StatsTimeSeriesReport = StatsNormalizedReport & { const nonMetricFields = [ 'period', 'time_interval', 'date', 'date_start', 'date_end', 'hour' ]; const dateFormat = 'yyyy-MM-dd'; -const referenceDate = new Date( 2001, 0, 1 ); function numericTimeSeriesRow( row: StatsRecord ) { return Object.fromEntries( @@ -117,8 +120,8 @@ function getPrimaryMetricValue( row: StatsRecord ) { function getDateFnsIntervalFields( startDate: Date, endDate: Date ) { return { time_interval: format( startDate, dateFormat ), - date_start: formatDatePartWithTime( format( startDate, dateFormat ), '00:00:00' ), - date_end: formatDatePartWithTime( format( endDate, dateFormat ), '23:59:59' ), + date_start: formatDatePartWithTime( format( startDate, dateFormat ), DAY_START_TIME ), + date_end: formatDatePartWithTime( format( endDate, dateFormat ), DAY_END_TIME ), }; } @@ -130,9 +133,9 @@ function getWeekIntervalFields( period: string ) { } const normalizedPeriod = `${ match[ 1 ] }-W${ match[ 2 ].padStart( 2, '0' ) }`; - const parsed = parse( normalizedPeriod, "RRRR-'W'II", referenceDate ); + const parsed = parseExactLabel( normalizedPeriod, "RRRR-'W'II" ); - if ( ! isValid( parsed ) || format( parsed, "RRRR-'W'II" ) !== normalizedPeriod ) { + if ( ! parsed ) { return null; } @@ -148,13 +151,9 @@ function getWpcomWeekIntervalFields( period: string ) { return null; } - const parsed = parse( - `${ match[ 1 ] }-${ match[ 2 ] }-${ match[ 3 ] }`, - 'yyyy-MM-dd', - referenceDate - ); + const parsed = parseExactLabel( `${ match[ 1 ] }-${ match[ 2 ] }-${ match[ 3 ] }`, dateFormat ); - if ( ! isValid( parsed ) ) { + if ( ! parsed ) { return null; } @@ -162,9 +161,9 @@ function getWpcomWeekIntervalFields( period: string ) { } function getMonthIntervalFields( period: string ) { - const parsed = parse( period, 'yyyy-MM', referenceDate ); + const parsed = parseExactLabel( period, 'yyyy-MM' ); - if ( ! isValid( parsed ) || format( parsed, 'yyyy-MM' ) !== period ) { + if ( ! parsed ) { return null; } @@ -172,9 +171,9 @@ function getMonthIntervalFields( period: string ) { } function getYearIntervalFields( period: string ) { - const parsed = parse( period, 'yyyy', referenceDate ); + const parsed = parseExactLabel( period, 'yyyy' ); - if ( ! isValid( parsed ) || format( parsed, 'yyyy' ) !== period ) { + if ( ! parsed ) { return null; } @@ -317,8 +316,8 @@ export function sanitizeStatsTimeSeriesResponse( summary: { ...getTimeSeriesSummarySidecars( response ), ...summary, - date_start: firstRow?.date_start ?? toSummaryBound( query?.start_date, '00:00:00' ), - date_end: lastRow?.date_end ?? toSummaryBound( query?.end_date ?? query?.date, '23:59:59' ), + date_start: firstRow?.date_start ?? toSummaryBound( query?.start_date, DAY_START_TIME ), + date_end: lastRow?.date_end ?? toSummaryBound( query?.end_date ?? query?.date, DAY_END_TIME ), }, data, }; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/stats/utils.ts b/projects/packages/premium-analytics/packages/data/src/processing/stats/utils.ts index 9bdfb611b3c6..798302a5ecb3 100644 --- a/projects/packages/premium-analytics/packages/data/src/processing/stats/utils.ts +++ b/projects/packages/premium-analytics/packages/data/src/processing/stats/utils.ts @@ -14,6 +14,10 @@ import type { } from './types'; import type { StatsQueryParams } from '../../utils/stats-params'; +/** Inclusive day bounds, in the offset-less second-precision shape Stats responses carry. */ +export const DAY_START_TIME = '00:00:00'; +export const DAY_END_TIME = '23:59:59'; + type StatsComparisonKey = string | number; type StatsComparisonEntry< TComparison > = { @@ -317,8 +321,8 @@ export function getStatsIntervalFields( date: string, period?: string ): StatsIn return { time_interval: date, - date_start: formatDatePartWithTime( startDate, '00:00:00' ), - date_end: formatDatePartWithTime( endDate, '23:59:59' ), + date_start: formatDatePartWithTime( startDate, DAY_START_TIME ), + date_end: formatDatePartWithTime( endDate, DAY_END_TIME ), }; } @@ -337,8 +341,8 @@ export function getStatsSummaryIntervalFields( const endDate = getStatsEndDateParam( query ) ?? responseDate ?? getDatePart( query?.start_date ); return { - ...( startDate ? { date_start: formatDatePartWithTime( startDate, '00:00:00' ) } : {} ), - ...( endDate ? { date_end: formatDatePartWithTime( endDate, '23:59:59' ) } : {} ), + ...( startDate ? { date_start: formatDatePartWithTime( startDate, DAY_START_TIME ) } : {} ), + ...( endDate ? { date_end: formatDatePartWithTime( endDate, DAY_END_TIME ) } : {} ), }; } diff --git a/projects/packages/premium-analytics/packages/datetime/src/__tests__/date.test.ts b/projects/packages/premium-analytics/packages/datetime/src/__tests__/date.test.ts index fcb8f0558b06..133df275b930 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/__tests__/date.test.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/date.test.ts @@ -1,7 +1,12 @@ /** * Internal dependencies */ -import { formatDatePartWithTime, getDateIntervalDateParts, getDatePart } from '../date'; +import { + formatDatePartWithTime, + getDateIntervalDateParts, + getDatePart, + parseExactLabel, +} from '../date'; describe( 'date helpers', () => { it( 'extracts date parts from ISO datetimes', () => { @@ -45,4 +50,29 @@ describe( 'date helpers', () => { endDate: '2026-12-31', } ); } ); + + describe( 'parseExactLabel', () => { + it( 'parses a label that round-trips through its format', () => { + expect( parseExactLabel( '2026-06-22', 'yyyy-MM-dd' ) ).toEqual( new Date( 2026, 5, 22 ) ); + expect( parseExactLabel( '2026-06', 'yyyy-MM' ) ).toEqual( new Date( 2026, 5, 1 ) ); + } ); + + it( 'rejects a day that does not exist', () => { + expect( parseExactLabel( '2026-02-31', 'yyyy-MM-dd' ) ).toBeNull(); + expect( parseExactLabel( 'not a date', 'yyyy-MM-dd' ) ).toBeNull(); + } ); + + // Both parse to a real date that `isValid` accepts; only the round trip + // rejects them. 2025-W53 resolves into 2026, and the loose day into June. + it( 'rejects a label that parses to a different label', () => { + expect( parseExactLabel( '2025-W53', "RRRR-'W'II" ) ).toBeNull(); + expect( parseExactLabel( '2026-6-22', 'yyyy-MM-dd' ) ).toBeNull(); + } ); + + // The round trip still succeeds, so a year-less format resolves against the + // reference year rather than failing. Every caller's format must carry one. + it( 'anchors a year-less format to the 2001 reference year', () => { + expect( parseExactLabel( '06-22', 'MM-dd' ) ).toEqual( new Date( 2001, 5, 22 ) ); + } ); + } ); } ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/date.ts b/projects/packages/premium-analytics/packages/datetime/src/date.ts index ad4ee2f941e0..0b8e47d45250 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/date.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/date.ts @@ -12,6 +12,26 @@ export type DateIntervalDateParts = { const DATE_PART_FORMAT = 'yyyy-MM-dd'; +// date-fns needs a reference date. Every format here carries a year, which resets +// the rest; a year-less format would silently inherit 2001. +const REFERENCE_DATE = new Date( 2001, 0, 1 ); + +/** + * Parse a label that must round-trip through its own format. + * + * `isValid` alone is not enough: date-fns reads `2025-W53` as a real date in 2026, + * and accepts a loosely written `2026-6-22`. Re-formatting catches both. + * + * @param label - The label as written. + * @param labelFormat - The date-fns format the label must match exactly. Must carry a year. + * @return The parsed date, or null when the label does not name a real one. + */ +export function parseExactLabel( label: string, labelFormat: string ): Date | null { + const parsed = parse( label, labelFormat, REFERENCE_DATE ); + + return isValid( parsed ) && format( parsed, labelFormat ) === label ? parsed : null; +} + /** * Extract the calendar date part from a date-like string. * diff --git a/projects/packages/premium-analytics/packages/datetime/src/index.ts b/projects/packages/premium-analytics/packages/datetime/src/index.ts index d29ae84a9ebf..29cb8c50b57b 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/index.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/index.ts @@ -38,6 +38,7 @@ export { formatDatePartWithTime, getDateIntervalDateParts, getDatePart, + parseExactLabel, type DateIntervalDateParts, type DateIntervalPeriod, } from './date'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/to-day.test.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/to-day.test.ts index 2cfe93c6a5a4..35b7dbcd4690 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/to-day.test.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/to-day.test.ts @@ -26,13 +26,8 @@ describe( 'toDay', () => { expect( toDay( 'not-a-date' ) ).toBeUndefined(); } ); - // The reason the stricter of the two former copies is the one hoisted: it - // protects two callers for two different reasons. `post-traffic-activity` - // feeds the result to parseISO/eachDayOfInterval, which throw on a - // well-shaped but non-existent day. `post-detail-highlights` only compares - // days as strings, so it wouldn't throw — but the loose check let a bad - // `from` still lexically match real days, producing a plausible-looking - // windowed sum instead of the documented all-time fallback. + // Callers feed the result to date maths that throws on an impossible day, or + // compare it as a string, where a loose check would lexically match real days. it( 'returns undefined for a well-shaped but impossible calendar date', () => { expect( toDay( '2026-02-31' ) ).toBeUndefined(); expect( toDay( '2026-13-01' ) ).toBeUndefined(); diff --git a/projects/packages/premium-analytics/widgets/popular-days/bucket-views-by-weekday.ts b/projects/packages/premium-analytics/widgets/popular-days/bucket-views-by-weekday.ts index 318d8f40fe1e..b9be173daf22 100644 --- a/projects/packages/premium-analytics/widgets/popular-days/bucket-views-by-weekday.ts +++ b/projects/packages/premium-analytics/widgets/popular-days/bucket-views-by-weekday.ts @@ -1,9 +1,9 @@ /** * External dependencies */ -import { getDatePart } from '@jetpack-premium-analytics/datetime'; +import { getDatePart, parseExactLabel } from '@jetpack-premium-analytics/datetime'; import { formatMondayFirstWeekday } from '@jetpack-premium-analytics/formatters'; -import { format, getDay, isValid, parse } from 'date-fns'; +import { getDay } from 'date-fns'; export type PopularDayBucket = { /** 0 = Monday … 6 = Sunday, matching the package's `weekStartsOn: 1` convention. */ @@ -17,9 +17,6 @@ export type PopularDayBucket = { const DATE_PART_FORMAT = 'yyyy-MM-dd'; -// Only consulted for fields the parsed string omits, and ours omits none. -const referenceDate = new Date( 2001, 0, 1 ); - function weekdayLabel( weekday: number ) { return formatMondayFirstWeekday( weekday ); } @@ -30,13 +27,7 @@ function weekdayLabel( weekday: number ) { function readRowDate( row: Record< string, unknown > ) { const datePart = getDatePart( row.date_start ?? row.time_interval ?? row.period ); - if ( ! datePart ) { - return undefined; - } - - const parsed = parse( datePart, DATE_PART_FORMAT, referenceDate ); - - return isValid( parsed ) && format( parsed, DATE_PART_FORMAT ) === datePart ? parsed : undefined; + return datePart ? parseExactLabel( datePart, DATE_PART_FORMAT ) : null; } function readRowViews( row: Record< string, unknown > ) { diff --git a/projects/packages/premium-analytics/widgets/post-views/__tests__/use-post-views.test.ts b/projects/packages/premium-analytics/widgets/post-views/__tests__/use-post-views.test.ts new file mode 100644 index 000000000000..2c6f62406f75 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/post-views/__tests__/use-post-views.test.ts @@ -0,0 +1,24 @@ +/** + * Internal dependencies + */ +import { toDayWindow } from '../use-post-views'; + +describe( 'toDayWindow', () => { + it( 'slices the date part off both ISO bounds', () => { + expect( + toDayWindow( '2026-07-01T00:00:00.000+08:00', '2026-07-07T23:59:59.999+08:00' ) + ).toEqual( { from: '2026-07-01', to: '2026-07-07' } ); + } ); + + it( 'returns undefined when a bound is missing', () => { + expect( toDayWindow( undefined, '2026-07-07T23:59:59.999+08:00' ) ).toBeUndefined(); + expect( toDayWindow( '2026-07-01T00:00:00.000+08:00', undefined ) ).toBeUndefined(); + } ); + + it( 'returns undefined for a well-shaped but impossible day', () => { + expect( + toDayWindow( '2026-02-31T00:00:00.000+08:00', '2026-07-07T23:59:59.999+08:00' ) + ).toBeUndefined(); + expect( toDayWindow( '2026-07-01T00:00:00.000+08:00', 'not-a-date' ) ).toBeUndefined(); + } ); +} ); diff --git a/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts b/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts index 35fa89c271ee..afc1cfc5a785 100644 --- a/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts +++ b/projects/packages/premium-analytics/widgets/post-views/use-post-views.ts @@ -8,6 +8,7 @@ import { type StatsPostDay, } from '@jetpack-premium-analytics/data'; import { parseSiteDateTime } from '@jetpack-premium-analytics/datetime'; +import { toDay } from '@jetpack-premium-analytics/widgets-toolkit'; import { useMemo } from '@wordpress/element'; import { addDays, @@ -57,34 +58,20 @@ type BucketWindow = { to: string; }; -/** - * Extract a validated `YYYY-MM-DD` day — validated because `bucketDays()` feeds - * it to `parseISO()`/`each*OfInterval()`, which throw on invalid dates. - */ -function toValidDay( value?: string ): string | undefined { - const day = value?.slice( 0, 10 ); - - if ( ! day || ! /^\d{4}-\d{2}-\d{2}$/.test( day ) || Number.isNaN( parseISO( day ).getTime() ) ) { - return undefined; - } - - return day; -} - /** * Extract a `YYYY-MM-DD` window from ISO report params, or undefined when * either bound is missing/malformed. The endpoint's day keys are date-only, * so comparing date prefixes keeps the slice timezone-stable. */ -function toDayWindow( from?: string, to?: string ): DayWindow | undefined { - const fromDay = toValidDay( from ); - const toDay = toValidDay( to ); +export function toDayWindow( from?: string, to?: string ): DayWindow | undefined { + const fromDay = toDay( from ); + const toBound = toDay( to ); - if ( ! fromDay || ! toDay ) { + if ( ! fromDay || ! toBound ) { return undefined; } - return { from: fromDay, to: toDay }; + return { from: fromDay, to: toBound }; } /**