Skip to content
Merged
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
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Format dates using the site's language and date format instead of US English.
1 change: 1 addition & 0 deletions projects/packages/premium-analytics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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' );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export {

export { formatRelativeSince } from './relative-since';

export { parseSiteDateTime } from './site-datetime';

export {
formatDatePartWithTime,
getDateIntervalDateParts,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
69 changes: 46 additions & 23 deletions projects/packages/premium-analytics/packages/formatters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -13,6 +13,7 @@ import {
formatMetricValue,
formatDate,
formatDateRange,
type DateFormatName,
} from '@jetpack-premium-analytics/formatters';
```

Expand Down Expand Up @@ -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? )`

Expand All @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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 ) );
Loading
Loading