From 00fed2dbff5130fba9736d0ce073c40eb57b0e0d Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 27 May 2026 15:23:32 +0800 Subject: [PATCH 01/18] feat(premium-analytics): add tsconfig paths and typecheck for internal packages --- pnpm-lock.yaml | 3 ++ projects/packages/premium-analytics/README.md | 29 +++++++++++++++---- .../changelog/add-internal-package-resolution | 4 +++ .../packages/premium-analytics/package.json | 2 ++ .../packages/premium-analytics/tsconfig.json | 9 ++++++ 5 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 projects/packages/premium-analytics/changelog/add-internal-package-resolution diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 526dabd96ca4..85e3d1751a8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3849,6 +3849,9 @@ importers: '@babel/core': specifier: 7.29.0 version: 7.29.0 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260225.1 + version: 7.0.0-dev.20260225.1 '@wordpress/build': specifier: 0.13.0 version: 0.13.0(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) diff --git a/projects/packages/premium-analytics/README.md b/projects/packages/premium-analytics/README.md index e3f361a44074..5b26b1fbcaa5 100644 --- a/projects/packages/premium-analytics/README.md +++ b/projects/packages/premium-analytics/README.md @@ -42,17 +42,19 @@ jetpack build packages/premium-analytics # via Jetpack CLI ### Adding a route 1. Create `routes//package.json`: + ```json { - "name": "-route", - "route": { - "path": "/", - "page": "jetpack-premium-analytics" - } + "name": "-route", + "route": { + "path": "/", + "page": "jetpack-premium-analytics" + } } ``` 2. Create `routes//stage.tsx` exporting `stage()`: + ```tsx export const stage = () =>
My new page
; ``` @@ -74,11 +76,28 @@ fixes the template or the minimum WordPress version is 7.0+. ### Init module (`packages/init/`) Serves two purposes: + 1. Sets the dashboard menu icon via `@wordpress/boot` store 2. Forces `@wordpress/build` to track `@wordpress/boot` as a module dependency — without an init module that imports boot, the build skips it +## Internal packages (`packages/*`) + +App-internal modules discovered by `@wordpress/build`. Types/IDE resolve +`@jetpack-premium-analytics/` imports via the `tsconfig.json` `paths` alias +(`pnpm typecheck`). + +To import one from a route/another package, the build also needs it symlinked in +`node_modules` under that specifier. Name the package +`@automattic/jetpack-premium-analytics-` (a bare `@jetpack-premium-analytics/*` +name fails the repo name lint and `_@…` is invalid to pnpm), then add a `link:` +dep on the top-level `package.json` (routes aren't workspace members): + +```jsonc +"dependencies": { "@jetpack-premium-analytics/": "link:packages/" } +``` + ## File structure ``` diff --git a/projects/packages/premium-analytics/changelog/add-internal-package-resolution b/projects/packages/premium-analytics/changelog/add-internal-package-resolution new file mode 100644 index 000000000000..d35865145ec1 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-internal-package-resolution @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add a tsconfig paths alias and typecheck script so internal packages/* resolve for types/IDE, and document how to wire cross-package imports for the build. diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index ada5924f05de..e30e5e144b49 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "wp-build && mkdir -p build/modules/boot && cp shims/boot-asset.php build/modules/boot/index.min.asset.php", "build-production": "NODE_ENV=production wp-build && mkdir -p build/modules/boot && cp shims/boot-asset.php build/modules/boot/index.min.asset.php", + "typecheck": "tsgo --noEmit", "watch": "wp-build --watch" }, "wpPlugin": { @@ -38,6 +39,7 @@ }, "devDependencies": { "@babel/core": "7.29.0", + "@typescript/native-preview": "7.0.0-dev.20260225.1", "@wordpress/build": "0.13.0", "browserslist": "4.28.2" } diff --git a/projects/packages/premium-analytics/tsconfig.json b/projects/packages/premium-analytics/tsconfig.json index 1e36ba0293d3..f018f27578e3 100644 --- a/projects/packages/premium-analytics/tsconfig.json +++ b/projects/packages/premium-analytics/tsconfig.json @@ -1,4 +1,13 @@ { "extends": "jetpack-js-tools/tsconfig.base.json", + "compilerOptions": { + // Resolve cross-package imports between internal `packages/*` modules + // (`@jetpack-premium-analytics/`) to their TypeScript source for + // type-checking + IDE. The build resolves the same specifier separately (see + // README → "Internal packages"); this keeps tsc/esbuild and `tsgo` in sync. + "paths": { + "@jetpack-premium-analytics/*": [ "./packages/*/src" ] + } + }, "include": [ "routes/**/*", "packages/**/*" ] } From 20e62414b3a35c1ef8cfb65f271e5bebf95d8134 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 27 May 2026 16:16:30 +0800 Subject: [PATCH 02/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- projects/packages/premium-analytics/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/projects/packages/premium-analytics/README.md b/projects/packages/premium-analytics/README.md index 5b26b1fbcaa5..5abfb8f0e512 100644 --- a/projects/packages/premium-analytics/README.md +++ b/projects/packages/premium-analytics/README.md @@ -45,11 +45,11 @@ jetpack build packages/premium-analytics # via Jetpack CLI ```json { - "name": "-route", - "route": { - "path": "/", - "page": "jetpack-premium-analytics" - } + "name": "-route", + "route": { + "path": "/", + "page": "jetpack-premium-analytics" + } } ``` From 662c887ff7538d3ea7aa57a8026f301a11d2ae0e Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 27 May 2026 16:16:40 +0800 Subject: [PATCH 03/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- projects/packages/premium-analytics/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/projects/packages/premium-analytics/README.md b/projects/packages/premium-analytics/README.md index 5abfb8f0e512..2785620dabee 100644 --- a/projects/packages/premium-analytics/README.md +++ b/projects/packages/premium-analytics/README.md @@ -92,7 +92,8 @@ To import one from a route/another package, the build also needs it symlinked in `node_modules` under that specifier. Name the package `@automattic/jetpack-premium-analytics-` (a bare `@jetpack-premium-analytics/*` name fails the repo name lint and `_@…` is invalid to pnpm), then add a `link:` -dep on the top-level `package.json` (routes aren't workspace members): +dep in this package's `projects/packages/premium-analytics/package.json` +(not the repo root `package.json`; routes aren't workspace members): ```jsonc "dependencies": { "@jetpack-premium-analytics/": "link:packages/" } From d4da9cad4304fe48243f8c8250e76a72f483e7de Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 28 May 2026 14:07:06 +0800 Subject: [PATCH 04/18] docs(premium-analytics): clarify internal-package naming and rename init Address PR review feedback on the "Internal packages" section: - Lead with scope intent: internal-only, never published, in-tree symlink-only resolution (answers the npm-squatting concern) - Explicitly explain the structural dual naming between the package name field and the wp-build-derived import specifier - Rename packages/init from `_@jetpack-premium-analytics/init` to `@automattic/jetpack-premium-analytics-init` so the codebase matches the documented pattern (the old placeholder is invalid to pnpm) --- projects/packages/premium-analytics/README.md | 40 ++++++++++++------- .../packages/init/package.json | 2 +- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/projects/packages/premium-analytics/README.md b/projects/packages/premium-analytics/README.md index 2785620dabee..bf7ad81a5191 100644 --- a/projects/packages/premium-analytics/README.md +++ b/projects/packages/premium-analytics/README.md @@ -45,11 +45,11 @@ jetpack build packages/premium-analytics # via Jetpack CLI ```json { - "name": "-route", - "route": { - "path": "/", - "page": "jetpack-premium-analytics" - } + "name": "-route", + "route": { + "path": "/", + "page": "jetpack-premium-analytics" + } } ``` @@ -84,16 +84,26 @@ Serves two purposes: ## Internal packages (`packages/*`) -App-internal modules discovered by `@wordpress/build`. Types/IDE resolve -`@jetpack-premium-analytics/` imports via the `tsconfig.json` `paths` alias -(`pnpm typecheck`). - -To import one from a route/another package, the build also needs it symlinked in -`node_modules` under that specifier. Name the package -`@automattic/jetpack-premium-analytics-` (a bare `@jetpack-premium-analytics/*` -name fails the repo name lint and `_@…` is invalid to pnpm), then add a `link:` -dep in this package's `projects/packages/premium-analytics/package.json` -(not the repo root `package.json`; routes aren't workspace members): +App-internal modules used only by this package — never published to npm, never +shared across the monorepo. Resolution is entirely in-tree (the local symlink); +the `@jetpack-premium-analytics/*` scope is never looked up against any registry. + +**The dual naming is structural.** `@wordpress/build` derives the import +specifier as `@/`, so the specifier here is +always `@jetpack-premium-analytics/`. The package's own `name` field has +to be different (`@automattic/jetpack-premium-analytics-`) because pnpm +rejects the `_@…` escape and the repo name lint (`lint-project-structure.sh`) +rejects the bare `@jetpack-premium-analytics/*` scope. They don't need to +match: pnpm symlinks under the **dep key**, so the import resolves regardless +of the linked package's `name`. + +Types/IDE: the `tsconfig.json` `paths` alias maps the specifier to +`./packages//src` (covered by `pnpm typecheck`). + +Build: to import one from a route or another package, add a `link:` dep on +**this package's `package.json`** (`projects/packages/premium-analytics/package.json` — +routes aren't workspace members, so the dep belongs here, not in the route's +`package.json`): ```jsonc "dependencies": { "@jetpack-premium-analytics/": "link:packages/" } diff --git a/projects/packages/premium-analytics/packages/init/package.json b/projects/packages/premium-analytics/packages/init/package.json index 6ee2ce1e4470..70de0aea9d5b 100644 --- a/projects/packages/premium-analytics/packages/init/package.json +++ b/projects/packages/premium-analytics/packages/init/package.json @@ -1,6 +1,6 @@ { "private": true, - "name": "_@jetpack-premium-analytics/init", + "name": "@automattic/jetpack-premium-analytics-init", "version": "0.1.0", "type": "module", "wpScript": true, From c2b0faefb854433349d3a44038b04bc845ea4e6a Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 28 May 2026 15:31:31 +0800 Subject: [PATCH 05/18] chore(premium-analytics): copy formatters package from next-woocommerce-analytics --- .../packages/formatters/README.md | 79 ++++ .../packages/formatters/package.json | 11 + .../date/__tests__/format-date-range.test.ts | 129 ++++++ .../src/date/__tests__/format-date.test.ts | 156 +++++++ .../formatters/src/date/format-date-range.ts | 58 +++ .../formatters/src/date/format-date.ts | 70 ++++ .../packages/formatters/src/date/index.ts | 2 + .../packages/formatters/src/index.ts | 2 + .../__tests__/format-metric-value.test.ts | 387 ++++++++++++++++++ .../src/metric/format-metric-value.ts | 184 +++++++++ .../packages/formatters/src/metric/index.ts | 1 + .../packages/formatters/tsconfig.json | 16 + 12 files changed, 1095 insertions(+) create mode 100644 projects/packages/premium-analytics/packages/formatters/README.md create mode 100644 projects/packages/premium-analytics/packages/formatters/package.json create mode 100644 projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date-range.test.ts create mode 100644 projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date.test.ts create mode 100644 projects/packages/premium-analytics/packages/formatters/src/date/format-date-range.ts create mode 100644 projects/packages/premium-analytics/packages/formatters/src/date/format-date.ts create mode 100644 projects/packages/premium-analytics/packages/formatters/src/date/index.ts create mode 100644 projects/packages/premium-analytics/packages/formatters/src/index.ts create mode 100644 projects/packages/premium-analytics/packages/formatters/src/metric/__tests__/format-metric-value.test.ts create mode 100644 projects/packages/premium-analytics/packages/formatters/src/metric/format-metric-value.ts create mode 100644 projects/packages/premium-analytics/packages/formatters/src/metric/index.ts create mode 100644 projects/packages/premium-analytics/packages/formatters/tsconfig.json 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..0814dc90aaa1 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/README.md @@ -0,0 +1,79 @@ +# @wc-analytics/formatters + +Locale-aware formatting utilities for WooCommerce Analytics. + +Thin wrapper over `@automattic/number-formatters` (numbers, currency) and `date-fns` (dates), plus a domain-specific orchestrator for analytics metric types. + +## Exports + +```typescript +import { + formatMetricValue, + formatDate, + formatDateRange, +} from '@wc-analytics/formatters'; +``` + +## `formatMetricValue( value, type?, options? )` + +Format a numeric value based on its metric type. +Returns `''` for null, undefined, or NaN. + +```typescript +formatMetricValue( 9876 ); // '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` (tier 2, published from Jetpack repo). Date formatting uses `date-fns`. The `formatMetricValue` orchestrator is domain-specific — it routes to the right formatter based on metric type. + +See [WOOA7S-1192](https://linear.app/a8c/issue/WOOA7S-1192) for the upstream audit and migration rationale. 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..8af204883796 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/package.json @@ -0,0 +1,11 @@ +{ + "name": "@wc-analytics/formatters", + "description": "Formatting utilities for WooCommerce Analytics", + "version": "1.0.0", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@automattic/number-formatters": "*", + "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..c706d8e18009 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date-range.test.ts @@ -0,0 +1,129 @@ +/** + * 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..66db81b57dad --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/__tests__/format-date.test.ts @@ -0,0 +1,156 @@ +/** + * 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..829b60952382 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/format-date-range.ts @@ -0,0 +1,58 @@ +/** + * Internal dependencies + */ +import { formatDate } from './format-date'; + +/** + * A date range with optional start and end. + * + * Mirrors `DateRange` from `@next-woo-analytics/datetime`. + * Defined locally to avoid a cross-namespace dependency. + * When `datetime` moves to root (`@wc-analytics/datetime`), + * import from there instead. + */ +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..1e8546b7a596 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/date/format-date.ts @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import { format } from 'date-fns'; + +/** + * Named date format presets for common use cases. + * Follows US date format standards as per CIAB guidelines. + * + * Link: https://ciabp2.wordpress.com/2025/10/16/ciab-date-and-time-formats/ + * + * |-------------|------------------------------|---------------------------| + * | 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..2c651583652e --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/metric/__tests__/format-metric-value.test.ts @@ -0,0 +1,387 @@ +/** + * 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', + code = 'USD', + 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..17d189a8049a --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/src/metric/format-metric-value.ts @@ -0,0 +1,184 @@ +/** + * 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 ) // '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'; diff --git a/projects/packages/premium-analytics/packages/formatters/tsconfig.json b/projects/packages/premium-analytics/packages/formatters/tsconfig.json new file mode 100644 index 000000000000..932732d7e5b4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/formatters/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "build", + "rootDir": "src", + "types": [ "@types/jest" ] + }, + "include": [ "src/**/*" ], + "exclude": [ "build", "node_modules" ] +} \ No newline at end of file From 5c2cd81ccdb083924b35be2fea928137791556e0 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 28 May 2026 15:32:10 +0800 Subject: [PATCH 06/18] chore(premium-analytics): adapt formatters package.json for internal-package convention --- .../packages/formatters/package.json | 8 +++++--- .../packages/formatters/tsconfig.json | 16 ---------------- 2 files changed, 5 insertions(+), 19 deletions(-) delete mode 100644 projects/packages/premium-analytics/packages/formatters/tsconfig.json diff --git a/projects/packages/premium-analytics/packages/formatters/package.json b/projects/packages/premium-analytics/packages/formatters/package.json index 8af204883796..cf7420accce5 100644 --- a/projects/packages/premium-analytics/packages/formatters/package.json +++ b/projects/packages/premium-analytics/packages/formatters/package.json @@ -1,9 +1,11 @@ { - "name": "@wc-analytics/formatters", - "description": "Formatting utilities for WooCommerce Analytics", - "version": "1.0.0", + "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": "*", "date-fns": "^4.1.0" diff --git a/projects/packages/premium-analytics/packages/formatters/tsconfig.json b/projects/packages/premium-analytics/packages/formatters/tsconfig.json deleted file mode 100644 index 932732d7e5b4..000000000000 --- a/projects/packages/premium-analytics/packages/formatters/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "outDir": "build", - "rootDir": "src", - "types": [ "@types/jest" ] - }, - "include": [ "src/**/*" ], - "exclude": [ "build", "node_modules" ] -} \ No newline at end of file From a0a3a417bad61d4399affef9378aa0c2325feda6 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 28 May 2026 15:32:19 +0800 Subject: [PATCH 07/18] chore(premium-analytics): pin formatters deps for Jetpack monorepo --- .../premium-analytics/packages/formatters/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/packages/premium-analytics/packages/formatters/package.json b/projects/packages/premium-analytics/packages/formatters/package.json index cf7420accce5..c9d7db16093a 100644 --- a/projects/packages/premium-analytics/packages/formatters/package.json +++ b/projects/packages/premium-analytics/packages/formatters/package.json @@ -7,7 +7,7 @@ "types": "src/index.ts", "sideEffects": false, "dependencies": { - "@automattic/number-formatters": "*", - "date-fns": "^4.1.0" + "@automattic/number-formatters": "workspace:*", + "date-fns": "4.1.0" } } From 27b23d2d0ca0437758eec70a205798d22a3cd11e Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 28 May 2026 15:33:23 +0800 Subject: [PATCH 08/18] docs(premium-analytics): adapt formatters README and code comments for monorepo --- .../packages/formatters/README.md | 72 +++++++++++-------- .../formatters/src/date/format-date-range.ts | 8 +-- .../formatters/src/date/format-date.ts | 6 +- 3 files changed, 47 insertions(+), 39 deletions(-) diff --git a/projects/packages/premium-analytics/packages/formatters/README.md b/projects/packages/premium-analytics/packages/formatters/README.md index 0814dc90aaa1..138ed56b8185 100644 --- a/projects/packages/premium-analytics/packages/formatters/README.md +++ b/projects/packages/premium-analytics/packages/formatters/README.md @@ -1,17 +1,19 @@ -# @wc-analytics/formatters +# @jetpack-premium-analytics/formatters -Locale-aware formatting utilities for WooCommerce Analytics. +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 for analytics metric types. +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 '@wc-analytics/formatters'; + formatMetricValue, + formatDate, + formatDateRange, +} from '@jetpack-premium-analytics/formatters'; ``` ## `formatMetricValue( value, type?, options? )` @@ -20,26 +22,28 @@ Format a numeric value based on its metric type. Returns `''` for null, undefined, or NaN. ```typescript -formatMetricValue( 9876 ); // '9,877' +formatMetricValue( 9876 ); // '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' + 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€' + 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 | +| 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? )` @@ -47,9 +51,9 @@ 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' ) ); // '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' ``` @@ -68,12 +72,18 @@ formatDateRange( { from, to } ); // cross-year: 'Jun 21, 2024-Jul 25, 2025' ``` -| Parameter | Type | Description | -|-----------|------|-------------| -| `range` | `{ from?: Date; to?: Date }` | Date range object | +| Parameter | Type | Description | +| --------- | ---------------------------- | ----------------- | +| `range` | `{ from?: Date; to?: Date }` | Date range object | ## Architecture -Number and currency formatting delegates to `@automattic/number-formatters` (tier 2, published from Jetpack repo). Date formatting uses `date-fns`. The `formatMetricValue` orchestrator is domain-specific — it routes to the right formatter based on metric type. +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. -See [WOOA7S-1192](https://linear.app/a8c/issue/WOOA7S-1192) for the upstream audit and migration rationale. +## Dependencies + +- `@automattic/number-formatters` — number/currency primitives +- `date-fns` — date formatting 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 829b60952382..b5e87d473e98 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 @@ -6,10 +6,10 @@ import { formatDate } from './format-date'; /** * A date range with optional start and end. * - * Mirrors `DateRange` from `@next-woo-analytics/datetime`. - * Defined locally to avoid a cross-namespace dependency. - * When `datetime` moves to root (`@wc-analytics/datetime`), - * import from there instead. + * 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 }; 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 1e8546b7a596..9f9ab9807850 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 @@ -4,10 +4,8 @@ import { format } from 'date-fns'; /** - * Named date format presets for common use cases. - * Follows US date format standards as per CIAB guidelines. - * - * Link: https://ciabp2.wordpress.com/2025/10/16/ciab-date-and-time-formats/ + * Named date format presets for common use cases. Follows US date format + * standards. * * |-------------|------------------------------|---------------------------| * | Format | Output | Use Case | From 390d7d80e95abcd43d1c2897146b7c11dcdff06f Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 28 May 2026 15:38:10 +0800 Subject: [PATCH 09/18] chore(premium-analytics): wire formatters deps into parent and relax JSDoc rules --- pnpm-lock.yaml | 6 ++++++ .../premium-analytics/eslint.config.mjs | 19 +++++++++++++++++++ .../packages/premium-analytics/package.json | 2 ++ 3 files changed, 27 insertions(+) create mode 100644 projects/packages/premium-analytics/eslint.config.mjs diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85e3d1751a8d..1d630346c378 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3824,6 +3824,9 @@ importers: projects/packages/premium-analytics: dependencies: + '@automattic/number-formatters': + specifier: workspace:* + version: link:../../js-packages/number-formatters '@wordpress/boot': specifier: 0.13.0 version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3839,6 +3842,9 @@ importers: '@wordpress/route': specifier: 0.12.0 version: 0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + date-fns: + specifier: 4.1.0 + version: 4.1.0 react: specifier: 18.3.1 version: 18.3.1 diff --git a/projects/packages/premium-analytics/eslint.config.mjs b/projects/packages/premium-analytics/eslint.config.mjs new file mode 100644 index 000000000000..8ec452409b79 --- /dev/null +++ b/projects/packages/premium-analytics/eslint.config.mjs @@ -0,0 +1,19 @@ +import { makeBaseConfig, defineConfig } from 'jetpack-js-tools/eslintrc/base.mjs'; + +/** + * Soften JSDoc rules for `packages/formatters/**` so the initial port 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 this override (at which point this whole file can go + * away). + */ +export default defineConfig( makeBaseConfig( import.meta.url ), { + 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 e30e5e144b49..5e1eabacf8c9 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -29,11 +29,13 @@ } }, "dependencies": { + "@automattic/number-formatters": "workspace:*", "@wordpress/boot": "0.13.0", "@wordpress/data": "10.46.0", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^13.0.0", "@wordpress/route": "0.12.0", + "date-fns": "4.1.0", "react": "18.3.1", "react-dom": "18.3.1" }, From ae03177f758da2645be182151a8bf1cb62c1a202 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 28 May 2026 15:44:39 +0800 Subject: [PATCH 10/18] chore(premium-analytics): format formatters and clean up lint issues --- .../date/__tests__/format-date-range.test.ts | 92 +++++++------- .../src/date/__tests__/format-date.test.ts | 6 +- .../formatters/src/date/format-date-range.ts | 5 +- .../formatters/src/date/format-date.ts | 5 +- .../__tests__/format-metric-value.test.ts | 114 ++++++------------ .../src/metric/format-metric-value.ts | 16 +-- 6 files changed, 87 insertions(+), 151 deletions(-) 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 c706d8e18009..c8f2d672f656 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 @@ -11,54 +11,52 @@ 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 }`; + ( 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( () => { 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 66db81b57dad..8a9a98d53b5a 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 @@ -2,7 +2,6 @@ * External dependencies */ import { format } from 'date-fns'; - /** * Internal dependencies */ @@ -98,10 +97,7 @@ describe( 'formatDate', () => { it( 'formats date with "monthYear" preset', () => { const result = formatDate( testDate, 'monthYear' ); expect( result ).toBe( 'Jun 2025' ); - expect( format ).toHaveBeenCalledWith( - testDate, - FORMATS.monthYear - ); + expect( format ).toHaveBeenCalledWith( testDate, FORMATS.monthYear ); } ); it( 'formats date with "numeric" preset', () => { 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 b5e87d473e98..dcc6a86e1609 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 @@ -44,10 +44,7 @@ export const formatDateRange = ( range?: DateRange ): string => { } if ( sameMonth ) { - return `${ formatDate( from, 'short' ) }-${ formatDate( - to, - 'd, yyyy' - ) }`; + return `${ formatDate( from, 'short' ) }-${ formatDate( to, 'd, yyyy' ) }`; } if ( sameYear ) { 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 9f9ab9807850..9aa7dedc2cb4 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 @@ -56,10 +56,7 @@ type DateType = Parameters< typeof format >[ 0 ]; * 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 => { +export const formatDate = ( date: DateType, formatString: DateFormatString = 'medium' ): string => { const formatPattern = Object.hasOwn( DATE_FORMATS, formatString ) ? DATE_FORMATS[ formatString as DateFormatName ] : formatString; 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 index 2c651583652e..779939e15a79 100644 --- 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 @@ -1,11 +1,7 @@ /** * External dependencies */ -import { - formatCurrency, - getCurrencyObject, -} from '@automattic/number-formatters'; - +import { formatCurrency, getCurrencyObject } from '@automattic/number-formatters'; /** * Internal dependencies */ @@ -27,7 +23,6 @@ describe( 'formatMetricValue', () => { const setupCurrency = ( { symbol = '$', position = 'before', - code = 'USD', hasSpace = false, }: { symbol?: string; @@ -46,19 +41,17 @@ describe( 'formatMetricValue', () => { 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 ? '-' : ''; + ( 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 }`; - } - ); + return position === 'before' + ? `${ sign }${ symbol }${ sp }${ formatted }` + : `${ sign }${ formatted }${ sp }${ symbol }`; + } ); }; beforeEach( () => { @@ -72,15 +65,11 @@ describe( 'formatMetricValue', () => { } ); it( 'returns empty string for null', () => { - expect( formatMetricValue( null as unknown as number ) ).toBe( - '' - ); + expect( formatMetricValue( null as unknown as number ) ).toBe( '' ); } ); it( 'returns empty string for undefined', () => { - expect( - formatMetricValue( undefined as unknown as number ) - ).toBe( '' ); + expect( formatMetricValue( undefined as unknown as number ) ).toBe( '' ); } ); it( 'coerces empty string to zero', () => { @@ -92,9 +81,7 @@ describe( 'formatMetricValue', () => { } ); it( 'accepts numeric strings with decimals', () => { - expect( formatMetricValue( '99.99', 'average' ) ).toBe( - '99.99' - ); + expect( formatMetricValue( '99.99', 'average' ) ).toBe( '99.99' ); } ); it( 'formats zero', () => { @@ -114,16 +101,11 @@ describe( 'formatMetricValue', () => { it( 'delegates to formatCurrency', () => { formatMetricValue( 192088.05, 'currency' ); - expect( formatCurrency ).toHaveBeenCalledWith( - 192088.05, - 'USD' - ); + expect( formatCurrency ).toHaveBeenCalledWith( 192088.05, 'USD' ); } ); it( 'formats currency without multipliers', () => { - expect( formatMetricValue( 192088.05, 'currency' ) ).toBe( - '$192,088.05' - ); + expect( formatMetricValue( 192088.05, 'currency' ) ).toBe( '$192,088.05' ); } ); it( 'passes currencyCode to formatCurrency', () => { @@ -143,46 +125,30 @@ describe( 'formatMetricValue', () => { } ); it( 'formats currency with multipliers and signDisplay', () => { - const negativeResult = formatMetricValue( - -192088.05, - 'currency', - { - useMultipliers: true, - signDisplay: 'always', - decimals: 2, - } - ); + 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, - } - ); + 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', - } - ); + const negativeResult = formatMetricValue( -192088.05, 'currency', { + signDisplay: 'always', + } ); expect( negativeResult ).toBe( '-$192,088.05' ); - const positiveResult = formatMetricValue( - 192088.05, - 'currency', - { - signDisplay: 'always', - } - ); + const positiveResult = formatMetricValue( 192088.05, 'currency', { + signDisplay: 'always', + } ); expect( positiveResult ).toBe( '+$192,088.05' ); } ); } ); @@ -308,15 +274,11 @@ describe( 'formatMetricValue', () => { } ); it( 'respects decimals option', () => { - expect( - formatMetricValue( 0.12345, 'percentage', { decimals: 1 } ) - ).toBe( '+12.3%' ); + expect( formatMetricValue( 0.12345, 'percentage', { decimals: 1 } ) ).toBe( '+12.3%' ); } ); it( 'formats negative percentage', () => { - expect( formatMetricValue( -0.25, 'percentage' ) ).toBe( - '-25%' - ); + expect( formatMetricValue( -0.25, 'percentage' ) ).toBe( '-25%' ); } ); it( 'allows disabling the sign display', () => { @@ -350,9 +312,7 @@ describe( 'formatMetricValue', () => { } ); it( 'respects custom decimals', () => { - expect( - formatMetricValue( 3.14159, 'average', { decimals: 4 } ) - ).toBe( '3.1416' ); + expect( formatMetricValue( 3.14159, 'average', { decimals: 4 } ) ).toBe( '3.1416' ); } ); it( 'formats zero with default 2 decimals', () => { @@ -362,9 +322,7 @@ describe( 'formatMetricValue', () => { describe( 'type: number', () => { it( 'formats number without multipliers', () => { - expect( formatMetricValue( 9876.543, 'number' ) ).toBe( - '9,877' - ); + expect( formatMetricValue( 9876.543, 'number' ) ).toBe( '9,877' ); } ); it( 'formats number with multipliers (default 0 decimals)', () => { 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 index 17d189a8049a..52da85d5b449 100644 --- 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 @@ -80,10 +80,7 @@ export function formatMetricValue( switch ( type ) { case 'currency': { if ( useMultipliers ) { - const { symbol, symbolPosition } = getCurrencyObject( - 0, - currencyCode - ); + 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 €"). @@ -96,11 +93,7 @@ export function formatMetricValue( symbolPosition === 'before' ? probe.indexOf( symbol ) + symbol.length : probe.lastIndexOf( symbol ) - 1; - const separator = /\s/.test( - probe.charAt( charIndex ) - ) - ? ' ' - : ''; + const separator = /\s/.test( probe.charAt( charIndex ) ) ? ' ' : ''; let sign = ''; let absoluteValue = numericValue; @@ -126,10 +119,7 @@ export function formatMetricValue( : `${ sign }${ compactFormatted }${ separator }${ symbol }`; } - const baseFormatted = formatCurrency( - numericValue, - currencyCode - ); + const baseFormatted = formatCurrency( numericValue, currencyCode ); if ( numericValue > 0 && From 216f6e2b179aeebf69b079e48701cbbee815b153 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 28 May 2026 15:44:52 +0800 Subject: [PATCH 11/18] changelog(premium-analytics): add entry for WOOA7S-1313 formatters port --- .../wooa7s-1313-integrate-formatters-package-into-analytics | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 projects/packages/premium-analytics/changelog/wooa7s-1313-integrate-formatters-package-into-analytics 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. From d8ad326f731ccc85085d40db27799afecbd884d7 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Thu, 28 May 2026 15:46:50 +0800 Subject: [PATCH 12/18] chore(premium-analytics): add @types/jest for formatters test typecheck --- pnpm-lock.yaml | 3 +++ projects/packages/premium-analytics/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d630346c378..c1a206c9fd75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3855,6 +3855,9 @@ importers: '@babel/core': specifier: 7.29.0 version: 7.29.0 + '@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/package.json b/projects/packages/premium-analytics/package.json index 5e1eabacf8c9..74be1aeb56db 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -41,6 +41,7 @@ }, "devDependencies": { "@babel/core": "7.29.0", + "@types/jest": "30.0.0", "@typescript/native-preview": "7.0.0-dev.20260225.1", "@wordpress/build": "0.13.0", "browserslist": "4.28.2" From feeb212fd34ef003d5e873ad87c6b33ec013f733 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 3 Jun 2026 10:15:02 +0800 Subject: [PATCH 13/18] docs(premium-analytics): revert internal-packages README section Defer the internal-packages naming docs until the upstream wp-build identity change (gutenberg#78822 / #48089) lands; restore README to trunk. --- projects/packages/premium-analytics/README.md | 40 +++---------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/projects/packages/premium-analytics/README.md b/projects/packages/premium-analytics/README.md index bf7ad81a5191..e3f361a44074 100644 --- a/projects/packages/premium-analytics/README.md +++ b/projects/packages/premium-analytics/README.md @@ -42,19 +42,17 @@ jetpack build packages/premium-analytics # via Jetpack CLI ### Adding a route 1. Create `routes//package.json`: - ```json { - "name": "-route", - "route": { - "path": "/", - "page": "jetpack-premium-analytics" - } + "name": "-route", + "route": { + "path": "/", + "page": "jetpack-premium-analytics" + } } ``` 2. Create `routes//stage.tsx` exporting `stage()`: - ```tsx export const stage = () =>
My new page
; ``` @@ -76,39 +74,11 @@ fixes the template or the minimum WordPress version is 7.0+. ### Init module (`packages/init/`) Serves two purposes: - 1. Sets the dashboard menu icon via `@wordpress/boot` store 2. Forces `@wordpress/build` to track `@wordpress/boot` as a module dependency — without an init module that imports boot, the build skips it -## Internal packages (`packages/*`) - -App-internal modules used only by this package — never published to npm, never -shared across the monorepo. Resolution is entirely in-tree (the local symlink); -the `@jetpack-premium-analytics/*` scope is never looked up against any registry. - -**The dual naming is structural.** `@wordpress/build` derives the import -specifier as `@/`, so the specifier here is -always `@jetpack-premium-analytics/`. The package's own `name` field has -to be different (`@automattic/jetpack-premium-analytics-`) because pnpm -rejects the `_@…` escape and the repo name lint (`lint-project-structure.sh`) -rejects the bare `@jetpack-premium-analytics/*` scope. They don't need to -match: pnpm symlinks under the **dep key**, so the import resolves regardless -of the linked package's `name`. - -Types/IDE: the `tsconfig.json` `paths` alias maps the specifier to -`./packages//src` (covered by `pnpm typecheck`). - -Build: to import one from a route or another package, add a `link:` dep on -**this package's `package.json`** (`projects/packages/premium-analytics/package.json` — -routes aren't workspace members, so the dep belongs here, not in the route's -`package.json`): - -```jsonc -"dependencies": { "@jetpack-premium-analytics/": "link:packages/" } -``` - ## File structure ``` From 2beeaa9a2c6ac17b2d050597d09229f1254bd49e Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 3 Jun 2026 10:35:23 +0800 Subject: [PATCH 14/18] fix(premium-analytics): align route name with internal-package convention Rename routes/dashboard to @automattic/jetpack-premium-analytics-dashboard-route to match the packages/* naming (per review), and drop the stale changelog line about README build docs that were reverted. Build output is unchanged (routes key off the directory name). --- .../premium-analytics/changelog/add-internal-package-resolution | 2 +- .../packages/premium-analytics/routes/dashboard/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/packages/premium-analytics/changelog/add-internal-package-resolution b/projects/packages/premium-analytics/changelog/add-internal-package-resolution index d35865145ec1..f4343aa091ea 100644 --- a/projects/packages/premium-analytics/changelog/add-internal-package-resolution +++ b/projects/packages/premium-analytics/changelog/add-internal-package-resolution @@ -1,4 +1,4 @@ Significance: patch Type: added -Add a tsconfig paths alias and typecheck script so internal packages/* resolve for types/IDE, and document how to wire cross-package imports for the build. +Add a tsconfig paths alias and typecheck script so internal packages/* resolve for types/IDE. diff --git a/projects/packages/premium-analytics/routes/dashboard/package.json b/projects/packages/premium-analytics/routes/dashboard/package.json index e71390452278..b0139ff6987b 100644 --- a/projects/packages/premium-analytics/routes/dashboard/package.json +++ b/projects/packages/premium-analytics/routes/dashboard/package.json @@ -1,6 +1,6 @@ { "private": true, - "name": "_@jetpack-premium-analytics/dashboard-route", + "name": "@automattic/jetpack-premium-analytics-dashboard-route", "route": { "path": "/", "page": "jetpack-premium-analytics" From b33e257db5f797f6cfbeaf08f3463635689ac825 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 3 Jun 2026 11:23:39 +0800 Subject: [PATCH 15/18] docs(premium-analytics): use canonical package name in formatters README --- .../packages/premium-analytics/packages/formatters/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/packages/premium-analytics/packages/formatters/README.md b/projects/packages/premium-analytics/packages/formatters/README.md index 138ed56b8185..b83563b4c248 100644 --- a/projects/packages/premium-analytics/packages/formatters/README.md +++ b/projects/packages/premium-analytics/packages/formatters/README.md @@ -1,4 +1,4 @@ -# @jetpack-premium-analytics/formatters +# @automattic/jetpack-premium-analytics-formatters Locale-aware formatting utilities for Jetpack Premium Analytics. @@ -13,7 +13,7 @@ import { formatMetricValue, formatDate, formatDateRange, -} from '@jetpack-premium-analytics/formatters'; +} from '@automattic/jetpack-premium-analytics-formatters'; ``` ## `formatMetricValue( value, type?, options? )` From 58e4756941ca9ff0c3b2e36e42302d1869482b87 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 3 Jun 2026 15:11:16 +0800 Subject: [PATCH 16/18] Update projects/packages/premium-analytics/packages/formatters/README.md Co-authored-by: Dognose --- .../packages/premium-analytics/packages/formatters/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/premium-analytics/packages/formatters/README.md b/projects/packages/premium-analytics/packages/formatters/README.md index b83563b4c248..eb0fabd3335f 100644 --- a/projects/packages/premium-analytics/packages/formatters/README.md +++ b/projects/packages/premium-analytics/packages/formatters/README.md @@ -13,7 +13,7 @@ import { formatMetricValue, formatDate, formatDateRange, -} from '@automattic/jetpack-premium-analytics-formatters'; +} from '@jetpack-premium-analytics/formatters'; ``` ## `formatMetricValue( value, type?, options? )` From b3cbdebd42fb16d387a1cd81422c99c0c3da1a91 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Wed, 3 Jun 2026 15:11:32 +0800 Subject: [PATCH 17/18] Update projects/packages/premium-analytics/packages/formatters/README.md Co-authored-by: Dognose --- .../packages/premium-analytics/packages/formatters/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/premium-analytics/packages/formatters/README.md b/projects/packages/premium-analytics/packages/formatters/README.md index eb0fabd3335f..9cabeb327e73 100644 --- a/projects/packages/premium-analytics/packages/formatters/README.md +++ b/projects/packages/premium-analytics/packages/formatters/README.md @@ -22,7 +22,7 @@ Format a numeric value based on its metric type. Returns `''` for null, undefined, or NaN. ```typescript -formatMetricValue( 9876 ); // '9,877' +formatMetricValue( 9876.543 ); // '9,877' formatMetricValue( 1500, 'number', { useMultipliers: true, decimals: 1, From c52d032d2bbc44670da022676ab9decbfc591c49 Mon Sep 17 00:00:00 2001 From: Chi-Hsuan Huang Date: Fri, 5 Jun 2026 14:08:37 +0800 Subject: [PATCH 18/18] Update projects/packages/premium-analytics/packages/formatters/src/metric/format-metric-value.ts Co-authored-by: Dognose --- .../packages/formatters/src/metric/format-metric-value.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 52da85d5b449..cb1b6491a8d6 100644 --- 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 @@ -52,7 +52,7 @@ export type FormatMetricValueOptions = { * Returns `''` for null, undefined, or NaN input. * * @example - * formatMetricValue( 9876 ) // '9,877' + * 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%'