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

Filter by extension

Filter by extension

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

Report dates: read every round-trip-validated date label and day bound through one helper.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This reads as implementation detail. Since the PR says no behavior changes, the changelog guideline in AGENTS.md points at an empty entry with a -c comment. Not blocking

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -15,6 +17,8 @@ import { createStatsBucketWindowFilter, type StatsBucketFilter } from './bucket-
import {
coerceStatsArray,
coerceStatsRecord,
DAY_END_TIME,
DAY_START_TIME,
getStatsIntervalFields,
normalizeStatsSummary,
} from './utils';
Expand All @@ -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(
Expand Down Expand Up @@ -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 ),
};
}

Expand All @@ -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;
}

Expand All @@ -148,33 +151,29 @@ 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;
}

return getDateFnsIntervalFields( startOfISOWeek( parsed ), endOfISOWeek( parsed ) );
}

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;
}

return getDateFnsIntervalFields( startOfMonth( parsed ), endOfMonth( parsed ) );
}

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;
}

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

Expand All @@ -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 ) } : {} ),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 ) );
} );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export {
formatDatePartWithTime,
getDateIntervalDateParts,
getDatePart,
parseExactLabel,
type DateIntervalDateParts,
type DateIntervalPeriod,
} from './date';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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 );
}
Expand All @@ -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 > ) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
}

/**
Expand Down
Loading