From 72704e2a56a1d659892c26259ff95d69e2887a30 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 16:41:30 +0800 Subject: [PATCH 1/4] refactor: converge the duplicated date-label validators and day-bound literals --- .../changelog/wooa7s-2079-dedup | 4 +++ .../src/processing/stats/bucket-window.ts | 11 +++++-- .../data/src/processing/stats/post.ts | 11 +++---- .../data/src/processing/stats/time-series.ts | 33 +++++++++++-------- .../data/src/processing/stats/utils.ts | 10 +++--- .../datetime/src/__tests__/date.test.ts | 24 +++++++++++++- .../packages/datetime/src/date.ts | 23 +++++++++++++ .../packages/datetime/src/index.ts | 3 ++ .../popular-days/bucket-views-by-weekday.ts | 15 ++------- .../widgets/post-views/use-post-views.ts | 23 +++---------- 10 files changed, 98 insertions(+), 59 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/wooa7s-2079-dedup 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..829598c0aabf 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 @@ -12,7 +12,12 @@ /** * External dependencies */ -import { formatDatePartWithTime, readSiteTimestamp } from '@jetpack-premium-analytics/datetime'; +import { + DAY_END_TIME, + DAY_START_TIME, + formatDatePartWithTime, + readSiteTimestamp, +} from '@jetpack-premium-analytics/datetime'; /** * Internal dependencies */ @@ -42,8 +47,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..4b79c2afed2e 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,9 @@ 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 ( + /^\d{4}-\d{2}-\d{2}$/.test( value ) && 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..542b21f3afe5 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,4 +1,10 @@ -import { formatDatePartWithTime, getDatePart } from '@jetpack-premium-analytics/datetime'; +import { + DAY_END_TIME, + DAY_START_TIME, + formatDatePartWithTime, + getDatePart, + parseExactLabel, +} from '@jetpack-premium-analytics/datetime'; import { endOfISOWeek, endOfMonth, @@ -37,7 +43,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 +122,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 +135,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; } @@ -150,8 +155,8 @@ function getWpcomWeekIntervalFields( period: string ) { const parsed = parse( `${ match[ 1 ] }-${ match[ 2 ] }-${ match[ 3 ] }`, - 'yyyy-MM-dd', - referenceDate + dateFormat, + new Date( 0 ) ); if ( ! isValid( parsed ) ) { @@ -162,9 +167,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 +177,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 +322,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..c863f13ea212 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 @@ -1,4 +1,6 @@ import { + DAY_END_TIME, + DAY_START_TIME, formatDatePartWithTime, getDateIntervalDateParts, getDatePart, @@ -317,8 +319,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 +339,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..ddde2b905575 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,21 @@ 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 ) ); + } ); + + // date-fns rolls this forward to 2026-03-03, which `isValid` alone accepts. + it( 'rejects a day that does not exist', () => { + expect( parseExactLabel( '2026-02-31', 'yyyy-MM-dd' ) ).toBeNull(); + } ); + + it( 'rejects a label written in another format', () => { + expect( parseExactLabel( '2026-6-22', 'yyyy-MM-dd' ) ).toBeNull(); + expect( parseExactLabel( 'not a date', 'yyyy-MM-dd' ) ).toBeNull(); + } ); + } ); } ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/date.ts b/projects/packages/premium-analytics/packages/datetime/src/date.ts index ad4ee2f941e0..392b9f0e9274 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/date.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/date.ts @@ -12,6 +12,29 @@ export type DateIntervalDateParts = { const DATE_PART_FORMAT = 'yyyy-MM-dd'; +/** Inclusive day bounds, for the offset-less shape Stats responses carry. */ +export const DAY_START_TIME = '00:00:00'; +export const DAY_END_TIME = '23:59:59'; + +// Only consulted for fields the parsed string omits, and every format we pass omits none. +const REFERENCE_DATE = new Date( 2001, 0, 1 ); + +/** + * Parse a label that must round-trip through its own format. + * + * date-fns rolls impossible values forward, so `2026-02-31` parses to 2026-03-03 + * and `isValid` alone would accept it. Re-formatting catches that. + * + * @param label - The label as written. + * @param labelFormat - The date-fns format the label must match exactly. + * @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..74b12836b2da 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/index.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/index.ts @@ -35,9 +35,12 @@ export { readSiteTimestamp, type SiteTimestamp, type TimestampParts } from './si export { reportingTimeZone, localTZDate, dateToISOStringWithLocalTZ } from './reporting-time-zone'; export { + DAY_END_TIME, + DAY_START_TIME, formatDatePartWithTime, getDateIntervalDateParts, getDatePart, + parseExactLabel, type DateIntervalDateParts, type DateIntervalPeriod, } from './date'; 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..c6f3d2835b46 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 ) ?? undefined : undefined; } function readRowViews( row: Record< string, unknown > ) { 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..dea166bb5ef2 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 ); + 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 }; } /** From 9bb650fd41d603acff343c8fd4221bcfc954cb90 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Tue, 8 Sep 2026 16:52:19 +0800 Subject: [PATCH 2/4] fix: correct the reference-date rationale and keep day bounds out of the datetime API --- .../data/src/processing/stats/bucket-window.ts | 8 ++------ .../packages/data/src/processing/stats/time-series.ts | 4 ++-- .../packages/data/src/processing/stats/utils.ts | 6 ++++-- .../packages/datetime/src/__tests__/date.test.ts | 8 +++++--- .../premium-analytics/packages/datetime/src/date.ts | 11 ++++------- .../premium-analytics/packages/datetime/src/index.ts | 2 -- .../src/helpers/__tests__/to-day.test.ts | 9 ++------- 7 files changed, 19 insertions(+), 29 deletions(-) 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 829598c0aabf..67b30f259872 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 @@ -12,12 +12,8 @@ /** * External dependencies */ -import { - DAY_END_TIME, - DAY_START_TIME, - formatDatePartWithTime, - readSiteTimestamp, -} from '@jetpack-premium-analytics/datetime'; +import { formatDatePartWithTime, readSiteTimestamp } from '@jetpack-premium-analytics/datetime'; +import { DAY_END_TIME, DAY_START_TIME } from './utils'; /** * Internal dependencies */ 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 542b21f3afe5..8974193d9009 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,6 +1,4 @@ import { - DAY_END_TIME, - DAY_START_TIME, formatDatePartWithTime, getDatePart, parseExactLabel, @@ -21,6 +19,8 @@ import { createStatsBucketWindowFilter, type StatsBucketFilter } from './bucket- import { coerceStatsArray, coerceStatsRecord, + DAY_END_TIME, + DAY_START_TIME, getStatsIntervalFields, normalizeStatsSummary, } from './utils'; 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 c863f13ea212..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 @@ -1,6 +1,4 @@ import { - DAY_END_TIME, - DAY_START_TIME, formatDatePartWithTime, getDateIntervalDateParts, getDatePart, @@ -16,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 > = { 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 ddde2b905575..754663ab7a1b 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 @@ -57,14 +57,16 @@ describe( 'date helpers', () => { expect( parseExactLabel( '2026-06', 'yyyy-MM' ) ).toEqual( new Date( 2026, 5, 1 ) ); } ); - // date-fns rolls this forward to 2026-03-03, which `isValid` alone accepts. 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(); } ); - it( 'rejects a label written in another format', () => { + // 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(); - expect( parseExactLabel( 'not a date', 'yyyy-MM-dd' ) ).toBeNull(); } ); } ); } ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/date.ts b/projects/packages/premium-analytics/packages/datetime/src/date.ts index 392b9f0e9274..5c0d3c056b59 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/date.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/date.ts @@ -12,18 +12,15 @@ export type DateIntervalDateParts = { const DATE_PART_FORMAT = 'yyyy-MM-dd'; -/** Inclusive day bounds, for the offset-less shape Stats responses carry. */ -export const DAY_START_TIME = '00:00:00'; -export const DAY_END_TIME = '23:59:59'; - -// Only consulted for fields the parsed string omits, and every format we pass omits none. +// 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. * - * date-fns rolls impossible values forward, so `2026-02-31` parses to 2026-03-03 - * and `isValid` alone would accept it. Re-formatting catches that. + * `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. diff --git a/projects/packages/premium-analytics/packages/datetime/src/index.ts b/projects/packages/premium-analytics/packages/datetime/src/index.ts index 74b12836b2da..29cb8c50b57b 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/index.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/index.ts @@ -35,8 +35,6 @@ export { readSiteTimestamp, type SiteTimestamp, type TimestampParts } from './si export { reportingTimeZone, localTZDate, dateToISOStringWithLocalTZ } from './reporting-time-zone'; export { - DAY_END_TIME, - DAY_START_TIME, formatDatePartWithTime, getDateIntervalDateParts, getDatePart, 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(); From da233adb0ffb7ace450b8debac63e19902a8611f Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 9 Sep 2026 13:20:06 +0800 Subject: [PATCH 3/4] refactor: converge the last inline date parse and pin the swapped guards with tests --- .../src/processing/stats/bucket-window.ts | 2 +- .../data/src/processing/stats/post.ts | 4 +--- .../data/src/processing/stats/time-series.ts | 10 ++------ .../datetime/src/__tests__/date.test.ts | 6 +++++ .../packages/datetime/src/date.ts | 2 +- .../popular-days/bucket-views-by-weekday.ts | 2 +- .../__tests__/use-post-views.test.ts | 24 +++++++++++++++++++ .../widgets/post-views/use-post-views.ts | 2 +- 8 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 projects/packages/premium-analytics/widgets/post-views/__tests__/use-post-views.test.ts 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 67b30f259872..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 @@ -13,10 +13,10 @@ * External dependencies */ import { formatDatePartWithTime, readSiteTimestamp } from '@jetpack-premium-analytics/datetime'; -import { DAY_END_TIME, DAY_START_TIME } from './utils'; /** * 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 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 4b79c2afed2e..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 @@ -99,9 +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 { - return ( - /^\d{4}-\d{2}-\d{2}$/.test( value ) && parseExactLabel( value, STATS_POST_DAY_FORMAT ) !== null - ); + 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 8974193d9009..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 @@ -8,8 +8,6 @@ import { endOfMonth, endOfYear, format, - isValid, - parse, startOfISOWeek, startOfMonth, startOfYear, @@ -153,13 +151,9 @@ function getWpcomWeekIntervalFields( period: string ) { return null; } - const parsed = parse( - `${ match[ 1 ] }-${ match[ 2 ] }-${ match[ 3 ] }`, - dateFormat, - new Date( 0 ) - ); + const parsed = parseExactLabel( `${ match[ 1 ] }-${ match[ 2 ] }-${ match[ 3 ] }`, dateFormat ); - if ( ! isValid( parsed ) ) { + if ( ! parsed ) { return null; } 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 754663ab7a1b..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 @@ -68,5 +68,11 @@ describe( 'date helpers', () => { 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 5c0d3c056b59..0b8e47d45250 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/date.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/date.ts @@ -23,7 +23,7 @@ const REFERENCE_DATE = new Date( 2001, 0, 1 ); * 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. + * @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 { 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 c6f3d2835b46..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 @@ -27,7 +27,7 @@ function weekdayLabel( weekday: number ) { function readRowDate( row: Record< string, unknown > ) { const datePart = getDatePart( row.date_start ?? row.time_interval ?? row.period ); - return datePart ? parseExactLabel( datePart, DATE_PART_FORMAT ) ?? undefined : 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 dea166bb5ef2..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 @@ -63,7 +63,7 @@ type BucketWindow = { * 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 { +export function toDayWindow( from?: string, to?: string ): DayWindow | undefined { const fromDay = toDay( from ); const toBound = toDay( to ); From 2e39381ab8ef9e9362e9dd2e517aa4ddcb5ca506 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 10 Sep 2026 10:38:21 +0800 Subject: [PATCH 4/4] chore: file the changelog entry as an internal-refactor comment --- .../packages/premium-analytics/changelog/wooa7s-2079-dedup | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/projects/packages/premium-analytics/changelog/wooa7s-2079-dedup b/projects/packages/premium-analytics/changelog/wooa7s-2079-dedup index 6b63cc2e8dd3..80ddcd396f0c 100644 --- a/projects/packages/premium-analytics/changelog/wooa7s-2079-dedup +++ b/projects/packages/premium-analytics/changelog/wooa7s-2079-dedup @@ -1,4 +1,3 @@ Significance: patch Type: changed - -Report dates: read every round-trip-validated date label and day bound through one helper. +Comment: Internal refactor: read the round-trip-validated date labels and day bounds through one shared helper.