diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 588ad2994d8f..54d70fd092e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3866,6 +3866,9 @@ importers: projects/packages/premium-analytics: dependencies: + '@automattic/number-formatters': + specifier: workspace:* + version: link:../../js-packages/number-formatters '@date-fns/tz': specifier: 1.4.1 version: 1.4.1 @@ -3906,6 +3909,9 @@ importers: '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 + '@types/jest': + specifier: 30.0.0 + version: 30.0.0 '@typescript/native-preview': specifier: 7.0.0-dev.20260225.1 version: 7.0.0-dev.20260225.1 diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1313-integrate-formatters-package-into-analytics b/projects/packages/premium-analytics/changelog/wooa7s-1313-integrate-formatters-package-into-analytics new file mode 100644 index 000000000000..58bba2bbae8c --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1313-integrate-formatters-package-into-analytics @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Port formatters package (number/currency/percentage metric formatter and date helpers) as an internal package from next-woocommerce-analytics. diff --git a/projects/packages/premium-analytics/eslint.config.mjs b/projects/packages/premium-analytics/eslint.config.mjs index 4d905c2efea4..38a1a71417d7 100644 --- a/projects/packages/premium-analytics/eslint.config.mjs +++ b/projects/packages/premium-analytics/eslint.config.mjs @@ -1,16 +1,31 @@ import { makeBaseConfig, defineConfig } from 'jetpack-js-tools/eslintrc/base.mjs'; /** - * Soften JSDoc rules for `packages/datetime/**` so the initial port can - * land. Temporary — backfill proper descriptions on the helpers and - * remove this override (at which point this whole file can go away). + * Soften JSDoc rules for `packages/datetime/**` and `packages/formatters/**` + * so the initial ports can land with the upstream JSDoc style (descriptions + * on the function body, not on per-param tags). Temporary — backfill proper + * JSDoc on the helpers and remove these overrides (at which point this whole + * file can go away). */ -export default defineConfig( makeBaseConfig( import.meta.url ), { - files: [ 'packages/datetime/**' ], - rules: { - 'jsdoc/require-description': 'off', - 'jsdoc/require-param-description': 'off', - 'jsdoc/require-returns': 'off', - 'jsdoc/check-indentation': 'off', +export default defineConfig( + makeBaseConfig( import.meta.url ), + { + files: [ 'packages/datetime/**' ], + rules: { + 'jsdoc/require-description': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/check-indentation': 'off', + }, }, -} ); + { + files: [ 'packages/formatters/**' ], + rules: { + 'jsdoc/require-description': 'off', + 'jsdoc/require-param': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/check-indentation': 'off', + }, + } +); diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index fb706a09b9f1..fd3c20aab0a9 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -30,6 +30,7 @@ } }, "dependencies": { + "@automattic/number-formatters": "workspace:*", "@date-fns/tz": "1.4.1", "@wordpress/boot": "0.13.0", "@wordpress/data": "10.46.0", @@ -45,6 +46,7 @@ "@babel/core": "7.29.0", "@storybook/react": "10.3.6", "@testing-library/dom": "10.4.1", + "@types/jest": "30.0.0", "@typescript/native-preview": "7.0.0-dev.20260225.1", "@wordpress/build": "0.14.0", "@wordpress/ui": "0.13.0", diff --git a/projects/packages/premium-analytics/packages/formatters/README.md b/projects/packages/premium-analytics/packages/formatters/README.md new file mode 100644 index 000000000000..9cabeb327e73 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/README.md @@ -0,0 +1,89 @@ +# @automattic/jetpack-premium-analytics-formatters + +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. + +## Exports + +```typescript +import { + formatMetricValue, + formatDate, + formatDateRange, +} from '@jetpack-premium-analytics/formatters'; +``` + +## `formatMetricValue( value, type?, options? )` + +Format a numeric value based on its metric type. +Returns `''` for null, undefined, or NaN. + +```typescript +formatMetricValue( 9876.543 ); // '9,877' +formatMetricValue( 1500, 'number', { + useMultipliers: true, + decimals: 1, +} ); // '1.5K' +formatMetricValue( 192088.05, 'currency' ); // '$192,088.05' +formatMetricValue( 0.25, 'percentage' ); // '+25%' +formatMetricValue( 4.75, 'average' ); // '4.75' +formatMetricValue( 192088, 'currency', { + useMultipliers: true, + currencyCode: 'EUR', +} ); // '192.09K€' +``` + +| Parameter | Type | Default | Description | +| ------------------------ | ----------------------------------------------------- | ---------------------------------------- | ---------------------------------------------- | +| `value` | `string \| number \| null` | | Value to format | +| `type` | `'number' \| 'currency' \| 'percentage' \| 'average'` | `'number'` | Formatting strategy | +| `options.decimals` | `number` | varies by type | Decimal precision (0 for number, 2 for others) | +| `options.useMultipliers` | `boolean` | `false` | Compact notation (K/M suffixes) | +| `options.signDisplay` | `Intl` sign mode | `'auto'` (`'exceptZero'` for percentage) | Sign display | +| `options.currencyCode` | `string` | `'USD'` | ISO 4217 currency code | + +## `formatDate( date, format? )` + +Format a date using a named preset or custom `date-fns` pattern. +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' +``` + +**Named presets:** `short`, `medium` (default), `long`, `full`, `day`, `month`, `year`, `monthYear`, `numeric`, `iso`, `dateTime`. + +## `formatDateRange( range? )` + +Format a date range into a human-readable string. +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' +``` + +| Parameter | Type | Description | +| --------- | ---------------------------- | ----------------- | +| `range` | `{ from?: Date; to?: Date }` | Date range object | + +## Architecture + +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 diff --git a/projects/packages/premium-analytics/packages/formatters/package.json b/projects/packages/premium-analytics/packages/formatters/package.json new file mode 100644 index 000000000000..c9d7db16093a --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/package.json @@ -0,0 +1,13 @@ +{ + "name": "@automattic/jetpack-premium-analytics-formatters", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "sideEffects": false, + "dependencies": { + "@automattic/number-formatters": "workspace:*", + "date-fns": "4.1.0" + } +} 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 new file mode 100644 index 000000000000..c8f2d672f656 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date-range.test.ts @@ -0,0 +1,127 @@ +/** + * Internal dependencies + */ +import { formatDate } from '../format-date'; +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 }`; + } + + if ( formatString === 'd, yyyy' ) { + return `${ dayNum }, ${ year }`; + } + + // Default: medium format + return `${ monthName } ${ dayNum }, ${ year }`; + } ); + }; + + beforeEach( () => { + jest.clearAllMocks(); + setupMocks(); + } ); + + 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( '' ); + } ); + + it( 'returns empty string when "to" is missing', () => { + const result = formatDateRange( { + from: new Date( '2025-06-21' ), + to: undefined, + } ); + expect( result ).toBe( '' ); + } ); + + it( 'returns empty string when both dates are missing', () => { + const result = formatDateRange( { + from: undefined, + to: undefined, + } ); + expect( result ).toBe( '' ); + } ); + } ); + + 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( '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' ); + } ); + } ); + + 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( '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' ); + } ); + } ); +} ); 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 new file mode 100644 index 000000000000..8a9a98d53b5a --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date.test.ts @@ -0,0 +1,152 @@ +/** + * External dependencies + */ +import { format } from 'date-fns'; +/** + * Internal dependencies + */ +import { formatDate, DATE_FORMATS as FORMATS } from '../format-date'; + +jest.mock( 'date-fns', () => ( { + format: jest.fn(), +} ) ); + +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 ); + } ); + + 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 date with "full" preset', () => { + const result = formatDate( testDate, 'full' ); + expect( result ).toBe( 'Wednesday, June 21, 2025' ); + expect( format ).toHaveBeenCalledWith( testDate, FORMATS.full ); + } ); + + it( 'formats date with "day" preset', () => { + const result = formatDate( testDate, 'day' ); + expect( result ).toBe( '21' ); + expect( format ).toHaveBeenCalledWith( testDate, FORMATS.day ); + } ); + + it( 'formats date with "month" preset', () => { + const result = formatDate( testDate, 'month' ); + expect( result ).toBe( 'Jun' ); + expect( format ).toHaveBeenCalledWith( testDate, FORMATS.month ); + } ); + + it( 'formats date with "year" preset', () => { + const result = formatDate( testDate, 'year' ); + expect( result ).toBe( '2025' ); + expect( format ).toHaveBeenCalledWith( testDate, FORMATS.year ); + } ); + + it( 'formats date with "monthYear" preset', () => { + const result = formatDate( testDate, 'monthYear' ); + expect( result ).toBe( 'Jun 2025' ); + expect( format ).toHaveBeenCalledWith( testDate, FORMATS.monthYear ); + } ); + + it( 'formats date with "numeric" preset', () => { + const result = formatDate( testDate, 'numeric' ); + expect( result ).toBe( '06/21/2025' ); + expect( format ).toHaveBeenCalledWith( testDate, FORMATS.numeric ); + } ); + + it( 'formats date with "iso" preset', () => { + const result = formatDate( testDate, 'iso' ); + expect( result ).toBe( '2025-06-21' ); + expect( format ).toHaveBeenCalledWith( testDate, FORMATS.iso ); + } ); + + 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 ); + } ); + } ); + + describe( 'custom format strings', () => { + const testDate = new Date( '2025-06-21T14:30:00' ); + + 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 ); + } ); + } ); +} ); 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 new file mode 100644 index 000000000000..dcc6a86e1609 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/format-date-range.ts @@ -0,0 +1,55 @@ +/** + * Internal dependencies + */ +import { formatDate } from './format-date'; + +/** + * A date range with optional start and end. + * + * Defined locally to avoid a cross-package import on + * `@jetpack-premium-analytics/datetime` (which exports an identical + * `DateRange` type). Switch to that import once the sibling-package + * `link:` wiring is settled. + */ +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. + * Returns `''` when `range`, `from`, or `to` is missing. + * + * @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' + */ +export const formatDateRange = ( range?: DateRange ): string => { + if ( ! range ) { + return ''; + } + + 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 ) }`; + } + + return `${ 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 new file mode 100644 index 000000000000..9aa7dedc2cb4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/format-date.ts @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { format } from 'date-fns'; + +/** + * Named date format presets for common use cases. Follows US date format + * standards. + * + * |-------------|------------------------------|---------------------------| + * | 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 | + * |-------------|------------------------------|---------------------------| + */ +// 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; + +/** Named preset key from `DATE_FORMATS`. */ +type DateFormatName = keyof typeof DATE_FORMATS; + +/** A named preset or a custom `date-fns` format pattern. */ +type DateFormatString = DateFormatName | ( string & {} ); + +/** Date input accepted by `date-fns/format`: Date, ISO string, or timestamp. */ +type DateType = Parameters< typeof format >[ 0 ]; + +/** + * Format a date using a named preset or a custom `date-fns` pattern. + * Defaults to `'medium'` (`'MMM d, yyyy'`). + * + * @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' + */ +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 ); +}; diff --git a/projects/packages/premium-analytics/packages/formatters/src/date/index.ts b/projects/packages/premium-analytics/packages/formatters/src/date/index.ts new file mode 100644 index 000000000000..e2716d471da3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/index.ts @@ -0,0 +1,2 @@ +export { formatDate } from './format-date'; +export { formatDateRange } from './format-date-range'; diff --git a/projects/packages/premium-analytics/packages/formatters/src/index.ts b/projects/packages/premium-analytics/packages/formatters/src/index.ts new file mode 100644 index 000000000000..bf4279c1165c --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/index.ts @@ -0,0 +1,2 @@ +export { formatDate, formatDateRange } from './date'; +export { formatMetricValue } from './metric'; diff --git a/projects/packages/premium-analytics/packages/formatters/src/metric/__tests__/format-metric-value.test.ts b/projects/packages/premium-analytics/packages/formatters/src/metric/__tests__/format-metric-value.test.ts new file mode 100644 index 000000000000..779939e15a79 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/metric/__tests__/format-metric-value.test.ts @@ -0,0 +1,345 @@ +/** + * External dependencies + */ +import { formatCurrency, getCurrencyObject } from '@automattic/number-formatters'; +/** + * Internal dependencies + */ +import { formatMetricValue } from '../format-metric-value'; + +jest.mock( '@automattic/number-formatters', () => { + const actual = jest.requireActual( '@automattic/number-formatters' ); + return { + ...actual, + formatCurrency: jest.fn(), + getCurrencyObject: jest.fn(), + }; +} ); + +describe( 'formatMetricValue', () => { + /** + * Default mock setup: USD, symbol before. + */ + const setupCurrency = ( { + symbol = '$', + position = 'before', + hasSpace = false, + }: { + symbol?: string; + position?: 'before' | 'after'; + code?: string; + hasSpace?: boolean; + } = {} ) => { + const sp = hasSpace ? ' ' : ''; + + ( getCurrencyObject as jest.Mock ).mockReturnValue( { + sign: '', + symbol, + symbolPosition: position, + integer: '0', + fraction: '00', + hasNonZeroFraction: false, + } ); + + ( formatCurrency as jest.Mock ).mockImplementation( ( value: number ) => { + const formatted = Math.abs( value ).toLocaleString( 'en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + } ); + const sign = value < 0 ? '-' : ''; + + return position === 'before' + ? `${ sign }${ symbol }${ sp }${ formatted }` + : `${ sign }${ formatted }${ sp }${ symbol }`; + } ); + }; + + beforeEach( () => { + jest.clearAllMocks(); + setupCurrency(); + } ); + + describe( 'invalid and edge-case inputs', () => { + it( 'returns empty string for NaN', () => { + expect( formatMetricValue( 'not-a-number' ) ).toBe( '' ); + } ); + + it( 'returns empty string for null', () => { + expect( formatMetricValue( null as unknown as number ) ).toBe( '' ); + } ); + + it( 'returns empty string for undefined', () => { + expect( formatMetricValue( undefined as unknown as number ) ).toBe( '' ); + } ); + + it( 'coerces empty string to zero', () => { + expect( formatMetricValue( '' ) ).toBe( '0' ); + } ); + + it( 'accepts numeric strings', () => { + expect( formatMetricValue( '1234' ) ).toBe( '1,234' ); + } ); + + it( 'accepts numeric strings with decimals', () => { + expect( formatMetricValue( '99.99', 'average' ) ).toBe( '99.99' ); + } ); + + it( 'formats zero', () => { + expect( formatMetricValue( 0 ) ).toBe( '0' ); + } ); + + it( 'formats negative zero', () => { + expect( formatMetricValue( -0 ) ).toBe( '-0' ); + } ); + } ); + + it( 'defaults to type number', () => { + expect( formatMetricValue( 42.42 ) ).toBe( '42' ); + } ); + + describe( 'type: currency (standard)', () => { + it( 'delegates to formatCurrency', () => { + formatMetricValue( 192088.05, 'currency' ); + + expect( formatCurrency ).toHaveBeenCalledWith( 192088.05, 'USD' ); + } ); + + it( 'formats currency without multipliers', () => { + expect( formatMetricValue( 192088.05, 'currency' ) ).toBe( '$192,088.05' ); + } ); + + it( 'passes currencyCode to formatCurrency', () => { + formatMetricValue( 100, 'currency', { + currencyCode: 'EUR', + } ); + + expect( formatCurrency ).toHaveBeenCalledWith( 100, 'EUR' ); + } ); + + it( 'formats currency with multipliers', () => { + const result = formatMetricValue( 192088.05, 'currency', { + useMultipliers: true, + decimals: 2, + } ); + expect( result ).toBe( '$192.09K' ); + } ); + + it( 'formats currency with multipliers and signDisplay', () => { + const negativeResult = formatMetricValue( -192088.05, 'currency', { + useMultipliers: true, + signDisplay: 'always', + decimals: 2, + } ); + expect( negativeResult ).toBe( '-$192.09K' ); + + const positiveResult = formatMetricValue( 192088.05, 'currency', { + useMultipliers: true, + signDisplay: 'always', + decimals: 2, + } ); + expect( positiveResult ).toBe( '+$192.09K' ); + } ); + + it( 'formats currency with signDisplay', () => { + const negativeResult = formatMetricValue( -192088.05, 'currency', { + signDisplay: 'always', + } ); + expect( negativeResult ).toBe( '-$192,088.05' ); + + const positiveResult = formatMetricValue( 192088.05, 'currency', { + signDisplay: 'always', + } ); + expect( positiveResult ).toBe( '+$192,088.05' ); + } ); + } ); + + describe( 'type: currency (symbol position)', () => { + it( 'places symbol after number when position is after', () => { + setupCurrency( { + symbol: '€', + position: 'after', + code: 'EUR', + hasSpace: true, + } ); + + const result = formatMetricValue( 1500, 'currency', { + useMultipliers: true, + decimals: 1, + currencyCode: 'EUR', + } ); + + expect( result ).toBe( '1.5K €' ); + } ); + + it( 'places symbol before number when position is before', () => { + setupCurrency( { symbol: '£', position: 'before', code: 'GBP' } ); + + const result = formatMetricValue( 1500, 'currency', { + useMultipliers: true, + decimals: 1, + currencyCode: 'GBP', + } ); + + expect( result ).toBe( '£1.5K' ); + } ); + + it( 'handles negative values with after position', () => { + setupCurrency( { + symbol: '€', + position: 'after', + code: 'EUR', + hasSpace: true, + } ); + + const result = formatMetricValue( -1500, 'currency', { + useMultipliers: true, + decimals: 1, + currencyCode: 'EUR', + } ); + + expect( result ).toBe( '-1.5K €' ); + } ); + + it( 'handles signDisplay with before position', () => { + setupCurrency( { symbol: '£', position: 'before', code: 'GBP' } ); + + const result = formatMetricValue( 1500, 'currency', { + useMultipliers: true, + decimals: 1, + signDisplay: 'always', + currencyCode: 'GBP', + } ); + + expect( result ).toBe( '+£1.5K' ); + } ); + + it( 'formats millions with after position', () => { + setupCurrency( { + symbol: '€', + position: 'after', + code: 'EUR', + hasSpace: true, + } ); + + const result = formatMetricValue( 1500000, 'currency', { + useMultipliers: true, + decimals: 1, + currencyCode: 'EUR', + } ); + + expect( result ).toBe( '1.5M €' ); + } ); + + it( 'passes currencyCode to getCurrencyObject for multipliers', () => { + formatMetricValue( 1500, 'currency', { + useMultipliers: true, + currencyCode: 'JPY', + } ); + + expect( getCurrencyObject ).toHaveBeenCalledWith( 0, 'JPY' ); + } ); + + it( 'preserves space between symbol and number for BRL', () => { + setupCurrency( { + symbol: 'R$', + position: 'before', + code: 'BRL', + hasSpace: true, + } ); + + const result = formatMetricValue( 2500, 'currency', { + useMultipliers: true, + decimals: 1, + currencyCode: 'BRL', + } ); + + expect( result ).toBe( 'R$ 2.5K' ); + } ); + + it( 'does not call getCurrencyObject for non-multiplier currency', () => { + formatMetricValue( 100, 'currency' ); + + expect( getCurrencyObject ).not.toHaveBeenCalled(); + expect( formatCurrency ).toHaveBeenCalled(); + } ); + } ); + + describe( 'type: percentage', () => { + it( 'formats decimal as percentage with default sign', () => { + expect( formatMetricValue( 0.5, 'percentage' ) ).toBe( '+50%' ); + } ); + + it( 'formats whole number as percentage with default sign', () => { + expect( formatMetricValue( 1, 'percentage' ) ).toBe( '+100%' ); + } ); + + it( 'respects decimals option', () => { + expect( formatMetricValue( 0.12345, 'percentage', { decimals: 1 } ) ).toBe( '+12.3%' ); + } ); + + it( 'formats negative percentage', () => { + expect( formatMetricValue( -0.25, 'percentage' ) ).toBe( '-25%' ); + } ); + + it( 'allows disabling the sign display', () => { + expect( + formatMetricValue( 0.5, 'percentage', { + signDisplay: 'auto', + } ) + ).toBe( '50%' ); + } ); + + it( 'omits sign for zero with default exceptZero', () => { + expect( formatMetricValue( 0, 'percentage' ) ).toBe( '0%' ); + } ); + + it( 'formats small decimals without trailing zeros', () => { + expect( formatMetricValue( 0.1, 'percentage' ) ).toBe( '+10%' ); + } ); + } ); + + describe( 'type: average', () => { + it( 'formats finite average', () => { + expect( formatMetricValue( 0.125, 'average' ) ).toBe( '0.13' ); + } ); + + it( 'returns em dash for Infinity', () => { + expect( formatMetricValue( Infinity, 'average' ) ).toBe( '—' ); + } ); + + it( 'returns em dash for negative Infinity', () => { + expect( formatMetricValue( -Infinity, 'average' ) ).toBe( '—' ); + } ); + + it( 'respects custom decimals', () => { + expect( formatMetricValue( 3.14159, 'average', { decimals: 4 } ) ).toBe( '3.1416' ); + } ); + + it( 'formats zero with default 2 decimals', () => { + expect( formatMetricValue( 0, 'average' ) ).toBe( '0.00' ); + } ); + } ); + + describe( 'type: number', () => { + it( 'formats number without multipliers', () => { + expect( formatMetricValue( 9876.543, 'number' ) ).toBe( '9,877' ); + } ); + + it( 'formats number with multipliers (default 0 decimals)', () => { + expect( + formatMetricValue( 1500, 'number', { + useMultipliers: true, + } ) + ).toBe( '2K' ); + } ); + + it( 'formats number with multipliers and specific decimals', () => { + expect( + formatMetricValue( 1500, 'number', { + useMultipliers: true, + decimals: 1, + } ) + ).toBe( '1.5K' ); + } ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/formatters/src/metric/format-metric-value.ts b/projects/packages/premium-analytics/packages/formatters/src/metric/format-metric-value.ts new file mode 100644 index 000000000000..cb1b6491a8d6 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/metric/format-metric-value.ts @@ -0,0 +1,174 @@ +/** + * External dependencies + */ +import { + formatNumber, + formatNumberCompact, + formatCurrency, + getCurrencyObject, +} from '@automattic/number-formatters'; + +/** + * Metric type that determines the formatting strategy. + * + * - `number` → `formatNumber` / `formatNumberCompact` (decimals default: 0) + * - `currency` → `formatCurrency` with symbol positioning (decimals default: 2) + * - `percentage` → `Intl` percent style, signDisplay defaults to `exceptZero` (decimals default: 2) + * - `average` → `formatNumber` (decimals default: 2), em dash for Infinity + */ +export type MetricType = 'number' | 'average' | 'currency' | 'percentage'; + +/** + * Options for `formatMetricValue`. + */ +export type FormatMetricValueOptions = { + /** + * Decimal precision. + * Defaults vary by type: 0 for number, 2 for average/currency/percentage. + */ + decimals?: number; + + /** + * Use compact notation with K/M suffixes. + * @default false + */ + useMultipliers?: boolean; + + /** + * Sign display mode. + * Percentage defaults to `'exceptZero'`; others default to `'auto'`. + */ + signDisplay?: Intl.NumberFormatOptions[ 'signDisplay' ]; + + /** + * ISO 4217 currency code (e.g. `'USD'`, `'EUR'`). + * @default 'USD' + */ + currencyCode?: string; +}; + +/** + * Format a numeric metric value based on its type, precision, and scale. + * Returns `''` for null, undefined, or NaN input. + * + * @example + * formatMetricValue( 9876.543 ) // '9,877' + * formatMetricValue( 1500, 'number', { useMultipliers: true, decimals: 1 } ) // '1.5K' + * formatMetricValue( 192088.05, 'currency' ) // '$192,088.05' + * formatMetricValue( 0.25, 'percentage' ) // '+25%' + * formatMetricValue( 0.125, 'average' ) // '0.13' + */ +export function formatMetricValue( + value: string | number | null | undefined, + type: MetricType = 'number', + { + decimals, + useMultipliers = false, + signDisplay, + currencyCode = 'USD', + }: FormatMetricValueOptions = {} +): string { + if ( value === null || value === undefined ) { + return ''; + } + + const numericValue = Number( value ); + if ( isNaN( numericValue ) ) { + return ''; + } + + switch ( type ) { + case 'currency': { + if ( useMultipliers ) { + const { symbol, symbolPosition } = getCurrencyObject( 0, currencyCode ); + + // Detect if the locale places a space between symbol + // and number (e.g. BRL "R$ 1.5K", EUR "1.5K €"). + // formatCurrency handles this internally; compact mode + // must preserve it. + // TODO(WOOA7S-1214): upstream formatCurrencyCompact() + // in @automattic/number-formatters would remove this. + const probe = formatCurrency( 0, currencyCode ); + const charIndex = + symbolPosition === 'before' + ? probe.indexOf( symbol ) + symbol.length + : probe.lastIndexOf( symbol ) - 1; + const separator = /\s/.test( probe.charAt( charIndex ) ) ? ' ' : ''; + + let sign = ''; + let absoluteValue = numericValue; + if ( numericValue < 0 ) { + sign = '-'; + absoluteValue = Math.abs( numericValue ); + } else if ( + signDisplay === 'always' || + ( signDisplay === 'exceptZero' && numericValue > 0 ) + ) { + sign = '+'; + } + + const compactFormatted = formatNumberCompact( absoluteValue, { + decimals: decimals ?? 2, + numberFormatOptions: { + maximumFractionDigits: decimals ?? 2, + }, + } ); + + return symbolPosition === 'before' + ? `${ sign }${ symbol }${ separator }${ compactFormatted }` + : `${ sign }${ compactFormatted }${ separator }${ symbol }`; + } + + const baseFormatted = formatCurrency( numericValue, currencyCode ); + + if ( + numericValue > 0 && + signDisplay && + signDisplay !== 'auto' && + ( signDisplay === 'always' || signDisplay === 'exceptZero' ) + ) { + return '+' + baseFormatted; + } + + return baseFormatted; + } + + case 'average': { + if ( ! Number.isFinite( numericValue ) ) { + return '—'; + } + + return formatNumber( numericValue, { + decimals: decimals ?? 2, + } ); + } + + case 'percentage': { + return formatNumber( numericValue, { + numberFormatOptions: { + style: 'percent', + maximumFractionDigits: decimals ?? 2, + signDisplay: signDisplay ?? 'exceptZero', + }, + } ); + } + + case 'number': + default: { + return useMultipliers + ? formatNumberCompact( numericValue, { + decimals: decimals ?? 0, + numberFormatOptions: { + maximumFractionDigits: decimals ?? 0, + signDisplay, + }, + } ) + : formatNumber( numericValue, { + decimals: decimals ?? 0, + numberFormatOptions: { + signDisplay, + }, + } ); + } + } +} diff --git a/projects/packages/premium-analytics/packages/formatters/src/metric/index.ts b/projects/packages/premium-analytics/packages/formatters/src/metric/index.ts new file mode 100644 index 000000000000..d22c90ad7a3f --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/metric/index.ts @@ -0,0 +1 @@ +export { formatMetricValue } from './format-metric-value';