diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afb56bc1f35e..6e2b2a26ead8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3995,6 +3995,9 @@ importers: '@wordpress/dataviews': specifier: 17.1.0 version: 17.1.0(@date-fns/tz@1.4.1)(@types/react@18.3.28)(react@18.3.1) + '@wordpress/date': + specifier: 5.51.0 + version: 5.51.0 '@wordpress/element': specifier: 8.3.0 version: 8.3.0 diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1766-locale-aware-dates b/projects/packages/premium-analytics/changelog/wooa7s-1766-locale-aware-dates new file mode 100644 index 000000000000..ba796585bb1b --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1766-locale-aware-dates @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Format dates using the site's language and date format instead of US English. diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index 752c16abbdd8..f782f098258b 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -63,6 +63,7 @@ "@wordpress/core-data": "7.51.0", "@wordpress/data": "10.51.0", "@wordpress/dataviews": "17.1.0", + "@wordpress/date": "5.51.0", "@wordpress/element": "8.3.0", "@wordpress/html-entities": "4.51.0", "@wordpress/i18n": "^6.9.0", diff --git a/projects/packages/premium-analytics/packages/data/src/utils/__tests__/date.test.ts b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/date.test.ts new file mode 100644 index 000000000000..2f766538a270 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/date.test.ts @@ -0,0 +1,30 @@ +const mockGetEntityRecord = jest.fn(); + +jest.mock( '@wordpress/core-data', () => ( { + store: 'core', +} ) ); + +jest.mock( '@wordpress/data', () => ( { + select: jest.fn( () => ( { + getEntityRecord: mockGetEntityRecord, + } ) ), +} ) ); + +/** + * Internal dependencies + */ +import { getSiteTimezone } from '../date'; + +describe( 'getSiteTimezone', () => { + it( 'returns UTC for a site with an empty timezone string and zero offset', () => { + mockGetEntityRecord.mockReturnValue( { timezone: '', gmt_offset: 0 } ); + + expect( getSiteTimezone() ).toBe( '+00:00' ); + } ); + + it( 'formats a fractional manual offset', () => { + mockGetEntityRecord.mockReturnValue( { timezone: '', gmt_offset: 5.5 } ); + + expect( getSiteTimezone() ).toBe( '+05:30' ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/data/src/utils/date.ts b/projects/packages/premium-analytics/packages/data/src/utils/date.ts index b4efa213570d..85dacbef8431 100644 --- a/projects/packages/premium-analytics/packages/data/src/utils/date.ts +++ b/projects/packages/premium-analytics/packages/data/src/utils/date.ts @@ -28,7 +28,7 @@ try { * @return {string} The formatted GMT offset. */ function formatGmtOffset( offset: number | undefined ): string { - if ( ! offset ) { + if ( offset === undefined ) { return DEFAULT_TIME_ZONE; } diff --git a/projects/packages/premium-analytics/packages/datetime/package.json b/projects/packages/premium-analytics/packages/datetime/package.json index b4a9dfcea148..133e0fd777e7 100644 --- a/projects/packages/premium-analytics/packages/datetime/package.json +++ b/projects/packages/premium-analytics/packages/datetime/package.json @@ -8,6 +8,7 @@ "sideEffects": false, "dependencies": { "@date-fns/tz": "1.4.1", + "@wordpress/date": "5.51.0", "@wordpress/i18n": "^6.9.0", "date-fns": "4.1.0" } 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 new file mode 100644 index 000000000000..1de69eac4339 --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/src/__tests__/site-datetime.test.ts @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import { getSettings, setSettings } from '@wordpress/date'; +/** + * Internal dependencies + */ +import { parseSiteDateTime } from '../site-datetime'; + +const DEFAULTS = getSettings(); + +const siteIn = ( timeZone: string, offset: number ) => + setSettings( { + ...DEFAULTS, + l10n: { + ...DEFAULTS.l10n, + locale: `site-datetime-${ timeZone.replace( /\W/g, '-' ) }-${ offset }`, + }, + timezone: { offset, offsetFormatted: String( offset ), string: timeZone, abbr: '' }, + } ); + +describe( 'parseSiteDateTime', () => { + beforeEach( () => siteIn( 'Europe/Amsterdam', 2 ) ); + + it( 'reads the wall time as belonging to the site timezone', () => { + const date = parseSiteDateTime( '2026-07-09 09:42:57' ); + + expect( date?.toISOString() ).toBe( '2026-07-09T07:42:57.000Z' ); + } ); + + it( 'accepts a date with no time part', () => { + const date = parseSiteDateTime( '2026-07-09' ); + + expect( date?.toISOString() ).toBe( '2026-07-08T22:00:00.000Z' ); + } ); + + it( 'preserves fractional seconds', () => { + const date = parseSiteDateTime( '2026-07-09 09:42:57.123' ); + + expect( date?.toISOString() ).toBe( '2026-07-09T07:42:57.123Z' ); + } ); + + it( 'trusts an explicit offset instead of re-anchoring', () => { + const date = parseSiteDateTime( '2026-06-29T00:00:00.000+02:00' ); + + expect( date?.toISOString() ).toBe( '2026-06-28T22:00:00.000Z' ); + } ); + + it( 'uses a manual site offset', () => { + siteIn( '', 5.5 ); + const date = parseSiteDateTime( '2026-07-09 09:42:57' ); + + expect( date?.toISOString() ).toBe( '2026-07-09T04:12:57.000Z' ); + } ); + + it( 'accepts a Date instance unchanged', () => { + const source = new Date( '2026-06-29T12:00:00.000Z' ); + + expect( parseSiteDateTime( source ) ).toBe( source ); + } ); + + it( 'returns undefined for a malformed value', () => { + expect( parseSiteDateTime( 'not a date' ) ).toBeUndefined(); + } ); + + it( 'returns undefined for an empty value', () => { + expect( parseSiteDateTime( '' ) ).toBeUndefined(); + } ); + + it( 'returns undefined for an impossible date', () => { + expect( parseSiteDateTime( '2026-13-45 00:00:00' ) ).toBeUndefined(); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/datetime/src/index.ts b/projects/packages/premium-analytics/packages/datetime/src/index.ts index 0a88807c1161..240f18793e7c 100644 --- a/projects/packages/premium-analytics/packages/datetime/src/index.ts +++ b/projects/packages/premium-analytics/packages/datetime/src/index.ts @@ -16,6 +16,8 @@ export { export { formatRelativeSince } from './relative-since'; +export { parseSiteDateTime } from './site-datetime'; + export { formatDatePartWithTime, getDateIntervalDateParts, diff --git a/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts b/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts new file mode 100644 index 000000000000..47a7c4341bb7 --- /dev/null +++ b/projects/packages/premium-analytics/packages/datetime/src/site-datetime.ts @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { getDate } from '@wordpress/date'; + +/** Date-only, MySQL datetime, or ISO datetime values used by Stats and report params. */ +const SITE_DATE_TIME = + /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/; + +/** + * Parse a timestamp in the WordPress site timezone. + * + * `getDate` anchors offset-less Stats API values to the timezone configured in + * WordPress while preserving the instant identified by offset-bearing values. + * + * @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 { + if ( value instanceof Date ) { + return isNaN( value.getTime() ) ? undefined : value; + } + + if ( typeof value !== 'string' ) { + return undefined; + } + + const raw = value.trim(); + + if ( ! SITE_DATE_TIME.test( raw ) ) { + return undefined; + } + + const parsed = getDate( raw ); + + return isNaN( parsed.getTime() ) ? undefined : parsed; +} diff --git a/projects/packages/premium-analytics/packages/formatters/README.md b/projects/packages/premium-analytics/packages/formatters/README.md index 9cabeb327e73..cf31d662c1f7 100644 --- a/projects/packages/premium-analytics/packages/formatters/README.md +++ b/projects/packages/premium-analytics/packages/formatters/README.md @@ -3,8 +3,8 @@ Locale-aware formatting utilities for Jetpack Premium Analytics. Thin wrapper over `@automattic/number-formatters` (numbers, currency) and -`date-fns` (dates), plus a domain-specific orchestrator (`formatMetricValue`) -that routes between formatters by analytics metric type. +`@wordpress/date` (dates), plus a domain-specific orchestrator +(`formatMetricValue`) that routes between formatters by analytics metric type. ## Exports @@ -13,6 +13,7 @@ import { formatMetricValue, formatDate, formatDateRange, + type DateFormatName, } from '@jetpack-premium-analytics/formatters'; ``` @@ -45,19 +46,42 @@ formatMetricValue( 192088, 'currency', { | `options.signDisplay` | `Intl` sign mode | `'auto'` (`'exceptZero'` for percentage) | Sign display | | `options.currencyCode` | `string` | `'USD'` | ISO 4217 currency code | -## `formatDate( date, format? )` +## `formatDate( date, name? )` -Format a date using a named preset or custom `date-fns` pattern. -Defaults to `'medium'`. +Format a date in the site's locale and timezone. Month and weekday names come +from WordPress's own translation tables and the ordering from the site's +`date_format` option, so dates match the rest of wp-admin rather than the +browser's locale. Defaults to `'medium'`. ```typescript -formatDate( new Date( '2025-06-21' ) ); // 'Jun 21, 2025' -formatDate( new Date( '2025-06-21' ), 'short' ); // 'Jun 21' -formatDate( new Date( '2025-06-21' ), 'long' ); // 'June 21, 2025' -formatDate( new Date( '2025-06-21' ), 'dd/MM/yyyy' ); // '21/06/2025' +// On a site left on the US English default: +formatDate( date ); // 'June 21, 2025' +formatDate( date, 'short' ); // 'June 21' +formatDate( date, 'year' ); // '2025' +formatDate( date, 'iso' ); // '2025-06-21' + +// The same calls on an es_ES site: +formatDate( date ); // '21 de junio de 2025' +formatDate( date, 'short' ); // '21 de junio' ``` -**Named presets:** `short`, `medium` (default), `long`, `full`, `day`, `month`, `year`, `monthYear`, `numeric`, `iso`, `dateTime`. +| Name | Purpose | +| -------- | ----------------------------------------------- | +| `medium` | Default. The site's `date_format` verbatim. | +| `short` | The site format minus its year. | +| `year` | Year alone. | +| `iso` | Machine-readable `YYYY-MM-DD`. Never localized. | + +There is no custom-pattern escape hatch: a hand-written pattern fixes the +day/month order to whatever the author happened to type, which is the problem +this formatter exists to avoid. + +**Pass an instant** — a `TZDate` from `@jetpack-premium-analytics/datetime`, or a +timestamp. Strings are outside the parameter type on purpose, even though +`dateI18n` accepts them: a bare `YYYY-MM-DD` is read as browser-local midnight +and then shifted into the site's timezone, landing on the previous day for any +visitor ahead of the site. Parse site-local strings with `parseSiteDateTime` from +`@jetpack-premium-analytics/datetime` first. ## `formatDateRange( range? )` @@ -66,24 +90,23 @@ Returns `''` when range or dates are missing. ```typescript formatDateRange( { from, to } ); -// same day: 'Jun 21, 2025' -// same month: 'Jun 21-25, 2025' -// same year: 'Jun 21-Jul 25, 2025' -// cross-year: 'Jun 21, 2024-Jul 25, 2025' +// same day: 'June 21, 2025' +// range: 'June 21, 2025 – June 25, 2025' ``` +Both ends are spelled out in full. Eliding the shared month or year +("Jun 21-25, 2025") is an English typographic convention that does not carry +over — on an es_ES site it yields "21 de junio-25 de junio de 2025" — and +WordPress publishes no per-locale elision rules to draw on. + | Parameter | Type | Description | | --------- | ---------------------------- | ----------------- | | `range` | `{ from?: Date; to?: Date }` | Date range object | -## Architecture +## Implementation Number and currency formatting delegates to `@automattic/number-formatters` -(a tier-2 published Jetpack package). Date formatting uses `date-fns`. The -`formatMetricValue` orchestrator is domain-specific — it routes to the right -formatter based on the metric type. - -## Dependencies - -- `@automattic/number-formatters` — number/currency primitives -- `date-fns` — date formatting +(a tier-2 published Jetpack package), while date formatting uses +`@wordpress/date`, which WordPress seeds with the site's format and +translations. `formatMetricValue` routes analytics metric types to the +appropriate formatter. diff --git a/projects/packages/premium-analytics/packages/formatters/package.json b/projects/packages/premium-analytics/packages/formatters/package.json index c9d7db16093a..75c35acbdc16 100644 --- a/projects/packages/premium-analytics/packages/formatters/package.json +++ b/projects/packages/premium-analytics/packages/formatters/package.json @@ -8,6 +8,7 @@ "sideEffects": false, "dependencies": { "@automattic/number-formatters": "workspace:*", - "date-fns": "4.1.0" + "@wordpress/date": "5.51.0", + "@wordpress/i18n": "^6.9.0" } } 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 new file mode 100644 index 000000000000..41c92ee23a4b --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/__fixtures__/wp-date-settings.ts @@ -0,0 +1,91 @@ +/** + * `@wordpress/date` settings fixtures. + * + * The formatter reads the site's `date_format` and WordPress's translated + * month names, so tests drive it by installing real settings rather than by + * mocking the formatter. + */ + +/** + * External dependencies + */ +import { getSettings, type DateSettings } from '@wordpress/date'; + +/** + * The package's own defaults, captured before any test installs settings over + * them. Fixtures vary only the fields under test and inherit the rest, so they + * cannot drift from what `@wordpress/date` actually ships. + */ +const DEFAULTS = getSettings(); + +/** + * `DateSettings['l10n']['months']` is moment's wider `LocaleSpecification` + * shape, which also admits a standalone/format object and a callback. The + * package ships a plain array, so narrow it once here rather than at each use. + */ +const DEFAULT_MONTHS = DEFAULTS.l10n.months as string[]; + +const ES_MONTHS = [ + 'enero', + 'febrero', + 'marzo', + 'abril', + 'mayo', + 'junio', + 'julio', + 'agosto', + 'septiembre', + 'octubre', + 'noviembre', + 'diciembre', +]; + +/** + * Build a settings object for a locale. + * + * The timezone is fixed to UTC so assertions do not depend on the machine + * running the suite. Tests that vary the zone spread the result and replace + * the `timezone` block. + * + * @param locale - Moment locale name. Must be unique per fixture, since + * `setSettings` skips redefining a locale it already knows. + * @param dateFormat - The site's `date_format` option, in PHP tokens. + * @param months - Translated month names, January first. Defaults to the + * package's English names. + * @return Settings ready for `setSettings`. + */ +export const settingsFor = ( + locale: string, + dateFormat: string, + months: string[] = DEFAULT_MONTHS +): DateSettings => ( { + ...DEFAULTS, + l10n: { + ...DEFAULTS.l10n, + locale, + months, + monthsShort: months.map( month => month.slice( 0, 3 ) ), + }, + formats: { ...DEFAULTS.formats, date: dateFormat }, + timezone: { offset: 0, offsetFormatted: '0', string: 'UTC', abbr: 'UTC' }, +} ); + +/** A site left on the US English default. */ +export const EN_US_SETTINGS = settingsFor( 'en-us-test', 'F j, Y' ); + +/** + * A Spanish site. Its `date_format` puts the day first and spells "de" with + * escaped literals (`\d\e`), which double as the day and timezone tokens. + */ +export const ES_ES_SETTINGS = settingsFor( 'es-es-test', 'j \\d\\e F \\d\\e Y', ES_MONTHS ); + +/** + * Build a UTC date, matching the fixtures' timezone so no day shift is in play. + * + * @param year - Full year. + * @param month - 1-based month. + * @param day - Day of month. + * @return The date. + */ +export const utcDate = ( year: number, month: number, day: number ): Date => + new Date( Date.UTC( year, month - 1, day ) ); 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 c8f2d672f656..4d5a49c311a1 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,127 +1,86 @@ +/** + * External dependencies + */ +import { setSettings } from '@wordpress/date'; /** * Internal dependencies */ -import { formatDate } from '../format-date'; +import { + EN_US_SETTINGS, + ES_ES_SETTINGS, + settingsFor, + utcDate, +} from '../__fixtures__/wp-date-settings'; import { formatDateRange } from '../format-date-range'; -jest.mock( '../format-date' ); - describe( 'formatDateRange', () => { - /** - * Setup mock for formatDate function. - */ - const setupMocks = () => { - ( formatDate as jest.Mock ).mockImplementation( ( date: Date, formatString?: string ) => { - const monthNames = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - - const dateStr = date.toISOString().split( 'T' )[ 0 ]; - - if ( formatString === 'iso' ) { - return dateStr; - } - - // eslint-disable-next-line @wordpress/no-unused-vars-before-return -- allow unused vars before return for test mock - const [ year, month, day ] = dateStr.split( '-' ); - const monthName = monthNames[ parseInt( month, 10 ) - 1 ]; - - if ( formatString === 'year' ) { - return year; - } - - if ( formatString === 'monthYear' ) { - return `${ monthName } ${ year }`; - } - - const dayNum = parseInt( day, 10 ); - if ( formatString === 'short' ) { - return `${ monthName } ${ dayNum }`; - } + describe( 'edge cases', () => { + beforeEach( () => setSettings( EN_US_SETTINGS ) ); - if ( formatString === 'd, yyyy' ) { - return `${ dayNum }, ${ year }`; - } + it( 'returns an empty string when "from" is missing', () => { + expect( formatDateRange( { from: undefined, to: utcDate( 2025, 6, 21 ) } ) ).toBe( '' ); + } ); - // Default: medium format - return `${ monthName } ${ dayNum }, ${ year }`; + it( 'returns an empty string when "to" is missing', () => { + expect( formatDateRange( { from: utcDate( 2025, 6, 21 ), to: undefined } ) ).toBe( '' ); } ); - }; - beforeEach( () => { - jest.clearAllMocks(); - setupMocks(); + it( 'returns an empty string when the range itself is missing', () => { + expect( formatDateRange() ).toBe( '' ); + } ); } ); - describe( 'edge cases', () => { - it( 'returns empty string when "from" is missing', () => { - const result = formatDateRange( { - from: undefined, - to: new Date( '2025-06-21' ), - } ); - expect( result ).toBe( '' ); + describe( 'en_US site', () => { + beforeEach( () => setSettings( EN_US_SETTINGS ) ); + + it( 'collapses a single-day range to one date', () => { + const date = utcDate( 2025, 6, 21 ); + expect( formatDateRange( { from: date, to: date } ) ).toBe( 'June 21, 2025' ); } ); - it( 'returns empty string when "to" is missing', () => { - const result = formatDateRange( { - from: new Date( '2025-06-21' ), - to: undefined, - } ); - expect( result ).toBe( '' ); + it( 'spells out both ends of a range within one month', () => { + expect( + formatDateRange( { from: utcDate( 2025, 6, 21 ), to: utcDate( 2025, 6, 25 ) } ) + ).toBe( 'June 21, 2025 – June 25, 2025' ); } ); - it( 'returns empty string when both dates are missing', () => { - const result = formatDateRange( { - from: undefined, - to: undefined, - } ); - expect( result ).toBe( '' ); + it( 'spells out both ends of a range spanning years', () => { + expect( + formatDateRange( { from: utcDate( 2024, 6, 21 ), to: utcDate( 2025, 7, 25 ) } ) + ).toBe( 'June 21, 2024 – July 25, 2025' ); } ); } ); - describe( 'same date', () => { - it( 'formats same date as single date', () => { - const date = new Date( '2025-06-21' ); - const result = formatDateRange( { from: date, to: date } ); - expect( result ).toBe( 'Jun 21, 2025' ); + describe( 'es_ES site', () => { + beforeEach( () => setSettings( ES_ES_SETTINGS ) ); + + it( 'follows the site date format on both ends', () => { + expect( + formatDateRange( { from: utcDate( 2025, 6, 21 ), to: utcDate( 2025, 6, 25 ) } ) + ).toBe( '21 de junio de 2025 – 25 de junio de 2025' ); } ); - } ); - describe( 'same month and year', () => { - it( 'formats date range within same month', () => { - const from = new Date( '2025-06-21' ); - const to = new Date( '2025-06-25' ); - const result = formatDateRange( { from, to } ); - expect( result ).toBe( 'Jun 21-25, 2025' ); + it( 'collapses a single-day range to one date', () => { + const date = utcDate( 2025, 6, 21 ); + expect( formatDateRange( { from: date, to: date } ) ).toBe( '21 de junio de 2025' ); } ); } ); - describe( 'same year, different months', () => { - it( 'formats date range across months in same year', () => { - const from = new Date( '2025-06-21' ); - const to = new Date( '2025-07-25' ); - const result = formatDateRange( { from, to } ); - expect( result ).toBe( 'Jun 21-Jul 25, 2025' ); + describe( 'site whose date format carries no year', () => { + // `date_format` is a free-text field, so a format without a year is + // reachable. Collapsing on the rendered strings would fold a whole year + // apart into a single date. + beforeEach( () => setSettings( settingsFor( 'en-no-year-test', 'F j' ) ) ); + + it( 'still spells out both ends of a range spanning years', () => { + expect( + formatDateRange( { from: utcDate( 2024, 6, 21 ), to: utcDate( 2025, 6, 21 ) } ) + ).toBe( 'June 21 – June 21' ); } ); - } ); - describe( 'different years', () => { - it( 'formats date range across different years', () => { - const from = new Date( '2024-06-21' ); - const to = new Date( '2025-07-25' ); - const result = formatDateRange( { from, to } ); - expect( result ).toBe( 'Jun 21, 2024-Jul 25, 2025' ); + it( 'collapses a genuine single-day range', () => { + const date = utcDate( 2025, 6, 21 ); + expect( formatDateRange( { from: date, to: date } ) ).toBe( 'June 21' ); } ); } ); } ); diff --git a/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date.test.ts b/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date.test.ts index 8a9a98d53b5a..adb8799bbbf6 100644 --- a/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date.test.ts +++ b/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date.test.ts @@ -1,152 +1,64 @@ /** * External dependencies */ -import { format } from 'date-fns'; +import { setSettings } from '@wordpress/date'; /** * Internal dependencies */ -import { formatDate, DATE_FORMATS as FORMATS } from '../format-date'; +import { EN_US_SETTINGS, ES_ES_SETTINGS, settingsFor } from '../__fixtures__/wp-date-settings'; +import { formatDate } from '../format-date'; -jest.mock( 'date-fns', () => ( { - format: jest.fn(), -} ) ); +// Midnight UTC, matching the fixtures' timezone, so no day shift is in play. +const JUNE_21 = new Date( '2025-06-21T00:00:00+00:00' ); describe( 'formatDate', () => { - /** - * Setup the date-fns format mock that simulates the library. - */ - const setupDateFormat = () => { - ( format as jest.Mock ).mockImplementation( - ( _date: Date | number | string, formatString: string ) => { - const formatMap: Record< string, string > = { - [ FORMATS.short ]: 'Jun 21', - [ FORMATS.medium ]: 'Jun 21, 2025', - [ FORMATS.long ]: 'June 21, 2025', - [ FORMATS.full ]: 'Wednesday, June 21, 2025', - [ FORMATS.day ]: '21', - [ FORMATS.month ]: 'Jun', - [ FORMATS.year ]: '2025', - [ FORMATS.monthYear ]: 'Jun 2025', - [ FORMATS.numeric ]: '06/21/2025', - [ FORMATS.iso ]: '2025-06-21', - [ FORMATS.dateTime ]: 'Jun 21, 2025 2:30 PM', - 'dd/MM/yyyy': '21/06/2025', - }; - - return formatMap[ formatString ] || formatString; - } - ); - }; - - beforeEach( () => { - jest.clearAllMocks(); - setupDateFormat(); - } ); - - describe( 'named formats', () => { - const testDate = new Date( '2025-06-21T14:30:00' ); - - it( 'formats date with "short" preset', () => { - const result = formatDate( testDate, 'short' ); - expect( result ).toBe( 'Jun 21' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.short ); - } ); - - it( 'formats date with "medium" preset (default)', () => { - const result = formatDate( testDate, 'medium' ); - expect( result ).toBe( 'Jun 21, 2025' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.medium ); - } ); - - it( 'uses "medium" as default when no format specified', () => { - const result = formatDate( testDate ); - expect( result ).toBe( 'Jun 21, 2025' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.medium ); + describe( 'en_US site', () => { + beforeEach( () => { + setSettings( EN_US_SETTINGS ); } ); - it( 'formats date with "long" preset', () => { - const result = formatDate( testDate, 'long' ); - expect( result ).toBe( 'June 21, 2025' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.long ); + it( 'formats "medium" with the site date format', () => { + expect( formatDate( JUNE_21, 'medium' ) ).toBe( 'June 21, 2025' ); } ); - it( 'formats date with "full" preset', () => { - const result = formatDate( testDate, 'full' ); - expect( result ).toBe( 'Wednesday, June 21, 2025' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.full ); + it( 'defaults to "medium"', () => { + expect( formatDate( JUNE_21 ) ).toBe( 'June 21, 2025' ); } ); - it( 'formats date with "day" preset', () => { - const result = formatDate( testDate, 'day' ); - expect( result ).toBe( '21' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.day ); + it( 'formats "short" as the site format without its year', () => { + expect( formatDate( JUNE_21, 'short' ) ).toBe( 'June 21' ); } ); - it( 'formats date with "month" preset', () => { - const result = formatDate( testDate, 'month' ); - expect( result ).toBe( 'Jun' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.month ); + it( 'formats "year"', () => { + expect( formatDate( JUNE_21, 'year' ) ).toBe( '2025' ); } ); - it( 'formats date with "year" preset', () => { - const result = formatDate( testDate, 'year' ); - expect( result ).toBe( '2025' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.year ); + it( 'formats "iso" as a machine-readable date', () => { + expect( formatDate( JUNE_21, 'iso' ) ).toBe( '2025-06-21' ); } ); + } ); - it( 'formats date with "monthYear" preset', () => { - const result = formatDate( testDate, 'monthYear' ); - expect( result ).toBe( 'Jun 2025' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.monthYear ); + describe( 'es_ES site', () => { + beforeEach( () => { + setSettings( ES_ES_SETTINGS ); } ); - it( 'formats date with "numeric" preset', () => { - const result = formatDate( testDate, 'numeric' ); - expect( result ).toBe( '06/21/2025' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.numeric ); + it( 'orders "medium" the way the site format does', () => { + expect( formatDate( JUNE_21, 'medium' ) ).toBe( '21 de junio de 2025' ); } ); - it( 'formats date with "iso" preset', () => { - const result = formatDate( testDate, 'iso' ); - expect( result ).toBe( '2025-06-21' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.iso ); + it( 'drops the trailing " de " for "short"', () => { + expect( formatDate( JUNE_21, 'short' ) ).toBe( '21 de junio' ); } ); - it( 'formats date with "dateTime" preset', () => { - const result = formatDate( testDate, 'dateTime' ); - expect( result ).toBe( 'Jun 21, 2025 2:30 PM' ); - expect( format ).toHaveBeenCalledWith( testDate, FORMATS.dateTime ); + it( 'keeps "iso" untranslated so it stays machine-readable', () => { + expect( formatDate( JUNE_21, 'iso' ) ).toBe( '2025-06-21' ); } ); } ); - describe( 'custom format strings', () => { - const testDate = new Date( '2025-06-21T14:30:00' ); + it( 'falls back to the site format when removing the year leaves nothing', () => { + setSettings( settingsFor( 'year-only-test', 'Y' ) ); - it( 'accepts custom format string', () => { - const customFormat = 'dd/MM/yyyy'; - const result = formatDate( testDate, customFormat ); - expect( result ).toBe( '21/06/2025' ); - expect( format ).toHaveBeenCalledWith( testDate, customFormat ); - } ); - } ); - - describe( 'date input types', () => { - it( 'accepts Date object', () => { - const dateObj = new Date( '2025-06-21' ); - formatDate( dateObj, 'short' ); - expect( format ).toHaveBeenCalledWith( dateObj, FORMATS.short ); - } ); - - it( 'accepts timestamp number', () => { - const timestamp = 1718985000000; - formatDate( timestamp, 'short' ); - expect( format ).toHaveBeenCalledWith( timestamp, FORMATS.short ); - } ); - - it( 'accepts date string', () => { - const dateString = '2025-06-21'; - formatDate( dateString, 'short' ); - expect( format ).toHaveBeenCalledWith( dateString, FORMATS.short ); - } ); + expect( formatDate( JUNE_21, 'short' ) ).toBe( '2025' ); } ); } ); diff --git a/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/php-format.test.ts b/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/php-format.test.ts new file mode 100644 index 000000000000..fed6b329dc5f --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/php-format.test.ts @@ -0,0 +1,64 @@ +/** + * Internal dependencies + */ +import { withoutYear } from '../php-format'; + +describe( 'withoutYear', () => { + // Real `date_format` defaults taken from the WordPress core language packs, + // so the cases below are the strings sites actually ship with. + it( 'drops a trailing year and the separator that introduces it', () => { + expect( withoutYear( 'F j, Y' ) ).toBe( 'F j' ); + } ); + + it( 'drops a leading year and the separator that follows it', () => { + // Year-first formats are offered in Settings → General, so the run to + // remove is the one after the year rather than before it. + expect( withoutYear( 'Y-m-d' ) ).toBe( 'm-d' ); + } ); + + it( 'keeps escaped literals that spell out token characters', () => { + // es_ES. `\d` and `\e` are the literal word "de"; a tokenizer that misses + // the backslash reads them as the day and timezone tokens instead. + expect( withoutYear( 'j \\d\\e F \\d\\e Y' ) ).toBe( 'j \\d\\e F' ); + } ); + + it( 'keeps a non-ASCII separator bound to the month token', () => { + // he_IL. + expect( withoutYear( 'j בF Y' ) ).toBe( 'j בF' ); + } ); + + it( 'drops a literal that trails the year', () => { + // A Russian-style format: "г." abbreviates "year", so it has nothing + // left to qualify once the year is gone. + expect( withoutYear( 'j F Y г.' ) ).toBe( 'j F' ); + } ); + + it( 'removes the year from an all-numeric format', () => { + expect( withoutYear( 'd/m/Y' ) ).toBe( 'd/m' ); + } ); + + it( 'removes a two-digit year token', () => { + expect( withoutYear( 'd/m/y' ) ).toBe( 'd/m' ); + } ); + + it( 'removes the ISO week-numbering year token', () => { + expect( withoutYear( 'j F o' ) ).toBe( 'j F' ); + } ); + + it( 'leaves an escaped year character alone and removes only the real token', () => { + expect( withoutYear( '\\Y j F Y' ) ).toBe( '\\Y j F' ); + } ); + + it( 'returns the format unchanged when it carries no year', () => { + expect( withoutYear( 'F j' ) ).toBe( 'F j' ); + } ); + + it( 'returns an empty string when the format is only a year', () => { + expect( withoutYear( 'Y' ) ).toBe( '' ); + } ); + + it( 'keeps the month ordinal dot in Finnish and Czech formats', () => { + expect( withoutYear( 'j.n.Y' ) ).toBe( 'j.n.' ); + expect( withoutYear( 'j. n. Y' ) ).toBe( 'j. n.' ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/formatters/src/date/format-date-range.ts b/projects/packages/premium-analytics/packages/formatters/src/date/format-date-range.ts index dcc6a86e1609..07e0181acff5 100644 --- a/projects/packages/premium-analytics/packages/formatters/src/date/format-date-range.ts +++ b/projects/packages/premium-analytics/packages/formatters/src/date/format-date-range.ts @@ -1,3 +1,7 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ @@ -15,41 +19,35 @@ type DateRange = { from?: Date; to?: Date }; /** * Format a date range into a human-readable string. - * Adjusts output based on whether dates share the same day, month, or year. + * + * Both ends are spelled out because WordPress provides no locale-specific + * range-elision rules. + * * Returns `''` when `range`, `from`, or `to` is missing. * + * @param range - The range to format. + * @return The formatted range. + * * @example - * formatDateRange( { from, to } ) // same day: 'Jun 21, 2025' - * // same month: 'Jun 21-25, 2025' - * // same year: 'Jun 21-Jul 25, 2025' - * // cross-year: 'Jun 21, 2024-Jul 25, 2025' + * formatDateRange( { from, to } ) // same day: 'June 21, 2025' + * // range: 'June 21, 2025 – June 25, 2025' */ export const formatDateRange = ( range?: DateRange ): string => { - if ( ! range ) { - return ''; - } - - const { from, to } = range; + const { from, to } = range ?? {}; if ( ! from || ! to ) { return ''; } - const sameYear = from.getFullYear() === to.getFullYear(); - const sameMonth = sameYear && from.getMonth() === to.getMonth(); - const sameDay = sameMonth && from.getDate() === to.getDate(); - - if ( sameDay ) { - return formatDate( from, 'medium' ); - } - - if ( sameMonth ) { - return `${ formatDate( from, 'short' ) }-${ formatDate( to, 'd, yyyy' ) }`; - } - - if ( sameYear ) { - return `${ formatDate( from, 'short' ) }-${ formatDate( to ) }`; + // Compare complete site-local dates because the display format may omit the year. + if ( formatDate( from, 'iso' ) === formatDate( to, 'iso' ) ) { + return formatDate( from ); } - return `${ formatDate( from ) }-${ formatDate( to ) }`; + return sprintf( + /* translators: 1: Start date. 2: End date. */ + __( '%1$s – %2$s', 'jetpack-premium-analytics-pkg' ), + formatDate( from ), + formatDate( to ) + ); }; diff --git a/projects/packages/premium-analytics/packages/formatters/src/date/format-date.ts b/projects/packages/premium-analytics/packages/formatters/src/date/format-date.ts index 9aa7dedc2cb4..db805dac8878 100644 --- a/projects/packages/premium-analytics/packages/formatters/src/date/format-date.ts +++ b/projects/packages/premium-analytics/packages/formatters/src/date/format-date.ts @@ -1,65 +1,68 @@ /** * External dependencies */ -import { format } from 'date-fns'; +import { dateI18n, getSettings } from '@wordpress/date'; +/** + * Internal dependencies + */ +import { withoutYear } from './php-format'; + +/** Fixed because this format backs form values and query parameters. */ +const ISO_FORMAT = 'Y-m-d'; + +const YEAR_FORMAT = 'Y'; + +/** Named formats derived from the site format or fixed for machine-readable uses. */ +export type DateFormatName = 'medium' | 'short' | 'year' | 'iso'; /** - * Named date format presets for common use cases. Follows US date format - * standards. + * An instant to render, such as a `TZDate` or a timestamp. * - * |-------------|------------------------------|---------------------------| - * | Format | Output | Use Case | - * |-------------|------------------------------|---------------------------| - * | short | Jun 21 | Compact displays, lists | - * | medium | Jun 21, 2025 | Default - general use | - * | long | June 21, 2025 | Prominent displays | - * | full | Wednesday, June 21, 2025 | Headers, announcements | - * | day | 21 | Day-only displays | - * | month | Jun | Month-only displays | - * | year | 2025 | Year-only displays | - * | monthYear | Jun 2025 | Period summaries | - * | numeric | 06/21/2025 | Forms, data entry | - * | iso | 2025-06-21 | APIs, technical use | - * | dateTime | Jun 21, 2025 2:30 PM | Timestamps with time | - * |-------------|------------------------------|---------------------------| + * Narrower than what `dateI18n` accepts, to keep strings out: a date-only + * string such as `'2026-01-01'` is read as browser-local midnight, so it + * renders as the previous day for anyone ahead of the site. Parse site-local + * strings with `parseSiteDateTime` first. */ -// Exported for use in tests. -export const DATE_FORMATS = { - short: 'MMM d', - medium: 'MMM d, yyyy', - long: 'MMMM d, yyyy', - full: 'EEEE, MMMM d, yyyy', - day: 'd', - month: 'MMM', - year: 'yyyy', - monthYear: 'MMM yyyy', - numeric: 'MM/dd/yyyy', - iso: 'yyyy-MM-dd', - dateTime: 'MMM d, yyyy h:mm a', -} as const; +type DateInput = Date | number; -/** Named preset key from `DATE_FORMATS`. */ -type DateFormatName = keyof typeof DATE_FORMATS; +/** + * Resolve a named format to the PHP format string for the current site. + * + * @param name - The named format. + * @return PHP `date()` format string. + */ +function formatFor( name: DateFormatName ): string { + if ( name === 'iso' ) { + return ISO_FORMAT; + } + + if ( name === 'year' ) { + return YEAR_FORMAT; + } + + const siteFormat = getSettings().formats.date; -/** A named preset or a custom `date-fns` format pattern. */ -type DateFormatString = DateFormatName | ( string & {} ); + if ( name === 'short' ) { + return withoutYear( siteFormat ) || siteFormat; + } -/** Date input accepted by `date-fns/format`: Date, ISO string, or timestamp. */ -type DateType = Parameters< typeof format >[ 0 ]; + return siteFormat; +} /** - * Format a date using a named preset or a custom `date-fns` pattern. - * Defaults to `'medium'` (`'MMM d, yyyy'`). + * Format a date in the site's locale and timezone. + * + * Month and weekday names come from WordPress's own translation tables, and + * the ordering from the site's `date_format` option, so dates match the rest + * of wp-admin rather than the browser's locale. + * + * @param date - The instant to render. See `DateInput`. + * @param name - Named format. Defaults to `'medium'`. + * @return The formatted date. * * @example - * formatDate( new Date( '2025-06-21' ) ) // 'Jun 21, 2025' - * formatDate( new Date( '2025-06-21' ), 'short' ) // 'Jun 21' - * formatDate( new Date( '2025-06-21' ), 'dd/MM/yyyy' ) // '21/06/2025' + * formatDate( date ) // 'June 21, 2025' — or '21 de junio de 2025' + * formatDate( date, 'short' ) // 'June 21' — or '21 de junio' */ -export const formatDate = ( date: DateType, formatString: DateFormatString = 'medium' ): string => { - const formatPattern = Object.hasOwn( DATE_FORMATS, formatString ) - ? DATE_FORMATS[ formatString as DateFormatName ] - : formatString; - - return format( date, formatPattern ); -}; +export const formatDate = ( date: DateInput, name: DateFormatName = 'medium' ): string => + dateI18n( formatFor( name ), date ); diff --git a/projects/packages/premium-analytics/packages/formatters/src/date/index.ts b/projects/packages/premium-analytics/packages/formatters/src/date/index.ts index e2716d471da3..122e80fd0de6 100644 --- a/projects/packages/premium-analytics/packages/formatters/src/date/index.ts +++ b/projects/packages/premium-analytics/packages/formatters/src/date/index.ts @@ -1,2 +1,2 @@ -export { formatDate } from './format-date'; +export { formatDate, type DateFormatName } from './format-date'; export { formatDateRange } from './format-date-range'; diff --git a/projects/packages/premium-analytics/packages/formatters/src/date/php-format.ts b/projects/packages/premium-analytics/packages/formatters/src/date/php-format.ts new file mode 100644 index 000000000000..bb5d15d47163 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/php-format.ts @@ -0,0 +1,110 @@ +/** + * Helpers for the PHP `date()` format strings WordPress stores in its + * `date_format` option and hands to the page via `@wordpress/date`. + */ + +/** Tokens that render a year: 4-digit, 2-digit, and ISO week-numbering. */ +const YEAR_TOKENS = new Set( [ 'Y', 'y', 'o' ] ); + +type Segment = { + /** The original text, backslash included, so re-joining is lossless. */ + source: string; + /** Whether this renders a date part rather than literal text. */ + isToken: boolean; + /** The token letter, or the literal character for an escaped segment. */ + char: string; +}; + +/** + * Split a PHP format string into tokens and literals. + * + * Backslash escapes have to be honoured rather than scanned past: `es_ES` + * ships `j \d\e F \d\e Y`, where `\d` and `\e` spell the word "de" but are + * also the letters for the day and timezone tokens. + * + * @param phpFormat - PHP `date()` format string. + * @return The segments, in order. + */ +function toSegments( phpFormat: string ): Segment[] { + const segments: Segment[] = []; + + for ( let index = 0; index < phpFormat.length; index++ ) { + const char = phpFormat[ index ]; + + if ( char === '\\' && index + 1 < phpFormat.length ) { + segments.push( { + source: phpFormat.slice( index, index + 2 ), + isToken: false, + char: phpFormat[ index + 1 ], + } ); + index++; + continue; + } + + segments.push( { source: char, isToken: /[A-Za-z]/.test( char ), char } ); + } + + return segments; +} + +/** + * Remove the year from a PHP format string, along with the punctuation that + * introduces it. + * + * WordPress only publishes whole date formats, so a month-and-day format has + * to be derived from one. The separator run adjoining the year is taken with + * it — the run on the side facing the rest of the format, which is the side + * the punctuation belongs to (`F j, Y` → `F j`, but `Y-m-d` → `m-d`). Where + * the year is the last token, any literal trailing it goes too, since it was + * qualifying the year (`j F Y г.` → `j F`). A dot immediately before a + * trailing year is retained because dot-separated locales use it to terminate + * the preceding ordinal (`j.n.Y` → `j.n.`). + * + * @param phpFormat - PHP `date()` format string. + * @return The format without its year, or unchanged when it has none. + */ +export function withoutYear( phpFormat: string ): string { + const segments = toSegments( phpFormat ); + const yearAt = segments.findIndex( + segment => segment.isToken && YEAR_TOKENS.has( segment.char ) + ); + + if ( yearAt === -1 ) { + return phpFormat; + } + + let precedingToken = yearAt - 1; + while ( precedingToken >= 0 && ! segments[ precedingToken ].isToken ) { + precedingToken--; + } + + let followingToken = yearAt + 1; + while ( followingToken < segments.length && ! segments[ followingToken ].isToken ) { + followingToken++; + } + + let start = yearAt; + let end = yearAt + 1; + + if ( precedingToken >= 0 ) { + start = precedingToken + 1; + // In dot-separated formats the dot also marks the preceding numeric + // day or month as ordinal (`j.n.Y` → `j.n.`). Keep it while dropping + // any whitespace before the year. + if ( segments[ start ]?.source === '.' ) { + start++; + } + } + + if ( followingToken === segments.length ) { + // Only literals follow the year, and with the token they qualified gone + // they have nothing left to say: `j F Y г.` → `j F`, not `j F г.`. + end = segments.length; + } else if ( precedingToken < 0 ) { + end = followingToken; + } + + return [ ...segments.slice( 0, start ), ...segments.slice( end ) ] + .map( segment => segment.source ) + .join( '' ); +} diff --git a/projects/packages/premium-analytics/packages/formatters/src/index.ts b/projects/packages/premium-analytics/packages/formatters/src/index.ts index bf4279c1165c..d3633800c196 100644 --- a/projects/packages/premium-analytics/packages/formatters/src/index.ts +++ b/projects/packages/premium-analytics/packages/formatters/src/index.ts @@ -1,2 +1,2 @@ -export { formatDate, formatDateRange } from './date'; +export { formatDate, formatDateRange, type DateFormatName } from './date'; export { formatMetricValue } from './metric'; 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 4a0c4336a7b3..c297337924a7 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 @@ -42,19 +42,21 @@ export type ReportDateFilters = { * Parse search-param dates into a picker range, dropping unparseable values to * `undefined`. The picker reads these straight from the URL, so a malformed * `from`/`to` (e.g. a hand-edited or under-encoded deep link where the `+` - * offset decoded to a space) must not become an invalid Date — `formatDate` - * throws "Invalid time value" on one and would white-screen the page. + * offset decoded to a space) must not become an invalid Date: the picker's + * `formatToTimezoneNaiveString` throws on one and would white-screen the page, + * and the trigger label would read "Invalid date". * - * @param from - The `from` search param. - * @param to - The `to` search param. + * @param from - The `from` search param. + * @param to - The `to` search param. + * @param timeZone - The timezone used by the picker. * @return The parsed range, with invalid endpoints as `undefined`. */ -function toPickerRange( from?: string, to?: string ) { +function toPickerRange( from: string | undefined, to: string | undefined, timeZone: string ) { const parse = ( value?: string ) => { if ( ! value ) { return undefined; } - const date = localTZDate( value ); + const date = localTZDate( value, timeZone ); return isValid( date ) ? date : undefined; }; @@ -83,16 +85,31 @@ export function useReportDateFilters< TFrom extends string >( from: TFrom ): Rep TFrom >( { from } ); + /* + * Read the site timezone reactively. A fully-specified deep link skips the + * seed's `ensureCoreSettingsReady()` await, so core `site` settings may not + * be loaded on first paint. Rebuild picker dates when the real timezone + * resolves instead of leaving them anchored to the browser fallback. + */ + const timeZone = useSelect( select => { + void ( + select( coreStore ) as unknown as { + getEntityRecord: ( kind: string, name: string ) => unknown; + } + ).getEntityRecord( 'root', 'site' ); + return getSiteTimezone(); + }, [] ); + const presetId = useMemo( () => effective.preset ?? undefined, [ effective.preset ] ); const range = useMemo( - () => toPickerRange( effective.from, effective.to ), - [ effective.from, effective.to ] + () => toPickerRange( effective.from, effective.to, timeZone ), + [ effective.from, effective.to, timeZone ] ); const appliedPresetId = useMemo( () => committed.preset ?? undefined, [ committed.preset ] ); const appliedRange = useMemo( - () => toPickerRange( committed.from, committed.to ), - [ committed.from, committed.to ] + () => toPickerRange( committed.from, committed.to, timeZone ), + [ committed.from, committed.to, timeZone ] ); const onChange = useCallback( @@ -141,21 +158,6 @@ export function useReportDateFilters< TFrom extends string >( from: TFrom ): Rep const onApply = useCallback( () => commit(), [ commit ] ); const onCancel = useCallback( () => revert(), [ revert ] ); - /* - * Read the site timezone reactively. A fully-specified deep link skips the - * seed's `ensureCoreSettingsReady()` await, so core `site` settings may not - * be loaded on first paint; subscribing here re-renders with the real site - * timezone once they resolve, instead of sticking with the browser fallback. - */ - const timeZone = useSelect( select => { - void ( - select( coreStore ) as unknown as { - getEntityRecord: ( kind: string, name: string ) => unknown; - } - ).getEntityRecord( 'root', 'site' ); - return getSiteTimezone(); - }, [] ); - return { presetId, range, diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-input/__tests__/date-range-input.test.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-input/__tests__/date-range-input.test.tsx new file mode 100644 index 000000000000..f172bd6ce81d --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-input/__tests__/date-range-input.test.tsx @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { createTZDateFromParts } from '@jetpack-premium-analytics/datetime'; +import { render, screen } from '@testing-library/react'; +/** + * Internal dependencies + */ +import { DateRangeInput } from '../date-range-input'; + +describe( 'DateRangeInput', () => { + it( 'formats values in the same timezone used to parse them', () => { + const timeZone = 'Asia/Taipei'; + + render( + + ); + + expect( screen.getByLabelText( 'From' ) ).toHaveValue( '2026-07-09' ); + expect( screen.getByLabelText( 'To' ) ).toHaveValue( '2026-07-10' ); + } ); +} ); 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 8e7385f16220..ff47a0b6ea97 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 @@ -1,8 +1,11 @@ /** * External dependencies */ -import { createTZDateFromParts } from '@jetpack-premium-analytics/datetime'; -import { formatDate } from '@jetpack-premium-analytics/formatters'; +import { + createTZDateFromParts, + formatToTimezoneNaiveString, + getDatePart, +} from '@jetpack-premium-analytics/datetime'; import { __ } from '@wordpress/i18n'; import { Field, Input, Stack } from '@wordpress/ui'; import { useCallback, useEffect, useState } from 'react'; @@ -25,7 +28,8 @@ type DateInputProps = Pick< DateRangeInputProps, 'timeZone' > & { onChange: ( date?: Date ) => void; }; -const formatToString = ( date?: Date ) => ( date ? formatDate( date, 'iso' ) : '' ); +const formatToString = ( date: Date | undefined, timeZone: string ) => + date ? getDatePart( formatToTimezoneNaiveString( date, timeZone ) ) ?? '' : ''; function parseFromString( dateString: string, timeZone: string ) { const [ year, month, day ] = dateString.split( '-' ).map( x => Number( x ) ); @@ -36,11 +40,11 @@ function parseFromString( dateString: string, timeZone: string ) { } function DateInput( { label, date, onChange, timeZone }: DateInputProps ) { - const [ value, setValue ] = useState( formatToString( date ) ); + const [ value, setValue ] = useState( formatToString( date, timeZone ) ); useEffect( () => { - setValue( formatToString( date ) ); - }, [ date ] ); + setValue( formatToString( date, timeZone ) ); + }, [ date, timeZone ] ); const onInputChange = useCallback( ( event: React.ChangeEvent< HTMLInputElement > ) => { diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/package.json b/projects/packages/premium-analytics/packages/widgets-toolkit/package.json index a7dc2099c222..5a1bd45a00c0 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/package.json +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/package.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@storybook/react": "10.4.6", - "@testing-library/react": "16.3.2" + "@testing-library/react": "16.3.2", + "@wordpress/date": "5.51.0" } } diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/README.md index 9f048c37a9bb..ff516ff86545 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/README.md +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/README.md @@ -122,13 +122,13 @@ function MyWidget( { series } ) { ## Props -| Prop | Type | Required | Description | -| ------------ | ------------------------------ | -------- | -------------------------------------------------- | -| `series` | `ComparativeLineChartSeries[]` | Yes | Array of series with data | -| `styles` | `SeriesStyle[]` | No | Styles for each series (by index) | -| `dataFormat` | `DataFormat` | Yes | Format for values (Y-axis ticks and tooltips) | -| `tickFormat` | `string` | No | Custom X-axis date format (date-fns format string) | -| `className` | `string` | No | CSS class for the chart container | +| Prop | Type | Required | Description | +| ------------ | ------------------------------ | -------- | ------------------------------------------------------------- | +| `series` | `ComparativeLineChartSeries[]` | Yes | Array of series with data | +| `styles` | `SeriesStyle[]` | No | Styles for each series (by index) | +| `dataFormat` | `DataFormat` | Yes | Format for values (Y-axis ticks and tooltips) | +| `tickFormat` | `DateFormatName` | No | Named X-axis date format; uses the chart default when omitted | +| `className` | `string` | No | CSS class for the chart container | ## SeriesStyle Type diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx index a4b672b9888f..ee54689a4288 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx @@ -2,7 +2,11 @@ * External dependencies */ import { LineChart } from '@automattic/charts'; -import { formatDate, formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { + formatDate, + formatMetricValue, + type DateFormatName, +} from '@jetpack-premium-analytics/formatters'; import { useResizeObserver } from '@wordpress/compose'; import { Stack } from '@wordpress/ui'; import clsx from 'clsx'; @@ -134,7 +138,8 @@ export type ComparativeLineChartProps = { */ dataFormat: DataFormat; - tickFormat?: string; + /** Named date format for the X-axis ticks. Uses the chart default when omitted. */ + tickFormat?: DateFormatName; /** * Degrade to a sparkline (no y-axis, grid, or legend) when the chart area @@ -294,7 +299,7 @@ export function ComparativeLineChart( { }, [ percentageDomain, createDomainMargin ] ); const xTickFormat = useCallback( - ( date: number ) => formatDate( date, xTickFormatType ?? 'short' ), + ( date: number ) => formatDate( date, xTickFormatType ), [ xTickFormatType ] ); @@ -306,7 +311,9 @@ export function ComparativeLineChart( { const baseOptions = { axis: { x: { - // Use the chart library's default behavior for 'custom' presets + // Must stay conditional: `formatDate` defaults to `medium`, so an + // unconditional `xTickFormat` would put full site-format dates on every + // tick. Without the prop, the chart library's own tick labels stay in use. tickFormat: xTickFormatType ? xTickFormat : undefined, }, y: { diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/format-legend-labels.test.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/format-legend-labels.test.ts new file mode 100644 index 000000000000..4cecde020624 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/format-legend-labels.test.ts @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { getSettings, setSettings } from '@wordpress/date'; +/** + * Internal dependencies + */ +import { formatLegendLabels } from '../format-legend-labels'; +import type { ReportParams } from '@jetpack-premium-analytics/data'; + +// Captured before any test installs settings over them, so the fixtures vary +// only the timezone and inherit what `@wordpress/date` actually ships. +const DEFAULTS = getSettings(); + +/** + * Install date settings for a site in a given timezone. + * + * @param timeZone - IANA zone name. + * @param offset - Offset in hours, as WordPress reports it. + * @param locale - Unique moment locale name for the fixture, since + * `setSettings` skips redefining a locale it already knows. + */ +const siteIn = ( timeZone: string, offset: number, locale: string ) => + setSettings( { + ...DEFAULTS, + l10n: { ...DEFAULTS.l10n, locale }, + timezone: { offset, offsetFormatted: String( offset ), string: timeZone, abbr: '' }, + } ); + +describe( 'formatLegendLabels', () => { + // A date-only param has no offset of its own. Read as UTC it lands on the + // previous day for every site west of Greenwich. + it( 'keeps date-only params on their own calendar day for a site west of UTC', () => { + siteIn( 'America/New_York', -5, 'en-nyc-test' ); + + const labels = formatLegendLabels( { + from: '2026-01-01', + to: '2026-01-31', + compare_from: '2025-12-01', + compare_to: '2025-12-31', + interval: 'day', + } as ReportParams ); + + expect( labels.primary ).toBe( 'January 1, 2026 – January 31, 2026' ); + expect( labels.comparison ).toBe( 'December 1, 2025 – December 31, 2025' ); + } ); + + it( 'keeps date-only params on their own calendar day for a site east of UTC', () => { + siteIn( 'Asia/Taipei', 8, 'en-tpe-test' ); + + const labels = formatLegendLabels( { + from: '2026-01-01', + to: '2026-01-31', + interval: 'day', + } as ReportParams ); + + expect( labels.primary ).toBe( 'January 1, 2026 – January 31, 2026' ); + } ); + + it( 'honours the offset a full ISO param already carries', () => { + siteIn( 'Europe/Amsterdam', 2, 'en-ams-test' ); + + const labels = formatLegendLabels( { + from: '2026-06-29T00:00:00.000+02:00', + to: '2026-07-28T23:59:59.999+02:00', + interval: 'day', + } as ReportParams ); + + expect( labels.primary ).toBe( 'June 29, 2026 – July 28, 2026' ); + } ); + + it( 'falls back to the generic comparison label without comparison params', () => { + siteIn( 'UTC', 0, 'en-utc-test' ); + + const labels = formatLegendLabels( { + from: '2026-01-01', + to: '2026-01-31', + interval: 'day', + } as ReportParams ); + + expect( labels.comparison ).toBe( 'Previous period' ); + } ); + + it( 'falls back to readable labels when date params are invalid', () => { + siteIn( 'UTC', 0, 'en-invalid-date-test' ); + + const labels = formatLegendLabels( { + from: 'garbage', + to: '2026-01-31', + compare_from: 'junk', + compare_to: '2025-12-31', + interval: 'day', + } as ReportParams ); + + expect( labels.primary ).toBe( 'Current period' ); + expect( labels.comparison ).toBe( 'Previous period' ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/format-legend-labels.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/format-legend-labels.ts index 25c4b7e1a409..5389346a1c19 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/format-legend-labels.ts +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/format-legend-labels.ts @@ -1,6 +1,7 @@ /** * External dependencies */ +import { parseSiteDateTime } from '@jetpack-premium-analytics/datetime'; import { formatDateRange } from '@jetpack-premium-analytics/formatters'; import { __ } from '@wordpress/i18n'; import type { LegendLabels } from '../components/chart-leaderboard'; @@ -29,21 +30,22 @@ import type { ReportParams } from '@jetpack-premium-analytics/data'; * compare_to: '2023-12-31', * interval: 'day' * }); - * // Returns: { primary: 'Jan 1 - 31, 2024', comparison: 'Dec 1 - 31, 2023' } + * // Returns: { primary: 'January 1, 2024 – January 31, 2024', … } * ``` */ export function formatLegendLabels( reportParams: ReportParams ): LegendLabels { - const primaryLabel = formatDateRange( { - from: new Date( reportParams.from ), - to: new Date( reportParams.to ), - } ); + const primaryLabel = + formatDateRange( { + from: parseSiteDateTime( reportParams.from ), + to: parseSiteDateTime( reportParams.to ), + } ) || __( 'Current period', 'jetpack-premium-analytics-pkg' ); const comparisonLabel = reportParams.compare_from && reportParams.compare_to ? formatDateRange( { - from: new Date( reportParams.compare_from ), - to: new Date( reportParams.compare_to ), - } ) + from: parseSiteDateTime( reportParams.compare_from ), + to: parseSiteDateTime( reportParams.compare_to ), + } ) || __( 'Previous period', 'jetpack-premium-analytics-pkg' ) : __( 'Previous period', 'jetpack-premium-analytics-pkg' ); return { diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/metric-comparison-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/metric-comparison-widget.tsx index c48fdc2158bf..ff08ccf2fdc8 100644 --- a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/metric-comparison-widget.tsx +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/metric-comparison-widget.tsx @@ -12,6 +12,7 @@ import type { SeriesStyle, } from '../../components/chart-comparative-line/types'; import type { DataFormat } from '../../types'; +import type { DateFormatName } from '@jetpack-premium-analytics/formatters'; export type MetricComparisonWidgetProps = { /** @@ -36,7 +37,8 @@ export type MetricComparisonWidgetProps = { seriesStyles?: SeriesStyle[]; dataFormat: DataFormat; - tickFormat?: string; + /** Named date format for the X-axis ticks. */ + tickFormat?: DateFormatName; }; export function MetricComparisonWidget( { diff --git a/projects/packages/premium-analytics/routes/reports/emails/config/fields.tsx b/projects/packages/premium-analytics/routes/reports/emails/config/fields.tsx index 32bebf38b2da..501facef73c8 100644 --- a/projects/packages/premium-analytics/routes/reports/emails/config/fields.tsx +++ b/projects/packages/premium-analytics/routes/reports/emails/config/fields.tsx @@ -1,7 +1,8 @@ /** * External dependencies */ -import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { parseSiteDateTime } from '@jetpack-premium-analytics/datetime'; +import { formatDate, formatMetricValue } from '@jetpack-premium-analytics/formatters'; import { __ } from '@wordpress/i18n'; import { Link } from '@wordpress/route'; /** @@ -54,11 +55,9 @@ function formatRate( rate: number, total: number, unique: number ): string { * @return The formatted date, or an em dash placeholder. */ function formatSentDate( value: unknown ): string { - const date = new Date( String( value ?? '' ) ); + const date = parseSiteDateTime( value ); - return Number.isNaN( date.getTime() ) - ? '—' - : date.toLocaleDateString( undefined, { year: 'numeric', month: 'short', day: 'numeric' } ); + return date ? formatDate( date ) : '—'; } /** diff --git a/projects/packages/premium-analytics/routes/reports/package.json b/projects/packages/premium-analytics/routes/reports/package.json index 90244f58726e..b7615a90f68d 100644 --- a/projects/packages/premium-analytics/routes/reports/package.json +++ b/projects/packages/premium-analytics/routes/reports/package.json @@ -8,6 +8,7 @@ "dependencies": { "@automattic/jetpack-script-data": "workspace:*", "@jetpack-premium-analytics/data": "workspace:*", + "@jetpack-premium-analytics/datetime": "workspace:*", "@jetpack-premium-analytics/formatters": "workspace:*", "@jetpack-premium-analytics/routing": "workspace:*", "@jetpack-premium-analytics/ui": "workspace:*", diff --git a/projects/packages/premium-analytics/widgets/most-popular-day/package.json b/projects/packages/premium-analytics/widgets/most-popular-day/package.json index ee2bb22e05c4..6752de4d9274 100644 --- a/projects/packages/premium-analytics/widgets/most-popular-day/package.json +++ b/projects/packages/premium-analytics/widgets/most-popular-day/package.json @@ -5,13 +5,13 @@ "type": "module", "dependencies": { "@jetpack-premium-analytics/data": "link:../../packages/data", + "@jetpack-premium-analytics/datetime": "link:../../packages/datetime", "@jetpack-premium-analytics/formatters": "link:../../packages/formatters", "@jetpack-premium-analytics/icons": "link:../../packages/icons", "@jetpack-premium-analytics/widgets-toolkit": "link:../../packages/widgets-toolkit", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^15.0.0", "@wordpress/ui": "0.17.0", - "@wordpress/widget-primitives": "0.2.0", - "date-fns": "4.1.0" + "@wordpress/widget-primitives": "0.2.0" } } diff --git a/projects/packages/premium-analytics/widgets/most-popular-day/render.tsx b/projects/packages/premium-analytics/widgets/most-popular-day/render.tsx index 7252754868ed..9596711953fc 100644 --- a/projects/packages/premium-analytics/widgets/most-popular-day/render.tsx +++ b/projects/packages/premium-analytics/widgets/most-popular-day/render.tsx @@ -2,6 +2,7 @@ * External dependencies */ import { useStatsSite } from '@jetpack-premium-analytics/data'; +import { parseSiteDateTime } from '@jetpack-premium-analytics/datetime'; import { formatDate, formatMetricValue } from '@jetpack-premium-analytics/formatters'; import { calendar } from '@jetpack-premium-analytics/icons'; import { @@ -12,7 +13,6 @@ import { } from '@jetpack-premium-analytics/widgets-toolkit'; import { __, sprintf } from '@wordpress/i18n'; import { Stack, Text } from '@wordpress/ui'; -import { isValid, parseISO } from 'date-fns'; /** * Internal dependencies */ @@ -82,7 +82,7 @@ export const MostPopularDayHighlight = ( { ); -/** - * Parses the best-day field (`YYYY-MM-DD`) into a date. `parseISO` validates the - * calendar date, so `isValid` rejects the "-" / empty sentinels low-traffic - * sites send and impossible days like `2020-02-31`, falling through to the empty - * state. Parsed and formatted in local time, so the calendar day is stable. - * - * @param {Record< string, unknown > | undefined} summary - The site summary. - * @return The best day, or undefined when unavailable. - */ function readBestDay( summary: Record< string, unknown > | undefined ) { - const value = summary?.views_best_day; - if ( typeof value !== 'string' || value === '' ) { - return undefined; - } - - const date = parseISO( value ); - - return isValid( date ) ? date : undefined; + return parseSiteDateTime( summary?.views_best_day ); } /**