diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54d70fd092e0..755b48c3fe13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3866,18 +3866,42 @@ importers: projects/packages/premium-analytics: dependencies: + '@automattic/charts': + specifier: workspace:* + version: link:../../js-packages/charts '@automattic/number-formatters': specifier: workspace:* version: link:../../js-packages/number-formatters + '@automattic/ui': + specifier: 1.0.2 + version: 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@date-fns/tz': specifier: 1.4.1 version: 1.4.1 + '@tanstack/react-query': + specifier: 5.90.8 + version: 5.90.8(react@18.3.1) + '@wordpress/api-fetch': + specifier: 7.46.0 + version: 7.46.0 '@wordpress/boot': specifier: 0.13.0 version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/components': + specifier: 33.1.0 + version: 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/compose': + specifier: 7.46.0 + version: 7.46.0(react@18.3.1) + '@wordpress/core-data': + specifier: 7.46.0 + version: 7.46.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/data': specifier: 10.46.0 version: 10.46.0(react@18.3.1) + '@wordpress/dataviews': + specifier: 14.3.0 + version: 14.3.0(react@18.3.1) '@wordpress/i18n': specifier: ^6.9.0 version: 6.19.0 @@ -3887,9 +3911,24 @@ importers: '@wordpress/primitives': specifier: 4.46.0 version: 4.46.0(react@18.3.1) + '@wordpress/private-apis': + specifier: 1.46.0 + version: 1.46.0 '@wordpress/route': specifier: 0.12.0 version: 0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/theme': + specifier: 0.13.0 + version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/ui': + specifier: 0.13.0 + version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/url': + specifier: 4.46.0 + version: 4.46.0 + clsx: + specifier: 2.1.1 + version: 2.1.1 date-fns: specifier: 4.1.0 version: 4.1.0 @@ -3900,30 +3939,42 @@ importers: specifier: 18.3.1 version: 18.3.1(react@18.3.1) devDependencies: + '@automattic/jetpack-webpack-config': + specifier: workspace:* + version: link:../../js-packages/webpack-config '@babel/core': specifier: 7.29.0 version: 7.29.0 '@storybook/react': specifier: 10.3.6 version: 10.3.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@10.3.6(@testing-library/dom@10.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3) + '@tanstack/react-query-devtools': + specifier: 5.90.2 + version: 5.90.2(@tanstack/react-query@5.90.8(react@18.3.1))(react@18.3.1) '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.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 + '@wordpress/base-styles': + specifier: 8.0.0 + version: 8.0.0 '@wordpress/build': specifier: 0.14.0 - version: 0.14.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) - '@wordpress/ui': - specifier: 0.13.0 - version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.14.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/private-apis@1.46.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) browserslist: specifier: 4.28.2 version: 4.28.2 + jest: + specifier: 30.4.2 + version: 30.4.2 storybook: specifier: 10.3.6 version: 10.3.6(@testing-library/dom@10.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -9989,6 +10040,15 @@ packages: '@tanstack/query-core@5.90.8': resolution: {integrity: sha512-4E0RP/0GJCxSNiRF2kAqE/LQkTJVlL/QNU7gIJSptaseV9HP6kOuA+N11y4bZKZxa3QopK3ZuewwutHx6DqDXQ==} + '@tanstack/query-devtools@5.90.1': + resolution: {integrity: sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==} + + '@tanstack/react-query-devtools@5.90.2': + resolution: {integrity: sha512-vAXJzZuBXtCQtrY3F/yUNJCV4obT/A/n81kb3+YqLbro5Z2+phdAbceO+deU3ywPw8B42oyJlp4FhO0SoivDFQ==} + peerDependencies: + '@tanstack/react-query': ^5.90.2 + react: ^18 || ^19 + '@tanstack/react-query@5.90.8': resolution: {integrity: sha512-/3b9QGzkf4rE5/miL6tyhldQRlLXzMHcySOm/2Tm2OLEFE9P1ImkH0+OviDBSvyAvtAOJocar5xhd7vxdLi3aQ==} peerDependencies: @@ -10977,6 +11037,10 @@ packages: resolution: {integrity: sha512-d4Dy9GeJ/VIORTgYKYXT026/hhpV6VOf3VUDj10f+QFoIJ86VMBrzV6KQn8KUVH4T3oH1MSpo/A5t8ttYFemsg==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/deprecated@4.48.0': + resolution: {integrity: sha512-aTa7oww6hvTjfIvxLsxlcwYj7skAGPnr1V2S0iBVQfiIn5wJPiGjM9hz4QEf6kyR44Vh0IYjW9wSxVuDMGZUdw==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/dom-ready@4.46.0': resolution: {integrity: sha512-CQ6KPaCkMzAmbxmR4E4Fu99ngyPpkP9VGaIFu0xUgx0ubkYOzcvEfEEPuyEV3n7PY2Jg/XWzBilgWCa8PmaxWw==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -11071,6 +11135,10 @@ packages: resolution: {integrity: sha512-fsKw4dmw4voIRoKc8t0XRREQlFvwj9XS/jTXvkh6mqRYCDpaEnrdB2Ji5jgbRXEMPU0GKVGMeAn5Wwi56gjBMg==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/hooks@4.48.0': + resolution: {integrity: sha512-rU1yGEy0Mb+2oRG5QX/bKIIwKQmYAvATfUQeXIF20/mbR0qutYeVTCIvWEyb4pf71tvnQFiN18RWRXWsvKrDbQ==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/html-entities@4.46.0': resolution: {integrity: sha512-YJ/V9R2p4lwYkhc9/bQrXxoX0rNDtt1WQGInKAxRWqF1w1gYQk0iWiwGcNnahnFofwK2LJSVf4/jYFjJrS/sPw==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -11327,6 +11395,10 @@ packages: resolution: {integrity: sha512-Z1CE6x732iMD+NcWziitqWUyhxVy1JlioHDtQUU2oqhDcA0d/P2ifOc/af02dDYFIuLh7umurU19LqpBX6EoWw==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/warning@3.48.0': + resolution: {integrity: sha512-En+A99j8aySNzUH0iXok0H2Xi+Uw2useKqYsvPm33VEMa0a0XIwa2I9srK5STp8RydCm1dK+/41K9e5xeFu23Q==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/widgets@4.46.0': resolution: {integrity: sha512-IwX6Vpfan3ShU/ezu9lzOcS4hGte2MyNFvc54TlXAb+nyQ9rYsvfNAmZAaVEXyJemBJJeH0tABVa8Ql3gtvUYA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -18558,7 +18630,7 @@ snapshots: '@wordpress/primitives': 4.46.0(react@18.3.1) '@wordpress/react-i18n': 4.45.0 '@wordpress/url': 4.46.0 - '@wordpress/warning': 3.46.0 + '@wordpress/warning': 3.48.0 canvas-confetti: 1.9.4 clsx: 2.1.1 colord: 2.9.3 @@ -18597,7 +18669,7 @@ snapshots: '@wordpress/api-fetch': 7.46.0 '@wordpress/data': 10.46.0(react@18.3.1) '@wordpress/data-controls': 4.46.0(react@18.3.1) - '@wordpress/deprecated': 4.46.0 + '@wordpress/deprecated': 4.48.0 '@wordpress/element': 6.46.0 '@wordpress/i18n': 6.19.0 '@wordpress/primitives': 4.46.0(react@18.3.1) @@ -22444,6 +22516,14 @@ snapshots: '@tanstack/query-core@5.90.8': {} + '@tanstack/query-devtools@5.90.1': {} + + '@tanstack/react-query-devtools@5.90.2(@tanstack/react-query@5.90.8(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/query-devtools': 5.90.1 + '@tanstack/react-query': 5.90.8(react@18.3.1) + react: 18.3.1 + '@tanstack/react-query@5.90.8(react@18.3.1)': dependencies: '@tanstack/query-core': 5.90.8 @@ -23575,7 +23655,7 @@ snapshots: '@babel/preset-env': 7.29.2(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) '@wordpress/browserslist-config': 6.46.0 - '@wordpress/warning': 3.46.0 + '@wordpress/warning': 3.48.0 browserslist: 4.28.2 core-js: 3.38.1 react: 18.3.1 @@ -24109,32 +24189,6 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.14.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)': - dependencies: - '@emotion/babel-plugin': 11.13.5 - '@wordpress/style-runtime': 0.2.0 - autoprefixer: 10.5.0(postcss@8.5.14) - browserslist-to-esbuild: 2.1.1(browserslist@4.28.2) - change-case: 4.1.2 - chokidar: 4.0.3 - cssnano: 7.1.9(postcss@8.5.14) - esbuild: 0.27.4 - esbuild-plugin-babel: 0.2.3(@babel/core@7.29.0) - esbuild-sass-plugin: 3.3.1(esbuild@0.27.4)(sass-embedded@1.97.3) - fast-glob: 3.3.3 - moment-timezone: 0.5.48 - postcss: 8.5.14 - postcss-modules: 6.0.1(postcss@8.5.14) - rtlcss: 4.3.0 - sass-embedded: 1.97.3 - optionalDependencies: - '@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) - transitivePeerDependencies: - - '@babel/core' - - browserslist - - supports-color - '@wordpress/build@0.14.0(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': dependencies: '@emotion/babel-plugin': 11.13.5 @@ -24429,7 +24483,7 @@ snapshots: '@wordpress/components': 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/compose': 7.46.0(react@18.3.1) '@wordpress/data': 10.46.0(react@18.3.1) - '@wordpress/deprecated': 4.46.0 + '@wordpress/deprecated': 4.48.0 '@wordpress/element': 6.46.0 '@wordpress/i18n': 6.19.0 '@wordpress/icons': 13.1.0(react@18.3.1) @@ -24437,7 +24491,7 @@ snapshots: '@wordpress/primitives': 4.46.0(react@18.3.1) '@wordpress/private-apis': 1.46.0 '@wordpress/ui': 0.13.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@wordpress/warning': 3.46.0 + '@wordpress/warning': 3.48.0 clsx: 2.1.1 react: 18.3.1 remove-accents: 0.5.0 @@ -24480,7 +24534,7 @@ snapshots: '@wordpress/components': 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/compose': 7.46.0(react@18.3.1) '@wordpress/data': 10.46.0(react@18.3.1) - '@wordpress/deprecated': 4.46.0 + '@wordpress/deprecated': 4.48.0 '@wordpress/element': 6.46.0 '@wordpress/i18n': 6.19.0 '@wordpress/icons': 13.1.0(react@18.3.1) @@ -24488,7 +24542,7 @@ snapshots: '@wordpress/primitives': 4.46.0(react@18.3.1) '@wordpress/private-apis': 1.46.0 '@wordpress/ui': 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@wordpress/warning': 3.46.0 + '@wordpress/warning': 3.48.0 clsx: 2.1.1 react: 18.3.1 remove-accents: 0.5.0 @@ -24538,6 +24592,10 @@ snapshots: dependencies: '@wordpress/hooks': 4.46.0 + '@wordpress/deprecated@4.48.0': + dependencies: + '@wordpress/hooks': 4.48.0 + '@wordpress/dom-ready@4.46.0': {} '@wordpress/dom@4.46.0': @@ -24966,7 +25024,7 @@ snapshots: '@wordpress/router': 1.46.0(react@18.3.1) '@wordpress/ui': 0.13.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/url': 4.46.0 - '@wordpress/warning': 3.46.0 + '@wordpress/warning': 3.48.0 '@wordpress/wordcount': 4.46.0 change-case: 4.1.2 client-zip: 2.5.0 @@ -25007,7 +25065,7 @@ snapshots: '@wordpress/router': 1.46.0(react@18.3.1) '@wordpress/ui': 0.13.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/url': 4.46.0 - '@wordpress/warning': 3.46.0 + '@wordpress/warning': 3.48.0 '@wordpress/wordcount': 4.46.0 change-case: 4.1.2 client-zip: 2.5.0 @@ -25048,7 +25106,7 @@ snapshots: '@wordpress/router': 1.46.0(react@18.3.1) '@wordpress/ui': 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/url': 4.46.0 - '@wordpress/warning': 3.46.0 + '@wordpress/warning': 3.48.0 '@wordpress/wordcount': 4.46.0 change-case: 4.1.2 client-zip: 2.5.0 @@ -25195,6 +25253,8 @@ snapshots: '@wordpress/hooks@4.46.0': {} + '@wordpress/hooks@4.48.0': {} + '@wordpress/html-entities@4.46.0': {} '@wordpress/i18n@6.19.0': @@ -26069,6 +26129,8 @@ snapshots: '@wordpress/warning@3.46.0': {} + '@wordpress/warning@3.48.0': {} + '@wordpress/widgets@4.46.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@wordpress/api-fetch': 7.46.0 diff --git a/projects/packages/premium-analytics/babel.config.cjs b/projects/packages/premium-analytics/babel.config.cjs new file mode 100644 index 000000000000..ed6bf0680c44 --- /dev/null +++ b/projects/packages/premium-analytics/babel.config.cjs @@ -0,0 +1,8 @@ +module.exports = { + presets: [ + [ + '@automattic/jetpack-webpack-config/babel/preset', + { pluginReplaceTextdomain: { textdomain: 'jetpack-premium-analytics' } }, + ], + ], +}; diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1316-integrate-data-package-into-analytics b/projects/packages/premium-analytics/changelog/wooa7s-1316-integrate-data-package-into-analytics new file mode 100644 index 000000000000..d2029922c0f4 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1316-integrate-data-package-into-analytics @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Port data package (React Query report hooks, fetchers, and processing) as an internal package from next-woocommerce-analytics. diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1317-integrate-routing-package-into-analytics b/projects/packages/premium-analytics/changelog/wooa7s-1317-integrate-routing-package-into-analytics new file mode 100644 index 000000000000..904cd0aaf523 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1317-integrate-routing-package-into-analytics @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Port routing package (date-range/comparison search-param helpers and the staged-search hook) as an internal package from next-woocommerce-analytics. diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1318-integrate-components-package-into-analytics b/projects/packages/premium-analytics/changelog/wooa7s-1318-integrate-components-package-into-analytics new file mode 100644 index 000000000000..777a39c9cea4 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1318-integrate-components-package-into-analytics @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Port the components package (date range/comparison filter UI components and SCSS) from next-woocommerce-analytics as the internal `ui` package. diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1319-integrate-widgets-toolkit-package-into-analytics b/projects/packages/premium-analytics/changelog/wooa7s-1319-integrate-widgets-toolkit-package-into-analytics new file mode 100644 index 000000000000..1cd2f13c7091 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1319-integrate-widgets-toolkit-package-into-analytics @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Port the widgets-toolkit package (dashboard widgets, chart components, fields, and helpers) from next-woocommerce-analytics as an internal package. diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1489-port-stats-top-posts-widget b/projects/packages/premium-analytics/changelog/wooa7s-1489-port-stats-top-posts-widget new file mode 100644 index 000000000000..dbd3bed59b12 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1489-port-stats-top-posts-widget @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add a Top posts & pages stats widget backed by the Jetpack Stats top-posts endpoint, with a useReportTopPosts data hook and a window.jpaConfig boot config emitter. diff --git a/projects/packages/premium-analytics/eslint.config.mjs b/projects/packages/premium-analytics/eslint.config.mjs index 38a1a71417d7..dc7422c58d66 100644 --- a/projects/packages/premium-analytics/eslint.config.mjs +++ b/projects/packages/premium-analytics/eslint.config.mjs @@ -1,16 +1,16 @@ import { makeBaseConfig, defineConfig } from 'jetpack-js-tools/eslintrc/base.mjs'; /** - * 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). + * Soften JSDoc rules for the internal `packages/*` ports so the initial + * ports can land with the upstream JSDoc style (descriptions on the + * function body, not on per-param tags). Temporary — backfill proper + * descriptions 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/**' ], + files: [ 'packages/datetime/**', 'packages/data/**', 'packages/routing/**' ], rules: { 'jsdoc/require-description': 'off', 'jsdoc/require-param-description': 'off', @@ -27,5 +27,68 @@ export default defineConfig( 'jsdoc/require-returns': 'off', 'jsdoc/check-indentation': 'off', }, + }, + { + // The data port carries a couple of upstream patterns this temporary + // override keeps as-is: intentional `any` escapes for the generic report + // `TData` (see use-report.ts), and `react` flagged as extraneous because + // the internal package's deps are declared on the parent manifest. + files: [ 'packages/data/**' ], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'import/no-extraneous-dependencies': 'off', + }, + }, + { + // The routing port imports `react` directly (the staged-search hook), + // flagged as extraneous because the internal package's deps are declared + // on the parent manifest. + files: [ 'packages/routing/**' ], + rules: { + 'import/no-extraneous-dependencies': 'off', + }, + }, + { + // First UI package in the port: also soften JSDoc rules for the ui + // package and allow the upstream inline-handler JSX style. Temporary — + // tighten these up in a follow-up alongside datetime/formatters. + files: [ 'packages/ui/**' ], + rules: { + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/require-description': 'off', + 'jsdoc/require-param': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/check-indentation': 'off', + 'jsdoc/escape-inline-tags': 'off', + 'react/jsx-no-bind': 'off', + }, + }, + { + // Same as the ui package: soften JSDoc rules for the widgets-toolkit + // port and allow the upstream inline-handler JSX style. Temporary — + // tighten these up in a follow-up alongside the other ports. + // The port also keeps a few upstream patterns as-is: + // - intentional `any` escapes in test fixtures and the router search + // record (see use-attributes-with-search-fallback.ts) + // - `__experimental*` imports from `@wordpress/components` + // (ToggleGroupControl, Grid) that have no stable equivalents yet + // - CIAB design-system tokens not yet in the local token inventory, + // plus raw/dynamic token names required by the `@automattic/charts` + // theme contract (see use-chart-theme.ts, metric-value.tsx) + files: [ 'packages/widgets-toolkit/**' ], + rules: { + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/require-description': 'off', + 'jsdoc/require-param': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/check-indentation': 'off', + 'jsdoc/escape-inline-tags': 'off', + 'react/jsx-no-bind': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@wordpress/no-unsafe-wp-apis': 'off', + '@wordpress/no-unknown-ds-tokens': 'off', + }, } ); diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index fd3c20aab0a9..14ee56081bd3 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -7,6 +7,7 @@ "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", "storybook": "cd ../../js-packages/storybook && pnpm run storybook:dev", + "test": "jest --config=tests/jest.config.cjs", "typecheck": "tsgo --noEmit", "watch": "wp-build --watch" }, @@ -30,27 +31,44 @@ } }, "dependencies": { + "@automattic/charts": "workspace:*", "@automattic/number-formatters": "workspace:*", + "@automattic/ui": "1.0.2", "@date-fns/tz": "1.4.1", + "@tanstack/react-query": "5.90.8", + "@wordpress/api-fetch": "7.46.0", "@wordpress/boot": "0.13.0", + "@wordpress/components": "33.1.0", + "@wordpress/compose": "7.46.0", + "@wordpress/core-data": "7.46.0", "@wordpress/data": "10.46.0", + "@wordpress/dataviews": "14.3.0", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^13.0.0", "@wordpress/primitives": "4.46.0", + "@wordpress/private-apis": "1.46.0", "@wordpress/route": "0.12.0", + "@wordpress/theme": "0.13.0", + "@wordpress/ui": "0.13.0", + "@wordpress/url": "4.46.0", + "clsx": "2.1.1", "date-fns": "4.1.0", "react": "18.3.1", "react-dom": "18.3.1" }, "devDependencies": { + "@automattic/jetpack-webpack-config": "workspace:*", "@babel/core": "7.29.0", "@storybook/react": "10.3.6", + "@tanstack/react-query-devtools": "5.90.2", "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", "@types/jest": "30.0.0", "@typescript/native-preview": "7.0.0-dev.20260225.1", + "@wordpress/base-styles": "8.0.0", "@wordpress/build": "0.14.0", - "@wordpress/ui": "0.13.0", "browserslist": "4.28.2", + "jest": "30.4.2", "storybook": "10.3.6", "typescript": "5.9.3" } diff --git a/projects/packages/premium-analytics/packages/data/README.md b/projects/packages/premium-analytics/packages/data/README.md new file mode 100644 index 000000000000..c8492a081340 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/README.md @@ -0,0 +1,431 @@ +# @automattic/jetpack-premium-analytics-data + +Data management for Jetpack Premium Analytics with React Query integration. + +## Installation + +This is an internal package of Jetpack Premium Analytics — it is never +published to npm and is resolved entirely in-tree. It's automatically +available to routes and other internal packages within +`@automattic/jetpack-premium-analytics`. + +```tsx +import { + AnalyticsQueryClientProvider, + useReport, + prefetchReport, + // ... other exports +} from '@automattic/jetpack-premium-analytics-data'; +``` + +## Features + +- **React Query Integration**: Built on `@tanstack/react-query` for + caching and state management +- **Prefetching Support**: Route-based data prefetching for improved + performance +- **Data Processing**: Automatic sanitization of API responses + (strings → numbers) +- **Comparison Support**: Built-in primary + comparison data fetching +- **TypeScript Support**: Fully typed data structures and API responses +- **Smart Caching**: Automatic cache invalidation and background + refetching + +## Usage + +### Setup + +```tsx +import { AnalyticsQueryClientProvider } from '@automattic/jetpack-premium-analytics-data'; + +function App() { + return ( + + {/* Your app components */} + + ); +} +``` + +### Fetching Data + +```tsx +import { + useReportOrders, + useReportOrdersByProductType, + useReportOrderAttribution, + useReportCoupons +} from '@automattic/jetpack-premium-analytics-data'; + +function OrdersReport() { + // Orders endpoint separates primary and comparison periods + const { primary, comparison, hasComparison } = useReportOrders( { + from: '2025-07-01T00:00:00', + to: '2025-07-29T23:59:59', + interval: 'day', + } ); +} + +function OrdersByProductTypeReport() { + // Orders by product type with filtering support + const { primary, comparison, hasComparison } = useReportOrdersByProductType( { + from: '2025-07-01T00:00:00', + to: '2025-07-29T23:59:59', + interval: 'day', + filters: [ + { + key: 'product_type', + value: ['simple'], + compare: 'IN' + } + ] + } ); +} + +function OrderAttributionReport() { + // Order attribution requires a view parameter + const { primary, comparison, hasComparison } = useReportOrderAttribution( { + from: '2025-07-01T00:00:00', + to: '2025-07-29T23:59:59', + interval: 'day', + view: 'channel', // 'channel' | 'source' | 'campaign' | 'device' | 'channel-source' + } ); +} + +function CouponsReport() { + // Coupons works like orders with separate comparison requests + const { primary, comparison, hasComparison } = useReportCoupons( { + from: '2025-07-01T00:00:00', + to: '2025-07-29T23:59:59', + interval: 'day', + } ); +} +``` + +### Prefetching + +```tsx +import { prefetchReport, ensureCoreSettingsReady } from '@automattic/jetpack-premium-analytics-data'; + +export const route = { + beforeLoad: async () => { + // Ensure site settings are loaded first + await ensureCoreSettingsReady(); + + // Now safely prefetch reports + await prefetchReport( 'orders' ); + }, +}; +``` + +## API Reference + +### Individual Hooks (Recommended) + +#### `useReportOrders( params )` + +Fetches orders report data with automatic processing and comparison support. + +**Parameters:** +- `params`: `ReportParams` with `from`, `to`, `interval`, and optional comparison params + +**Returns:** `{ primary, comparison, hasComparison }` + +#### `useReportOrdersByProductType( params )` + +Fetches orders report data filtered by product type or other product characteristics with automatic processing and comparison support. + +**Parameters:** +- `params`: `ReportParams` with `from`, `to`, `interval`, optional `filters` array, and optional comparison params + +**Filters Structure:** +```typescript +filters: Array<{ + key: string; // e.g., 'product_type', 'virtual' + value: string | string[]; // e.g., ['simple'], '1' + compare: '=' | 'IN' | 'NOT IN' | '!=' | '>' | '<' | '>=' | '<='; +}> +``` + +**Common Filter Examples:** +- Product types: `{ key: 'product_type', value: ['simple', 'variable'], compare: 'IN' }` +- Virtual products: `{ key: 'virtual', value: '1', compare: '=' }` +- Non-virtual products: `{ key: 'virtual', value: '0', compare: '=' }` + +**Returns:** `{ primary, comparison, hasComparison }` + +#### `useReportOrderAttribution( params )` + +Fetches order attribution data with built-in comparison handling. + +**Parameters:** +- `params`: `ReportParams` with `from`, `to`, `interval`, `view`, and optional comparison params + +**Returns:** `{ primary, comparison, hasComparison }` + +#### `useReportCoupons( params )` + +Fetches coupons report data with automatic processing and comparison support. + +**Parameters:** +- `params`: `ReportParams` with `from`, `to`, `interval`, and optional comparison params + +**Returns:** `{ primary, comparison, hasComparison }` + +### Legacy Hook (Deprecated) + +#### `useReport( reportType, params )` + +**⚠️ Deprecated:** Use individual hooks instead for better type safety and performance. + +**Parameters:** +- `reportType`: `'orders'` | `'orders-by-product-type'` | `'order-attribution'` | `'coupons'` +- `params`: `ReportParams` + +**Returns:** Same as individual hooks above + +### `prefetchReport( reportType, params )` + +Prefetches data for improved performance. Same parameters as `useReport`. + +**Usage:** Call in route `beforeLoad` functions for instant data loading + +**Returns:** Promise that resolves when data is prefetched + +**Caching:** Uses React Query cache, so subsequent `useReport` calls are +instant + +**Example:** +```tsx +// Prefetch orders data +await prefetchReport( 'orders', { from, to, interval } ); + +// Prefetch orders by product type data +await prefetchReport( 'orders-by-product-type', { + from, + to, + interval, + filters: [{ key: 'product_type', value: ['simple'], compare: 'IN' }] +} ); + +// Prefetch order attribution data +await prefetchReport( 'order-attribution', { from, to, interval, view: 'channel' } ); + +// Prefetch coupons data +await prefetchReport( 'coupons', { from, to, interval } ); +``` + +### `normalizeReportParams( params? )` + +Normalizes and validates report parameters, providing defaults when needed. + +**Parameters:** +- `params`: Optional partial parameters object + +**Returns:** `{ primary, comparison? }` with normalized parameters + +**Defaults:** Last 30 days, daily interval when not specified + +**Validation:** Ensures required fields are present for API calls + +### `getDefaultIntervalForPeriod( period, from, to )` + +Returns the optimal default interval for a given time period. + +**Parameters:** +- `period`: `string` - Period identifier (e.g., 'today', 'last-7-days', 'last-30-days') +- `from`: `string` - Start date +- `to`: `string` - End date + +**Returns:** `IntervalType` - Optimal interval ('hour', 'day', 'week', 'month', 'quarter', 'year') + +**Example:** +```tsx +import { getDefaultIntervalForPeriod } from '@automattic/jetpack-premium-analytics-data'; + +const interval = getDefaultIntervalForPeriod( 'last-7-days', from, to ); // Returns 'day' +``` + +### `ORDER_ATTRIBUTION_VIEWS` + +Constant array of available order attribution views. + +**Values:** `['channel', 'source', 'campaign', 'device', 'channel-source']` + +**Example:** +```tsx +import { ORDER_ATTRIBUTION_VIEWS } from '@automattic/jetpack-premium-analytics-data'; + +// Use in components for view selection +const views = ORDER_ATTRIBUTION_VIEWS; // ['channel', 'source', ...] +``` + +## Architecture + +``` +src/ +├── api/ # API functions and query keys +│ ├── index.ts # API exports +│ ├── constants.ts # Shared API endpoint constants +│ ├── report-orders-fetch/ # Orders API client +│ │ ├── index.ts # Orders API exports +│ │ └── report-orders-fetch.ts # Orders API implementation +│ ├── report-orders-by-product-type-fetch/ # Orders by product type API client +│ │ ├── index.ts # Orders by product type API exports +│ │ └── report-orders-by-product-type-fetch.ts # Orders by product type API implementation +│ ├── report-order-attribution-summary-fetch/ # Attribution API client +│ │ ├── index.ts # Attribution API exports +│ │ └── report-order-attribution-summary-fetch.ts # Attribution API implementation +│ └── report-coupons-fetch/ # Coupons API client +│ ├── index.ts # Coupons API exports +│ └── report-coupons-fetch.ts # Coupons API implementation +├── queries/ # React Query configurations +│ ├── index.ts # Query exports +│ ├── report-orders-query.ts # Orders query definition +│ ├── report-orders-by-product-type-query.ts # Orders by product type query definition +│ ├── report-order-attribution-summary-query.ts # Attribution query definition +│ └── report-coupons-query.ts # Coupons query definition +├── hooks/ # React hooks +│ ├── index.ts # Hook exports +│ └── use-report.ts # Main useReport hook with comparison +├── prefetch/ # Prefetching functions +│ ├── index.ts # Prefetch exports +│ └── prefetch-report-orders.ts # Multi-report prefetch logic (orders + attribution + coupons) +├── processing/ # Data sanitization and transformation +│ ├── orders/ # Orders-specific data processing +│ ├── orders-by-product-type/ # Orders by product type data processing +│ ├── coupons/ # Coupons data processing +│ └── order-attribution/ # Attribution data processing +├── providers/ # React Context providers +│ ├── index.ts # Provider exports +│ └── query-client-provider.tsx # React Query client setup +├── defaults/ # Default parameters and configurations +├── utils/ # Utility functions +│ ├── date.ts # Date manipulation utilities (timezone-aware) +│ ├── ensure-core-settings.ts # Core settings initialization +│ ├── interval.ts # Interval calculation and optimization +│ ├── search.ts # Search parameter utilities +│ └── types.ts # Shared utility types (Override, etc.) +└── types.ts # TypeScript type definitions +``` + +### Data Flow + +1. **Route Prefetching**: `beforeLoad` calls `prefetchReport()` to load + data +2. **Component Consumption**: Components use `useReport()` to access cached + data +3. **Automatic Processing**: Raw API responses are sanitized + (strings → numbers) +4. **Comparison Handling**: Primary and comparison queries are managed + automatically +5. **Cache Management**: React Query handles caching, background updates, + and invalidation + +## Date Utilities + +This package provides timezone-aware date utilities that integrate with +WordPress site settings: + +### `localTZDate( value?, timezone? )` + +Creates a timezone-aware date using the site's configured timezone by +default. + +```typescript +import { localTZDate } from '@automattic/jetpack-premium-analytics-data'; + +const now = localTZDate(); // Current time in site timezone +const custom = localTZDate( '2024-01-15', 'America/New_York' ); +``` + +**Parameters:** +- `value` (optional): `number | string | Date` - Date value to convert +- `timezone` (optional): `string` - Target timezone (defaults to site + timezone) + +**Returns:** `TZDate` - Timezone-aware date object + + +### `dateToISOStringWithLocalTZ( date, timezone? )` + +Converts a date to ISO string with the site's timezone offset applied. + +```typescript +const withTZ = dateToISOStringWithLocalTZ( new Date() ); +// Returns: "2024-01-15T14:30:00.000-05:00" (with site timezone offset) +``` + +**Parameters:** +- `date`: `Date` - Date to convert +- `timezone` (optional): `string` - Target timezone (defaults to site + timezone) + +**Returns:** `string` - ISO string with timezone offset + +### `getSiteTimezone()` + +Returns the WordPress site's configured timezone string. + +```typescript +const timezone = getSiteTimezone(); +// Returns: "America/New_York" or "+05:30" (offset format) +``` + +**Returns:** `string` - Site timezone from WordPress settings + +**Note:** This function will throw an error if called before core settings +are loaded. Use `ensureCoreSettingsReady()` in route loaders to prevent +this. + +### `ensureCoreSettingsReady()` + +Ensures WordPress core settings (site and general settings) are loaded +before accessing timezone-dependent functions. + +```typescript +// In route loaders or beforeLoad hooks +await ensureCoreSettingsReady(); +// Now getSiteTimezone() can be safely called +``` + +**Returns:** `Promise` - Resolves when settings are loaded + +**Features:** +- Memoizes the promise to avoid duplicate requests +- Prevents race conditions during navigation +- Essential for route prefetching and hover preloading + +These functions automatically use the WordPress site's timezone settings +and provide consistent date handling across the analytics interface. + +## Complete API Exports + +This package exports the following public API: + +### Components +- `AnalyticsQueryClientProvider` - React Query provider wrapper + +### Hooks +- `useReportOrders` - Hook for fetching orders report data +- `useReportOrdersByProductType` - Hook for fetching orders by product type with filtering +- `useReportOrderAttribution` - Hook for fetching order attribution data +- `useReportCoupons` - Hook for fetching coupons report data +- `useReport` - Legacy main hook for fetching report data (deprecated) + +### Functions +- `prefetchReport` - Prefetch data for routes +- `normalizeReportParams` - Normalize and validate parameters +- `getDefaultIntervalForPeriod` - Get optimal interval for time period + +### Date Utilities +- `localTZDate` - Create timezone-aware dates +- `dateToISOStringWithLocalTZ` - Convert to ISO with timezone +- `getSiteTimezone` - Get WordPress site timezone +- `ensureCoreSettingsReady` - Ensure settings are loaded + +### Constants +- `ORDER_ATTRIBUTION_VIEWS` - Available attribution view types + +### Types +- `ReportDataMap` - TypeScript type mapping for report data structures diff --git a/projects/packages/premium-analytics/packages/data/package.json b/projects/packages/premium-analytics/packages/data/package.json new file mode 100644 index 000000000000..74470af6fbd8 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/package.json @@ -0,0 +1,23 @@ +{ + "name": "@automattic/jetpack-premium-analytics-data", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "sideEffects": false, + "dependencies": { + "@date-fns/tz": "1.4.1", + "@jetpack-premium-analytics/datetime": "workspace:*", + "@tanstack/react-query": "5.90.8", + "@wordpress/api-fetch": "7.46.0", + "@wordpress/core-data": "7.46.0", + "@wordpress/data": "10.46.0", + "@wordpress/i18n": "^6.9.0", + "@wordpress/url": "4.46.0", + "date-fns": "4.1.0" + }, + "devDependencies": { + "@tanstack/react-query-devtools": "5.90.2" + } +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/constants.ts b/projects/packages/premium-analytics/packages/data/src/api/constants.ts new file mode 100644 index 000000000000..dfa2b51ed261 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/constants.ts @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import { getJpaConfig } from '../utils/jpa-config'; + +/** + * Constants for API endpoints + */ +export const reportsPath = '/wc/v3/woocommerce-analytics/proxy/reports'; + +/** + * Base path of the Jetpack Stats proxy REST API for the current site. + * + * A function rather than a constant because the site ID is only known at + * runtime (read from `window.jpaConfig`). + * + * @return The stats API base path, e.g. `/jetpack/v4/stats-app/sites/123/stats`. + */ +export function getStatsApiPath(): string { + const { siteId } = getJpaConfig(); + + return `/jetpack/v4/stats-app/sites/${ siteId }/stats`; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/index.ts b/projects/packages/premium-analytics/packages/data/src/api/index.ts new file mode 100644 index 000000000000..feaa5a0a60ce --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/index.ts @@ -0,0 +1,52 @@ +/** + * Internal dependencies + */ +import type { RequestReportBookingsParams } from './report-bookings-fetch'; +import type { RequestReportCouponsByDateParams } from './report-coupons-by-date-fetch'; +import type { RequestReportCouponsParams } from './report-coupons-fetch'; +import type { RequestReportCustomersParams } from './report-customers-fetch'; +import type { RequestReportOrderAttributionByProductParams } from './report-order-attribution-by-product-fetch'; +import type { RequestReportOrderAttributionSummaryParams } from './report-order-attribution-summary-fetch'; +import type { RequestReportOrdersParams } from './report-orders-fetch'; +import type { RequestReportProductsParams } from './report-products-fetch'; +import type { RequestReportSessionsByDeviceParams } from './report-sessions-by-device-fetch'; +import type { RequestReportVisitorsByLocationParams } from './report-visitors-by-location-fetch'; +import type { RequestReportVisitorsParams } from './report-visitors-fetch'; + +export type ReportQueryParams = Partial< + RequestReportOrdersParams & + RequestReportOrderAttributionSummaryParams & + RequestReportOrderAttributionByProductParams & + RequestReportCouponsParams & + RequestReportCouponsByDateParams & + RequestReportCustomersParams & + RequestReportProductsParams & + RequestReportVisitorsParams & + RequestReportVisitorsByLocationParams & + RequestReportBookingsParams & + RequestReportSessionsByDeviceParams +>; + +export { fetchReportOrders } from './report-orders-fetch'; +export { + fetchReportOrderAttributionSummary, + ORDER_ATTRIBUTION_VIEWS, +} from './report-order-attribution-summary-fetch'; +export { fetchReportOrderAttributionByProduct } from './report-order-attribution-by-product-fetch'; +export { fetchReportCoupons } from './report-coupons-fetch'; +export { fetchReportCouponsByDate } from './report-coupons-by-date-fetch'; +export { fetchReportCustomers } from './report-customers-fetch'; +export { fetchReportProducts } from './report-products-fetch'; +export { fetchReportVisitors } from './report-visitors-fetch'; +export { fetchReportVisitorsByLocation } from './report-visitors-by-location-fetch'; +export { fetchReportBookings } from './report-bookings-fetch'; +export { fetchReportSessionsByDevice } from './report-sessions-by-device-fetch'; +export { fetchReportTopPosts } from './report-top-posts-fetch'; +export type { + RequestReportTopPostsParams, + TopPostsPeriod, + TopPostsPostView, + TopPostsResponse, +} from './report-top-posts-fetch'; +export { exportReport } from './report-export-fetch'; +export type { ExportReportParams, ExportReportResponse } from './report-export-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/index.ts new file mode 100644 index 000000000000..8dd1607b5e9e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/index.ts @@ -0,0 +1,5 @@ +export { fetchReportBookings } from './report-bookings-fetch'; +export type { + ReportsBookingsByDateResponse, + RequestReportBookingsParams, +} from './report-bookings-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/report-bookings-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/report-bookings-fetch.ts new file mode 100644 index 000000000000..c06c8fa36fa2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-bookings-fetch/report-bookings-fetch.ts @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type ReportsBookingsByDateSummary = { + status_unpaid: string; + status_pending_confirmation: string; + status_confirmed: string; + status_paid: string; + status_cancelled: string; + status_complete: string; + attendance_status_booked: string; + attendance_status_no_show: string; + attendance_status_checked_in: string; + date_start: string; + date_end: string; +}; + +type BookingsReportDataItem = ReportsBookingsByDateSummary & { + time_interval: string; +}; + +export type ReportsBookingsByDateResponse = { + data: BookingsReportDataItem[]; + summary: ReportsBookingsByDateSummary; +}; + +export type RequestReportBookingsParams = BaseReportParams & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.filters + * @param root0.date_type + */ +export async function fetchReportBookings( { + from, + to, + interval, + filters, + date_type, +}: RequestReportBookingsParams ): Promise< ReportsBookingsByDateResponse > { + const apiUrl = `${ reportsPath }/bookings/by-date`; + + const path = addQueryArgs( apiUrl, { + from, + to, + interval, + filters, + date_type, + } ); + + return apiFetch( { path } ) as Promise< ReportsBookingsByDateResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/index.ts new file mode 100644 index 000000000000..f9cc046877b8 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/index.ts @@ -0,0 +1,4 @@ +export { + fetchReportConversionRate, + type RequestReportConversionRateParams, +} from './report-conversion-rate-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/report-conversion-rate-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/report-conversion-rate-fetch.ts new file mode 100644 index 000000000000..eaea267f5bdc --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-conversion-rate-fetch/report-conversion-rate-fetch.ts @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type ReportsConversionRateByDateSummary = { + active_sessions: string; + visitors: string; + with_cart_addition: string; + reached_checkout: string; + completed_checkout: string; + date_end: string; + date_start: string; +}; + +type ConversionRateReportDataItem = { + date_start: string; + date_end: string; + active_sessions: string; + visitors: string; + with_cart_addition: string; + reached_checkout: string; + completed_checkout: string; +}; + +type ReportsConversionRateByDateResponse = { + data: ConversionRateReportDataItem[]; + summary: ReportsConversionRateByDateSummary; +}; + +export type RequestReportConversionRateParams = BaseReportParams & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.filters + */ +export async function fetchReportConversionRate( { + from, + to, + interval, + filters, +}: RequestReportConversionRateParams ): Promise< ReportsConversionRateByDateResponse > { + const path = addQueryArgs( `${ reportsPath }/sessions/by-conversion-rate`, { + from, + to, + interval, + filters, + } ); + + return apiFetch( { + path, + } ) as Promise< ReportsConversionRateByDateResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/index.ts new file mode 100644 index 000000000000..cd9dbe298e36 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/index.ts @@ -0,0 +1,4 @@ +export { + fetchReportCouponsByDate, + type RequestReportCouponsByDateParams, +} from './report-coupons-by-date-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/report-coupons-by-date-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/report-coupons-by-date-fetch.ts new file mode 100644 index 000000000000..d58602ec6645 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-by-date-fetch/report-coupons-by-date-fetch.ts @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type CouponsByDateDataItem = { + time_interval: string; + date_start: string; + date_end: string; + total_orders: string; + orders_with_coupon: string; + orders_without_coupon: string; + total_sales: string; + sales_with_coupon: string; + sales_without_coupon: string; + total_discount_amount: string; + net_sales_after_discount: string; + coupon_usage_percentage: string; +}; + +type CouponsByDateSummary = { + total_orders: string; + orders_with_coupon: string; + orders_without_coupon: string; + total_sales: string; + sales_with_coupon: string; + sales_without_coupon: string; + total_discount_amount: string; + net_sales_after_discount: string; + coupon_usage_percentage: string; + date_start: string; + date_end: string; +}; + +export type ReportsCouponsByDateResponse = { + summary: CouponsByDateSummary; + data: CouponsByDateDataItem[]; +}; + +export type RequestReportCouponsByDateParams = BaseReportParams & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.filters + * @param root0.date_type + */ +export async function fetchReportCouponsByDate( { + from, + to, + interval, + filters, + date_type, +}: RequestReportCouponsByDateParams ): Promise< ReportsCouponsByDateResponse > { + const path = addQueryArgs( `${ reportsPath }/coupons/by-date`, { + from, + to, + interval, + filters, + date_type, + } ); + + return apiFetch< ReportsCouponsByDateResponse >( { path } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/index.ts new file mode 100644 index 000000000000..54bbee440ef2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/index.ts @@ -0,0 +1 @@ +export { fetchReportCoupons, type RequestReportCouponsParams } from './report-coupons-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/report-coupons-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/report-coupons-fetch.ts new file mode 100644 index 000000000000..c25dc9dd29c0 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-coupons-fetch/report-coupons-fetch.ts @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type CouponsDataItem = { + coupon_code: string; + discount_amount: string; + total_sales: string; + orders_count: string; +}; + +type CouponsDataSummary = { + total_sales: string; + total_discount_amount: string; + total_orders: string; + date_start: string; + date_end: string; +}; + +export type ReportsCouponsResponse = { + summary: CouponsDataSummary; + data: CouponsDataItem[]; +}; + +export type RequestReportCouponsParams = BaseReportParams & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.filters + * @param root0.date_type + */ +export async function fetchReportCoupons( { + from, + to, + interval, + filters, + date_type, +}: RequestReportCouponsParams ): Promise< ReportsCouponsResponse > { + const path = addQueryArgs( `${ reportsPath }/coupons/`, { + from, + to, + interval, + filters, + date_type, + orderby: 'total_sales', + } ); + + return apiFetch< ReportsCouponsResponse >( { path } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/index.ts new file mode 100644 index 000000000000..ec5993677f34 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/index.ts @@ -0,0 +1 @@ +export * from './report-customers-by-date-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/report-customers-by-date-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/report-customers-by-date-fetch.ts new file mode 100644 index 000000000000..9d9e83b66d5d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-customers-by-date-fetch/report-customers-by-date-fetch.ts @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { BaseReportParams } from '../../utils/types'; + +type ReportsCustomersByDateSummary = { + total_net_sales: string; + total_gross_sales: string; + total_discounts: string; + total_refunds: string; + total_orders: string; + total_average_order_value: string; + total_avg_items_per_order: string; + total_customers: string; + new_customers: string; + returning_customers: string; + new_customer_sales: string; + new_customer_gross_sales: string; + new_customer_discounts: string; + new_customer_refunds: string; + new_customer_orders: string; + new_customer_avg_order_value: string; + new_customer_avg_items_per_order: string; + returning_customer_sales: string; + returning_customer_gross_sales: string; + returning_customer_discounts: string; + returning_customer_refunds: string; + returning_customer_orders: string; + returning_customer_avg_order_value: string; + returning_customer_avg_items_per_order: string; + date_start: string; + date_end: string; +}; + +type CustomersReportDataItem = { + time_interval: string; + date_start: string; + date_end: string; + total_customers: string; + new_customers: string; + returning_customers: string; + orders_count: string; + new_customer_orders: string; + returning_customer_orders: string; + net_sales: string; + new_customer_net_sales: string; + returning_customer_net_sales: string; +}; + +type ReportsCustomersByDateResponse = { + data: CustomersReportDataItem[]; + summary: ReportsCustomersByDateSummary; +}; + +export type RequestReportCustomersByDateParams = BaseReportParams; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.date_type + */ +export async function fetchReportCustomersByDate( { + from, + to, + interval, + date_type, +}: RequestReportCustomersByDateParams ): Promise< ReportsCustomersByDateResponse > { + const path = addQueryArgs( `${ reportsPath }/customers/by-date`, { + from, + to, + interval, + date_type, + } ); + + return apiFetch( { path } ) as Promise< ReportsCustomersByDateResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/index.ts new file mode 100644 index 000000000000..b5d7c2f32fe4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/index.ts @@ -0,0 +1 @@ +export { fetchReportCustomers, type RequestReportCustomersParams } from './report-customers-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/report-customers-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/report-customers-fetch.ts new file mode 100644 index 000000000000..1747d7c8cdb4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-customers-fetch/report-customers-fetch.ts @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type CustomersNewReturningSummary = { + total_net_sales: string; + total_orders: string; + new_customer_sales: string; + returning_customer_sales: string; + date_start: string; + date_end: string; +}; + +type CustomersNewReturningItem = { + customer_type: 'new' | 'returning'; + net_sales: string; + orders_count: string; +}; + +type ReportsCustomersNewReturningResponse = { + summary: CustomersNewReturningSummary; + data: CustomersNewReturningItem[]; +}; + +export type RequestReportCustomersParams = Omit< BaseReportParams, 'interval' > & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.filters + * @param root0.date_type + */ +export async function fetchReportCustomers( { + from, + to, + filters, + date_type, +}: RequestReportCustomersParams ): Promise< ReportsCustomersNewReturningResponse > { + const path = addQueryArgs( `${ reportsPath }/customers/new-returning`, { + from, + to, + filters, + date_type, + } ); + + return apiFetch( { + path, + } ) as Promise< ReportsCustomersNewReturningResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/index.ts new file mode 100644 index 000000000000..4b36b16295d5 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/index.ts @@ -0,0 +1,2 @@ +export { exportReport } from './report-export-fetch'; +export type { ExportReportParams, ExportReportResponse } from './report-export-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/report-export-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/report-export-fetch.ts new file mode 100644 index 000000000000..645bdff72b3f --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-export-fetch/report-export-fetch.ts @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Export request parameters + */ +export interface ExportReportParams { + reportType: string | string[]; + from: string; // ISO 8601 date string + to: string; // ISO 8601 date string + interval?: string; + compareFrom?: string; // ISO 8601 date string + compareTo?: string; // ISO 8601 date string +} + +/** + * Export response from the API + */ +export interface ExportReportResponse { + success: boolean; + message: string; + job_ids?: Record< string, number >; // Multiple report exports + partial?: boolean; // Indicates if some exports failed + errors?: Record< string, string >; // Failed report types and their error messages +} + +/** + * Export one or more reports via email + * + * @param params - Export parameters + * @return Promise that resolves to the export response + */ +export async function exportReport( params: ExportReportParams ): Promise< ExportReportResponse > { + const path = '/wc/v3/woocommerce-analytics/reports/csv-export'; + + const body = { + report_type: Array.isArray( params.reportType ) ? params.reportType : [ params.reportType ], + from: params.from, + to: params.to, + interval: params.interval || 'day', + delivery_method: 'email', + ...( params.compareFrom && params.compareTo + ? { + compare_from: params.compareFrom, + compare_to: params.compareTo, + } + : {} ), + }; + + return apiFetch( { + path, + method: 'POST', + data: body, + } ) as Promise< ExportReportResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/index.ts new file mode 100644 index 000000000000..2039d9b82507 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/index.ts @@ -0,0 +1 @@ +export * from './report-order-attribution-by-product-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/report-order-attribution-by-product-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/report-order-attribution-by-product-fetch.ts new file mode 100644 index 000000000000..7e35aff2130e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-by-product-fetch/report-order-attribution-by-product-fetch.ts @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; +import type { ORDER_ATTRIBUTION_VIEWS } from '../report-order-attribution-summary-fetch/report-order-attribution-summary-fetch'; + +type OrderAttributionView = ( typeof ORDER_ATTRIBUTION_VIEWS )[ number ]; + +type OrderAttributionByProductInterval = { + time_interval: string; + date_start: string; + date_end: string; + net_sales: string; +}; + +type OrderAttributionByProductItem = { + item: string; + value: string; + intervals: OrderAttributionByProductInterval[]; +}; + +export type OrderAttributionByProductResponse = { + view: OrderAttributionView; + order_by: string; + data: OrderAttributionByProductItem[]; +}; + +export type RequestReportOrderAttributionByProductParams = BaseReportParams & { + view: OrderAttributionView; + filters?: FilterCondition[]; +}; + +/** + * Fetches order attribution by product data from the WC Analytics REST API + * + * This endpoint supports product filtering similar to fetchReportOrdersByProductType. + * Unlike the regular order-attribution endpoint, this one: + * - Does not support compare_from/compare_to parameters + * - Returns data in a flatter structure (no current_period/previous_period nesting) + * - Requires separate requests for comparison data + * + * @param params - Query parameters + * @return Promise resolving to order attribution by product response + */ +export async function fetchReportOrderAttributionByProduct( + params: RequestReportOrderAttributionByProductParams +): Promise< OrderAttributionByProductResponse > { + const { from, to, interval, view, filters, date_type } = params; + + const queryParams: Record< string, any > = { + from, + to, + interval, + view, + date_type, + }; + + // Add filters to query params if provided + if ( filters && filters.length > 0 ) { + queryParams.filters = filters; + } + + const path = addQueryArgs( + `${ reportsPath }/order-attribution-by-product/${ view }/summary`, + queryParams + ); + + return apiFetch< OrderAttributionByProductResponse >( { path } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/index.ts new file mode 100644 index 000000000000..285b15296160 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/index.ts @@ -0,0 +1,6 @@ +export { + fetchReportOrderAttributionSummary, + ORDER_ATTRIBUTION_VIEWS, + type RequestReportOrderAttributionSummaryParams, + type OrderAttributionSummaryResponse, +} from './report-order-attribution-summary-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/report-order-attribution-summary-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/report-order-attribution-summary-fetch.ts new file mode 100644 index 000000000000..6c62c763f539 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-order-attribution-summary-fetch/report-order-attribution-summary-fetch.ts @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { BaseReportParams } from '../../utils/types'; + +export const ORDER_ATTRIBUTION_VIEWS = [ + 'channel', + 'source', + 'campaign', + 'device', + 'channel-source', +] as const; + +type OrderAttributionView = ( typeof ORDER_ATTRIBUTION_VIEWS )[ number ]; + +type OrderAttributionInterval = { + time_interval: string; + date_start: string; + date_end: string; + net_sales: string; +}; + +type OrderAttributionPeriod = { + value: string; + intervals: OrderAttributionInterval[]; +}; + +type OrderAttributionSummaryItem = { + item: string; + current_period: OrderAttributionPeriod; + previous_period: OrderAttributionPeriod; +}; + +export type OrderAttributionSummaryResponse = { + view: OrderAttributionView; + order_by: string; + data: OrderAttributionSummaryItem[]; +}; + +export type RequestReportOrderAttributionSummaryParams = BaseReportParams & { + view: OrderAttributionView; + compare_from: string; + compare_to: string; +}; + +/** + * Fetches order attribution summary data from the WC Analytics REST API + * + * Note: Order attribution summary endpoint returns both primary and comparison + * data in a single response, unlike orders endpoint which requires + * separate requests. The endpoint requires compare_from and compare_to parameters; + * when no comparison is needed, it uses the same values as the primary range. + * + * @param params - Query parameters + * @return Promise resolving to order attribution summary response + */ +export async function fetchReportOrderAttributionSummary( + params: RequestReportOrderAttributionSummaryParams +): Promise< OrderAttributionSummaryResponse > { + const { from, to, interval, view, compare_from, compare_to, date_type } = params; + + /* + * Order attribution endpoint requires compare_from and compare_to. + * When no comparison is needed, use the same values as primary range. + */ + const queryParams: Record< string, string | undefined > = { + from, + to, + interval, + view, + compare_from, + compare_to, + date_type, + }; + + const path = addQueryArgs( `${ reportsPath }/order-attribution/${ view }/summary`, queryParams ); + + return apiFetch< OrderAttributionSummaryResponse >( { path } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/index.ts new file mode 100644 index 000000000000..2762252eb226 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/index.ts @@ -0,0 +1,5 @@ +export { + fetchReportOrders, + type RequestReportOrdersParams, + type ReportsOrdersByDateResponse, +} from './report-orders-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/report-orders-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/report-orders-fetch.ts new file mode 100644 index 000000000000..c97e7f3d8aa9 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-orders-fetch/report-orders-fetch.ts @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { hasProductFilters } from '../../utils/product-filters'; +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; +import type { BaseReportParams } from '../../utils/types'; + +type ReportsOrdersByDateSummary = { + average_order_value: string; + avg_items: string; + cogs_amount: string; + coupons: string; + date_end: string; + date_start: string; + orders_no: string; + orders_value_gross: string; + orders_value_net: string; + paid_orders_count: string; + paid_net_sales: string; + product_net_revenue: string; + profit_margin: string; + refunds: string; + total_sales: string; + unpaid_orders_count: string; + unpaid_net_sales: string; +}; + +type OrdersReportDataItem = ReportsOrdersByDateSummary & { + time_interval?: string; +}; + +export type ReportsOrdersByDateResponse = { + data: OrdersReportDataItem[]; + summary: ReportsOrdersByDateSummary; +}; + +export type RequestReportOrdersParams = BaseReportParams & { + filters?: FilterCondition[]; +}; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.filters + * @param root0.date_type + */ +export async function fetchReportOrders( { + from, + to, + interval, + filters, + date_type, +}: RequestReportOrdersParams ): Promise< ReportsOrdersByDateResponse > { + const hasProductFiltersValue = hasProductFilters( filters ); + const apiUrl = hasProductFiltersValue + ? `${ reportsPath }/orders-by-product-type/by-date` + : `${ reportsPath }/orders/by-date`; + + const path = addQueryArgs( apiUrl, { + from, + to, + interval, + filters, + date_type, + } ); + + return apiFetch( { path } ) as Promise< ReportsOrdersByDateResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/index.ts new file mode 100644 index 000000000000..c786309418cf --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/index.ts @@ -0,0 +1,4 @@ +/** + * Internal dependencies + */ +export { fetchReportProducts, type RequestReportProductsParams } from './report-products-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/report-products-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/report-products-fetch.ts new file mode 100644 index 000000000000..8af88655042d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-products-fetch/report-products-fetch.ts @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { BaseReportParams } from '../../utils/types'; +import { reportsPath } from '../constants'; +import type { FilterCondition } from '../../types/filter-condition'; + +export type RequestReportProductsParams = Omit< BaseReportParams, 'interval' > & { + limit?: number; + orderby?: string; + order?: 'asc' | 'desc'; + filters?: FilterCondition[]; +}; + +type ReportProductsResponse = { + data: { + product_id: string; + product_name: string; + product_net_revenue: string; + product_gross_revenue: string; + product_type: string; + orders_count: string; + sku: string; + total_quantity: string; + stock_status: string; + }[]; + summary: { + total_orders: string; + total_products: string; + total_quantity: string; + total_revenue: string; + }; +}; + +/** + * Fetches products report data from the WooCommerce Analytics API + * @param params + */ +export async function fetchReportProducts( + params: RequestReportProductsParams +): Promise< ReportProductsResponse > { + const queryArgs: Record< string, any > = { + from: params.from, + to: params.to, + date_type: params.date_type, + }; + + if ( params.limit ) { + queryArgs.limit = params.limit; + } + + if ( params.orderby ) { + queryArgs.orderby = params.orderby; + } + + if ( params.order ) { + queryArgs.order = params.order; + } + + // Add filters to query params if provided + if ( params.filters && params.filters.length > 0 ) { + queryArgs.filters = params.filters; + } + + return apiFetch< ReportProductsResponse >( { + path: addQueryArgs( `${ reportsPath }/products`, queryArgs ), + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/index.ts new file mode 100644 index 000000000000..2da42f37b7e6 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/index.ts @@ -0,0 +1,4 @@ +export { + fetchReportSessionsByDevice, + type RequestReportSessionsByDeviceParams, +} from './report-sessions-by-device-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/report-sessions-by-device-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/report-sessions-by-device-fetch.ts new file mode 100644 index 000000000000..3909742411dc --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-sessions-by-device-fetch/report-sessions-by-device-fetch.ts @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { BaseReportParams } from '../../utils/types'; + +/** + * Raw response item from the sessions/by-device endpoint. + */ +type SessionsByDeviceItem = { + device_type: string; + active_sessions: string; +}; + +/** + * Summary data from the sessions/by-device endpoint. + */ +type SessionsByDeviceSummary = { + active_sessions: string; + total_orders: string; + date_start: string; + date_end: string; +}; + +/** + * Raw response structure from the sessions/by-device endpoint. + */ +type ReportsSessionsByDeviceResponse = { + summary: SessionsByDeviceSummary; + data: SessionsByDeviceItem[]; +}; + +export type RequestReportSessionsByDeviceParams = Omit< BaseReportParams, 'interval' >; + +/** + * Fetch sessions by device type report data. + * + * This endpoint returns a breakdown of sessions by device category + * (Mobile, Desktop, Tablet) for the specified date range. + * + * @param params - Request parameters + * @param params.from - Start date in YYYY-MM-DD format + * @param params.to - End date in YYYY-MM-DD format + */ +export async function fetchReportSessionsByDevice( { + from, + to, +}: RequestReportSessionsByDeviceParams ): Promise< ReportsSessionsByDeviceResponse > { + const path = addQueryArgs( `${ reportsPath }/sessions/by-device`, { + from, + to, + } ); + + return apiFetch( { path } ) as Promise< ReportsSessionsByDeviceResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-top-posts-fetch/__tests__/report-top-posts-fetch.test.ts b/projects/packages/premium-analytics/packages/data/src/api/report-top-posts-fetch/__tests__/report-top-posts-fetch.test.ts new file mode 100644 index 000000000000..301568e49e74 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-top-posts-fetch/__tests__/report-top-posts-fetch.test.ts @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +/** + * Internal dependencies + */ +import { fetchReportTopPosts } from '../report-top-posts-fetch'; + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +const mockApiFetch = apiFetch as unknown as jest.Mock; + +describe( 'fetchReportTopPosts', () => { + beforeEach( () => { + mockApiFetch.mockReset(); + mockApiFetch.mockResolvedValue( { date: '2026-06-10', days: {} } ); + window.jpaConfig = { + siteId: 123, + apiRoot: 'https://example.com/wp-json/', + nonce: 'abc', + }; + } ); + + afterEach( () => { + delete window.jpaConfig; + } ); + + it( 'composes the stats proxy URL from the boot config site ID', async () => { + await fetchReportTopPosts( { period: 'day', date: '2026-06-10', num: 10 } ); + + expect( mockApiFetch ).toHaveBeenCalledWith( { + path: '/jetpack/v4/stats-app/sites/123/stats/top-posts?period=day&date=2026-06-10&max=10', + } ); + } ); + + it( 'omits max when num is not provided', async () => { + await fetchReportTopPosts( { period: 'week', date: '2026-06-10' } ); + + expect( mockApiFetch ).toHaveBeenCalledWith( { + path: '/jetpack/v4/stats-app/sites/123/stats/top-posts?period=week&date=2026-06-10', + } ); + } ); + + it( 'throws a clear error when the boot config is missing', async () => { + delete window.jpaConfig; + + await expect( fetchReportTopPosts( { period: 'day', date: '2026-06-10' } ) ).rejects.toThrow( + 'window.jpaConfig is not available' + ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-top-posts-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-top-posts-fetch/index.ts new file mode 100644 index 000000000000..3011256393a1 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-top-posts-fetch/index.ts @@ -0,0 +1,7 @@ +export { fetchReportTopPosts } from './report-top-posts-fetch'; +export type { + RequestReportTopPostsParams, + TopPostsPeriod, + TopPostsPostView, + TopPostsResponse, +} from './report-top-posts-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-top-posts-fetch/report-top-posts-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-top-posts-fetch/report-top-posts-fetch.ts new file mode 100644 index 000000000000..3b7bb99e82de --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-top-posts-fetch/report-top-posts-fetch.ts @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { getStatsApiPath } from '../constants'; + +export type TopPostsPeriod = 'day' | 'week' | 'month' | 'year'; + +export type TopPostsPostView = { + id: number; + href: string; + /** + * Publication date. `null` for pages without one (e.g. home, archives). + */ + date: string | null; + title: string; + type: string; + views: number; + video_play?: boolean; + public?: boolean; +}; + +type TopPostsBucket = { + postviews: TopPostsPostView[]; + total_views: number; +}; + +export type TopPostsResponse = { + date: string; + days: Record< string, TopPostsBucket >; + summary?: TopPostsBucket; +}; + +export type RequestReportTopPostsParams = { + period: TopPostsPeriod; + /** + * Reference date within the period, YYYY-MM-DD. + */ + date: string; + /** + * Maximum number of posts to return. + */ + num?: number; +}; + +/** + * Fetch the top-viewed posts/pages via the Jetpack Stats proxy + * (`jetpack/v4/stats-app`, provided by the jetpack-stats-admin package). + * + * @param params - Request parameters. + * @param params.period - Stats period granularity. + * @param params.date - Reference date within the period, YYYY-MM-DD. + * @param params.num - Maximum number of posts to return. + */ +export async function fetchReportTopPosts( { + period, + date, + num, +}: RequestReportTopPostsParams ): Promise< TopPostsResponse > { + // In the WPCOM stats API `max` caps the number of posts per period, while + // `num` counts periods — leave `num` at its server default of 1. + const path = addQueryArgs( `${ getStatsApiPath() }/top-posts`, { + period, + date, + max: num, + } ); + + return apiFetch( { path } ) as Promise< TopPostsResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/index.ts new file mode 100644 index 000000000000..18115cbe9222 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/index.ts @@ -0,0 +1,4 @@ +export { + fetchReportVisitorsByLocation, + type RequestReportVisitorsByLocationParams, +} from './report-visitors-by-location-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/report-visitors-by-location-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/report-visitors-by-location-fetch.ts new file mode 100644 index 000000000000..bbb9c7fa6193 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-by-location-fetch/report-visitors-by-location-fetch.ts @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { BaseReportParams } from '../../utils/types'; + +type VisitorsByLocationReportDataItem = { + country_code: string; + label: string; + region?: string; + visitors: string; +}; + +type ReportsVisitorsByLocationSummary = { + visitors: string; + date_start: string; + date_end: string; +}; + +type ReportsVisitorsByLocationResponse = { + data: VisitorsByLocationReportDataItem[]; + summary?: ReportsVisitorsByLocationSummary; +}; + +export type RequestReportVisitorsByLocationParams = BaseReportParams & { + group_by: 'country' | 'region'; + country_code?: string; + limit?: number; +}; + +/** + * Fetch visitors grouped by location (country or region) for the selected period. + * + * This endpoint is proxied through `/wc/v3/woocommerce-analytics/proxy/reports/...` + * and ultimately served by wpcom analytics. + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + * @param root0.group_by + * @param root0.country_code + * @param root0.limit + */ +export async function fetchReportVisitorsByLocation( { + from, + to, + interval, + group_by, + country_code, + limit, +}: RequestReportVisitorsByLocationParams ): Promise< ReportsVisitorsByLocationResponse > { + const path = addQueryArgs( `${ reportsPath }/sessions/by-location`, { + from, + to, + interval, + group_by, + country_code, + limit, + } ); + + return apiFetch( { path } ) as Promise< ReportsVisitorsByLocationResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/index.ts b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/index.ts new file mode 100644 index 000000000000..164ef5351eb7 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/index.ts @@ -0,0 +1 @@ +export { fetchReportVisitors, type RequestReportVisitorsParams } from './report-visitors-fetch'; diff --git a/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/report-visitors-fetch.ts b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/report-visitors-fetch.ts new file mode 100644 index 000000000000..93895fca50cd --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/api/report-visitors-fetch/report-visitors-fetch.ts @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { reportsPath } from '../constants'; +import type { BaseReportParams } from '../../utils/types'; + +type ReportsVisitorsByDateSummary = { + active_sessions: string; + date_end: string; + date_start: string; + visitors: string; +}; + +type VisitorsReportDataItem = ReportsVisitorsByDateSummary & { + time_interval: string; +}; + +type ReportsVisitorsByDateResponse = { + data: VisitorsReportDataItem[]; + summary: ReportsVisitorsByDateSummary; +}; + +export type RequestReportVisitorsParams = BaseReportParams; + +/** + * + * @param root0 + * @param root0.from + * @param root0.to + * @param root0.interval + */ +export async function fetchReportVisitors( { + from, + to, + interval, +}: RequestReportVisitorsParams ): Promise< ReportsVisitorsByDateResponse > { + const path = addQueryArgs( `${ reportsPath }/sessions/by-date`, { + from, + to, + interval, + } ); + + return apiFetch( { path } ) as Promise< ReportsVisitorsByDateResponse >; +} diff --git a/projects/packages/premium-analytics/packages/data/src/defaults/__tests__/get-default-preset.test.ts b/projects/packages/premium-analytics/packages/data/src/defaults/__tests__/get-default-preset.test.ts new file mode 100644 index 000000000000..ab6da5ee50fb --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/defaults/__tests__/get-default-preset.test.ts @@ -0,0 +1,92 @@ +/** + * Mock WordPress dependencies so date.ts can load. The select mock + * returns site settings with timezone: 'UTC' so getSiteTimezone() + * returns UTC, making localTZDate create UTC-aware TZDates. + */ +jest.mock( '@wordpress/core-data', () => ( { + store: 'core', +} ) ); + +jest.mock( '@wordpress/data', () => ( { + select: jest.fn( () => ( { + getEntityRecord: jest.fn( () => ( { timezone: 'UTC' } ) ), + } ) ), +} ) ); + +jest.mock( '../../utils/ensure-core-settings', () => ( { + ensureCoreSettingsReady: jest.fn( () => Promise.resolve() ), +} ) ); +/** + * Internal dependencies + */ +import { getDefaultPreset, getDefaultQueryParams } from '../reports'; + +describe( 'getDefaultQueryParams - preset override', () => { + beforeEach( () => { + jest.useFakeTimers(); + jest.setSystemTime( new Date( '2025-03-15T12:00:00.000Z' ) ); + } ); + + afterEach( () => { + jest.useRealTimers(); + } ); + + it( 'defaults to last-30-days when no preset is given', () => { + expect( getDefaultQueryParams().preset ).toBe( 'last-30-days' ); + } ); + + it( 'uses today preset when passed', () => { + expect( getDefaultQueryParams( false, 'today' ).preset ).toBe( 'today' ); + } ); + + it( 'uses last-7-days preset when passed', () => { + expect( getDefaultQueryParams( false, 'last-7-days' ).preset ).toBe( 'last-7-days' ); + } ); + + it( 'uses last-30-days preset when passed', () => { + expect( getDefaultQueryParams( false, 'last-30-days' ).preset ).toBe( 'last-30-days' ); + } ); +} ); + +describe( 'getDefaultPreset', () => { + beforeEach( () => { + jest.useFakeTimers(); + jest.setSystemTime( new Date( '2025-03-15T12:00:00.000Z' ) ); + } ); + + afterEach( () => { + jest.useRealTimers(); + } ); + + it( 'returns last-30-days when no launched date', () => { + expect( getDefaultPreset() ).toBe( 'last-30-days' ); + } ); + + it( 'returns last-30-days for undefined', () => { + expect( getDefaultPreset( undefined ) ).toBe( 'last-30-days' ); + } ); + + it( 'returns today when store launched today', () => { + expect( getDefaultPreset( '2025-03-15T00:00:00Z' ) ).toBe( 'today' ); + } ); + + it( 'returns last-7-days when launched 3 days ago', () => { + expect( getDefaultPreset( '2025-03-12T00:00:00Z' ) ).toBe( 'last-7-days' ); + } ); + + it( 'returns last-7-days when launched exactly 7 days ago', () => { + expect( getDefaultPreset( '2025-03-08T00:00:00Z' ) ).toBe( 'last-7-days' ); + } ); + + it( 'returns last-30-days when launched 8 days ago', () => { + expect( getDefaultPreset( '2025-03-07T00:00:00Z' ) ).toBe( 'last-30-days' ); + } ); + + it( 'returns last-30-days when launched months ago', () => { + expect( getDefaultPreset( '2024-01-01T00:00:00Z' ) ).toBe( 'last-30-days' ); + } ); + + it( 'returns today when launched in the future', () => { + expect( getDefaultPreset( '2025-04-01T00:00:00Z' ) ).toBe( 'today' ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/data/src/defaults/index.ts b/projects/packages/premium-analytics/packages/data/src/defaults/index.ts new file mode 100644 index 000000000000..1231063dab14 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/defaults/index.ts @@ -0,0 +1 @@ +export { getDefaultPreset, getDefaultQueryParams } from './reports'; diff --git a/projects/packages/premium-analytics/packages/data/src/defaults/reports.ts b/projects/packages/premium-analytics/packages/data/src/defaults/reports.ts new file mode 100644 index 000000000000..76813df11046 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/defaults/reports.ts @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import { getComparisonRangeFromPreset } from '@jetpack-premium-analytics/datetime'; +import { differenceInCalendarDays, startOfDay } from 'date-fns'; +/** + * Internal dependencies + */ +import { + localTZDate, + dateToISOStringWithLocalTZ, + getDefaultIntervalForPeriod, + computeDateRangeFromPreset, + type PresetType, + type ReportParams, +} from '../utils'; + +const DEFAULT_PRESET: PresetType = 'last-30-days'; + +/** + * Pick the default date-range preset based on how long + * the store has been live. + * + * - Not launched / unknown → last-30-days (safe default) + * - Launched today → today + * - Launched ≤ 7 days ago → last-7-days + * - Launched > 7 days ago → last-30-days + * @param launchedDate + */ +export function getDefaultPreset( launchedDate?: string ): PresetType { + if ( ! launchedDate ) { + return DEFAULT_PRESET; + } + + const today = startOfDay( localTZDate() ); + const launched = startOfDay( localTZDate( launchedDate ) ); + const daysSinceLaunch = differenceInCalendarDays( today, launched ); + + if ( daysSinceLaunch <= 0 ) { + return 'today'; + } + + if ( daysSinceLaunch <= 7 ) { + return 'last-7-days'; + } + + return DEFAULT_PRESET; +} + +/** + * Build report query parameters (from, to, interval, preset) + * for the given date-range preset. Defaults to `last-30-days`. + * + * Callers that need a dynamic default (e.g. based on store + * age) should resolve the preset externally and pass it in. + * @param withComparison + * @param preset + */ +export const getDefaultQueryParams = ( + /** + * Include previous-period comparison range. + */ + withComparison: boolean = false, + + /** + * Date-range preset. Defaults to `last-30-days`. + */ + preset: PresetType = DEFAULT_PRESET +): ReportParams => { + const range = computeDateRangeFromPreset( preset ); + + if ( ! range ) { + throw new Error( `Unknown preset: ${ preset }` ); + } + + const { from: fromString, to: toString } = range; + + const interval = getDefaultIntervalForPeriod( undefined, fromString, toString ); + + if ( ! withComparison ) { + return { + from: fromString, + to: toString, + preset, + interval, + }; + } + + const from = localTZDate( new Date( fromString ) ); + const to = localTZDate( new Date( toString ) ); + + const comparisonParams = getComparisonRangeFromPreset( + { + from, + to, + }, + 'previous-period' + ); + + return { + from: fromString, + to: toString, + preset, + interval, + compare_from: comparisonParams?.from + ? dateToISOStringWithLocalTZ( comparisonParams?.from ) + : undefined, + compare_to: comparisonParams?.to + ? dateToISOStringWithLocalTZ( comparisonParams?.to ) + : undefined, + compare_preset: 'previous-period', + comp: '1', + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/index.ts b/projects/packages/premium-analytics/packages/data/src/hooks/index.ts new file mode 100644 index 000000000000..5fa16b2b37ac --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/index.ts @@ -0,0 +1,13 @@ +export { useReportOrders } from './use-report-orders'; +export { useReportOrderAttribution } from './use-report-order-attribution'; +export { useReportCoupons } from './use-report-coupons'; +export { useReportCouponsByDate } from './use-report-coupons-by-date'; +export { useReportCustomers } from './use-report-customers'; +export { useReportConversionRate } from './use-report-conversion-rate'; +export { useReportBookings } from './use-report-bookings'; +export { useReportTopPosts } from './use-report-top-posts'; + +/** + * @deprecated Use individual hooks instead: useReportOrders, useReportOrderAttribution, useReportCoupons + */ +export { useReport } from './use-report'; diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-product-images.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-product-images.ts new file mode 100644 index 000000000000..4c76b44e3610 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-product-images.ts @@ -0,0 +1,90 @@ +/** + * External dependencies + */ +import { useQuery } from '@tanstack/react-query'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +/** + * Internal dependencies + */ +import { sanitizeReportProductsResponse } from '../processing/products'; +import type { ProductImage } from '../types/product-image'; + +// Infer the product ID type from the sanitized products response +type ProductId = ReturnType< + typeof sanitizeReportProductsResponse +>[ 'data' ][ number ][ 'product_id' ]; + +export interface UseProductImagesParams { + productIds: ProductId[]; +} + +export interface ProductImageResponse { + id: number; + name: string; + images: { + id: number; + src: string; + name: string; + alt: string; + }[]; +} + +/** + * Fetches product images from the WooCommerce REST API + * @param productIds + */ +async function fetchProductImages( + productIds: ProductId[] +): Promise< ( ProductImage & { productId: ProductId } )[] > { + if ( ! productIds.length ) { + return []; + } + + // Use the include parameter to get only the products we need + const queryArgs = { + include: productIds.join( ',' ), + per_page: productIds.length, + }; + + try { + const response = await apiFetch< ProductImageResponse[] >( { + path: addQueryArgs( '/wc/v3/products', queryArgs ), + } ); + + return response.map( product => ( { + productId: product.id, + imageUrl: product.images?.[ 0 ]?.src || '', + imageAlt: product.images?.[ 0 ]?.alt || product.name, + } ) ); + } catch { + return []; + } +} + +const getProductImagesQueryKey = ( params: UseProductImagesParams ) => + [ 'product-images', params.productIds.sort().join( ',' ) ] as const; + +/** + * Hook to fetch product images for a list of product IDs + * @param params - Object containing the list of product IDs to fetch images for + */ +export function useProductImages( params: UseProductImagesParams ) { + return useQuery( { + queryKey: getProductImagesQueryKey( params ), + queryFn: async () => { + const images = await fetchProductImages( params.productIds ); + return images.reduce( + ( acc: Record< number, ProductImage >, image: ProductImage & { productId: number } ) => { + acc[ image.productId ] = { + imageUrl: image.imageUrl, + imageAlt: image.imageAlt, + }; + return acc; + }, + {} + ); + }, + enabled: params.productIds.length > 0, + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-bookings.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-bookings.ts new file mode 100644 index 000000000000..7c50d401510d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-bookings.ts @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { reportBookingsQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +/** + * + * @param params + */ +export function useReportBookings( params: ReportParams ) { + return useReport( p => reportBookingsQuery( p ), params, { + disabledComparisonKey: [ 'reports', 'bookings', 'by-date', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-conversion-rate.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-conversion-rate.ts new file mode 100644 index 000000000000..b27ec3f9360f --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-conversion-rate.ts @@ -0,0 +1,25 @@ +/** + * Internal dependencies + */ +import { reportConversionRateQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportConversionRateOptions = { + enabled?: boolean; +}; + +/** + * + * @param params + * @param options + */ +export function useReportConversionRate( + params: ReportParams, + options?: UseReportConversionRateOptions +) { + return useReport( p => reportConversionRateQuery( p ), params, { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'conversion-rate', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons-by-date.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons-by-date.ts new file mode 100644 index 000000000000..a88b7a7b282b --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons-by-date.ts @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { reportCouponsByDateQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +/** + * + * @param params + */ +export function useReportCouponsByDate( params: ReportParams ) { + return useReport( p => reportCouponsByDateQuery( p ), params, { + disabledComparisonKey: [ 'reports', 'couponsByDate', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons.ts new file mode 100644 index 000000000000..205e0d4780af --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-coupons.ts @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { reportCouponsQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +/** + * + * @param params + */ +export function useReportCoupons( params: ReportParams ) { + return useReport( p => reportCouponsQuery( p ), params, { + disabledComparisonKey: [ 'reports', 'coupons', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers-by-date.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers-by-date.ts new file mode 100644 index 000000000000..08217edd8423 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers-by-date.ts @@ -0,0 +1,25 @@ +/** + * Internal dependencies + */ +import { reportCustomersByDateQuery } from '../queries/report-customers-by-date-query'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportCustomersByDateOptions = { + enabled?: boolean; +}; + +/** + * + * @param params + * @param options + */ +export function useReportCustomersByDate( + params: ReportParams, + options?: UseReportCustomersByDateOptions +) { + return useReport( p => reportCustomersByDateQuery( p ), params, { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'customers', 'by-date', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers.ts new file mode 100644 index 000000000000..2fa65e6724ec --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-customers.ts @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import { reportCustomersQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +/** + * + * @param params + */ +export function useReportCustomers( params: ReportParams ) { + return useReport( p => reportCustomersQuery( p ), params, { + disabledComparisonKey: [ + 'reports', + 'customers', + 'new-returning', + '__comparison__', + 'disabled', + ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-order-attribution.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-order-attribution.ts new file mode 100644 index 000000000000..6377f6e68735 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-order-attribution.ts @@ -0,0 +1,66 @@ +/** + * Internal dependencies + */ +import { reportOrderAttributionSummaryQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportOrderAttributionOptions = { + enabled?: boolean; +}; + +const DISABLED_COMPARISON_KEY = [ + 'reports', + 'order-attribution', + '__comparison__', + 'included-in-primary', +]; + +/** + * + * @param params + * @param options + */ +export function useReportOrderAttribution( + params: ReportParams, + options?: UseReportOrderAttributionOptions +) { + /* + * Compare from and to are required for order attribution summary query. + * When they aren't provided, use the same dates as the primary period. + */ + const compareFrom = params.compare_from ?? params.from; + const compareTo = params.compare_to ?? params.to; + + return useReport( + ( p, queryType ) => { + // Order attribution requires the view parameter + if ( ! params.view ) { + return { + queryKey: [ 'reports', 'order-attribution', '__disabled__', 'no-view-param' ], + enabled: false, + }; + } + + if ( queryType === 'comparison' ) { + return { + queryKey: DISABLED_COMPARISON_KEY, + enabled: false, + }; + } + + return reportOrderAttributionSummaryQuery( { + ...p, + view: params.view, + compare_from: compareFrom, + compare_to: compareTo, + date_type: params.date_type, + } ); + }, + params, + { + enabled: options?.enabled, + disabledComparisonKey: DISABLED_COMPARISON_KEY, + } + ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-orders.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-orders.ts new file mode 100644 index 000000000000..89cf74039adb --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-orders.ts @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import { reportOrdersQuery } from '../queries'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportOrdersOptions = { + enabled?: boolean; +}; + +/** + * + * @param params + * @param options + */ +export function useReportOrders( params: ReportParams, options?: UseReportOrdersOptions ) { + return useReport( p => reportOrdersQuery( p ), params, { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'orders', 'by-date', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-products.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-products.ts new file mode 100644 index 000000000000..905477389c79 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-products.ts @@ -0,0 +1,17 @@ +/** + * Internal dependencies + */ +import { reportProductsQuery } from '../queries/report-products-query'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +/** + * + * @param params + * @param limit + */ +export function useReportProducts( params: ReportParams, limit = 5 ) { + return useReport( p => reportProductsQuery( { ...p, limit } ), params, { + disabledComparisonKey: [ 'reports', 'products', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-sessions-by-device.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-sessions-by-device.ts new file mode 100644 index 000000000000..7ac25ef8f69b --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-sessions-by-device.ts @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +import { reportSessionsByDeviceQuery } from '../queries/report-sessions-by-device-query'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportSessionsByDeviceOptions = { + enabled?: boolean; +}; + +/** + * Hook for fetching sessions by device type report data. + * + * Returns a breakdown of website sessions by device category + * (Mobile, Desktop, Tablet) for the specified date range. + * + * @param params - Report parameters including date range and comparison dates + * @param options - Optional configuration + * + * @example + * ```typescript + * const { primary, comparison, hasComparison, isLoading, hasData } = + * useReportSessionsByDevice( reportParams ); + * + * // Access the data + * const { summary, data } = primary.data; + * const totalSessions = summary.total_sessions; + * ``` + */ +export function useReportSessionsByDevice( + params: ReportParams, + options?: UseReportSessionsByDeviceOptions +) { + return useReport( p => reportSessionsByDeviceQuery( p ), params, { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'sessions', 'by-device', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-top-posts.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-top-posts.ts new file mode 100644 index 000000000000..15555ad63acc --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-top-posts.ts @@ -0,0 +1,142 @@ +/** + * External dependencies + */ +import { useQuery } from '@tanstack/react-query'; +import { format, parseISO, startOfMonth, startOfWeek, startOfYear } from 'date-fns'; +import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import { fetchReportTopPosts } from '../api/report-top-posts-fetch'; +import type { TopPostsPeriod, TopPostsResponse } from '../api/report-top-posts-fetch'; + +export type UseReportTopPostsParams = { + period: TopPostsPeriod; + /** + * Reference date within the period, YYYY-MM-DD. + */ + date: string; + /** + * Maximum number of posts to return. + */ + num?: number; + /** + * Filter kind. Only `postType` is supported in v1. + */ + kind?: 'postType'; + /** + * Post type(s) to keep, e.g. `'post'`, `'page'`, `[ 'post', 'page' ]`. + * When undefined, no filtering is applied. + */ + name?: string | string[]; +}; + +type UseReportTopPostsOptions = { + enabled?: boolean; +}; + +export type TopPostRow = { + label: string; + value: number; + href: string; + type: string; +}; + +/** + * Compute the start date of the stats period containing `date`, which is the + * key the WPCOM stats API uses for the matching bucket in `days`. Port of + * Calypso's `rangeOfPeriod` (wp-calypso `client/state/stats/lists/utils.js`), + * start side only. + * + * @param period - Stats period granularity. + * @param date - Reference date, YYYY-MM-DD. + */ +function periodStartDate( period: TopPostsPeriod, date: string ): string { + const parsed = parseISO( date ); + + switch ( period ) { + case 'week': + // WPCOM stats weeks run Monday through Sunday. + return format( startOfWeek( parsed, { weekStartsOn: 1 } ), 'yyyy-MM-dd' ); + case 'month': + return format( startOfMonth( parsed ), 'yyyy-MM-dd' ); + case 'year': + return format( startOfYear( parsed ), 'yyyy-MM-dd' ); + case 'day': + default: + return date; + } +} + +/** + * Normalize a WPCOM top-posts response into flat rows. Port of Calypso's + * `statsTopPosts` normalizer (wp-calypso `client/state/stats/lists/utils.js`) + * minus the Calypso UI fields. + * + * @param response - Raw top-posts response. + * @param period - Stats period granularity used in the request. + * @param date - Reference date used in the request, YYYY-MM-DD. + */ +function normalizeTopPosts( + response: TopPostsResponse, + period: TopPostsPeriod, + date: string +): TopPostRow[] { + const bucket = response.days?.[ periodStartDate( period, date ) ]; + + return ( bucket?.postviews ?? [] ).map( item => ( { + label: item.title, + value: item.views, + href: item.href, + type: item.type, + } ) ); +} + +/** + * Fetch the top-viewed posts/pages for the site. + * + * Unlike the WooCommerce report hooks this uses `useQuery` directly — stats + * has no comparison-period concept in v1, so `useReport` does not apply. + * + * @param params - Report parameters. + * @param options - Optional configuration. + * @return The react-query result fields plus `rows`, the normalized + * `{ label, value, href, type }` rows filtered by `params.name`. + */ +export function useReportTopPosts( + params: UseReportTopPostsParams, + options?: UseReportTopPostsOptions +) { + const { period, date, num, name } = params; + + const query = useQuery( { + queryKey: [ 'stats', 'top-posts', period, date, num ?? null ], + queryFn: () => fetchReportTopPosts( { period, date, num } ), + enabled: options?.enabled ?? true, + } ); + + const nameKey = Array.isArray( name ) ? name.join( ',' ) : name; + const { data } = query; + + const rows = useMemo( () => { + if ( ! data ) { + return []; + } + + const allowedTypes = nameKey === undefined ? null : nameKey.split( ',' ); + + return normalizeTopPosts( data, period, date ).filter( + row => ! allowedTypes || allowedTypes.includes( row.type ) + ); + }, [ data, period, date, nameKey ] ); + + return { + data: query.data, + isLoading: query.isLoading, + isFetching: query.isFetching, + isError: query.isError, + error: query.error, + refetch: query.refetch, + rows, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors-by-location.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors-by-location.ts new file mode 100644 index 000000000000..ac9b8146508d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors-by-location.ts @@ -0,0 +1,38 @@ +/** + * Internal dependencies + */ +import { reportVisitorsByLocationQuery } from '../queries/report-visitors-by-location-query'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportVisitorsByLocationOptions = { + enabled?: boolean; + groupBy?: 'country' | 'region'; + countryCode?: string; + limit?: number; +}; + +/** + * + * @param params + * @param options + */ +export function useReportVisitorsByLocation( + params: ReportParams, + options?: UseReportVisitorsByLocationOptions +) { + return useReport( + p => + reportVisitorsByLocationQuery( { + ...p, + group_by: options?.groupBy ?? 'country', + country_code: options?.countryCode, + limit: options?.limit, + } ), + params, + { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'visitors', 'by-location', '__comparison__', 'disabled' ], + } + ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors.ts new file mode 100644 index 000000000000..b6eafd7a3f3f --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report-visitors.ts @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import { reportVisitorsQuery } from '../queries/report-visitors-query'; +import { type ReportParams } from '../utils/search'; +import { useReport } from './use-report'; + +type UseReportVisitorsOptions = { + enabled?: boolean; +}; + +/** + * + * @param params + * @param options + */ +export function useReportVisitors( params: ReportParams, options?: UseReportVisitorsOptions ) { + return useReport( p => reportVisitorsQuery( p ), params, { + enabled: options?.enabled, + disabledComparisonKey: [ 'reports', 'visitors', 'by-date', '__comparison__', 'disabled' ], + } ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/hooks/use-report.ts b/projects/packages/premium-analytics/packages/data/src/hooks/use-report.ts new file mode 100644 index 000000000000..e72a203f2e80 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/hooks/use-report.ts @@ -0,0 +1,152 @@ +/** + * External dependencies + */ +import { useQuery, type UseQueryOptions } from '@tanstack/react-query'; +import { useCallback } from 'react'; +/** + * Internal dependencies + */ +import { hasComparisonEnabled, type ReportParams } from '../utils/search'; + +type UseReportOptions = { + enabled?: boolean; + disabledComparisonKey?: string[]; +}; + +type QueryFactory< TData > = ( + params: any, + queryType: 'primary' | 'comparison' +) => UseQueryOptions< TData >; + +/** + * Generic hook for fetching report data with comparison support. + * + * This hook handles the common pattern of fetching primary and comparison + * data for analytics reports. It automatically manages the comparison query + * based on the presence of comparison dates in the params. + * + * @template TData - The type of data returned by the query + * + * @param queryFactory - Function that creates a query options object from params + * @param params - Report parameters including dates, filters, and comparison dates + * @param options - Optional configuration + * @param options.enabled - Whether the queries should be enabled (default: true) + * @param options.disabledComparisonKey - Query key to use when comparison is disabled + * + * @return Object containing primary and comparison query results + * + * @example + * ```typescript + * const { primary, comparison, hasComparison, isLoading, hasData } = useReport( + * (params) => reportOrdersQuery(params, hasProductFilters), + * reportParams, + * { + * enabled: true, + * disabledComparisonKey: ['reports', 'orders', '__comparison__', 'disabled'], + * } + * ); + * ``` + */ +export function useReport< TData >( + queryFactory: QueryFactory< TData >, + params: ReportParams, + options?: UseReportOptions +) { + const queryEnabled = options?.enabled ?? true; + const comparisonEnabled = hasComparisonEnabled( params ); + + // Create primary query + const primaryQueryOptions = queryFactory( + { + from: params.from, + to: params.to, + interval: params.interval, + filters: params.filters, + date_type: params.date_type, + }, + 'primary' + ); + + // Create comparison query if comparison is enabled + const comparisonQueryOptions = comparisonEnabled + ? queryFactory( + { + from: params.compare_from, + to: params.compare_to, + interval: params.interval, + filters: params.filters, + date_type: params.date_type, + }, + 'comparison' + ) + : { + queryKey: options?.disabledComparisonKey ?? [ 'reports', '__comparison__', 'disabled' ], + }; + + const primary = useQuery( { + ...primaryQueryOptions, + enabled: queryEnabled && ( primaryQueryOptions.enabled ?? true ), + } ); + + const comparison = useQuery( { + ...comparisonQueryOptions, + enabled: queryEnabled && comparisonEnabled && ( comparisonQueryOptions.enabled ?? true ), + } ); + + // Compute common derived states + const isLoading = primary.isLoading || comparison.isLoading; + const isFetching = primary.isFetching || comparison.isFetching; + + /** + * Check if data exists using standardized response fields. + * + * All sanitized report responses follow a consistent structure: + * - `summary`: Always present (aggregated metrics) + * - `data`: Array of time-series or items (orders, bookings, products, etc.) + * - `steps`: Array of funnel steps (conversion-rate only) + * + * We check multiple fields because different endpoints return different combinations: + * - Time-series reports (orders, bookings, visitors): { summary, data } + * - Conversion funnel: { summary, data, steps, overallRate } + * - List reports (products, coupons): { summary, data } + * + * The use of `as any` is intentional here to handle the generic TData type, + * since we cannot add constraints to the generic without breaking existing usage. + * + * Note: With placeholderData enabled in queries, this hasData check is sufficient + * to determine loading states: + * - If hasData is true: We have data to display (show with loading indicator if fetching) + * - If hasData is false: No data to display (show skeleton) + */ + const hasData = + Boolean( ( primary.data as any )?.summary ) || + Boolean( ( primary.data as any )?.data?.length ) || + Boolean( ( primary.data as any )?.steps?.length ) || + Boolean( ( comparison.data as any )?.summary ) || + Boolean( ( comparison.data as any )?.data?.length ) || + Boolean( ( comparison.data as any )?.steps?.length ); + + // Combined refetch function that refetches both queries. + // If both primary and comparison queries fail, clicking "Retry" should refetch both. + const primaryRefetch = primary.refetch; + const comparisonRefetch = comparison.refetch; + const refetch = useCallback( async () => { + await Promise.all( [ + primaryRefetch(), + comparisonEnabled ? comparisonRefetch() : Promise.resolve(), + ] ); + }, [ comparisonEnabled, primaryRefetch, comparisonRefetch ] ); + + return { + primary, + comparison, + hasComparison: comparisonEnabled, + isLoading, + isFetching, + hasData, + // Error handling + isError: primary.isError || comparison.isError, + error: primary.error ?? comparison.error, + refetch, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/index.ts b/projects/packages/premium-analytics/packages/data/src/index.ts new file mode 100644 index 000000000000..175c5ed70585 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/index.ts @@ -0,0 +1,48 @@ +export { AnalyticsQueryClientProvider, queryClient } from './providers/query-client-provider'; +export { GlobalErrorProvider, useGlobalError } from './providers/global-error-context'; +export { globalErrorManager, type GlobalErrorType } from './providers/global-error-manager'; +export { useReportOrders } from './hooks/use-report-orders'; +export { useReportOrderAttribution } from './hooks/use-report-order-attribution'; +export { useReportCoupons } from './hooks/use-report-coupons'; +export { useReportCouponsByDate } from './hooks/use-report-coupons-by-date'; +export { useReportCustomers } from './hooks/use-report-customers'; +export { useReportCustomersByDate } from './hooks/use-report-customers-by-date'; +export { useReportConversionRate } from './hooks/use-report-conversion-rate'; +export { useReportProducts } from './hooks/use-report-products'; +export { useProductImages } from './hooks/use-product-images'; +export { useReportVisitors } from './hooks/use-report-visitors'; +export { useReportVisitorsByLocation } from './hooks/use-report-visitors-by-location'; +export { useReportBookings } from './hooks/use-report-bookings'; +export { useReportSessionsByDevice } from './hooks/use-report-sessions-by-device'; +export { + useReportTopPosts, + type UseReportTopPostsParams, + type TopPostRow, +} from './hooks/use-report-top-posts'; +export { getJpaConfig, type JpaConfig } from './utils/jpa-config'; +export type { TopPostsPeriod, TopPostsResponse } from './api'; +export { prefetchReport } from './prefetch'; +export { + normalizeReportParams, + hasComparisonEnabled, + type PresetType, + type ReportParams, +} from './utils/search'; +export { + dateToISOStringWithLocalTZ, + ensureCoreSettingsReady, + getSiteTimezone, + getSiteGmtOffset, + localTZDate, + hasProductFilters, + isSelectablePreset, +} from './utils'; +export type { ReportDataMap } from './types'; +export type { ReportQueryParams } from './api'; +export type { FilterCondition } from './types/filter-condition'; +export type { ProductType } from './types/product-type'; +export { ORDER_ATTRIBUTION_VIEWS } from './api/report-order-attribution-summary-fetch'; +export { getDefaultIntervalForPeriod, getDateFormatFromInterval } from './utils/interval'; +export { getDefaultPreset, getDefaultQueryParams } from './defaults'; +export { exportReport } from './api'; +export type { ExportReportParams, ExportReportResponse } from './api'; diff --git a/projects/packages/premium-analytics/packages/data/src/prefetch/index.ts b/projects/packages/premium-analytics/packages/data/src/prefetch/index.ts new file mode 100644 index 000000000000..88eaef8a0f2a --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/prefetch/index.ts @@ -0,0 +1 @@ +export { prefetchReport } from './prefetch-report'; diff --git a/projects/packages/premium-analytics/packages/data/src/prefetch/prefetch-report.ts b/projects/packages/premium-analytics/packages/data/src/prefetch/prefetch-report.ts new file mode 100644 index 000000000000..b73aac40dca0 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/prefetch/prefetch-report.ts @@ -0,0 +1,103 @@ +/** + * Internal dependencies + */ +import { queryClient } from '../providers'; +import { + reportOrdersQuery, + reportOrderAttributionSummaryQuery, + reportCouponsQuery, + reportCouponsByDateQuery, + reportCustomersQuery, + reportCustomersByDateQuery, + reportVisitorsQuery, + reportVisitorsByLocationQuery, + reportSessionsByDeviceQuery, + reportProductsQuery, + reportConversionRateQuery, +} from '../queries'; + +type RequestReportParamsMap = { + orders: Parameters< typeof reportOrdersQuery >[ 0 ]; + 'order-attribution': Parameters< typeof reportOrderAttributionSummaryQuery >[ 0 ]; + coupons: Parameters< typeof reportCouponsQuery >[ 0 ]; + 'coupons-by-date': Parameters< typeof reportCouponsByDateQuery >[ 0 ]; + customers: Parameters< typeof reportCustomersQuery >[ 0 ]; + 'customers-by-date': Parameters< typeof reportCustomersByDateQuery >[ 0 ]; + visitors: Parameters< typeof reportVisitorsQuery >[ 0 ]; + 'visitors-by-location': Parameters< typeof reportVisitorsByLocationQuery >[ 0 ]; + 'sessions-by-device': Parameters< typeof reportSessionsByDeviceQuery >[ 0 ]; + products: Parameters< typeof reportProductsQuery >[ 0 ]; + 'conversion-rate': Parameters< typeof reportConversionRateQuery >[ 0 ]; +}; + +/** + * + * @param reportType + * @param params + */ +export async function prefetchReport< T extends keyof RequestReportParamsMap >( + reportType: T = 'orders' as T, + params: RequestReportParamsMap[ T ] +) { + switch ( reportType ) { + case 'orders': + return queryClient.ensureQueryData( + reportOrdersQuery( params as RequestReportParamsMap[ 'orders' ] ) + ); + + case 'order-attribution': + return queryClient.ensureQueryData( + reportOrderAttributionSummaryQuery( + params as RequestReportParamsMap[ 'order-attribution' ] + ) + ); + + case 'coupons': + return queryClient.ensureQueryData( + reportCouponsQuery( params as RequestReportParamsMap[ 'coupons' ] ) + ); + + case 'coupons-by-date': + return queryClient.ensureQueryData( + reportCouponsByDateQuery( params as RequestReportParamsMap[ 'coupons-by-date' ] ) + ); + + case 'customers': + return queryClient.ensureQueryData( + reportCustomersQuery( params as RequestReportParamsMap[ 'customers' ] ) + ); + + case 'customers-by-date': + return queryClient.ensureQueryData( + reportCustomersByDateQuery( params as RequestReportParamsMap[ 'customers-by-date' ] ) + ); + + case 'visitors': + return queryClient.ensureQueryData( + reportVisitorsQuery( params as RequestReportParamsMap[ 'visitors' ] ) + ); + + case 'visitors-by-location': + return queryClient.ensureQueryData( + reportVisitorsByLocationQuery( params as RequestReportParamsMap[ 'visitors-by-location' ] ) + ); + + case 'sessions-by-device': + return queryClient.ensureQueryData( + reportSessionsByDeviceQuery( params as RequestReportParamsMap[ 'sessions-by-device' ] ) + ); + + case 'products': + return queryClient.ensureQueryData( + reportProductsQuery( params as RequestReportParamsMap[ 'products' ] ) + ); + + case 'conversion-rate': + return queryClient.ensureQueryData( + reportConversionRateQuery( params as RequestReportParamsMap[ 'conversion-rate' ] ) + ); + + default: + throw new Error( `Unsupported report type: ${ reportType }` ); + } +} diff --git a/projects/packages/premium-analytics/packages/data/src/processing/bookings/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/bookings/index.ts new file mode 100644 index 000000000000..7073cb9e7d32 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/bookings/index.ts @@ -0,0 +1,105 @@ +/** + * Internal dependencies + */ +import { fetchReportBookings } from '../../api/report-bookings-fetch'; +import { safeParseInt } from '../../utils/parsing'; +import type { Override } from '../../utils/types'; + +type ReportsBookingsByDateResponse = Awaited< ReturnType< typeof fetchReportBookings > >; +type RawBookingsReportDataItem = ReportsBookingsByDateResponse[ 'data' ][ number ]; +type RawBookingsReportSummaryItem = ReportsBookingsByDateResponse[ 'summary' ]; + +type SanitizedBookingsByDateItem = Override< + RawBookingsReportDataItem, + { + status_unpaid: number; + status_pending_confirmation: number; + status_confirmed: number; + status_paid: number; + status_cancelled: number; + status_complete: number; + attendance_status_booked: number; + attendance_status_no_show: number; + attendance_status_checked_in: number; + } +>; + +type SanitizedBookingsSummaryItem = Override< + RawBookingsReportSummaryItem, + { + status_unpaid: number; + status_pending_confirmation: number; + status_confirmed: number; + status_paid: number; + status_cancelled: number; + status_complete: number; + attendance_status_booked: number; + attendance_status_no_show: number; + attendance_status_checked_in: number; + } +>; + +/** + * Sanitize/process a single booking item by converting strings to numbers + * @param item + */ +function sanitizeBookingItem( item: RawBookingsReportDataItem ): SanitizedBookingsByDateItem { + return { + ...item, + status_unpaid: safeParseInt( item.status_unpaid ), + status_pending_confirmation: safeParseInt( item.status_pending_confirmation ), + status_confirmed: safeParseInt( item.status_confirmed ), + status_paid: safeParseInt( item.status_paid ), + status_cancelled: safeParseInt( item.status_cancelled ), + status_complete: safeParseInt( item.status_complete ), + attendance_status_booked: safeParseInt( item.attendance_status_booked ), + attendance_status_no_show: safeParseInt( item.attendance_status_no_show ), + attendance_status_checked_in: safeParseInt( item.attendance_status_checked_in ), + }; +} + +/** + * Sanitize/process a single booking summary item by converting strings to numbers + * @param item + */ +function sanitizeBookingSummaryItem( + item: RawBookingsReportSummaryItem +): SanitizedBookingsSummaryItem { + return { + ...item, + status_unpaid: safeParseInt( item.status_unpaid ), + status_pending_confirmation: safeParseInt( item.status_pending_confirmation ), + status_confirmed: safeParseInt( item.status_confirmed ), + status_paid: safeParseInt( item.status_paid ), + status_cancelled: safeParseInt( item.status_cancelled ), + status_complete: safeParseInt( item.status_complete ), + attendance_status_booked: safeParseInt( item.attendance_status_booked ), + attendance_status_no_show: safeParseInt( item.attendance_status_no_show ), + attendance_status_checked_in: safeParseInt( item.attendance_status_checked_in ), + }; +} + +/** + * Processed response with numeric values + */ +type SanitizedBookingsByDateResponse = { + summary: SanitizedBookingsSummaryItem; + data: SanitizedBookingsByDateItem[]; +}; + +/** + * Sanitize the response from the reports/bookings/by-date endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` and `data` items have different structures (summary lacks time_interval), + * so we use different sanitizer functions for each. + * @param response + */ +export const sanitizeReportBookingsResponse = ( + response: ReportsBookingsByDateResponse +): SanitizedBookingsByDateResponse => { + return { + summary: sanitizeBookingSummaryItem( response.summary ), + data: response.data.map( sanitizeBookingItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/conversion-rate/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/conversion-rate/index.ts new file mode 100644 index 000000000000..1135cf3a43f0 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/conversion-rate/index.ts @@ -0,0 +1,144 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { fetchReportConversionRate } from '../../api/report-conversion-rate-fetch'; +import { safeParseInt } from '../../utils/parsing'; +import type { Override } from '../../utils/types'; + +type ReportsConversionRateByDateResponse = Awaited< + ReturnType< typeof fetchReportConversionRate > +>; +type RawConversionRateReportDataItem = ReportsConversionRateByDateResponse[ 'data' ][ number ]; +type SanitizedConversionRateByDateItem = Override< + RawConversionRateReportDataItem, + { + active_sessions: number; + visitors: number; + with_cart_addition: number; + reached_checkout: number; + completed_checkout: number; + conversion_rate: number; // calculated field + } +>; + +/** + * Sanitize/process a single conversion rate item by converting strings to numbers + * and calculating the conversion rate + * @param item + */ +function sanitizeConversionRateItem( + item: RawConversionRateReportDataItem +): SanitizedConversionRateByDateItem { + const activeSessionsNum = safeParseInt( item.active_sessions ); + const visitorsNum = safeParseInt( item.visitors ); + const withCartAdditionNum = safeParseInt( item.with_cart_addition ); + const reachedCheckoutNum = safeParseInt( item.reached_checkout ); + const completedCheckoutNum = safeParseInt( item.completed_checkout ); + + // Calculate conversion rate as decimal (e.g., 0.035 for 3.5%) + // This format works with formatMetricValue 'percentage' type + const conversionRate = activeSessionsNum > 0 ? completedCheckoutNum / activeSessionsNum : 0; + + return { + ...item, + active_sessions: activeSessionsNum, + visitors: visitorsNum, + with_cart_addition: withCartAdditionNum, + reached_checkout: reachedCheckoutNum, + completed_checkout: completedCheckoutNum, + conversion_rate: conversionRate, + }; +} + +/** + * Funnel step for conversion rate visualization + */ +type FunnelStep = { + id: string; + label: string; + count: number; + rate: number; +}; + +/** + * Processed response with funnel steps and overall conversion rate + */ +type SanitizedConversionRateByDateResponse = { + summary: SanitizedConversionRateByDateItem; + data: SanitizedConversionRateByDateItem[]; + steps: FunnelStep[]; + overallRate: number; +}; + +/** + * Sanitize the response from the sessions/by-conversion-rate endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` single item has basically the same structure + * as the `data` array items, so we can use the same mapper function for both. + * @param response + */ +export const sanitizeReportConversionRateResponse = ( + response: ReportsConversionRateByDateResponse +): SanitizedConversionRateByDateResponse => { + // Handle cases where response might not have the expected structure + const defaultSummary = { + active_sessions: '0', + visitors: '0', + with_cart_addition: '0', + reached_checkout: '0', + completed_checkout: '0', + date_start: '', + date_end: '', + }; + + const sanitizedSummary = sanitizeConversionRateItem( response?.summary || defaultSummary ); + + // Create funnel steps from the summary data + const steps: FunnelStep[] = [ + { + id: 'sessions', + label: __( 'Sessions', 'jetpack-premium-analytics' ), + count: sanitizedSummary.active_sessions, + rate: 100, // Starting point + }, + { + id: 'cart-addition', + label: __( 'Cart', 'jetpack-premium-analytics' ), + count: sanitizedSummary.with_cart_addition, + rate: + sanitizedSummary.active_sessions > 0 + ? ( sanitizedSummary.with_cart_addition / sanitizedSummary.active_sessions ) * 100 + : 0, + }, + { + id: 'checkout', + label: __( 'Checkout', 'jetpack-premium-analytics' ), + count: sanitizedSummary.reached_checkout, + rate: + sanitizedSummary.active_sessions > 0 + ? ( sanitizedSummary.reached_checkout / sanitizedSummary.active_sessions ) * 100 + : 0, + }, + { + id: 'completed', + label: __( 'Purchase', 'jetpack-premium-analytics' ), + count: sanitizedSummary.completed_checkout, + rate: + sanitizedSummary.active_sessions > 0 + ? ( sanitizedSummary.completed_checkout / sanitizedSummary.active_sessions ) * 100 + : 0, + }, + ]; + + return { + summary: sanitizedSummary, + data: response?.data ? response.data.map( sanitizeConversionRateItem ) : [], + steps, + overallRate: sanitizedSummary.conversion_rate, + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/coupons-by-date/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/coupons-by-date/index.ts new file mode 100644 index 000000000000..c8af73181fce --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/coupons-by-date/index.ts @@ -0,0 +1,105 @@ +/** + * Internal dependencies + */ +import { fetchReportCouponsByDate } from '../../api/report-coupons-by-date-fetch'; +import type { Override } from '../../utils/types'; + +type ReportsCouponsByDateResponse = Awaited< ReturnType< typeof fetchReportCouponsByDate > >; +type RawSummary = ReportsCouponsByDateResponse[ 'summary' ]; +type RawDataItem = ReportsCouponsByDateResponse[ 'data' ][ number ]; + +/** + * Processed summary with numeric values. + */ +type SanitizedCouponsByDateSummary = Override< + RawSummary, + { + total_orders: number; + orders_with_coupon: number; + orders_without_coupon: number; + total_sales: number; + sales_with_coupon: number; + sales_without_coupon: number; + total_discount_amount: number; + net_sales_after_discount: number; + coupon_usage_percentage: number; + } +>; + +/** + * Processed data item with numeric values. + */ +type SanitizedCouponsByDateDataItem = Override< + RawDataItem, + { + total_orders: number; + orders_with_coupon: number; + orders_without_coupon: number; + total_sales: number; + sales_with_coupon: number; + sales_without_coupon: number; + total_discount_amount: number; + net_sales_after_discount: number; + coupon_usage_percentage: number; + } +>; + +/** + * Processed response with numeric values. + */ +type SanitizedCouponsByDateResponse = { + summary: SanitizedCouponsByDateSummary; + data: SanitizedCouponsByDateDataItem[]; +}; + +/** + * + * @param item + */ +function sanitizeItem( item: RawDataItem ): SanitizedCouponsByDateDataItem { + return { + ...item, + total_orders: parseInt( item.total_orders, 10 ), + orders_with_coupon: parseInt( item.orders_with_coupon, 10 ), + orders_without_coupon: parseInt( item.orders_without_coupon, 10 ), + total_sales: parseFloat( item.total_sales ), + sales_with_coupon: parseFloat( item.sales_with_coupon ), + sales_without_coupon: parseFloat( item.sales_without_coupon ), + total_discount_amount: parseFloat( item.total_discount_amount ), + net_sales_after_discount: parseFloat( item.net_sales_after_discount ), + coupon_usage_percentage: parseFloat( item.coupon_usage_percentage ), + }; +} + +/** + * + * @param summary + */ +function sanitizeSummary( summary: RawSummary ): SanitizedCouponsByDateSummary { + return { + ...summary, + total_orders: parseInt( summary.total_orders, 10 ), + orders_with_coupon: parseInt( summary.orders_with_coupon, 10 ), + orders_without_coupon: parseInt( summary.orders_without_coupon, 10 ), + total_sales: parseFloat( summary.total_sales ), + sales_with_coupon: parseFloat( summary.sales_with_coupon ), + sales_without_coupon: parseFloat( summary.sales_without_coupon ), + total_discount_amount: parseFloat( summary.total_discount_amount ), + net_sales_after_discount: parseFloat( summary.net_sales_after_discount ), + coupon_usage_percentage: parseFloat( summary.coupon_usage_percentage ), + }; +} + +/** + * Sanitize the response from the reports/coupons/by-date endpoint. + * Converts string values to numbers for calculations and charting. + * @param response + */ +export const sanitizeReportCouponsByDateResponse = ( + response: ReportsCouponsByDateResponse +): SanitizedCouponsByDateResponse => { + return { + summary: sanitizeSummary( response.summary ), + data: response.data.map( sanitizeItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/coupons/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/coupons/index.ts new file mode 100644 index 000000000000..1f40db489cd3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/coupons/index.ts @@ -0,0 +1,81 @@ +/** + * Internal dependencies + */ +import { fetchReportCoupons } from '../../api/report-coupons-fetch'; +import type { Override } from '../../utils/types'; + +type ReportsCouponsResponse = Awaited< ReturnType< typeof fetchReportCoupons > >; +type RawCouponsDataItem = ReportsCouponsResponse[ 'data' ][ number ]; +type RawCouponsDataSummary = ReportsCouponsResponse[ 'summary' ]; + +/** + * Processed data item (numbers for calculations) + */ +type SanitizedCouponsDataItem = Override< + RawCouponsDataItem, + { + discount_amount: number; + total_sales: number; + orders_count: number; + } +>; + +/** + * Processed summary (numbers for calculations) + */ +type SanitizedCouponsDataSummary = Override< + RawCouponsDataSummary, + { + total_sales: number; + total_discount_amount: number; + total_orders: number; + } +>; + +/** + * Processed response with numeric values + */ +type SanitizedCouponsResponse = { + summary: SanitizedCouponsDataSummary; + data: SanitizedCouponsDataItem[]; +}; + +/** + * Sanitize/process a single coupon item by converting strings to numbers + * @param item + */ +function sanitizeCouponItem( item: RawCouponsDataItem ): SanitizedCouponsDataItem { + return { + ...item, + discount_amount: parseFloat( item.discount_amount ), + total_sales: parseFloat( item.total_sales ), + orders_count: parseInt( item.orders_count, 10 ), + }; +} + +/** + * Sanitize/process summary by converting strings to numbers + * @param summary + */ +function sanitizeCouponSummary( summary: RawCouponsDataSummary ): SanitizedCouponsDataSummary { + return { + ...summary, + total_sales: parseFloat( summary.total_sales ), + total_discount_amount: parseFloat( summary.total_discount_amount ), + total_orders: parseInt( summary.total_orders, 10 ), + }; +} + +/** + * Sanitize the response from the reports/coupons endpoint + * Converts string values to numbers for easier calculations and charting. + * @param response + */ +export const sanitizeReportCouponsResponse = ( + response: ReportsCouponsResponse +): SanitizedCouponsResponse => { + return { + summary: sanitizeCouponSummary( response.summary ), + data: response.data.map( sanitizeCouponItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/customers-by-date/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/customers-by-date/index.ts new file mode 100644 index 000000000000..8b098d7adde2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/customers-by-date/index.ts @@ -0,0 +1,149 @@ +/** + * Internal dependencies + */ +import { fetchReportCustomersByDate } from '../../api/report-customers-by-date-fetch'; +import type { Override } from '../../utils/types'; + +type ReportsCustomersByDateResponse = Awaited< ReturnType< typeof fetchReportCustomersByDate > >; +type RawCustomersByDateSummary = ReportsCustomersByDateResponse[ 'summary' ]; +type RawCustomersByDateItem = ReportsCustomersByDateResponse[ 'data' ][ number ]; + +/** + * Processed summary (numbers for calculations) + */ +type SanitizedCustomersByDateSummary = Override< + RawCustomersByDateSummary, + { + total_net_sales: number; + total_gross_sales: number; + total_discounts: number; + total_refunds: number; + total_orders: number; + total_average_order_value: number; + total_avg_items_per_order: number; + total_customers: number; + new_customers: number; + returning_customers: number; + new_customer_sales: number; + new_customer_gross_sales: number; + new_customer_discounts: number; + new_customer_refunds: number; + new_customer_orders: number; + new_customer_avg_order_value: number; + new_customer_avg_items_per_order: number; + returning_customer_sales: number; + returning_customer_gross_sales: number; + returning_customer_discounts: number; + returning_customer_refunds: number; + returning_customer_orders: number; + returning_customer_avg_order_value: number; + returning_customer_avg_items_per_order: number; + // Add computed field for compatibility + customers: number; + } +>; + +/** + * Processed item (numbers for calculations) + */ +type SanitizedCustomersByDateItem = Override< + RawCustomersByDateItem, + { + total_customers: number; + new_customers: number; + returning_customers: number; + orders_count: number; + new_customer_orders: number; + returning_customer_orders: number; + net_sales: number; + new_customer_net_sales: number; + returning_customer_net_sales: number; + // Add computed field for compatibility + customers: number; + } +>; + +/** + * Processed response with numeric values + */ +export type SanitizedCustomersByDateResponse = { + summary: SanitizedCustomersByDateSummary; + data: SanitizedCustomersByDateItem[]; +}; + +/** + * Sanitize/process a single customer item by converting strings to numbers + * @param item + */ +function sanitizeCustomerByDateItem( item: RawCustomersByDateItem ): SanitizedCustomersByDateItem { + const totalCustomers = parseInt( item.total_customers, 10 ); + return { + ...item, + total_customers: totalCustomers, + new_customers: parseInt( item.new_customers, 10 ), + returning_customers: parseInt( item.returning_customers, 10 ), + orders_count: parseInt( item.orders_count, 10 ), + new_customer_orders: parseInt( item.new_customer_orders, 10 ), + returning_customer_orders: parseInt( item.returning_customer_orders, 10 ), + net_sales: parseFloat( item.net_sales ), + new_customer_net_sales: parseFloat( item.new_customer_net_sales ), + returning_customer_net_sales: parseFloat( item.returning_customer_net_sales ), + // Add alias for compatibility with chart builder + customers: totalCustomers, + }; +} + +/** + * Sanitize/process the summary by converting strings to numbers + * @param summary + */ +function sanitizeCustomerByDateSummary( + summary: RawCustomersByDateSummary +): SanitizedCustomersByDateSummary { + const totalCustomers = parseInt( summary.total_customers, 10 ); + return { + ...summary, + total_net_sales: parseFloat( summary.total_net_sales ), + total_gross_sales: parseFloat( summary.total_gross_sales ), + total_discounts: parseFloat( summary.total_discounts ), + total_refunds: parseFloat( summary.total_refunds ), + total_orders: parseInt( summary.total_orders, 10 ), + total_average_order_value: parseFloat( summary.total_average_order_value ), + total_avg_items_per_order: parseFloat( summary.total_avg_items_per_order ), + total_customers: totalCustomers, + new_customers: parseInt( summary.new_customers, 10 ), + returning_customers: parseInt( summary.returning_customers, 10 ), + new_customer_sales: parseFloat( summary.new_customer_sales ), + new_customer_gross_sales: parseFloat( summary.new_customer_gross_sales ), + new_customer_discounts: parseFloat( summary.new_customer_discounts ), + new_customer_refunds: parseFloat( summary.new_customer_refunds ), + new_customer_orders: parseInt( summary.new_customer_orders, 10 ), + new_customer_avg_order_value: parseFloat( summary.new_customer_avg_order_value ), + new_customer_avg_items_per_order: parseFloat( summary.new_customer_avg_items_per_order ), + returning_customer_sales: parseFloat( summary.returning_customer_sales ), + returning_customer_gross_sales: parseFloat( summary.returning_customer_gross_sales ), + returning_customer_discounts: parseFloat( summary.returning_customer_discounts ), + returning_customer_refunds: parseFloat( summary.returning_customer_refunds ), + returning_customer_orders: parseInt( summary.returning_customer_orders, 10 ), + returning_customer_avg_order_value: parseFloat( summary.returning_customer_avg_order_value ), + returning_customer_avg_items_per_order: parseFloat( + summary.returning_customer_avg_items_per_order + ), + // Add alias for compatibility with chart builder + customers: totalCustomers, + }; +} + +/** + * Sanitize the response from the reports/customers/by-date endpoint + * Converts string values to numbers for easier calculations and charting. + * @param response + */ +export const sanitizeReportCustomersByDateResponse = ( + response: ReportsCustomersByDateResponse +): SanitizedCustomersByDateResponse => { + return { + summary: sanitizeCustomerByDateSummary( response.summary ), + data: response.data.map( sanitizeCustomerByDateItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/customers/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/customers/index.ts new file mode 100644 index 000000000000..780eca7c831f --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/customers/index.ts @@ -0,0 +1,85 @@ +/** + * Internal dependencies + */ +import { fetchReportCustomers } from '../../api/report-customers-fetch'; +import type { Override } from '../../utils/types'; + +type ReportsCustomersNewReturningResponse = Awaited< ReturnType< typeof fetchReportCustomers > >; +type RawCustomersNewReturningSummary = ReportsCustomersNewReturningResponse[ 'summary' ]; +type RawCustomersNewReturningItem = ReportsCustomersNewReturningResponse[ 'data' ][ number ]; + +/** + * Processed summary (numbers for calculations) + */ +type SanitizedCustomersNewReturningSummary = Override< + RawCustomersNewReturningSummary, + { + total_net_sales: number; + total_orders: number; + new_customer_sales: number; + returning_customer_sales: number; + } +>; + +/** + * Processed item (numbers for calculations) + */ +type SanitizedCustomersNewReturningItem = Override< + RawCustomersNewReturningItem, + { + net_sales: number; + orders_count: number; + } +>; + +/** + * Processed response with numeric values + */ +type SanitizedCustomersNewReturningResponse = { + summary: SanitizedCustomersNewReturningSummary; + data: SanitizedCustomersNewReturningItem[]; +}; + +/** + * Sanitize/process a single customer item by converting strings to numbers + * @param item + */ +function sanitizeCustomerItem( + item: RawCustomersNewReturningItem +): SanitizedCustomersNewReturningItem { + return { + ...item, + net_sales: parseFloat( item.net_sales ), + orders_count: parseInt( item.orders_count, 10 ), + }; +} + +/** + * Sanitize/process the summary by converting strings to numbers + * @param summary + */ +function sanitizeCustomerSummary( + summary: RawCustomersNewReturningSummary +): SanitizedCustomersNewReturningSummary { + return { + ...summary, + total_net_sales: parseFloat( summary.total_net_sales ), + total_orders: parseInt( summary.total_orders, 10 ), + new_customer_sales: parseFloat( summary.new_customer_sales ), + returning_customer_sales: parseFloat( summary.returning_customer_sales ), + }; +} + +/** + * Sanitize the response from the reports/customers/new-returning endpoint + * Converts string values to numbers for easier calculations and charting. + * @param response + */ +export const sanitizeReportCustomersResponse = ( + response: ReportsCustomersNewReturningResponse +): SanitizedCustomersNewReturningResponse => { + return { + summary: sanitizeCustomerSummary( response.summary ), + data: response.data.map( sanitizeCustomerItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/index.ts new file mode 100644 index 000000000000..2e9bf6a848fa --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/index.ts @@ -0,0 +1,12 @@ +// Resource-specific processing +export * from './orders'; +export * from './customers'; +export * from './products'; +export * from './visitors'; +export * from './visitors-by-location'; + +// TODO: Add coupons processing functions +// export * from './coupons'; + +// TODO: Add order attribution processing functions +// export * from './order-attribution'; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/index.ts new file mode 100644 index 000000000000..c98abdb066b7 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/index.ts @@ -0,0 +1,2 @@ +export * from './sanitize-order-attribution-summary-response'; +export * from './normalize-order-attribution-by-product-response'; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/normalize-order-attribution-by-product-response.ts b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/normalize-order-attribution-by-product-response.ts new file mode 100644 index 000000000000..5ed83bf8404e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/normalize-order-attribution-by-product-response.ts @@ -0,0 +1,82 @@ +/** + * Internal dependencies + */ +import type { OrderAttributionByProductResponse } from '../../api/report-order-attribution-by-product-fetch'; +import type { OrderAttributionSummaryResponse } from '../../api/report-order-attribution-summary-fetch'; + +/** + * Normalizes the order-attribution-by-product API response to match the + * structure of the regular order-attribution API response. + * + * The new API has a flatter structure without current_period/previous_period nesting, + * so we need to transform it to match the expected format for widgets. + * + * @param currentResponse - Response from the current period request + * @param previousResponse - Optional response from the comparison period request + * @return Normalized response matching OrderAttributionSummaryResponse structure + */ +export function normalizeOrderAttributionByProductResponse( + currentResponse: OrderAttributionByProductResponse, + previousResponse?: OrderAttributionByProductResponse +): OrderAttributionSummaryResponse { + // Create a map for quick lookup of previous period data by item + const previousDataMap = new Map< string, ( typeof currentResponse.data )[ 0 ] >(); + if ( previousResponse ) { + previousResponse.data.forEach( item => { + previousDataMap.set( item.item, item ); + } ); + } + + // Transform the flat structure to nested structure + const normalizedData = currentResponse.data.map( currentItem => { + const previousItem = previousDataMap.get( currentItem.item ); + + // If no previous response provided (no comparison), use current data for both periods + // This matches the behavior of the existing API when compare_from/to equal from/to + const previousValue = previousItem?.value || currentItem.value; + const previousIntervals = previousItem?.intervals || currentItem.intervals; + + return { + item: currentItem.item, + current_period: { + value: currentItem.value, + intervals: currentItem.intervals, + }, + previous_period: { + value: previousValue, + intervals: previousIntervals, + }, + }; + } ); + + // Handle items that exist in previous period but not in current + // This ensures we don't lose data when an item had sales in the previous period but not current + if ( previousResponse ) { + previousResponse.data.forEach( previousItem => { + const existsInCurrent = currentResponse.data.some( item => item.item === previousItem.item ); + + if ( ! existsInCurrent ) { + normalizedData.push( { + item: previousItem.item, + current_period: { + value: '0', + intervals: previousItem.intervals.map( interval => ( { + ...interval, + net_sales: '0', + } ) ), + }, + previous_period: { + value: previousItem.value, + intervals: previousItem.intervals, + }, + } ); + } + } ); + } + + return { + view: currentResponse.view, + order_by: currentResponse.order_by, + data: normalizedData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/sanitize-order-attribution-summary-response.ts b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/sanitize-order-attribution-summary-response.ts new file mode 100644 index 000000000000..09572282b098 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/order-attribution/sanitize-order-attribution-summary-response.ts @@ -0,0 +1,118 @@ +/** + * Internal dependencies + */ +import { fetchReportOrderAttributionSummary } from '../../api/report-order-attribution-summary-fetch'; +import { sanitizeStringNumber } from '../utils'; + +type OrderAttributionSummaryResponse = Awaited< + ReturnType< typeof fetchReportOrderAttributionSummary > +>; + +type OrderAttributionView = OrderAttributionSummaryResponse[ 'view' ]; + +/** + * Internal types for processing + */ +type OrderAttributionInterval = { + time_interval: string; + date_start: string; + date_end: string; + net_sales: string; +}; + +type OrderAttributionPeriod = { + value: string; + intervals: OrderAttributionInterval[]; +}; + +type OrderAttributionSummaryItem = { + item: string; + current_period: OrderAttributionPeriod; + previous_period: OrderAttributionPeriod; +}; + +/** + * Processed (sanitized) response types + */ +type SanitizedOrderAttributionInterval = { + time_interval: string; + date_start: string; + date_end: string; + net_sales: number; +}; + +type SanitizedOrderAttributionPeriod = { + value: number; + intervals: SanitizedOrderAttributionInterval[]; +}; + +type SanitizedOrderAttributionSummaryItem = { + item: string; + current_period: SanitizedOrderAttributionPeriod; + previous_period: SanitizedOrderAttributionPeriod; +}; + +export type SanitizedOrderAttributionSummaryResponse = { + view: OrderAttributionView; + order_by: string; + data: SanitizedOrderAttributionSummaryItem[]; +}; + +/** + * Sanitizes a single interval by converting string net_sales to number + * @param interval + */ +function sanitizeOrderAttributionInterval( + interval: OrderAttributionInterval +): SanitizedOrderAttributionInterval { + return { + time_interval: interval.time_interval, + date_start: interval.date_start, + date_end: interval.date_end, + net_sales: sanitizeStringNumber( interval.net_sales ), + }; +} + +/** + * Sanitizes a period by converting value to number and intervals + * @param period + */ +function sanitizeOrderAttributionPeriod( + period: OrderAttributionPeriod +): SanitizedOrderAttributionPeriod { + return { + value: sanitizeStringNumber( period.value ), + intervals: period.intervals.map( sanitizeOrderAttributionInterval ), + }; +} + +/** + * Sanitizes a single order attribution summary item + * @param item + */ +function sanitizeOrderAttributionSummaryItem( + item: OrderAttributionSummaryItem +): SanitizedOrderAttributionSummaryItem { + return { + item: item.item, + current_period: sanitizeOrderAttributionPeriod( item.current_period ), + previous_period: sanitizeOrderAttributionPeriod( item.previous_period ), + }; +} + +/** + * Sanitizes the order attribution summary response by converting all string + * numbers to actual numbers + * + * @param response - Raw API response from /summary endpoint + * @return Sanitized response with numbers instead of strings + */ +export function sanitizeReportOrderAttributionSummaryResponse( + response: OrderAttributionSummaryResponse +): SanitizedOrderAttributionSummaryResponse { + return { + view: response.view, + order_by: response.order_by, + data: response.data.map( sanitizeOrderAttributionSummaryItem ), + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/processing/orders-by-product-type/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/orders-by-product-type/index.ts new file mode 100644 index 000000000000..75be705212bb --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/orders-by-product-type/index.ts @@ -0,0 +1,84 @@ +/** + * Internal dependencies + */ +import { safeParseFloat, safeParseInt } from '../../utils/parsing'; +import type { + ReportsOrdersByDateResponse, + RequestReportOrdersParams, +} from '../../api/report-orders-fetch'; +import type { Override } from '../../utils/types'; + +/** + * Re-export the request params type for backwards compatibility. + * The orders-by-product-type endpoint uses the same request/response + * types as the orders endpoint. + */ +export type { RequestReportOrdersParams as RequestReportOrdersByProductTypeParams }; + +type ReportsOrdersByProductTypeByDateResponse = ReportsOrdersByDateResponse; +type RawOrdersByProductTypeReportDataItem = + ReportsOrdersByProductTypeByDateResponse[ 'data' ][ number ]; +type SanitizedOrdersByProductTypeByDateItem = Override< + RawOrdersByProductTypeReportDataItem, + { + average_order_value: number; + avg_items: number; + cogs_amount: number; + coupons: number; + orders_no: number; + orders_value_gross: number; + orders_value_net: number; + product_net_revenue: number; + profit_margin: number; + refunds: number; + total_sales: number; + } +>; + +/** + * Sanitize/process a single orders by product type item by converting strings to numbers + * @param item + */ +function sanitizeOrdersByProductTypeItem( + item: RawOrdersByProductTypeReportDataItem +): SanitizedOrdersByProductTypeByDateItem { + return { + ...item, + average_order_value: safeParseFloat( item.average_order_value ), + avg_items: safeParseFloat( item.avg_items ), + cogs_amount: safeParseFloat( item.cogs_amount ), + coupons: safeParseInt( item.coupons ), + orders_no: safeParseInt( item.orders_no ), + orders_value_gross: safeParseFloat( item.orders_value_gross ), + orders_value_net: safeParseFloat( item.orders_value_net ), + product_net_revenue: safeParseFloat( item.product_net_revenue ), + profit_margin: safeParseFloat( item.profit_margin ), + refunds: safeParseFloat( item.refunds ), + total_sales: safeParseFloat( item.total_sales ), + }; +} + +/** + * Processed response with numeric values + */ +type SanitizedOrdersByProductTypeByDateResponse = { + summary: SanitizedOrdersByProductTypeByDateItem; + data: SanitizedOrdersByProductTypeByDateItem[]; +}; + +/** + * Sanitize the response from the reports/orders-by-product-type/by-date endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` single item has basically the same structure + * as the `data` array items, so we can use the same mapper function for both. + * @param response + */ +export const sanitizeReportOrdersByProductTypeResponse = ( + response: ReportsOrdersByProductTypeByDateResponse +): SanitizedOrdersByProductTypeByDateResponse => { + return { + summary: sanitizeOrdersByProductTypeItem( response.summary ), + data: response.data.map( sanitizeOrdersByProductTypeItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/orders/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/orders/index.ts new file mode 100644 index 000000000000..00b7a8acf6d4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/orders/index.ts @@ -0,0 +1,79 @@ +/** + * Internal dependencies + */ +import { fetchReportOrders } from '../../api/report-orders-fetch'; +import { safeParseFloat, safeParseInt } from '../../utils/parsing'; +import type { Override } from '../../utils/types'; + +type ReportsOrdersByDateResponse = Awaited< ReturnType< typeof fetchReportOrders > >; +type RawOrdersReportDataItem = ReportsOrdersByDateResponse[ 'data' ][ number ]; +type SanitizedOrdersByDateItem = Override< + RawOrdersReportDataItem, + { + average_order_value: number; + avg_items: number; + cogs_amount: number; + coupons: number; + orders_no: number; + orders_value_gross: number; + orders_value_net: number; + paid_orders_count: number; + paid_net_sales: number; + product_net_revenue: number; + profit_margin: number; + refunds: number; + total_sales: number; + unpaid_orders_count: number; + unpaid_net_sales: number; + } +>; + +/** + * Sanitize/process a single order item by converting strings to numbers + * @param item + */ +function sanitizeOrderItem( item: RawOrdersReportDataItem ): SanitizedOrdersByDateItem { + return { + ...item, + average_order_value: safeParseFloat( item.average_order_value ), + avg_items: safeParseFloat( item.avg_items ), + cogs_amount: safeParseFloat( item.cogs_amount ), + coupons: safeParseInt( item.coupons ), + orders_no: safeParseInt( item.orders_no ), + orders_value_gross: safeParseFloat( item.orders_value_gross ), + orders_value_net: safeParseFloat( item.orders_value_net ), + paid_orders_count: safeParseInt( item.paid_orders_count ), + paid_net_sales: safeParseFloat( item.paid_net_sales ), + product_net_revenue: safeParseFloat( item.product_net_revenue ), + profit_margin: safeParseFloat( item.profit_margin ), + refunds: safeParseFloat( item.refunds ), + total_sales: safeParseFloat( item.total_sales ), + unpaid_orders_count: safeParseInt( item.unpaid_orders_count ), + unpaid_net_sales: safeParseFloat( item.unpaid_net_sales ), + }; +} + +/** + * Processed response with numeric values + */ +type SanitizedOrdersByDateResponse = { + summary: SanitizedOrdersByDateItem; + data: SanitizedOrdersByDateItem[]; +}; + +/** + * Sanitize the response from the reports/orders/by-date endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` single item has basically the same structure + * as the `data` array items, so we can use the same mapper function for both. + * @param response + */ +export const sanitizeReportOrdersResponse = ( + response: ReportsOrdersByDateResponse +): SanitizedOrdersByDateResponse => { + return { + summary: sanitizeOrderItem( response.summary ), + data: response.data.map( sanitizeOrderItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/products/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/products/index.ts new file mode 100644 index 000000000000..6d2ba237964f --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/products/index.ts @@ -0,0 +1,83 @@ +/** + * Internal dependencies + */ +import { fetchReportProducts } from '../../api/report-products-fetch'; +import type { Override } from '../../utils/types'; + +type ReportProductsResponse = Awaited< ReturnType< typeof fetchReportProducts > >; + +type RawProductsReportDataItem = ReportProductsResponse[ 'data' ][ number ]; +type RawProductsReportSummary = ReportProductsResponse[ 'summary' ]; + +type SanitizedProductsItem = Override< + RawProductsReportDataItem, + { + product_id: number; + orders_count: number; + product_net_revenue: number; + total_quantity: number; + } +>; + +type SanitizedProductsSummary = Override< + RawProductsReportSummary, + { + total_orders: number; + total_products: number; + total_quantity: number; + total_revenue: number; + } +>; + +/** + * Sanitize/process a single product item by converting strings to numbers + * @param item + */ +function sanitizeProductItem( item: RawProductsReportDataItem ): SanitizedProductsItem { + return { + ...item, + product_id: parseInt( item.product_id, 10 ), + orders_count: parseInt( item.orders_count, 10 ), + product_net_revenue: parseFloat( item.product_net_revenue ), + total_quantity: parseInt( item.total_quantity, 10 ), + }; +} + +/** + * + * @param summary + */ +function sanitizeProductSummary( summary: RawProductsReportSummary ): SanitizedProductsSummary { + return { + ...summary, + total_orders: parseInt( summary.total_orders, 10 ), + total_products: parseInt( summary.total_products, 10 ), + total_quantity: parseInt( summary.total_quantity, 10 ), + total_revenue: parseFloat( summary.total_revenue ), + }; +} + +/** + * Processed response with numeric values + */ +type SanitizedProductsResponse = { + summary: SanitizedProductsSummary; + data: SanitizedProductsItem[]; +}; + +/** + * Sanitize the response from the reports/products endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` single item has basically the same structure + * as the `data` array items, so we can use the same mapper function for both. + * @param response + */ +export const sanitizeReportProductsResponse = ( + response: ReportProductsResponse +): SanitizedProductsResponse => { + return { + summary: sanitizeProductSummary( response.summary ), + data: ( response.data || [] ).map( sanitizeProductItem ), + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/sessions-by-device/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/sessions-by-device/index.ts new file mode 100644 index 000000000000..127c39843e31 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/sessions-by-device/index.ts @@ -0,0 +1,75 @@ +/** + * Internal dependencies + */ +import { fetchReportSessionsByDevice } from '../../api/report-sessions-by-device-fetch'; + +/** + * Inferred types from fetch response + */ +type ReportsSessionsByDeviceResponse = Awaited< ReturnType< typeof fetchReportSessionsByDevice > >; + +/** + * Raw item type from API response + */ +type SessionsByDeviceItem = ReportsSessionsByDeviceResponse[ 'data' ][ number ]; + +/** + * Sanitized item with numeric values + */ +type SanitizedSessionsByDeviceItem = { + device_type: string; + active_sessions: number; +}; + +/** + * Summary with total sessions + */ +type SessionsByDeviceSummary = { + total_sessions: number; +}; + +/** + * Processed response structure + */ +type SanitizedSessionsByDeviceResponse = { + summary: SessionsByDeviceSummary; + data: SanitizedSessionsByDeviceItem[]; +}; + +/** + * Sanitize a single sessions by device item by converting strings to numbers. + * + * @param item - Raw item from API response + */ +function sanitizeSessionsByDeviceItem( item: SessionsByDeviceItem ): SanitizedSessionsByDeviceItem { + return { + device_type: item.device_type || '', + active_sessions: parseInt( item.active_sessions, 10 ) || 0, + }; +} + +/** + * Sanitize the response from the sessions/by-device endpoint. + * + * Converts string values to numbers for easier calculations and charting. + * Also calculates total sessions summary. + * + * @param response - Raw API response with summary and items + */ +export const sanitizeReportSessionsByDeviceResponse = ( + response: ReportsSessionsByDeviceResponse +): SanitizedSessionsByDeviceResponse => { + const items = response?.data ?? []; + const data = items + .filter( item => item.device_type ) // Filter out empty device types + .map( sanitizeSessionsByDeviceItem ); + + const totalSessions = data.reduce( ( acc, item ) => acc + item.active_sessions, 0 ); + + return { + summary: { + total_sessions: totalSessions, + }, + data, + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/utils.ts b/projects/packages/premium-analytics/packages/data/src/processing/utils.ts new file mode 100644 index 000000000000..c0dc5636992a --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/utils.ts @@ -0,0 +1,11 @@ +/** + * Converts a string number to an actual number, with fallback to 0 + * for invalid values. + * + * @param value - String number from API + * @return Parsed number or 0 if invalid + */ +export function sanitizeStringNumber( value: string ): number { + const parsed = parseFloat( value ); + return isNaN( parsed ) ? 0 : parsed; +} diff --git a/projects/packages/premium-analytics/packages/data/src/processing/visitors-by-location/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/visitors-by-location/index.ts new file mode 100644 index 000000000000..b9dac4936c16 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/visitors-by-location/index.ts @@ -0,0 +1,78 @@ +/** + * Internal dependencies + */ +import { fetchReportVisitorsByLocation } from '../../api/report-visitors-by-location-fetch'; +import type { Override } from '../../utils/types'; + +/** + * Inferred types + */ +type ReportsVisitorsByLocationResponse = Awaited< + ReturnType< typeof fetchReportVisitorsByLocation > +>; +type RawVisitorsByLocationItem = ReportsVisitorsByLocationResponse[ 'data' ][ number ]; +type RawVisitorsByLocationSummary = NonNullable< ReportsVisitorsByLocationResponse[ 'summary' ] >; + +type SanitizedVisitorsByLocationItem = Override< + RawVisitorsByLocationItem, + { + visitors: number; + } +>; + +type SanitizedVisitorsByLocationSummary = Override< + RawVisitorsByLocationSummary, + { + visitors: number; + } +>; + +/** + * + * @param item + */ +function sanitizeVisitorsByLocationItem( + item: RawVisitorsByLocationItem +): SanitizedVisitorsByLocationItem { + const visitors = Number.parseInt( item.visitors, 10 ); + + return { + ...item, + visitors: Number.isNaN( visitors ) ? 0 : visitors, + }; +} + +/** + * + * @param summary + */ +function sanitizeVisitorsByLocationSummary( + summary: RawVisitorsByLocationSummary +): SanitizedVisitorsByLocationSummary { + const visitors = Number.parseInt( summary.visitors, 10 ); + + return { + ...summary, + visitors: Number.isNaN( visitors ) ? 0 : visitors, + }; +} + +type SanitizedVisitorsByLocationResponse = { + summary: SanitizedVisitorsByLocationSummary; + data: SanitizedVisitorsByLocationItem[]; +}; + +export const sanitizeReportVisitorsByLocationResponse = ( + response: ReportsVisitorsByLocationResponse +): SanitizedVisitorsByLocationResponse => { + const defaultSummary: RawVisitorsByLocationSummary = { + visitors: '0', + date_start: '', + date_end: '', + }; + + return { + summary: sanitizeVisitorsByLocationSummary( response?.summary ?? defaultSummary ), + data: response?.data ? response.data.map( sanitizeVisitorsByLocationItem ) : [], + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/processing/visitors/index.ts b/projects/packages/premium-analytics/packages/data/src/processing/visitors/index.ts new file mode 100644 index 000000000000..7d14cf71ebc1 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/processing/visitors/index.ts @@ -0,0 +1,80 @@ +/** + * Internal dependencies + */ +import { fetchReportVisitors } from '../../api/report-visitors-fetch'; +import type { Override } from '../../utils/types'; + +/** + * Inferred types + */ +type ReportsVisitorsByDateResponse = Awaited< ReturnType< typeof fetchReportVisitors > >; +type RawVisitorsReportDataItem = ReportsVisitorsByDateResponse[ 'data' ][ number ]; +type RawVisitorsReportDataSummary = ReportsVisitorsByDateResponse[ 'summary' ]; + +type SanitizedVisitorsByDateItem = Override< + RawVisitorsReportDataItem, + { + active_sessions: number; + visitors: number; + time_interval?: string; + } +>; + +type SanitizedVisitorsByDateSummary = Override< + RawVisitorsReportDataSummary, + { + active_sessions: number; + visitors: number; + } +>; + +type SanitizeVisitorsItemArg = Override< + RawVisitorsReportDataItem, + { + time_interval?: string; + } +>; + +/** + * Sanitize/process a single visitors item by converting strings to numbers + * @param item + */ +function sanitizeVisitorsItem( item: SanitizeVisitorsItemArg ): SanitizedVisitorsByDateItem { + return { + ...item, + active_sessions: parseInt( item.active_sessions, 10 ), + visitors: parseInt( item.visitors, 10 ), + }; +} + +/** + * Processed response with numeric values + */ +type SanitizedVisitorsByDateResponse = { + summary: SanitizedVisitorsByDateSummary; + data: SanitizedVisitorsByDateItem[]; +}; + +/** + * Sanitize the response from the sessions/by-date endpoint + * Converts string values to numbers for easier calculations and charting. + * + * The `summary` single item has basically the same structure + * as the `data` array items, so we can use the same mapper function for both. + * @param response + */ +export const sanitizeReportVisitorsResponse = ( + response: ReportsVisitorsByDateResponse +): SanitizedVisitorsByDateResponse => { + const defaultSummary = { + active_sessions: '0', + visitors: '0', + date_start: '', + date_end: '', + }; + + return { + summary: sanitizeVisitorsItem( response?.summary ?? defaultSummary ), + data: response?.data ? response.data.map( sanitizeVisitorsItem ) : [], + }; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/providers/global-error-context.tsx b/projects/packages/premium-analytics/packages/data/src/providers/global-error-context.tsx new file mode 100644 index 000000000000..cf1058f6255e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/providers/global-error-context.tsx @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import { onlineManager } from '@tanstack/react-query'; +import { + createContext, + useContext, + useEffect, + useMemo, + useSyncExternalStore, + type ReactNode, +} from 'react'; +/** + * Internal dependencies + */ +import { globalErrorManager, type GlobalErrorType } from './global-error-manager'; + +interface GlobalErrorContextValue { + globalError: GlobalErrorType; + setGlobalError: ( error: GlobalErrorType ) => void; + clearGlobalError: () => void; + isGlobalError: boolean; +} + +const GlobalErrorContext = createContext< GlobalErrorContextValue | null >( null ); + +/** + * Connects React to the global error manager via useSyncExternalStore. + * Also subscribes to network status changes via onlineManager. + * @param root0 + * @param root0.children + */ +export function GlobalErrorProvider( { children }: { children: ReactNode } ) { + const globalError = useSyncExternalStore( + globalErrorManager.subscribe, + globalErrorManager.getError, + globalErrorManager.getError + ); + + /** + * Subscribe to TanStack Query's onlineManager to detect network status. + * + * When offline, TanStack Query pauses queries (doesn't execute them), + * so QueryCache.onError never fires. We detect offline status here and + * properly clean up the subscription when the provider unmounts. + */ + useEffect( () => { + // Check initial online status on mount + if ( ! onlineManager.isOnline() ) { + globalErrorManager.setError( 'network' ); + } + + const unsubscribe = onlineManager.subscribe( isOnline => { + if ( ! isOnline ) { + globalErrorManager.setError( 'network' ); + } else if ( globalErrorManager.getError() === 'network' ) { + globalErrorManager.clearError(); + } + } ); + + return unsubscribe; + }, [] ); + + const contextValue = useMemo( + () => ( { + globalError, + setGlobalError: globalErrorManager.setError, + clearGlobalError: globalErrorManager.clearError, + isGlobalError: globalError !== null, + } ), + [ globalError ] + ); + + return ( + { children } + ); +} + +let hasWarnedAboutMissingProvider = false; + +const defaultContextValue: GlobalErrorContextValue = { + globalError: null, + setGlobalError: () => {}, + clearGlobalError: () => {}, + isGlobalError: false, +}; + +/** + * Access global error state. Returns defaults if used outside GlobalErrorProvider. + */ +export function useGlobalError(): GlobalErrorContextValue { + const context = useContext( GlobalErrorContext ); + + if ( context ) { + return context; + } + + if ( ! hasWarnedAboutMissingProvider ) { + hasWarnedAboutMissingProvider = true; + // eslint-disable-next-line no-console + console.warn( + 'useGlobalError was called outside of GlobalErrorProvider. ' + + 'Wrap your component tree with GlobalErrorProvider.' + ); + } + + return defaultContextValue; +} diff --git a/projects/packages/premium-analytics/packages/data/src/providers/global-error-manager.ts b/projects/packages/premium-analytics/packages/data/src/providers/global-error-manager.ts new file mode 100644 index 000000000000..9686d09e595b --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/providers/global-error-manager.ts @@ -0,0 +1,35 @@ +/** + * External dependencies + */ + +export type GlobalErrorType = 'network' | 'auth' | 'server' | null; + +type Listener = () => void; + +/** + * Manages global error state outside of React, enabling error state to be set + * from onlineManager subscription and consumed via useSyncExternalStore. + */ +class GlobalErrorManager { + private error: GlobalErrorType = null; + private listeners = new Set< Listener >(); + + getError = (): GlobalErrorType => this.error; + + setError = ( error: GlobalErrorType ): void => { + if ( this.error === error ) { + return; + } + this.error = error; + this.listeners.forEach( listener => listener() ); + }; + + clearError = (): void => this.setError( null ); + + subscribe = ( listener: Listener ): ( () => void ) => { + this.listeners.add( listener ); + return () => this.listeners.delete( listener ); + }; +} + +export const globalErrorManager = new GlobalErrorManager(); diff --git a/projects/packages/premium-analytics/packages/data/src/providers/index.ts b/projects/packages/premium-analytics/packages/data/src/providers/index.ts new file mode 100644 index 000000000000..9b6bc769e9bf --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/providers/index.ts @@ -0,0 +1,5 @@ +export { queryClient, AnalyticsQueryClientProvider } from './query-client-provider'; + +export { GlobalErrorProvider, useGlobalError } from './global-error-context'; + +export { globalErrorManager, type GlobalErrorType } from './global-error-manager'; diff --git a/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx b/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx new file mode 100644 index 000000000000..1baf4f0ea5be --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/providers/query-client-provider.tsx @@ -0,0 +1,162 @@ +/** + * External dependencies + */ +import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; +import { ReactNode, lazy, Suspense } from 'react'; +/** + * Internal dependencies + */ +import { globalErrorManager } from './global-error-manager'; + +const DEFAULT_STALE_TIME = 5 * 60 * 1000; +const DEFAULT_GC_TIME = 10 * 60 * 1000; + +/** + * Whether to render the React Query Devtools. + * + * Devtools are opt-in and OFF by default. Enable them by setting a global + * debug flag on `window` (e.g. in the browser console: + * `window.jetpackPremiumAnalyticsQueryDevtools = true`) and reloading. They + * are also enabled automatically outside of production builds. + * + * @return Whether the devtools should be rendered. + */ +function areQueryDevtoolsEnabled(): boolean { + if ( + typeof window !== 'undefined' && + ( window as { jetpackPremiumAnalyticsQueryDevtools?: boolean } ) + .jetpackPremiumAnalyticsQueryDevtools === true + ) { + return true; + } + + return process.env.NODE_ENV !== 'production'; +} + +const ReactQueryDevtoolsProduction = lazy( () => + import( '@tanstack/react-query-devtools/production' ).then( d => ( { + default: d.ReactQueryDevtools, + } ) ) +); + +/** + * Extract HTTP status code from various error formats. + * WordPress REST API errors may have different shapes. + * @param error + */ +function getErrorStatus( error: unknown ): number | null { + if ( ! error || typeof error !== 'object' ) { + return null; + } + + const err = error as Record< string, unknown >; + + // Standard fetch Response error + if ( typeof err.status === 'number' ) { + return err.status; + } + + // WordPress REST API error format + if ( err.data && typeof err.data === 'object' ) { + const data = err.data as Record< string, unknown >; + if ( typeof data.status === 'number' ) { + return data.status; + } + } + + // Nested response object + if ( err.response && typeof err.response === 'object' ) { + const response = err.response as Record< string, unknown >; + if ( typeof response.status === 'number' ) { + return response.status; + } + } + + return null; +} + +/** + * QueryCache with global error detection for auth and server errors. + * + * Error codes handled: + * - 401: Authentication failure (session expired, invalid token) + * - 502: Bad gateway (proxy/load balancer can't reach upstream) + * - 503: Service unavailable (server overloaded or under maintenance) + * - 504: Gateway timeout (request took too long) + * + * This is QueryClient configuration (not a side effect subscription), so it's + * appropriate at module level. The globalErrorManager singleton is used here + * because QueryClient must be instantiated once (singleton pattern), but the + * error state is safely consumed via useSyncExternalStore in GlobalErrorProvider. + * + * Network errors are handled separately in GlobalErrorProvider via onlineManager. + */ +const queryCache = new QueryCache( { + onError: error => { + const currentError = globalErrorManager.getError(); + + // Don't override network error (highest priority) + if ( currentError === 'network' ) { + return; + } + + const status = getErrorStatus( error ); + + if ( status === 401 ) { + // Auth errors take precedence over server errors, but not network errors. + if ( currentError !== 'auth' ) { + globalErrorManager.setError( 'auth' ); + } + } else if ( status === 502 || status === 503 || status === 504 ) { + // Server errors: only set if no higher-priority error exists. + if ( currentError !== 'auth' && currentError !== 'server' ) { + globalErrorManager.setError( 'server' ); + } + } + }, + onSuccess: () => { + // Clear transient server errors once queries start succeeding again. + if ( globalErrorManager.getError() === 'server' ) { + globalErrorManager.clearError(); + } + }, +} ); + +export const queryClient = new QueryClient( { + queryCache, + defaultOptions: { + queries: { + /* + * Stale time is the time after which the data + * is considered stale and a new request is made. + * Stale time: 5 minutes + */ + staleTime: DEFAULT_STALE_TIME, + + /* + * GC time is the time after which the data is considered garbage + * collected and removed from the cache. + * GC time: 10 minutes + */ + gcTime: DEFAULT_GC_TIME, + + /** + * Noop fetcher to prevent react-query errors for empty queries in console. + */ + queryFn: () => Promise.resolve( undefined ), + }, + }, +} ); + +export const AnalyticsQueryClientProvider = ( { children }: { children: ReactNode } ) => { + return ( + + <>{ children } + { areQueryDevtoolsEnabled() && ( + + + + ) } + + ); +}; diff --git a/projects/packages/premium-analytics/packages/data/src/queries/index.ts b/projects/packages/premium-analytics/packages/data/src/queries/index.ts new file mode 100644 index 000000000000..f5b0604ab3f9 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/index.ts @@ -0,0 +1,12 @@ +export { reportOrdersQuery } from './report-orders-query'; +export { reportOrderAttributionSummaryQuery } from './report-order-attribution-summary-query'; +export { reportCouponsQuery } from './report-coupons-query'; +export { reportCouponsByDateQuery } from './report-coupons-by-date-query'; +export { reportCustomersQuery } from './report-customers-query'; +export { reportCustomersByDateQuery } from './report-customers-by-date-query'; +export { reportConversionRateQuery } from './report-conversion-rate-query'; +export { reportProductsQuery } from './report-products-query'; +export { reportVisitorsQuery } from './report-visitors-query'; +export { reportVisitorsByLocationQuery } from './report-visitors-by-location-query'; +export { reportSessionsByDeviceQuery } from './report-sessions-by-device-query'; +export { reportBookingsQuery } from './report-bookings-query'; diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-bookings-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-bookings-query.ts new file mode 100644 index 000000000000..8a223b3f8ce9 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-bookings-query.ts @@ -0,0 +1,43 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportBookings } from '../api'; +import { sanitizeReportBookingsResponse } from '../processing/bookings'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportBookingsParams = Parameters< typeof fetchReportBookings >[ 0 ]; + +const getReportBookingsQueryKey = ( p: RequestReportBookingsParams ) => + [ 'reports', 'bookings', 'by-date', p.from, p.to, p.interval, p.date_type, p.filters ] as const; + +/** + * + * @param params + */ +export function reportBookingsQuery( + params: RequestReportBookingsParams +): UseQueryOptions< ReportDataMap[ 'bookings' ] > { + return { + queryKey: getReportBookingsQueryKey( params ), + queryFn: async () => { + const response = await fetchReportBookings( params ); + return sanitizeReportBookingsResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-conversion-rate-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-conversion-rate-query.ts new file mode 100644 index 000000000000..7e60af58f333 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-conversion-rate-query.ts @@ -0,0 +1,41 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportConversionRate } from '../api/report-conversion-rate-fetch'; +import { sanitizeReportConversionRateResponse } from '../processing/conversion-rate'; +import type { RequestReportConversionRateParams } from '../api/report-conversion-rate-fetch'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +const getReportConversionRateQueryKey = ( p: RequestReportConversionRateParams ) => + [ 'reports', 'conversion-rate', p.from, p.to, p.interval, p.date_type, p.filters ] as const; + +/** + * + * @param params + */ +export function reportConversionRateQuery( + params: RequestReportConversionRateParams +): UseQueryOptions< ReturnType< typeof sanitizeReportConversionRateResponse > > { + return { + queryKey: getReportConversionRateQueryKey( params ), + queryFn: async () => { + const response = await fetchReportConversionRate( params ); + return sanitizeReportConversionRateResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-by-date-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-by-date-query.ts new file mode 100644 index 000000000000..a02418ab87e1 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-by-date-query.ts @@ -0,0 +1,46 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportCouponsByDate } from '../api'; +import { sanitizeReportCouponsByDateResponse } from '../processing/coupons-by-date'; +import { FilterCondition } from '../types/filter-condition'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportCouponsByDateParams = Parameters< typeof fetchReportCouponsByDate >[ 0 ] & { + filters?: FilterCondition[]; +}; + +const getQueryKey = ( p: RequestReportCouponsByDateParams ) => + [ 'reports', 'couponsByDate', p.from, p.to, p.interval, p.date_type, p.filters ] as const; + +/** + * + * @param params + */ +export function reportCouponsByDateQuery( + params: RequestReportCouponsByDateParams +): UseQueryOptions< ReportDataMap[ 'couponsByDate' ] > { + return { + queryKey: getQueryKey( params ), + queryFn: async () => { + const response = await fetchReportCouponsByDate( params ); + return sanitizeReportCouponsByDateResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-query.ts new file mode 100644 index 000000000000..c37f0d78ec0d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-coupons-query.ts @@ -0,0 +1,46 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportCoupons } from '../api'; +import { sanitizeReportCouponsResponse } from '../processing/coupons'; +import { FilterCondition } from '../types/filter-condition'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportCouponsParams = Parameters< typeof fetchReportCoupons >[ 0 ] & { + filters?: FilterCondition[]; +}; + +const getReportCouponsQueryKey = ( p: RequestReportCouponsParams ) => + [ 'reports', 'coupons', p.from, p.to, p.interval, p.date_type, p.filters ] as const; + +/** + * + * @param params + */ +export function reportCouponsQuery( + params: RequestReportCouponsParams +): UseQueryOptions< ReportDataMap[ 'coupons' ] > { + return { + queryKey: getReportCouponsQueryKey( params ), + queryFn: async () => { + const response = await fetchReportCoupons( params ); + return sanitizeReportCouponsResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-customers-by-date-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-customers-by-date-query.ts new file mode 100644 index 000000000000..1537d84c7353 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-customers-by-date-query.ts @@ -0,0 +1,43 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportCustomersByDate } from '../api/report-customers-by-date-fetch'; +import { sanitizeReportCustomersByDateResponse } from '../processing/customers-by-date'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportCustomersByDateParams = Parameters< typeof fetchReportCustomersByDate >[ 0 ]; + +const getReportCustomersByDateQueryKey = ( p: RequestReportCustomersByDateParams ) => + [ 'reports', 'customers', 'by-date', p.from, p.to, p.interval, p.date_type ] as const; + +/** + * + * @param params + */ +export function reportCustomersByDateQuery( + params: RequestReportCustomersByDateParams +): UseQueryOptions< ReportDataMap[ 'customersByDate' ] > { + return { + queryKey: getReportCustomersByDateQueryKey( params ), + queryFn: async () => { + const response = await fetchReportCustomersByDate( params ); + return sanitizeReportCustomersByDateResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-customers-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-customers-query.ts new file mode 100644 index 000000000000..0cfdb1a30cfa --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-customers-query.ts @@ -0,0 +1,51 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportCustomers } from '../api'; +import { sanitizeReportCustomersResponse } from '../processing/customers'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportCustomersParams = Parameters< typeof fetchReportCustomers >[ 0 ]; + +const getReportCustomersQueryKey = ( p: RequestReportCustomersParams ) => [ + 'reports', + 'customers', + 'new-returning', + p.from, + p.to, + p.date_type, + p.filters, +]; + +/** + * + * @param params + */ +export function reportCustomersQuery( + params: RequestReportCustomersParams +): UseQueryOptions< ReportDataMap[ 'customers' ] > { + return { + queryKey: getReportCustomersQueryKey( params ), + queryFn: async () => { + const response = await fetchReportCustomers( params ); + return sanitizeReportCustomersResponse( response ); + }, + + /** + * Enable the query only if the from and to are set. + * Note: interval is not required for customers endpoint. + */ + enabled: !! ( params.from && params.to ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-order-attribution-summary-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-order-attribution-summary-query.ts new file mode 100644 index 000000000000..55be6f0e756d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-order-attribution-summary-query.ts @@ -0,0 +1,130 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportOrderAttributionSummary, fetchReportOrderAttributionByProduct } from '../api'; +import { + sanitizeReportOrderAttributionSummaryResponse, + normalizeOrderAttributionByProductResponse, + type SanitizedOrderAttributionSummaryResponse, +} from '../processing/order-attribution'; +import { hasProductFilters } from '../utils/product-filters'; +import type { FilterCondition } from '../types/filter-condition'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type ReportOrderAttributionSummaryParams = Parameters< + typeof fetchReportOrderAttributionSummary +>[ 0 ] & { + filters?: FilterCondition[]; +}; + +/** + * Creates a query key for order attribution queries. + * + * Note: All comparison parameters are included in the query key because + * order attribution returns both primary and comparison data in a single response. + * @param params + */ +const getReportOrderAttributionQueryKey = ( params: ReportOrderAttributionSummaryParams ) => + [ + 'reports', + 'order-attribution', + params.view, + params.from, + params.to, + params.interval, + params.date_type, + params.compare_from, + params.compare_to, + params.filters, + ] as const; + +/** + * React Query configuration for order attribution summary data. + * + * This query is designed to be used with `use-report` hook, which provides + * standardized loading states and comparison handling. + * + * Important architectural notes: + * - Unlike other report queries, order attribution includes comparison data in the + * PRIMARY response, not in a separate comparison query + * - When used with `use-report`, the comparison query is disabled (it's a no-op) + * - This query supports two API endpoints: + * 1. Regular order-attribution API: Returns both periods in a single response + * 2. By-product API: Fetches periods separately, then normalizes to match (1) + * + * @param params - Query parameters including date ranges and optional filters + * @return React Query options with query key, fetch function, and enabled state + */ +export function reportOrderAttributionSummaryQuery( + params: ReportOrderAttributionSummaryParams +): UseQueryOptions< SanitizedOrderAttributionSummaryResponse > { + return { + queryKey: getReportOrderAttributionQueryKey( params ), + queryFn: async () => { + const hasProductFiltersValue = hasProductFilters( params.filters ); + + // Choose API based on whether product filters are present + if ( hasProductFiltersValue ) { + // By-product API path: Fetch primary and comparison periods in parallel + const { compare_from, compare_to } = params; + + // Determine if we need to fetch comparison period + const shouldFetchComparison = + compare_from && + compare_to && + ( compare_from !== params.from || compare_to !== params.to ); + + // Fetch both periods in parallel for better performance + const [ currentResponse, previousResponse ] = await Promise.all( [ + fetchReportOrderAttributionByProduct( { + from: params.from, + to: params.to, + interval: params.interval, + view: params.view, + filters: params.filters, + date_type: params.date_type, + } ), + shouldFetchComparison + ? fetchReportOrderAttributionByProduct( { + from: compare_from, + to: compare_to, + interval: params.interval, + view: params.view, + filters: params.filters, + date_type: params.date_type, + } ) + : Promise.resolve( undefined ), + ] ); + + // Normalize to match the regular API structure (includes both periods) + const normalizedResponse = normalizeOrderAttributionByProductResponse( + currentResponse, + previousResponse + ); + + return sanitizeReportOrderAttributionSummaryResponse( normalizedResponse ); + } + + // Regular API path: Returns both primary and comparison in one response + const response = await fetchReportOrderAttributionSummary( params ); + return sanitizeReportOrderAttributionSummaryResponse( response ); + }, + + /** + * Enable the query only when all required parameters are present. + * The 'view' parameter is required for order attribution queries. + */ + enabled: !! ( params.from && params.to && params.interval && params.view ), + + /** + * Keep previous data while fetching to prevent flash of empty state. + * This provides a smoother user experience during data refetching. + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-orders-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-orders-query.ts new file mode 100644 index 000000000000..29deb2e9a552 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-orders-query.ts @@ -0,0 +1,50 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportOrders } from '../api'; +import { sanitizeReportOrdersResponse } from '../processing/orders'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportOrdersParams = Parameters< typeof fetchReportOrders >[ 0 ]; + +const getReportOrdersQueryKey = ( p: RequestReportOrdersParams ) => [ + 'reports', + 'orders', + p.from, + p.to, + p.interval, + p.date_type, + p.filters || [], +]; + +/** + * + * @param params + */ +export function reportOrdersQuery( + params: RequestReportOrdersParams +): UseQueryOptions< ReportDataMap[ 'orders' ] > { + return { + queryKey: getReportOrdersQueryKey( params ), + queryFn: async () => { + const response = await fetchReportOrders( params ); + return sanitizeReportOrdersResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-products-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-products-query.ts new file mode 100644 index 000000000000..a46bb686d4ed --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-products-query.ts @@ -0,0 +1,54 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportProducts } from '../api/report-products-fetch'; +import { sanitizeReportProductsResponse } from '../processing/products'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportProductsParams = Parameters< typeof fetchReportProducts >[ 0 ]; + +type SanitizedProductsResponse = ReturnType< typeof sanitizeReportProductsResponse >; + +const getReportProductsQueryKey = ( p: RequestReportProductsParams ) => + [ + 'reports', + 'products', + p.from, + p.to, + p.date_type, + p.limit, + p.orderby, + p.order, + p.filters, + ] as const; + +/** + * + * @param params + */ +export function reportProductsQuery( + params: RequestReportProductsParams +): UseQueryOptions< SanitizedProductsResponse > { + return { + queryKey: getReportProductsQueryKey( params ), + queryFn: async () => { + const response = await fetchReportProducts( params ); + return sanitizeReportProductsResponse( response ); + }, + + /** + * Enable the query only if the from and to are set. + */ + enabled: !! ( params.from && params.to ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-sessions-by-device-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-sessions-by-device-query.ts new file mode 100644 index 000000000000..b00ce00bf33c --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-sessions-by-device-query.ts @@ -0,0 +1,45 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportSessionsByDevice } from '../api/report-sessions-by-device-fetch'; +import { sanitizeReportSessionsByDeviceResponse } from '../processing/sessions-by-device'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportSessionsByDeviceParams = Parameters< typeof fetchReportSessionsByDevice >[ 0 ]; + +const getReportSessionsByDeviceQueryKey = ( p: RequestReportSessionsByDeviceParams ) => + [ 'reports', 'sessions', 'by-device', p.from, p.to ] as const; + +/** + * Creates query options for fetching sessions by device report data. + * + * @param params - Request parameters with from/to dates + */ +export function reportSessionsByDeviceQuery( + params: RequestReportSessionsByDeviceParams +): UseQueryOptions< ReportDataMap[ 'sessionsByDevice' ] > { + return { + queryKey: getReportSessionsByDeviceQueryKey( params ), + queryFn: async () => { + const response = await fetchReportSessionsByDevice( params ); + return sanitizeReportSessionsByDeviceResponse( response ); + }, + + /** + * Enable the query only if from and to dates are set. + * Note: This endpoint doesn't use interval (it's not a time-series). + */ + enabled: !! ( params.from && params.to ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-by-location-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-by-location-query.ts new file mode 100644 index 000000000000..32663a38b75e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-by-location-query.ts @@ -0,0 +1,48 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportVisitorsByLocation } from '../api'; +import { sanitizeReportVisitorsByLocationResponse } from '../processing/visitors-by-location'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportVisitorsByLocationParams = Parameters< + typeof fetchReportVisitorsByLocation +>[ 0 ]; + +const getReportVisitorsByLocationQueryKey = ( p: RequestReportVisitorsByLocationParams ) => + [ + 'reports', + 'visitors', + 'by-location', + p.group_by, + p.country_code ?? null, + p.from, + p.to, + p.interval, + p.limit ?? null, + ] as const; + +/** + * + * @param params + */ +export function reportVisitorsByLocationQuery( + params: RequestReportVisitorsByLocationParams +): UseQueryOptions< ReportDataMap[ 'visitorsByLocation' ] > { + return { + queryKey: getReportVisitorsByLocationQueryKey( params ), + queryFn: async () => { + const response = await fetchReportVisitorsByLocation( params ); + return sanitizeReportVisitorsByLocationResponse( response ); + }, + + enabled: !! ( params.from && params.to && params.interval ), + + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-query.ts b/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-query.ts new file mode 100644 index 000000000000..78d2f138d554 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/queries/report-visitors-query.ts @@ -0,0 +1,43 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { fetchReportVisitors } from '../api'; +import { sanitizeReportVisitorsResponse } from '../processing/visitors'; +import type { ReportDataMap } from '../types'; +import type { UseQueryOptions } from '@tanstack/react-query'; + +type RequestReportVisitorsParams = Parameters< typeof fetchReportVisitors >[ 0 ]; + +const getReportVisitorsQueryKey = ( p: RequestReportVisitorsParams ) => + [ 'reports', 'visitors', 'by-date', p.from, p.to, p.interval, p.date_type ] as const; + +/** + * + * @param params + */ +export function reportVisitorsQuery( + params: RequestReportVisitorsParams +): UseQueryOptions< ReportDataMap[ 'visitors' ] > { + return { + queryKey: getReportVisitorsQueryKey( params ), + queryFn: async () => { + const response = await fetchReportVisitors( params ); + return sanitizeReportVisitorsResponse( response ); + }, + + /** + * Enable the query only if the from, to, and interval are set. + */ + enabled: !! ( params.from && params.to && params.interval ), + + /** + * Keep previous data while fetching new data to prevent blank states + * @param previousData + */ + placeholderData: previousData => previousData, + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/types.ts b/projects/packages/premium-analytics/packages/data/src/types.ts new file mode 100644 index 000000000000..76b5d9f1a945 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/types.ts @@ -0,0 +1,99 @@ +/** + * Internal dependencies + */ +import { sanitizeReportOrdersResponse, sanitizeReportProductsResponse } from './processing'; +import { sanitizeReportBookingsResponse } from './processing/bookings'; +import { sanitizeReportConversionRateResponse } from './processing/conversion-rate'; +import { sanitizeReportCouponsResponse } from './processing/coupons'; +import { sanitizeReportCouponsByDateResponse } from './processing/coupons-by-date'; +import { sanitizeReportCustomersResponse } from './processing/customers'; +import { sanitizeReportCustomersByDateResponse } from './processing/customers-by-date'; +import { sanitizeReportOrderAttributionSummaryResponse } from './processing/order-attribution'; +import { sanitizeReportOrdersByProductTypeResponse } from './processing/orders-by-product-type'; +import { sanitizeReportSessionsByDeviceResponse } from './processing/sessions-by-device'; +import { sanitizeReportVisitorsResponse } from './processing/visitors'; +import { sanitizeReportVisitorsByLocationResponse } from './processing/visitors-by-location'; +import type { ReportParams } from './utils/search'; + +export type ReportType = + | 'orders' + | 'orders-by-product-type' + | 'order-attribution' + | 'coupons' + | 'couponsByDate' + | 'customers' + | 'customersByDate' + | 'products' + | 'visitors' + | 'visitorsByLocation' + | 'conversionRate' + | 'bookings' + | 'sessionsByDevice'; + +export type QueryParams = ReportParams & { + p?: string; // encoded pathname +}; + +// Inferred from processing/orders.ts +type SanitizedOrdersByDateResponse = ReturnType< typeof sanitizeReportOrdersResponse >; + +// Inferred from processing/order-attribution.ts +type SanitizedOrderAttributionSummaryResponse = ReturnType< + typeof sanitizeReportOrderAttributionSummaryResponse +>; + +// Inferred from processing/coupons.ts +type SanitizedCouponsResponse = ReturnType< typeof sanitizeReportCouponsResponse >; + +// Inferred from processing/coupons-by-date/index.ts +type SanitizedCouponsByDateResponse = ReturnType< typeof sanitizeReportCouponsByDateResponse >; + +// Inferred from processing/customers.ts +type SanitizedCustomersResponse = ReturnType< typeof sanitizeReportCustomersResponse >; + +// Inferred from processing/customers-by-date/index.ts +type SanitizedCustomersByDateResponse = ReturnType< typeof sanitizeReportCustomersByDateResponse >; + +// Inferred from processing/products.ts +type SanitizedProductsResponse = ReturnType< typeof sanitizeReportProductsResponse >; + +// Inferred from processing/visitors.ts +type SanitizedVisitorsResponse = ReturnType< typeof sanitizeReportVisitorsResponse >; + +// Inferred from processing/visitors-by-location.ts +type SanitizedVisitorsByLocationResponse = ReturnType< + typeof sanitizeReportVisitorsByLocationResponse +>; + +// Inferred from processing/conversion-rate.ts +type SanitizedConversionRateResponse = ReturnType< typeof sanitizeReportConversionRateResponse >; + +// Inferred from processing/orders-by-product-type.ts +type SanitizedOrdersByProductTypeResponse = ReturnType< + typeof sanitizeReportOrdersByProductTypeResponse +>; + +// Inferred from processing/bookings.ts +type SanitizedBookingsResponse = ReturnType< typeof sanitizeReportBookingsResponse >; + +// Inferred from processing/sessions-by-device.ts +type SanitizedSessionsByDeviceResponse = ReturnType< + typeof sanitizeReportSessionsByDeviceResponse +>; + +// Type mapping for report types to their PROCESSED data structures +export interface ReportDataMap { + orders: SanitizedOrdersByDateResponse; // Returns processed data with numbers + 'orders-by-product-type': SanitizedOrdersByProductTypeResponse; // Returns processed orders by product type data with numbers + 'order-attribution': SanitizedOrderAttributionSummaryResponse; // Returns processed attribution data + coupons: SanitizedCouponsResponse; // Returns processed coupons data with numbers + couponsByDate: SanitizedCouponsByDateResponse; // Returns processed coupons-by-date data with numbers + customers: SanitizedCustomersResponse; // Returns processed customers data with numbers + customersByDate: SanitizedCustomersByDateResponse; // Returns processed customers by date data with numbers + products: SanitizedProductsResponse; // Returns raw products data + visitors: SanitizedVisitorsResponse; // Returns processed visitors data with numbers + visitorsByLocation: SanitizedVisitorsByLocationResponse; // Returns processed visitors grouped by location (country or region) + conversionRate: SanitizedConversionRateResponse; // Returns processed conversion rate data with numbers + bookings: SanitizedBookingsResponse; // Returns processed bookings data with numbers + sessionsByDevice: SanitizedSessionsByDeviceResponse; // Returns processed sessions by device data with numbers +} diff --git a/projects/packages/premium-analytics/packages/data/src/types/filter-condition.ts b/projects/packages/premium-analytics/packages/data/src/types/filter-condition.ts new file mode 100644 index 000000000000..d6bb433880a2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/types/filter-condition.ts @@ -0,0 +1,23 @@ +/** + * Filter condition types for API queries + */ + +// Different type of filters have different comparison operators +// @see https://github.a8c.com/Automattic/wpcom/tree/72572945acd96d29adf9ea8f38fc3e99c9a4a668/wp-content/rest-api-plugins/endpoints/woocommerce-analytics/Reports/Filter +export type FilterCondition = { + key: string; + value: string | string[]; + compare: + | '=' + | 'IN' + | 'NOT IN' + | '!=' + | '>' + | '<' + | '>=' + | '<=' + | 'BETWEEN' + | 'NOT BETWEEN' + | 'LIKE' + | 'NOT LIKE'; +}; diff --git a/projects/packages/premium-analytics/packages/data/src/types/product-image.ts b/projects/packages/premium-analytics/packages/data/src/types/product-image.ts new file mode 100644 index 000000000000..8fc5b65ad822 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/types/product-image.ts @@ -0,0 +1,7 @@ +/** + * Product image type definition + */ +export interface ProductImage { + imageUrl: string; + imageAlt: string; +} diff --git a/projects/packages/premium-analytics/packages/data/src/types/product-type.ts b/projects/packages/premium-analytics/packages/data/src/types/product-type.ts new file mode 100644 index 000000000000..2b5a41ab1cf8 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/types/product-type.ts @@ -0,0 +1,4 @@ +/** + * Product type categories for filtering and organization + */ +export type ProductType = 'general' | 'products' | 'bookings'; diff --git a/projects/packages/premium-analytics/packages/data/src/utils/__tests__/compute-date-range-from-preset.test.ts b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/compute-date-range-from-preset.test.ts new file mode 100644 index 000000000000..e6c2ea4e4a58 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/compute-date-range-from-preset.test.ts @@ -0,0 +1,150 @@ +/** + * External dependencies + */ +import { tz } from '@date-fns/tz'; +import { + startOfDay, + endOfDay, + subDays, + subMonths, + subYears, + startOfMonth, + endOfMonth, + startOfYear, + endOfYear, +} from 'date-fns'; +/** + * Mocks – getSiteTimezone and dateToISOStringWithLocalTZ + * depend on WordPress core store. + * We mock them to remove that dependency. + * + * dateToISOStringWithLocalTZ normalizes to UTC Z-format + * (matching native Date.toISOString) since the mock timezone + * is +00:00 and all dates are UTC. + */ +jest.mock( '../date', () => ( { + getSiteTimezone: jest.fn( () => '+00:00' ), + dateToISOStringWithLocalTZ: jest.fn( ( date: Date ) => new Date( date.getTime() ).toISOString() ), +} ) ); +/** + * Internal dependencies + */ +import { computeDateRangeFromPreset } from '../preset-date-range'; + +/* + * Pin "now" to 2026-02-19 12:00:00 UTC for deterministic results. + * + * Expected dates are computed in UTC. Since TZ is mocked to +00:00, + * computePrimaryRange runs in UTC and dateToISOStringWithLocalTZ + * normalizes to Z-format via the mock. + */ +const NOW = new Date( '2026-02-19T12:00:00.000Z' ); +const UTC = tz( '+00:00' ); + +/* + * Normalize a TZDate or Date to Z-format ISO string, + * ensuring the expected values match the mock's output format. + */ +/** + * + * @param date + */ +function toZ( date: Date ): string { + return new Date( date.getTime() ).toISOString(); +} + +const TODAY_START = startOfDay( NOW, { in: UTC } ); +const TODAY_END = endOfDay( NOW, { in: UTC } ); +const YESTERDAY_END = endOfDay( subDays( TODAY_START, 1 ), { in: UTC } ); +const LAST_MONTH = subMonths( TODAY_START, 1 ); + +beforeAll( () => { + jest.useFakeTimers(); + jest.setSystemTime( NOW ); +} ); + +afterAll( () => { + jest.useRealTimers(); +} ); + +describe( 'computeDateRangeFromPreset', () => { + it( 'returns today range for "today"', () => { + const range = computeDateRangeFromPreset( 'today' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( TODAY_START ) ); + expect( range!.to ).toBe( toZ( TODAY_END ) ); + } ); + + it( 'returns yesterday range for "yesterday"', () => { + const range = computeDateRangeFromPreset( 'yesterday' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( subDays( TODAY_START, 1 ) ) ); + expect( range!.to ).toBe( toZ( YESTERDAY_END ) ); + } ); + + it( 'returns 7-day range ending yesterday for "last-7-days"', () => { + const range = computeDateRangeFromPreset( 'last-7-days' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( subDays( TODAY_START, 7 ) ) ); + expect( range!.to ).toBe( toZ( YESTERDAY_END ) ); + } ); + + it( 'returns 30-day range ending yesterday for "last-30-days"', () => { + const range = computeDateRangeFromPreset( 'last-30-days' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( subDays( TODAY_START, 30 ) ) ); + expect( range!.to ).toBe( toZ( YESTERDAY_END ) ); + } ); + + it( 'returns 90-day range ending yesterday for "last-90-days"', () => { + const range = computeDateRangeFromPreset( 'last-90-days' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( subDays( TODAY_START, 90 ) ) ); + expect( range!.to ).toBe( toZ( YESTERDAY_END ) ); + } ); + + it( 'returns 365-day range ending yesterday for "last-365-days"', () => { + const range = computeDateRangeFromPreset( 'last-365-days' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( subDays( TODAY_START, 365 ) ) ); + expect( range!.to ).toBe( toZ( YESTERDAY_END ) ); + } ); + + it( 'returns last calendar month for "last-month"', () => { + const range = computeDateRangeFromPreset( 'last-month' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( startOfMonth( LAST_MONTH, { in: UTC } ) ) ); + expect( range!.to ).toBe( toZ( endOfMonth( LAST_MONTH, { in: UTC } ) ) ); + } ); + + it( 'returns last 12 calendar months for "last-12-months"', () => { + const range = computeDateRangeFromPreset( 'last-12-months' ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( startOfMonth( subMonths( TODAY_START, 12 ), { in: UTC } ) ) ); + expect( range!.to ).toBe( toZ( endOfMonth( LAST_MONTH, { in: UTC } ) ) ); + } ); + + it( 'returns last calendar year for "last-year"', () => { + const range = computeDateRangeFromPreset( 'last-year' ); + const lastYear = subYears( TODAY_START, 1 ); + + expect( range ).toBeDefined(); + expect( range!.from ).toBe( toZ( startOfYear( lastYear, { in: UTC } ) ) ); + expect( range!.to ).toBe( toZ( endOfYear( lastYear, { in: UTC } ) ) ); + } ); + + it( 'returns undefined for unrecognized preset', () => { + // @ts-expect-error – testing with invalid preset on purpose + const range = computeDateRangeFromPreset( 'not-a-preset' ); + + expect( range ).toBeUndefined(); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/data/src/utils/__tests__/has-comparison-enabled.test.ts b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/has-comparison-enabled.test.ts new file mode 100644 index 000000000000..1dc771865b48 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/has-comparison-enabled.test.ts @@ -0,0 +1,81 @@ +/** + * Mocks – break the dependency chain to `@wordpress/core-data`. + */ +jest.mock( '../../defaults', () => ( { + getDefaultQueryParams: jest.fn(), +} ) ); + +jest.mock( '../preset-date-range', () => ( { + computeDateRangeFromPreset: jest.fn(), +} ) ); + +jest.mock( '../interval', () => ( { + getDefaultIntervalForPeriod: jest.fn(), +} ) ); +/** + * Internal dependencies + */ +import { hasComparisonEnabled } from '../search'; + +describe( 'hasComparisonEnabled', () => { + it( 'returns true when all comparison fields are present', () => { + expect( + hasComparisonEnabled( { + comp: '1', + compare_from: '2026-01-01T00:00:00.000-05:00', + compare_to: '2026-01-31T23:59:59.999-05:00', + } ) + ).toBe( true ); + } ); + + it( 'returns false when comp is undefined', () => { + expect( + hasComparisonEnabled( { + compare_from: '2026-01-01T00:00:00.000-05:00', + compare_to: '2026-01-31T23:59:59.999-05:00', + } ) + ).toBe( false ); + } ); + + it( 'returns false when compare_from is missing', () => { + expect( + hasComparisonEnabled( { + comp: '1', + compare_to: '2026-01-31T23:59:59.999-05:00', + } ) + ).toBe( false ); + } ); + + it( 'returns false when compare_to is missing', () => { + expect( + hasComparisonEnabled( { + comp: '1', + compare_from: '2026-01-01T00:00:00.000-05:00', + } ) + ).toBe( false ); + } ); + + it( 'returns false when compare_from is whitespace', () => { + expect( + hasComparisonEnabled( { + comp: '1', + compare_from: ' ', + compare_to: '2026-01-31T23:59:59.999-05:00', + } ) + ).toBe( false ); + } ); + + it( 'returns false when compare_to is whitespace', () => { + expect( + hasComparisonEnabled( { + comp: '1', + compare_from: '2026-01-01T00:00:00.000-05:00', + compare_to: ' ', + } ) + ).toBe( false ); + } ); + + it( 'returns false for empty object', () => { + expect( hasComparisonEnabled( {} ) ).toBe( false ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/data/src/utils/__tests__/normalize-report-params.test.ts b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/normalize-report-params.test.ts new file mode 100644 index 000000000000..00644be04800 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/__tests__/normalize-report-params.test.ts @@ -0,0 +1,291 @@ +/** + * Mocks – must appear before the import of the module under test. + */ +jest.mock( '../../defaults', () => ( { + getDefaultQueryParams: jest.fn(), +} ) ); + +jest.mock( '../preset-date-range', () => ( { + computeDateRangeFromPreset: jest.fn(), +} ) ); + +jest.mock( '../interval', () => ( { + getDefaultIntervalForPeriod: jest.fn(), +} ) ); +/** + * Internal dependencies + */ +import { getDefaultQueryParams } from '../../defaults'; +import { getDefaultIntervalForPeriod } from '../interval'; +import { computeDateRangeFromPreset } from '../preset-date-range'; +import { normalizeReportParams } from '../search'; +import type { ReportParams } from '../search'; + +const mockGetDefaults = getDefaultQueryParams as jest.MockedFunction< + typeof getDefaultQueryParams +>; +const mockComputeRange = computeDateRangeFromPreset as jest.MockedFunction< + typeof computeDateRangeFromPreset +>; +const mockGetInterval = getDefaultIntervalForPeriod as jest.MockedFunction< + typeof getDefaultIntervalForPeriod +>; + +/* + * Deterministic date strings. + * FRESH = what computeDateRangeFromPreset returns "today". + * STALE = what the URL had from a previous day. + */ +const FRESH_FROM = '2026-01-20T00:00:00.000-05:00'; +const FRESH_TO = '2026-02-18T23:59:59.999-05:00'; +const STALE_FROM = '2026-01-19T00:00:00.000-05:00'; +const STALE_TO = '2026-02-17T23:59:59.999-05:00'; + +const DEFAULTS_WITH_COMPARISON: ReportParams = { + from: FRESH_FROM, + to: FRESH_TO, + preset: 'last-30-days', + interval: 'day', + compare_from: '2025-12-21T00:00:00.000-05:00', + compare_to: '2026-01-19T23:59:59.999-05:00', + compare_preset: 'previous-period', + comp: '1', +}; + +beforeEach( () => { + jest.clearAllMocks(); + + // Sensible defaults for every test – override per-scenario as needed. + mockGetDefaults.mockReturnValue( { ...DEFAULTS_WITH_COMPARISON } ); + mockComputeRange.mockReturnValue( { + from: FRESH_FROM, + to: FRESH_TO, + } ); + mockGetInterval.mockReturnValue( 'day' ); +} ); + +describe( 'normalizeReportParams', () => { + /* + * Scenario 1 – Fresh load (no params in URL) + * The user visits /dashboard with no query string. + * Expected: defaults kick in, preset "last-30-days" is used, + * and default comparison is applied. + */ + it( 'applies defaults with preset and comparison on fresh load', () => { + const result = normalizeReportParams(); + + // Preset should come from defaults. + expect( result.preset ).toBe( 'last-30-days' ); + expect( mockComputeRange ).toHaveBeenCalledWith( 'last-30-days' ); + + // Dates should come from computeDateRangeFromPreset. + expect( result.from ).toBe( FRESH_FROM ); + expect( result.to ).toBe( FRESH_TO ); + + // Default comparison should be applied (search is undefined + // → !search?.from → true → default branch). + expect( result.comp ).toBe( '1' ); + expect( result.compare_from ).toBe( DEFAULTS_WITH_COMPARISON.compare_from ); + expect( result.compare_to ).toBe( DEFAULTS_WITH_COMPARISON.compare_to ); + } ); + + /* + * Scenario 2 – Same-day reload with preset + * The URL has preset=last-30-days and from/to that match today's + * computation. The dates are still fresh → no redirect needed. + */ + it( 'returns same dates when preset range is still fresh', () => { + const result = normalizeReportParams( { + from: FRESH_FROM, + to: FRESH_TO, + preset: 'last-30-days', + interval: 'day', + } ); + + expect( result.from ).toBe( FRESH_FROM ); + expect( result.to ).toBe( FRESH_TO ); + expect( result.preset ).toBe( 'last-30-days' ); + + // No comparison in search → no comparison in output + // (search.from is present → !search.from is false + // → default comparison branch is skipped). + expect( result.comp ).toBeUndefined(); + } ); + + /* + * Scenario 3 – Next-day reload with stale dates + * The URL has yesterday's dates but the same preset. + * computeDateRangeFromPreset returns fresh dates → redirect. + */ + it( 'recalculates dates when preset range is stale', () => { + const result = normalizeReportParams( { + from: STALE_FROM, + to: STALE_TO, + preset: 'last-30-days', + interval: 'day', + } ); + + // Should use the fresh range from the preset, not stale URL dates. + expect( result.from ).toBe( FRESH_FROM ); + expect( result.to ).toBe( FRESH_TO ); + expect( result.preset ).toBe( 'last-30-days' ); + expect( mockComputeRange ).toHaveBeenCalledWith( 'last-30-days' ); + } ); + + /* + * Scenario 4 – Custom range (no preset) + * The user picked explicit from/to dates without a preset. + * The dates should be used as-is, no recalculation. + */ + it( 'uses explicit dates as-is when no preset is set', () => { + const customFrom = '2026-01-01T00:00:00.000-05:00'; + const customTo = '2026-01-31T23:59:59.999-05:00'; + + const result = normalizeReportParams( { + from: customFrom, + to: customTo, + } ); + + expect( result.from ).toBe( customFrom ); + expect( result.to ).toBe( customTo ); + expect( result.preset ).toBeUndefined(); + // computeDateRangeFromPreset should NOT be called. + expect( mockComputeRange ).not.toHaveBeenCalled(); + } ); + + it( 'uses explicit dates as-is when preset is custom', () => { + const customFrom = '2026-01-01T00:00:00.000-05:00'; + const customTo = '2026-01-31T23:59:59.999-05:00'; + + const result = normalizeReportParams( { + from: customFrom, + to: customTo, + preset: 'custom', + } ); + + expect( result.from ).toBe( customFrom ); + expect( result.to ).toBe( customTo ); + expect( result.preset ).toBeUndefined(); + expect( mockComputeRange ).not.toHaveBeenCalled(); + } ); + + /* + * Scenario 5 – Preset with stale comparison enabled + * The URL has a stale preset and comparison params. + * Primary range is recalculated; comparison is preserved from URL. + */ + it( 'recalculates primary but preserves comparison from URL', () => { + const compFrom = '2025-12-20T00:00:00.000-05:00'; + const compTo = '2026-01-18T23:59:59.999-05:00'; + + const result = normalizeReportParams( { + from: STALE_FROM, + to: STALE_TO, + preset: 'last-30-days', + interval: 'day', + comp: '1', + compare_from: compFrom, + compare_to: compTo, + compare_preset: 'previous-period', + } ); + + // Primary recalculated from preset. + expect( result.from ).toBe( FRESH_FROM ); + expect( result.to ).toBe( FRESH_TO ); + + // Comparison passed through from search. + expect( result.comp ).toBe( '1' ); + expect( result.compare_from ).toBe( compFrom ); + expect( result.compare_to ).toBe( compTo ); + expect( result.compare_preset ).toBe( 'previous-period' ); + } ); + + /* + * Scenario 6 – Preset without comparison + * The URL has a stale preset but comparison is disabled. + * Primary is recalculated; comparison params are absent. + */ + it( 'recalculates primary with no comparison when comp is absent', () => { + const result = normalizeReportParams( { + from: STALE_FROM, + to: STALE_TO, + preset: 'last-30-days', + interval: 'day', + } ); + + // Primary recalculated. + expect( result.from ).toBe( FRESH_FROM ); + expect( result.to ).toBe( FRESH_TO ); + + // No comparison in search, and search.from is present + // → default comparison is NOT applied. + expect( result.comp ).toBeUndefined(); + expect( result.compare_from ).toBeUndefined(); + expect( result.compare_to ).toBeUndefined(); + } ); + + /* + * Edge case – Invalid preset in URL is ignored. + */ + it( 'ignores invalid preset and uses URL dates', () => { + const customFrom = '2026-02-01T00:00:00.000-05:00'; + const customTo = '2026-02-15T23:59:59.999-05:00'; + + const result = normalizeReportParams( { + from: customFrom, + to: customTo, + // @ts-expect-error – testing with invalid preset on purpose + preset: 'not-a-real-preset', + } ); + + expect( result.from ).toBe( customFrom ); + expect( result.to ).toBe( customTo ); + expect( result.preset ).toBeUndefined(); + expect( mockComputeRange ).not.toHaveBeenCalled(); + } ); + + /* + * Edge case – computeDateRangeFromPreset returns undefined + * (e.g., an unimplemented preset). Falls back to search dates. + */ + it( 'falls back to URL dates when preset has no range implementation', () => { + mockComputeRange.mockReturnValue( undefined ); + + const result = normalizeReportParams( { + from: STALE_FROM, + to: STALE_TO, + preset: 'last-30-days', + } ); + + // Preset should be cleared. + expect( result.preset ).toBeUndefined(); + // Falls back to search dates. + expect( result.from ).toBe( STALE_FROM ); + expect( result.to ).toBe( STALE_TO ); + } ); + + /* + * Edge case – date_type is preserved from search. + */ + it( 'preserves date_type from search', () => { + const result = normalizeReportParams( { + from: FRESH_FROM, + to: FRESH_TO, + date_type: 'paid', + } ); + + expect( result.date_type ).toBe( 'paid' ); + } ); + + /* + * Edge case – date_type defaults to "created". + */ + it( 'defaults date_type to created', () => { + const result = normalizeReportParams( { + from: FRESH_FROM, + to: FRESH_TO, + } ); + + expect( result.date_type ).toBe( 'created' ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/data/src/utils/date.ts b/projects/packages/premium-analytics/packages/data/src/utils/date.ts new file mode 100644 index 000000000000..81f1950d8d4e --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/date.ts @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import { type TZDate } from '@date-fns/tz'; +import { + toLocalTZ, + formatToTimezoneNaiveString as _formatNaive, + dateToISOStringWithTZ as _toISOWithTZ, +} from '@jetpack-premium-analytics/datetime'; +import { store as coreStore, type Settings } from '@wordpress/core-data'; +import { select } from '@wordpress/data'; + +type FullSettings = Settings & { + gmt_offset: number; +}; + +let DEFAULT_TIME_ZONE: string; +try { + DEFAULT_TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '+00:00'; +} catch { + DEFAULT_TIME_ZONE = '+00:00'; +} + +/** + * Format the GMT offset to a string. + * + * @param {number | undefined} offset - The GMT offset. + * @return {string} The formatted GMT offset. + */ +function formatGmtOffset( offset: number | undefined ): string { + if ( ! offset ) { + return DEFAULT_TIME_ZONE; + } + + const sign = offset >= 0 ? '+' : '-'; + const abs = Math.abs( offset ); + const hours = Math.floor( abs ); + const minutes = Math.floor( ( abs - hours ) * 60 + 1e-6 ); + return `${ sign }${ String( hours ).padStart( 2, '0' ) }:${ String( minutes ).padStart( + 2, + '0' + ) }`; +} + +/* + * Get the timezone from the site settings. + * If the timezone is not set, use the GMT offset. + * If the GMT offset is not set, use the default timezone. + * + * @param {string} timezone - The timezone to use. + * @return {string} The timezone. + */ +/** + * + */ +export function getSiteTimezone() { + const siteSettings = select( coreStore ).getEntityRecord( 'root', 'site' ) as FullSettings; + + if ( ! siteSettings ) { + return DEFAULT_TIME_ZONE; + } + + return siteSettings?.timezone?.length + ? siteSettings?.timezone + : formatGmtOffset( siteSettings?.gmt_offset ) || DEFAULT_TIME_ZONE; +} + +/** + * Returns the site's GMT offset as a string (e.g. "+05:30", "-08:00"). + * If site settings are not loaded, throws an error. + * @return {string} The site's GMT offset. + */ +export function getSiteGmtOffset(): string { + const siteSettings = select( coreStore ).getEntityRecord( 'root', 'site' ) as FullSettings; + if ( ! siteSettings ) { + throw new Error( 'getSiteGmtOffset() called before core settings are ready' ); + } + return formatGmtOffset( siteSettings?.gmt_offset ) || DEFAULT_TIME_ZONE; +} + +/** + * Same API and behavior as your current localTZDate: + * - Accepts number | string | Date (or undefined -> now) + * - Uses site timezone by default + * - Returns TZDate (timezone-aware) + * @param value + * @param timezone + */ +export function localTZDate( value?: number | string | Date, timezone?: string ): TZDate { + const tz = timezone ?? getSiteTimezone(); + return toLocalTZ( value, tz ); +} + +/** + * Same semantics as your current helper: + * TZ-aware -> timezone-naive "YYYY-MM-DDTHH:mm:ss.SSS" + * @param date + * @param timezone + */ +export function formatToTimezoneNaiveString( date: Date, timezone?: string ): string { + const tz = timezone ?? getSiteTimezone(); + return _formatNaive( date, tz ); +} + +/** + * Same semantics as your current helper: + * TZ-aware -> ISO with offset "YYYY-MM-DDTHH:mm:ss.SSSxxx" + * @param date + * @param timezone + */ +export function dateToISOStringWithLocalTZ( date: Date, timezone?: string ): string { + const tz = timezone ?? getSiteTimezone(); + return _toISOWithTZ( date, tz ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/ensure-core-settings.ts b/projects/packages/premium-analytics/packages/data/src/utils/ensure-core-settings.ts new file mode 100644 index 000000000000..cd00c8a6e0e5 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/ensure-core-settings.ts @@ -0,0 +1,21 @@ +/** + * External dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { resolveSelect } from '@wordpress/data'; + +let readyPromise: Promise< void > | null = null; + +/** + * Ensures that 'site' and 'general settings' are in the coreStore. + * Memoizes the same promise to avoid races and duplicate requests. + */ +export function ensureCoreSettingsReady(): Promise< void > { + if ( ! readyPromise ) { + readyPromise = Promise.all( [ + resolveSelect( coreStore ).getEntityRecord( 'root', 'site' ), + resolveSelect( coreStore ).getEntityRecord( 'root', 'settings', 'general' ), + ] ).then( () => void 0 ); + } + return readyPromise; +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/index.ts b/projects/packages/premium-analytics/packages/data/src/utils/index.ts new file mode 100644 index 000000000000..0a0763050244 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/index.ts @@ -0,0 +1,15 @@ +export { + localTZDate, + dateToISOStringWithLocalTZ, + formatToTimezoneNaiveString, + getSiteTimezone, + getSiteGmtOffset, +} from './date'; +export { ensureCoreSettingsReady } from './ensure-core-settings'; +export { getDefaultIntervalForPeriod } from './interval'; +export { safeParseInt, safeParseFloat } from './parsing'; +export { computeDateRangeFromPreset } from './preset-date-range'; +export { hasProductFilters } from './product-filters'; +export type { PresetType, ReportParams } from './search'; +export { isSelectablePreset } from '@jetpack-premium-analytics/datetime'; +export type { Override, BaseReportParams } from './types'; diff --git a/projects/packages/premium-analytics/packages/data/src/utils/interval.ts b/projects/packages/premium-analytics/packages/data/src/utils/interval.ts new file mode 100644 index 000000000000..30c962cb435d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/interval.ts @@ -0,0 +1,116 @@ +/** + * External dependencies + */ +import { differenceInHours } from 'date-fns'; +/** + * Internal dependencies + */ +import { localTZDate } from './date'; +import type { IntervalType } from './search'; + +/** + * + * @param from + * @param to + */ +function getAllowedIntervalsByRange( from: string, to: string ): IntervalType[] { + // Use hours instead of days to handle ranges that are 1 second short of a full day. + // E.g., '2024-11-01 00:00:00' to '2025-10-31 23:59:59' is 8759 hours (364.958 days), + // which rounds to 365 days, correctly categorizing it as a yearly interval. + const daysDiff = Math.round( + Math.abs( differenceInHours( localTZDate( to ), localTZDate( from ) ) / 24 ) + ); + + if ( daysDiff >= 1095 ) { + return [ 'quarter', 'year' ]; + } else if ( daysDiff >= 365 ) { + return [ 'month', 'quarter' ]; + } else if ( daysDiff >= 90 ) { + return [ 'week', 'month' ]; + } else if ( daysDiff >= 28 ) { + return [ 'day', 'week' ]; + } else if ( daysDiff >= 3 ) { + return [ 'day' ]; + } else if ( daysDiff >= 1 ) { + return [ 'hour', 'day' ]; + } + + return [ 'hour', 'day' ]; +} + +/** + * Returns the allowed selectable intervals for a specific period. + * + * @param period + * @param from + * @param to + * @return {Array} Array containing allowed intervals. + */ +function getAllowedIntervalsForPeriod( + period: string | undefined, + from: string, + to: string +): IntervalType[] { + switch ( period ) { + case 'today': + case 'yesterday': + return [ 'hour', 'day' ]; + case 'last-7-days': + return [ 'day' ]; + case 'last-30-days': + case 'last-month': + return [ 'day', 'week' ]; + case 'last-90-days': + return [ 'week', 'month' ]; + case 'last-12-months': + case 'last-365-days': + case 'last-year': + return [ 'month', 'quarter' ]; + default: + return getAllowedIntervalsByRange( from, to ); + } +} + +/** + * + * @param period + * @param from + * @param to + */ +export function getDefaultIntervalForPeriod( + period: string | undefined, + from: string, + to: string +): IntervalType { + return getAllowedIntervalsForPeriod( period, from, to )?.[ 0 ] ?? 'day'; +} + +/** + * + * @param period + * @param from + * @param to + */ +export function getDateFormatFromInterval( + period: string | undefined, // Pass in undefined to use the default interval. + from: string, + to: string +): string { + const interval = getDefaultIntervalForPeriod( period, from, to ); + + switch ( interval ) { + case 'hour': + return 'HH:mm'; + case 'day': + case 'week': + return 'MMM d'; + case 'month': + return 'MMM yyyy'; + case 'quarter': + return 'qqq yyyy'; + case 'year': + return 'yyyy'; + default: + return 'MMM d'; + } +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/jpa-config.ts b/projects/packages/premium-analytics/packages/data/src/utils/jpa-config.ts new file mode 100644 index 000000000000..8673ddd2aa93 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/jpa-config.ts @@ -0,0 +1,40 @@ +export type JpaConfig = { + /** + * WordPress.com blog ID of the connected site. + */ + siteId: number; + + /** + * REST API root URL, e.g. `https://example.com/wp-json/`. + */ + apiRoot: string; + + /** + * REST API nonce (`wp_rest`). + */ + nonce: string; +}; + +declare global { + interface Window { + jpaConfig?: JpaConfig; + } +} + +/** + * Read the boot configuration emitted by the PHP `Config_Data` class as + * `window.jpaConfig` ahead of the boot script on the Premium Analytics admin + * page. + * + * @return The boot configuration. + * @throws {Error} If called outside a browser context or before the config is emitted. + */ +export function getJpaConfig(): JpaConfig { + if ( typeof window === 'undefined' || ! window.jpaConfig ) { + throw new Error( + 'window.jpaConfig is not available. It is emitted by the Config_Data PHP class on the Premium Analytics admin page; outside that page (or in tests) it must be stubbed.' + ); + } + + return window.jpaConfig; +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/parsing.ts b/projects/packages/premium-analytics/packages/data/src/utils/parsing.ts new file mode 100644 index 000000000000..8a4535a7231d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/parsing.ts @@ -0,0 +1,19 @@ +/** + * Safe integer parsing with fallback value + * @param value + * @param fallback + */ +export function safeParseInt( value: unknown, fallback = 0 ): number { + const num = parseInt( String( value ), 10 ); + return isNaN( num ) ? fallback : num; +} + +/** + * Safe float parsing with fallback value + * @param value + * @param fallback + */ +export function safeParseFloat( value: unknown, fallback = 0 ): number { + const num = parseFloat( String( value ) ); + return isNaN( num ) ? fallback : num; +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/preset-date-range.ts b/projects/packages/premium-analytics/packages/data/src/utils/preset-date-range.ts new file mode 100644 index 000000000000..cf0345d1003d --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/preset-date-range.ts @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { computePrimaryRange } from '@jetpack-premium-analytics/datetime'; +import { getSiteTimezone, dateToISOStringWithLocalTZ } from './date'; +import type { SelectablePresetId } from '@jetpack-premium-analytics/datetime'; +/** + * Internal dependencies + */ + +/** + * Compute the absolute date range for a given preset ID + * based on the current date and the site's timezone. + * + * Thin wrapper over datetime's computePrimaryRange that + * resolves the site timezone and converts Date -> ISO string. + * + * @param presetId - A valid selectable preset identifier. + * @return The computed { from, to } ISO strings, or undefined + * if the preset is not recognized. + */ +export function computeDateRangeFromPreset( + presetId: SelectablePresetId +): { from: string; to: string } | undefined { + const range = computePrimaryRange( presetId, getSiteTimezone() ); + if ( ! range?.from || ! range?.to ) { + return undefined; + } + + return { + from: dateToISOStringWithLocalTZ( range.from ), + to: dateToISOStringWithLocalTZ( range.to ), + }; +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/product-filters.ts b/projects/packages/premium-analytics/packages/data/src/utils/product-filters.ts new file mode 100644 index 000000000000..063b924a50b2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/product-filters.ts @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import type { FilterCondition } from '../types/filter-condition'; + +/** + * Product lookup table level filters. + */ +const PRODUCT_FILTER_KEYS = [ 'product_type', 'virtual', 'downloadable' ]; + +/** + * Checks if any of the provided filters are product-related filters + * + * @param filters - Array of filter conditions to check + * @return True if any filter is product-related, false otherwise + */ +export function hasProductFilters( filters?: FilterCondition[] ): boolean { + if ( ! filters || ! Array.isArray( filters ) || filters.length === 0 ) { + return false; + } + + return filters.some( filter => PRODUCT_FILTER_KEYS.includes( filter.key ) ); +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/search.ts b/projects/packages/premium-analytics/packages/data/src/utils/search.ts new file mode 100644 index 000000000000..0aa97aacb501 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/search.ts @@ -0,0 +1,145 @@ +/** + * External dependencies + */ +import { + isSelectablePreset, + type SelectablePresetId, + type ComparisonPresetId, + type PrimaryPresetId, +} from '@jetpack-premium-analytics/datetime'; +/** + * Internal dependencies + */ +import { ORDER_ATTRIBUTION_VIEWS } from '../api/report-order-attribution-summary-fetch'; +import { getDefaultQueryParams } from '../defaults'; +import { getDefaultIntervalForPeriod } from './interval'; +import { computeDateRangeFromPreset } from './preset-date-range'; +import type { DateType } from './types'; +import type { FilterCondition } from '../types/filter-condition'; + +export type { FilterCondition }; + +/** + * Re-export SelectablePresetId as PresetType for backward compatibility. + * The canonical type now lives in `@jetpack-premium-analytics/datetime`. + */ +export type PresetType = SelectablePresetId; + +type OrderAttributionView = ( typeof ORDER_ATTRIBUTION_VIEWS )[ number ]; + +export type IntervalType = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'; + +/* + * ReportParams are the expected params present in the client URL. + * They aren't meant to be the reports params + * of the API endpoint (RequestReportOrdersParams) + */ +export type ReportParams = { + from: string; + to: string; + preset?: PresetType; + interval: IntervalType; + period?: string; + compare_from?: string; + compare_to?: string; + compare_preset?: ComparisonPresetId; + comp?: '1'; + view?: OrderAttributionView; // For order attribution reports + filters?: FilterCondition[]; + section?: string; + date_type?: DateType; // For filtering by different date fields (created, paid, completed) +}; + +type PartialComparisonFields = Partial< + Pick< ReportParams, 'comp' | 'compare_from' | 'compare_to' > +>; + +/* + * Checks if the comparison is present in the search params. + */ +/** + * + * @param p + */ +export function hasComparisonEnabled< T extends PartialComparisonFields >( p: T ) { + return p.comp === '1' && !! p.compare_from?.trim() && !! p.compare_to?.trim(); +} + +type NormalizeReportParamsArgType = Omit< ReportParams, 'from' | 'to' | 'interval' | 'preset' > & { + from?: string; + to?: string; + interval?: string; + preset?: PrimaryPresetId; +}; + +/** + * Returns normalized params for the report request query. + * When no defined, it will use the defaults. + * + * @param {NormalizeReportParamsArgType} [search] - URL search params. + * @param {PresetType} [defaultPreset] - Override the fallback preset. + */ +export function normalizeReportParams( + search?: NormalizeReportParamsArgType, + defaultPreset?: PresetType +): ReportParams { + const defaults = defaultPreset + ? getDefaultQueryParams( true, defaultPreset ) + : getDefaultQueryParams( true ); + + // Preset handling: + // - Use search.preset only if valid + // - On fresh load (no from/to), fallback to defaults.preset + // - If user has explicit dates but no/invalid preset, + // keep undefined (custom range) + let preset: PresetType | undefined; + if ( search?.preset && isSelectablePreset( search.preset ) ) { + preset = search.preset; + } else if ( ! search?.from && ! search?.to ) { + preset = defaults.preset; + } + + // When a valid preset is present, recalculate from/to + // so rolling ranges like "Last 30 days" stay fresh + // on every page load instead of using stale URL dates. + // If the preset is valid but has no range implementation, + // clear it to avoid silently falling back to stale dates. + let presetRange: ReturnType< typeof computeDateRangeFromPreset >; + if ( preset ) { + presetRange = computeDateRangeFromPreset( preset ); + if ( ! presetRange ) { + preset = undefined; + } + } + + const from = presetRange?.from ?? search?.from ?? defaults.from; + const to = presetRange?.to ?? search?.to ?? defaults.to; + + // Calculate the interval from the resolved date range. + const interval = getDefaultIntervalForPeriod( undefined, from, to ); + + // Params from `search`, or fallback to defaults. + const normalized: ReportParams = { + from, + to, + interval: interval ?? defaults.interval, + preset, + date_type: search?.date_type ?? 'created', + }; + + // Add comparison params from search if enabled + if ( search && hasComparisonEnabled( search ) ) { + normalized.compare_from = search.compare_from; + normalized.compare_to = search.compare_to; + normalized.compare_preset = search.compare_preset; + normalized.comp = '1'; + } else if ( ! search?.from && hasComparisonEnabled( defaults ) ) { + // Fresh load (missing primary params) - apply default comparison + normalized.compare_from = defaults.compare_from; + normalized.compare_to = defaults.compare_to; + normalized.compare_preset = defaults.compare_preset; + normalized.comp = '1'; + } + + return normalized; +} diff --git a/projects/packages/premium-analytics/packages/data/src/utils/types.ts b/projects/packages/premium-analytics/packages/data/src/utils/types.ts new file mode 100644 index 000000000000..35f200844148 --- /dev/null +++ b/projects/packages/premium-analytics/packages/data/src/utils/types.ts @@ -0,0 +1,29 @@ +/** + * Utility type to override properties of a type. + * Useful for transforming API responses where some properties change type. + * + * @example + * type Raw = { count: string; name: string; } + * type Processed = Override< Raw, { count: number } > + * // Result: { count: number; name: string; } + */ +export type Override< T, U > = Omit< T, keyof U > & U; + +/** + * Date type parameter for filtering reports by different date fields. + * - 'created': Filter by order creation date (date_created_gmt) + * - 'paid': Filter by order payment date (date_paid_gmt) + * - 'completed': Filter by order completion date (date_completed_gmt) + */ +export type DateType = 'created' | 'paid' | 'completed'; + +/** + * Base parameters required by all report endpoints. + * These three parameters are common across all analytics reports. + */ +export type BaseReportParams = { + from: string; + to: string; + interval: string; + date_type?: DateType; +}; diff --git a/projects/packages/premium-analytics/packages/routing/README.md b/projects/packages/premium-analytics/packages/routing/README.md new file mode 100644 index 000000000000..bfeefb8ff1fc --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/README.md @@ -0,0 +1,133 @@ +# @automattic/jetpack-premium-analytics-routing + +Utilities for handling **routing and URL search parameters** in +WooCommerce Analytics with TypeScript integration. + +This package centralizes logic for encoding and decoding route params so +that date ranges, filters, comparison parameters, and other query +parameters are handled consistently across the application. + +## Features + +- **Date range encoding** – Convert `DateRange` objects into ISO strings + with timezone support +- **Comparison parameters** – Handle `compare_from`, `compare_to`, + `compare_preset`, and `comp` flags +- **@wordpress/route integration** – Type-safe navigation with search + parameter management +- **Timezone handling** – Automatic timezone conversion for consistent + date handling +- **URL state persistence** – Maintains filter and comparison state + across page refreshes +- **Navigation utilities** – Write encoded parameters directly to the URL + using router navigation + +## Usage Examples + +### Date Range Navigation + +```typescript +import { writeDateRangeToSearch } from '@automattic/jetpack-premium-analytics-routing'; +import { useNavigate } from '@wordpress/route'; + +function DateRangeSelector() { + const navigate = useNavigate(); + + const handleRangeChange = ( nextRange ) => { + writeDateRangeToSearch( { + navigate, + to: '/wc-analytics/dashboard', + range: nextRange, + search: { interval: 'day' } // Preserve other params + } ); + }; +} +``` + +### Comparison Parameter Management + +```typescript +import { writeComparisonToSearch } from '@automattic/jetpack-premium-analytics-routing'; + +function ComparisonSelector() { + const navigate = useNavigate(); + + const handleComparisonChange = ( range, presetId ) => { + writeComparisonToSearch( { + navigate, + to: '/wc-analytics/dashboard', + range, + presetId, + enabled: !!range + } ); + }; +} +``` + +## API Reference + +### `writeDateRangeToSearch( options )` + +Writes a `DateRange` to the URL using the provided `navigate` function. + +**Parameters:** +- **`navigate`** – Navigation function from `useNavigate()` (`@wordpress/route`) +- **`to`** – Destination path (e.g., `'/wc-analytics/dashboard'`) +- **`range`** – `{ from: Date | undefined; to?: Date | undefined }` +- **`timezone?`** *(optional)* – Override timezone for date conversion +- **`search?`** *(optional)* – Additional search params to preserve/set + +**URL Parameters Generated:** +- `from` – ISO string with timezone offset +- `to` – ISO string with timezone offset + +### `writeComparisonToSearch( options )` + +Writes comparison parameters to the URL for period-over-period analysis. + +**Parameters:** +- **`navigate`** – Navigation function from `@wordpress/route` +- **`to`** – Destination path +- **`range?`** – Comparison date range +- **`presetId?`** – Preset identifier (e.g., 'previous_period') +- **`enabled?`** – Whether comparison is active +- **`timezone?`** – Override timezone +- **`search?`** – Additional search params + +**URL Parameters Generated:** +- `compare_from` – Comparison start date (ISO string) +- `compare_to` – Comparison end date (ISO string) +- `compare_preset` – Preset identifier +- `comp` – '1' when comparison enabled, undefined when disabled + +### `encodeDateToSearchParam( date?, timezone? )` + +Low-level function to convert a Date to an ISO string with timezone. + +**Parameters:** +- **`date?`** – Date to encode (returns undefined if not provided) +- **`timezone?`** – Timezone override + +**Returns:** ISO string with timezone offset or undefined + +## Architecture + +### URL Parameter Structure + +``` +/wc-analytics/dashboard? + from=2025-01-01T00:00:00-08:00& # Primary date range + to=2025-01-31T23:59:59-08:00& + interval=day& # Data granularity + compare_from=2024-12-01T00:00:00-08:00& # Comparison range + compare_to=2024-12-31T23:59:59-08:00& + compare_preset=previous_period& # Comparison preset + comp=1 # Comparison enabled flag +``` + +### Timezone Handling + +1. **Local Timezone Detection**: Uses site timezone from WordPress settings +2. **ISO String Generation**: Converts dates to ISO strings with timezone offset +3. **Consistent API Calls**: Ensures all API requests use properly formatted dates +4. **Cross-browser Support**: Handles timezone differences across different environments diff --git a/projects/packages/premium-analytics/packages/routing/package.json b/projects/packages/premium-analytics/packages/routing/package.json new file mode 100644 index 000000000000..8533c37fae25 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/package.json @@ -0,0 +1,14 @@ +{ + "name": "@automattic/jetpack-premium-analytics-routing", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "sideEffects": false, + "dependencies": { + "@jetpack-premium-analytics/data": "workspace:*", + "@jetpack-premium-analytics/datetime": "workspace:*", + "@wordpress/route": "0.12.0" + } +} diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/index.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/index.ts new file mode 100644 index 000000000000..a29505a5a269 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/index.ts @@ -0,0 +1 @@ +export { useStagedSearch } from './use-staged-search'; diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-date-range-search/index.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/use-date-range-search/index.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-date-range-search/use-date-range-search.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/use-date-range-search/use-date-range-search.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/README.md b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/README.md new file mode 100644 index 000000000000..43f6bd7b19c0 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/README.md @@ -0,0 +1,149 @@ +# `useStagedSearch` — staged UI + atomic URL commits + +Make the UI react instantly while the URL stays the source of truth. Edits are +staged locally and then committed atomically to the URL (one navigation). Back/ +Forward stays smooth. + +--- + +## Concepts + +* **committed**: current URL state (`useSearch`). +* **staged**: optimistic local edits (what the user is changing now). +* **effective**: `staged` merged over `committed` (ignoring `undefined`). + +Atomic commit: + +* `commit()` writes all staged changes in one `navigate({ search })`. +* Optional debounced auto-commit uses `replace: true` to avoid dirty history. +* On confirm, call `commit({ replace: false })` to push a history entry. + +--- + +## API + +```ts +type UseStagedSearchOptions< TFrom extends string > = { + from: TFrom; // TanStack route id/path + autoCommitDebounceMs?: number; // optional debounce in ms +}; + +type UseStagedSearchReturn< TSearch > = { + committed: TSearch; // current URL state + staged: TSearch; // optimistic local snapshot + effective: TSearch; // staged over committed per key + isSyncing: boolean; // true while committing + isDirty: boolean; // staged differs from committed + stage( patch: Partial< TSearch > ): void; + commit( opts?: { replace?: boolean } ): void; + revert(): void; + cancelAutoCommit(): void; +}; +``` + +Notes: + +* Internally uses `useSearch( { from } )` and `useNavigate( { from } )`. +* No `to` is passed on commit, so the current route is preserved. + +--- + +## Minimal usage + +```tsx +import { useMemo, useCallback } from 'react'; +import { + useStagedSearch, + encodeDateToSearchParam, +} from '@automattic/jetpack-premium-analytics-routing'; +import { localTZDate } from '@automattic/jetpack-premium-analytics-data'; +import type { DateRange } from '@automattic/jetpack-premium-analytics-datetime'; + +type Search = { + from?: string; + to?: string; + compare_preset?: string; + comp?: string; +}; + +export function DashboardHeader() { + const { effective, stage, commit } = useStagedSearch< + Search, + '/wc-analytics/dashboard' + >( { + from: '/wc-analytics/dashboard', + // autoCommitDebounceMs: 250, + } ); + + const range = useMemo( () => ( { + from: effective.from ? localTZDate( effective.from ) : undefined, + to: effective.to ? localTZDate( effective.to ) : undefined, + } ), [ effective.from, effective.to ] ); + + const onRangeChange = useCallback( + ( next: DateRange | undefined ) => { + if ( ! next ) { + return; + } + stage( { + from: encodeDateToSearchParam( next.from ), + to: encodeDateToSearchParam( next.to ), + } ); + commit( { replace: false } ); + }, + [ stage, commit ] + ); + + // ... +} +``` + +--- + +## Best practices + +**What to use when** + +* Render and fetch: **`effective`** +* Inputs being edited: **`staged`** +* URL-driven side effects / analytics / share links: **`committed`** + +**Navigation and history** + +* Do not pass `to` on commit; update only `search` for SPA smoothness. +* Explicit commit: `commit( { replace: false } )` pushes history. +* Auto-commit (debounce): `replace: true` during continuous edits. +* The URL→UI mirror keeps Back/Forward fluid and flicker-free. + +**Data fetching** + +```ts +const { effective, isSyncing } = useStagedSearch< Search >( { + from: '/wc-analytics/dashboard', +} ); + +const query = useQuery( { + queryKey: [ 'orders', effective ], + enabled: ! isSyncing, + queryFn: () => fetchOrders( effective ), +} ); +``` + +**Debounce guidance** + +* `autoCommitDebounceMs`: 200–300 ms works well for date pickers. +* During edits → debounced replace-commits. +* On confirm (Apply/close) → `commit( { replace: false } )`. + +**Removing params** + +* `effective` ignores `undefined` in `staged`. To remove a key, stage it as + `undefined` and commit; the updater omits the key in the URL. + +**Avoid** + +* Writing the URL from multiple components (breaks atomicity). +* Mixing `useSearch()` reads in children that also depend on staging. +* Always using `replace: true` on explicit commits. + +--- diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/index.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/index.ts new file mode 100644 index 000000000000..a29505a5a269 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/index.ts @@ -0,0 +1 @@ +export { useStagedSearch } from './use-staged-search'; diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/use-staged-search.tsx b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/use-staged-search.tsx new file mode 100644 index 000000000000..4b8ab233e360 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/use-staged-search.tsx @@ -0,0 +1,287 @@ +/** + * External dependencies + */ +import { useNavigate, useSearch } from '@wordpress/route'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +type AnyObject = Record< string, unknown >; + +export type UseStagedSearchOptions< TFrom extends string > = { + from: TFrom; // e.g., '/wc-analytics/dashboard', + + /** + * If provided, stage() will schedule an automatic debounced commit + * after the given milliseconds. Those auto-commits use replace: true + * to avoid polluting the browser history during continuous interaction. + */ + autoCommitDebounceMs?: number; +}; + +export type UseStagedSearchReturn< TSearch extends AnyObject > = { + /** + * The current URL state. + */ + committed: TSearch; + + /** + * The optimistic snapshot for immediate UI. + */ + staged: TSearch; + + /** + * The effective state for rendering and data fetching. + */ + effective: TSearch; + + /** + * Whether the process is syncing. + */ + isSyncing: boolean; + + /** + * Whether the staged state differs from the committed state. + */ + isDirty: boolean; + + /** + * Stage a local patch without touching the URL. + */ + stage: ( patch: Partial< TSearch > ) => void; + + /** + * Commit all staged changes in a single atomic navigate(). + */ + commit: ( opts?: { replace?: boolean } ) => void; + + /** + * Discard local changes and return to committed snapshot. + */ + revert: () => void; + + /** + * Cancel pending debounced commit. + */ + cancelAutoCommit: () => void; +}; + +/** + * + * @param a + * @param b + */ +function shallowEqual( a: AnyObject, b: AnyObject ) { + if ( a === b ) { + return true; + } + + const ak = Object.keys( a ); + const bk = Object.keys( b ); + if ( ak.length !== bk.length ) { + return false; + } + + for ( const k of ak ) { + if ( a[ k ] !== b[ k ] ) { + return false; + } + } + + return true; +} + +/** + * + * @param base + * @param patch + */ +function mergeDefined< T extends AnyObject >( base: T, patch: Partial< T > ): T { + const out: AnyObject = { ...base }; + for ( const key in patch ) { + const val = patch[ key as keyof T ]; + if ( val !== undefined ) { + out[ key ] = val as unknown; + } + } + return out as T; +} + +/** + * + * @param opts + */ +export function useStagedSearch< TSearch extends AnyObject, TFrom extends string >( + opts: UseStagedSearchOptions< TFrom > +): UseStagedSearchReturn< TSearch > { + const navigate = useNavigate( { from: opts.from } ); + const committed = useSearch( { from: opts.from } ) as TSearch; + + /* + * Stage the search params. + */ + const [ staged, setStaged ] = useState< TSearch >( committed ); + + /* + * Track if the process is syncing. + */ + const [ isSyncing, setIsSyncing ] = useState( false ); // not used yet + + // Buffer for not-yet-committed changes. + const bufferRef = useRef< Partial< TSearch > >( {} ); + + // Debounce timer for auto-commit. + const timerRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + + /** + * Mirror URL -> staged. + * If URL changes (back/forward or external writes), align staged snapshot. + * Also clear syncing flag after the router applies the new committed state. + */ + useEffect( () => { + setStaged( committed ); + bufferRef.current = {}; + if ( isSyncing ) { + setIsSyncing( false ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ committed ] ); + + /** + * Cancel pending debounced auto-commit. + */ + const cancelAutoCommit = useCallback( () => { + if ( timerRef.current ) { + clearTimeout( timerRef.current ); + timerRef.current = null; + } + }, [] ); + + /** + * Cleanup on unmount. + */ + useEffect( () => { + return () => { + cancelAutoCommit(); + }; + }, [ cancelAutoCommit ] ); + + /** + * Stage a local patch without touching the URL immediately. + * If autoCommitDebounceMs is set, schedule a debounced replace-commit. + */ + const stage = useCallback( + ( patch: Partial< TSearch > ) => { + setStaged( prev => ( { ...prev, ...patch } ) ); + bufferRef.current = { ...bufferRef.current, ...patch }; + + if ( typeof opts.autoCommitDebounceMs === 'number' ) { + cancelAutoCommit(); + timerRef.current = setTimeout( () => { + navigate( { + replace: true, // do not pollute history while interacting + search: prev => ( { + ...prev, + ...( bufferRef.current as Partial< TSearch > ), + } ), + } ); + timerRef.current = null; + }, opts.autoCommitDebounceMs ); + } + }, + [ navigate, opts.autoCommitDebounceMs, cancelAutoCommit ] + ); + + /** + * Commit all staged changes in a single atomic navigate(). + * - No `to`: keep the current route (prevents heavy remounts). + * - Default `replace` to false so history is preserved on explicit commits. + * - Cancels any pending debounced commit. + */ + const commit = useCallback( + ( commitOpts?: { replace?: boolean } ) => { + const patch = bufferRef.current; + const hasPatch = patch && Object.keys( patch ).length > 0; + + // Cancel any pending debounced replace-commit + cancelAutoCommit(); + + // If buffer is empty but staged differs from committed, compute a minimal diff + let diff: Partial< TSearch > | null = null; + if ( ! hasPatch ) { + const merged = { + ...( committed as AnyObject ), + ...( staged as AnyObject ), + } as TSearch; + + if ( ! shallowEqual( merged as AnyObject, committed as AnyObject ) ) { + diff = {}; + for ( const key in merged ) { + // eslint-disable-next-line no-prototype-builtins + if ( ( committed as AnyObject ).hasOwnProperty( key ) ) { + if ( ( committed as AnyObject )[ key ] !== ( staged as AnyObject )[ key ] ) { + ( diff as AnyObject )[ key ] = ( staged as AnyObject )[ key ]; + } + } else { + ( diff as AnyObject )[ key ] = ( staged as AnyObject )[ key ]; + } + } + } + } + + const finalPatch = hasPatch ? ( patch as Partial< TSearch > ) : diff; + + if ( ! finalPatch || Object.keys( finalPatch ).length === 0 ) { + return; + } + + setIsSyncing( true ); + + navigate( { + replace: commitOpts?.replace ?? false, // explicit commits push into history + search: prev => ( { + ...prev, + ...( finalPatch as Partial< TSearch > ), + } ), + } ); + + // isSyncing is flipped off by the committed->staged mirror effect. + }, + [ navigate, committed, staged, cancelAutoCommit ] + ); + + /** + * Discard local changes and return to committed snapshot. + */ + const revert = useCallback( () => { + cancelAutoCommit(); + bufferRef.current = {}; + setStaged( committed ); + }, [ committed, cancelAutoCommit ] ); + + /** + * Effective = committed merged with defined staged keys. + * Use this as the single source for rendering and data fetching. + */ + const effective = useMemo( + () => mergeDefined( committed, staged ), + [ committed, staged ] + ) as TSearch; + + /* + * Dirty if there is a buffer or staged differs from committed. + */ + const isDirty = + Object.keys( bufferRef.current ).length > 0 && + ! shallowEqual( staged as AnyObject, committed as AnyObject ); + + return { + committed, + staged, + effective, + isSyncing, + isDirty, + stage, + commit, + revert, + cancelAutoCommit, + }; +} diff --git a/projects/packages/premium-analytics/packages/routing/src/index.ts b/projects/packages/premium-analytics/packages/routing/src/index.ts new file mode 100644 index 000000000000..b6518f4c17a1 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/index.ts @@ -0,0 +1,8 @@ +export { + encodeDateToSearchParam, + writeDateRangeToSearch, + writeComparisonToSearch, +} from './search/date-range'; + +export { deriveComparisonRange } from './search/comparison'; +export { useStagedSearch } from './hooks'; diff --git a/projects/packages/premium-analytics/packages/routing/src/search/comparison/derive-comparison-range.ts b/projects/packages/premium-analytics/packages/routing/src/search/comparison/derive-comparison-range.ts new file mode 100644 index 000000000000..f680fc4b5d4e --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/search/comparison/derive-comparison-range.ts @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import { + normalizeReportParams, + dateToISOStringWithLocalTZ, + getSiteTimezone, +} from '@jetpack-premium-analytics/data'; +import { + getComparisonRangeFromPreset, + type ComparisonPresetId, + startOfDayTZ, + endOfDayTZ, +} from '@jetpack-premium-analytics/datetime'; + +type ReportParams = NonNullable< Parameters< typeof normalizeReportParams >[ 0 ] >; + +/** + * Normalize URL/UI comparison preset IDs to canonical ComparisonPresetId. + * Accepts variants with hyphen or underscore for robustness. + * + * @param value - Raw preset ID from URL or UI (e.g., 'previous_period' or 'previous-period') + * @return Canonical ComparisonPresetId or undefined if invalid + */ +const toComparisonPresetId = ( value?: string ): ComparisonPresetId | undefined => { + switch ( value ) { + case 'previous-period': + case 'previous_period': + return 'previous-period'; + case 'previous-week': + case 'previous_week': + return 'previous-week'; + case 'previous-month': + case 'previous_month': + return 'previous-month'; + case 'previous-year': + case 'previous_year': + return 'previous-year'; + default: + return undefined; + } +}; + +/** + * Derive compare_from/compare_to from the main range + preset, + * honoring the site's timezone via existing data utils. + * + * Rules: + * - Only derive when comparison is enabled (comp === "1") AND a preset is present. + * - Normalize main range to site-local day bounds before computing presets. + * - Return ISO strings WITH site offset (same format you write to the URL). + * @param opts + */ +export function deriveComparisonRange( opts: ReportParams ): + | { + compare_from: string; + compare_to: string; + } + | undefined { + // Require comparison enabled + preset + const presetId = toComparisonPresetId( opts.compare_preset ); + if ( opts.comp !== '1' || ! presetId ) { + return undefined; + } + + // Need valid main range + if ( ! opts.from || ! opts.to ) { + return undefined; + } + + // Parse URL params (ISO+offset) to instants + const fromInstant = new Date( opts.from ); + const toInstant = new Date( opts.to ); + if ( isNaN( fromInstant.getTime() ) || isNaN( toInstant.getTime() ) ) { + return undefined; + } + + // Normalize to site-local day bounds + const timezone = getSiteTimezone(); + const reference = { + from: startOfDayTZ( fromInstant, timezone ), + to: endOfDayTZ( toInstant, timezone ), + }; + + // Compute comparison range (Dates) + const cmp = getComparisonRangeFromPreset( reference, presetId ); + if ( ! cmp?.from || ! cmp?.to ) { + return undefined; + } + + // Serialize back to ISO with site offset (string-to-string stable) + return { + compare_from: dateToISOStringWithLocalTZ( cmp.from ), + compare_to: dateToISOStringWithLocalTZ( cmp.to ), + }; +} diff --git a/projects/packages/premium-analytics/packages/routing/src/search/comparison/index.ts b/projects/packages/premium-analytics/packages/routing/src/search/comparison/index.ts new file mode 100644 index 000000000000..33bfe8d0427a --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/search/comparison/index.ts @@ -0,0 +1 @@ +export { deriveComparisonRange } from './derive-comparison-range'; diff --git a/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts new file mode 100644 index 000000000000..b615430bedfd --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts @@ -0,0 +1,117 @@ +/** + * External dependencies + */ +import { localTZDate, dateToISOStringWithLocalTZ } from '@jetpack-premium-analytics/data'; +import type { DateRange } from '@jetpack-premium-analytics/datetime'; + +/** + * Serializes a Date into an ISO string with the site's timezone + * (or returns an empty string if no date is provided). + * Useful for writing dates to the URL and for API requests. + * @param date + * @param timezone + */ +export function encodeDateToSearchParam( date?: Date, timezone?: string ): string | undefined { + return date ? dateToISOStringWithLocalTZ( localTZDate( date, timezone ) ) : undefined; +} + +type WriteDateRangeToSearchProps = { + navigate: ( opts: { + to: string; + search: + | Record< string, string | undefined > + | ( ( prev: Record< string, string | undefined > ) => Record< string, string | undefined > ); + } ) => void; + to: string; + range: DateRange; + timezone?: string; + search?: Record< string, string | undefined | null >; +}; + +/** + * Writes a DateRange to the URL using navigate(). + * + * - Centralizes the conversion from Date -> ISO(+offset) according to + * the site's timezone. + * - If you need to preserve/propagate `interval` or other params, + * pass them in `search`. + * - Note: whether other existing params are preserved depends on the + * router's navigate implementation. This helper sets an explicit object. + * @param root0 + * @param root0.navigate + * @param root0.to + * @param root0.range + * @param root0.timezone + * @param root0.search + */ +export function writeDateRangeToSearch( { + navigate, + to: toPath, + range, + timezone, + search, +}: WriteDateRangeToSearchProps ) { + const fromParam = encodeDateToSearchParam( range?.from, timezone ); + const toParam = encodeDateToSearchParam( range?.to, timezone ); + + navigate( { + to: toPath, + search: ( prev: Record< string, string | undefined > ) => ( { + ...prev, + from: fromParam, + to: toParam, + ...search, + } ), + } ); +} + +type WriteComparisonToSearchProps = { + navigate: ( opts: { + to: string; + search: + | Record< string, string | undefined > + | ( ( prev: Record< string, string | undefined > ) => Record< string, string | undefined > ); + } ) => void; + to: string; + range?: DateRange; + presetId?: string; + enabled?: boolean; + timezone?: string; + search?: Record< string, string | undefined | null >; +}; + +/** + * + * @param root0 + * @param root0.navigate + * @param root0.to + * @param root0.range + * @param root0.presetId + * @param root0.enabled + * @param root0.timezone + * @param root0.search + */ +export function writeComparisonToSearch( { + navigate, + to: toPath, + range, + presetId, + enabled, + timezone, + search, +}: WriteComparisonToSearchProps ) { + const fromParam = encodeDateToSearchParam( range?.from, timezone ); + const toParam = encodeDateToSearchParam( range?.to, timezone ); + + navigate( { + to: toPath, + search: ( prev: Record< string, string | undefined > ) => ( { + ...prev, + compare_from: fromParam, + compare_to: toParam, + compare_preset: presetId ?? undefined, + comp: enabled ? '1' : undefined, + ...search, + } ), + } ); +} diff --git a/projects/packages/premium-analytics/packages/routing/src/search/date-range/index.ts b/projects/packages/premium-analytics/packages/routing/src/search/date-range/index.ts new file mode 100644 index 000000000000..4d8482211148 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/search/date-range/index.ts @@ -0,0 +1,5 @@ +export { + encodeDateToSearchParam, + writeDateRangeToSearch, + writeComparisonToSearch, +} from './date-range'; diff --git a/projects/packages/premium-analytics/packages/ui/package.json b/projects/packages/premium-analytics/packages/ui/package.json new file mode 100644 index 000000000000..1aae2e2cda00 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/package.json @@ -0,0 +1,24 @@ +{ + "name": "@automattic/jetpack-premium-analytics-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "sideEffects": [ + "*.scss" + ], + "dependencies": { + "@automattic/ui": "1.0.2", + "@jetpack-premium-analytics/datetime": "workspace:*", + "@jetpack-premium-analytics/formatters": "workspace:*", + "@wordpress/components": "33.1.0", + "@wordpress/compose": "7.46.0", + "@wordpress/i18n": "^6.9.0", + "@wordpress/icons": "^13.0.0", + "@wordpress/private-apis": "1.46.0", + "@wordpress/ui": "0.13.0", + "clsx": "2.1.1", + "react": "18.3.1" + } +} diff --git a/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.scss b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.scss new file mode 100644 index 000000000000..29fea4bf18a3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.scss @@ -0,0 +1,36 @@ +.date-comparison-dropdown { + + &__button { + background-color: var(--wpds-color-bg-surface-neutral-strong); + } +} + +.date-filters-panel-button { + background-color: var(--wpds-color-bg-surface-neutral-strong); +} + +.date-comparison-dropdown__popover { + width: 235px; +} + +/* disable animation for the date range popover */ +/* stylelint-disable property-no-unknown, selector-pseudo-element-no-unknown */ +@media not ( prefers-reduced-motion: reduce ) { + + .date-comparison-dropdown__popover { + view-transition-name: next-admin--date-comparison-dropdown; + transition: none !important; + } +} + +/* ensure it's above the canvas/stage during the transition */ +::view-transition-group(next-admin--date-comparison-dropdown) { + z-index: 3000; +} + +/* no animation for the snapshot (avoid "flashing") */ +::view-transition-new(next-admin--date-comparison-dropdown), +::view-transition-old(next-admin--date-comparison-dropdown) { + animation: none; +} +/* stylelint-enable property-no-unknown, selector-pseudo-element-no-unknown */ diff --git a/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.tsx b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.tsx new file mode 100644 index 000000000000..34f39700b781 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/date-comparison-dropdown.tsx @@ -0,0 +1,168 @@ +/** + * External dependencies + */ +import { formatDateRange } from '@jetpack-premium-analytics/formatters'; +import { privateApis as componentsPrivateApis } from '@wordpress/components'; +import { sprintf, __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/ui'; +import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import { DateRangePresets } from '../date-range-presets'; +import { unlock } from '../lock/unlock'; +import type { ComparisonDateRangePreset } from '../use-comparison-date-presets'; +import type { + ComparisonPresetId, + DateRangePreset, + PrimaryPresetId, +} from '@jetpack-premium-analytics/datetime'; +import './date-comparison-dropdown.scss'; + +const { Menu } = unlock( componentsPrivateApis ); + +type DateComparisonDropdownProps = { + /** + * Available comparison presets (e.g., previous-period, previous-month) + */ + presets: ComparisonDateRangePreset[]; + /** + * Whether comparison is enabled + */ + enabled: boolean; + /** + * Currently selected comparison preset ID + */ + presetId?: ComparisonPresetId; + /** + * Whether to remove "Compare to:" prefix from button label + */ + removeCompareToPrefix?: boolean; + /** + * Callback when comparison is enabled + */ + onEnable: () => void; + /** + * Callback when a comparison preset is selected + */ + onPresetChange: ( id: ComparisonPresetId ) => void; + /** + * Callback when comparison is cleared + */ + onClear: () => void; +}; + +export function DateComparisonDropdown( { + presets, + enabled, + presetId, + removeCompareToPrefix = false, + onEnable, + onPresetChange, + onClear, +}: DateComparisonDropdownProps ) { + const selectedPreset = useMemo( + () => ( presetId ? presets.find( p => p.id === presetId ) : undefined ), + [ presets, presetId ] + ); + + const comparisonRange = selectedPreset?.range; + const hasValidPreset = !! comparisonRange; + const hasPresets = presets.length > 0; + + if ( ! enabled ) { + return ( + + + { __( 'No comparison', 'jetpack-premium-analytics' ) } + + } + /> + + + + + { __( 'No comparison', 'jetpack-premium-analytics' ) } + + + + + + { __( 'Comparison to past', 'jetpack-premium-analytics' ) } + + + + + + ); + } + + let label: string = __( 'Select comparison', 'jetpack-premium-analytics' ); + if ( hasValidPreset ) { + if ( removeCompareToPrefix ) { + label = formatDateRange( comparisonRange ); + } else { + label = sprintf( + // translators: %s is the comparison range label + __( 'Compare to: %s', 'jetpack-premium-analytics' ), + formatDateRange( comparisonRange ) + ); + } + } + + return ( + + + { label } + + } + /> + + { hasPresets && ( + { + /* + * Type assertion is safe here because: + * 1. presets is ComparisonDateRangePreset[] (strongly typed) + * 2. DateRangePresets picks id from our presets array + * 3. Therefore id must be ComparisonPresetId + */ + onPresetChange( id as ComparisonPresetId ); + } } + onClear={ onClear } + /> + ) } + + + ); +} diff --git a/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/index.ts b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/index.ts new file mode 100644 index 000000000000..89ca07f36f91 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-comparison-dropdown/index.ts @@ -0,0 +1 @@ +export { DateComparisonDropdown } from './date-comparison-dropdown'; diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx new file mode 100644 index 000000000000..dd9f6a3fc189 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/date-filters-panel.tsx @@ -0,0 +1,229 @@ +/** + * External dependencies + */ +import { + isComparisonPresetId, + isPrimaryPreset, + type ComparisonPresetId, + type PrimaryPresetId, +} from '@jetpack-premium-analytics/datetime'; +import { BaseControl } from '@wordpress/components'; +import { Stack } from '@wordpress/ui'; +import { useMemo, useCallback } from 'react'; +/** + * Internal dependencies + */ +import { DateComparisonDropdown } from '../date-comparison-dropdown'; +import { DateRangePopover } from '../date-range-popover'; +import { useComparisonDatePresets } from '../use-comparison-date-presets'; + +type DateRangePopoverProps = Parameters< typeof DateRangePopover >[ 0 ]; + +export type DateRange = DateRangePopoverProps[ 'range' ]; + +export type DateFiltersPanelProps = { + /** + * The current date range preset ID (e.g., 'last-7-days', 'last-30-days'). + */ + presetId?: PrimaryPresetId; + + /** + * The current primary date range. + */ + range: DateRange; + + /** + * The current comparison preset ID (e.g., 'previous-period', 'previous-month'). + */ + comparisonPresetId?: ComparisonPresetId; + + /** + * Callback when the primary date range changes. + */ + onChange: DateRangePopoverProps[ 'onChange' ]; + + /** + * Callback when the comparison date range changes. + * Receives the calculated comparison range and the preset ID used. + */ + onComparisonChange: ( range: DateRange | undefined, presetId?: ComparisonPresetId ) => void; + + /** + * Props for the date range popover. + */ + rangeControlProps?: Omit< Parameters< typeof BaseControl >[ 0 ], 'children' >; + + /** + * Props for the date comparison dropdown. + */ + comparisonControlProps?: Omit< Parameters< typeof BaseControl >[ 0 ], 'children' >; + + /** + * Callback when the primary date range is applied. + */ + onApply: DateRangePopoverProps[ 'onApply' ]; + + /** + * Callback when the primary date range is canceled. + */ + onCancel: DateRangePopoverProps[ 'onCancel' ]; + + /** + * Whether the primary date range can be applied. + */ + canApply?: boolean; + + /** + * IANA timezone string (e.g., 'America/New_York', 'Europe/London'). + * Required for proper date/time handling. + */ + timeZone: string; + + /** + * Optional external container element for responsive calculations. + * When provided, the DateRangePopover will measure this container's width + * instead of its own wrapper to determine mobile/wide layouts. + */ + containerElement?: HTMLElement | null; +}; + +/** + * DateFiltersPanel - Manages date range selection and comparison controls + * + * This component serves as the container for date filtering functionality, + * managing both the primary date range selection and the comparison date range. + * It owns the comparison state and delegates to child components for UI. + */ +export function DateFiltersPanel( { + presetId, + range, + comparisonPresetId, + onChange, + onComparisonChange, + rangeControlProps = { + label: null, + help: null, + }, + comparisonControlProps = { + label: null, + help: null, + }, + onApply, + onCancel, + canApply = true, + timeZone, + containerElement, +}: DateFiltersPanelProps ) { + /** + * Validate and normalize the primary preset ID. + * Only accepts built-in preset IDs (including 'custom'). + * Invalid/unknown values are treated as undefined, which allows + * DateRangePopover to handle them gracefully (falls back to custom). + */ + const validatedPresetId = useMemo( () => { + if ( ! presetId ) { + return undefined; + } + // Only accept known built-in presets + // Unknown/garbage values from URL are rejected to prevent UI inconsistency + return isPrimaryPreset( presetId ) ? presetId : undefined; + }, [ presetId ] ); + + // Validate and normalize the comparison preset ID + const validatedComparisonPresetId = useMemo( () => { + return isComparisonPresetId( comparisonPresetId ) ? comparisonPresetId : undefined; + }, [ comparisonPresetId ] ); + + // Derive comparison enabled state directly from validated prop + const comparisonEnabled = !! validatedComparisonPresetId; + + // Get available presets for the current range + const presets = useComparisonDatePresets( range ); + + /** + * Determines the default preset ID to use when comparison is enabled. + * Priority order: + * 1. 'previous-period' + * 2. 'previous-month' + * 3. First available preset + */ + const defaultPresetId = useMemo( () => { + return ( + presets.find( p => p.id === 'previous-period' )?.id ?? + presets.find( p => p.id === 'previous-month' )?.id ?? + presets[ 0 ]?.id + ); + }, [ presets ] ); + + /** + * Currently selected comparison preset, + * based on the validated stored preset ID, or the default preset. + * Returns undefined if no preset is selected + * or if the ID doesn't match any available preset. + */ + const preset = useMemo( () => { + const id = validatedComparisonPresetId ?? defaultPresetId; + return id ? presets.find( p => p.id === id ) : undefined; + }, [ presets, validatedComparisonPresetId, defaultPresetId ] ); + + const presetChange = useCallback( + ( id: ComparisonPresetId ) => { + const nextPreset = presets.find( p => p.id === id ); + onComparisonChange( nextPreset?.range, id ); + }, + [ onComparisonChange, presets ] + ); + + /** + * Handles clearing the comparison completely. + * Clears the selected preset and notifies parent. + */ + const clearComparison = useCallback( () => { + onComparisonChange( undefined, undefined ); + }, [ onComparisonChange ] ); + + const handleEnable = useCallback( () => { + // Use validated ID with fallback to default + const presetIdToUse = validatedComparisonPresetId ?? defaultPresetId; + if ( preset?.range && presetIdToUse ) { + onComparisonChange( preset.range, presetIdToUse ); + } + }, [ onComparisonChange, preset, validatedComparisonPresetId, defaultPresetId ] ); + + return ( + + + + + + + + + + ); +} diff --git a/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/index.ts b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/index.ts new file mode 100644 index 000000000000..9a1d3322329e --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-filters-panel/index.ts @@ -0,0 +1 @@ +export { DateFiltersPanel } from './date-filters-panel'; diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.scss b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.scss new file mode 100644 index 000000000000..f0ebd4c84d75 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.scss @@ -0,0 +1,38 @@ +.input-date-control { + flex: 1; + font-size: var(--wpds-typography-font-size-sm); + + @supports selector( &::-webkit-calendar-picker-indicator ) { + + input[type="date"] { + // Removes extra spaces for the calendar icon. + width: fit-content; + padding-right: 0; + + appearance: none; + -webkit-appearance: none; + background: none; + + font-size: var(--wpds-typography-font-size-md); + + // Removes the calendar icon. + &::-webkit-calendar-picker-indicator { + display: none; + } + } + } + + @supports not selector( &::-webkit-calendar-picker-indicator ) { + + input[type="date"] { + padding: 0; // We'll control input's inner spacing manually + + min-width: fit-content; // Prevent extra space on smaller screens + + // Use flex to center the input's content horizontally + display: flex; + width: calc(100% - 20px); // Take almost all the space + margin-inline: auto; // Keep things centered + } + } +} diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx new file mode 100644 index 000000000000..562fe3f07f98 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-input/date-range-input.tsx @@ -0,0 +1,103 @@ +/** + * External dependencies + */ +import { createTZDateFromParts } from '@jetpack-premium-analytics/datetime'; +import { formatDate } from '@jetpack-premium-analytics/formatters'; +import { __ } from '@wordpress/i18n'; +import { Field, Input, Stack } from '@wordpress/ui'; +import { useCallback, useEffect, useState } from 'react'; +/** + * Internal dependencies + */ +import { DateRangePopover } from '../date-range-popover/date-range-filter'; +import './date-range-input.scss'; + +type DateRangeInputProps = Pick< + Parameters< typeof DateRangePopover >[ 0 ], + 'range' | 'onChange' +> & { + timeZone: string; +}; + +type DateInputProps = Pick< DateRangeInputProps, 'timeZone' > & { + label: string; + date?: Date; + onChange: ( date?: Date ) => void; +}; + +const formatToString = ( date?: Date ) => ( date ? formatDate( date, 'iso' ) : '' ); + +function parseFromString( dateString: string, timeZone: string ) { + const [ year, month, day ] = dateString.split( '-' ).map( x => Number( x ) ); + + const parsedDate = createTZDateFromParts( [ year, month - 1, day ], timeZone ); + + return ! isNaN( parsedDate.getTime() ) ? parsedDate : undefined; +} + +function DateInput( { label, date, onChange, timeZone }: DateInputProps ) { + const [ value, setValue ] = useState( formatToString( date ) ); + + useEffect( () => { + setValue( formatToString( date ) ); + }, [ date ] ); + + const onInputChange = useCallback( + ( event: React.ChangeEvent< HTMLInputElement > ) => { + const newValue = event.target.value; + setValue( newValue ); + + const newDate = parseFromString( newValue, timeZone ); + + // Call onChange only when the date is complete and reasonable, to avoid unwanted updates. + // Also avoids parseFromString auto-filling partial input (e.g. "20" → "1920"). + if ( newDate && newDate.getFullYear() > 2000 ) { + onChange( newDate ); + } + }, + [ onChange, timeZone ] + ); + + const onClick = useCallback( ( e: React.MouseEvent ) => { + // Prevents the date input from opening the browser date picker, + // as we want to use a custom date picker elsewhere. + e.preventDefault(); + }, [] ); + + return ( + + { label } + + + ); +} + +export function DateRangeInput( { range, onChange, timeZone }: DateRangeInputProps ) { + const { from, to } = range; + + return ( + + { + if ( nextFrom && to && nextFrom <= to ) { + onChange( { from: nextFrom, to } ); + } + } } + /> + + { + if ( nextTo && from && from <= nextTo ) { + onChange( { from, to: nextTo } ); + } + } } + /> + + ); +} diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-input/index.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-input/index.ts new file mode 100644 index 000000000000..d42bdcd634fe --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-input/index.ts @@ -0,0 +1 @@ +export { DateRangeInput } from './date-range-input'; diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.scss b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.scss new file mode 100644 index 000000000000..aa17958cb048 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.scss @@ -0,0 +1,106 @@ +@use "@wordpress/base-styles/variables" as vars; +@use "@wordpress/base-styles/colors" as colors; + +.date-range-popover-content { + --wca-popover-padding: var(--wpds-dimension-padding-lg); + --wca-popover-border-color: #{colors.$gray-300}; + --wca-popover-border-width: #{vars.$border-width}; + + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: 1fr auto; + + // Grid lines: background = line color, gap = line width + background-color: var(--wca-popover-border-color); + column-gap: var(--wca-popover-border-width); + row-gap: var(--wca-popover-border-width); + + .date-range-calendar { + display: flex; + justify-content: center; + align-items: center; + } + + // Mobile layout: override grid to use flex column + &--mobile { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-lg); + padding: var(--wca-popover-padding); + background-color: #fff; + + .date-range-calendar { + --a8c-calendar-button-width: 32px; + --a8c-calendar-button-height: 32px; + } + + .date-range-popover-actions { + padding: 0; + padding-block-start: var(--wpds-dimension-gap-sm); + } + } +} + +.date-range-presets-wrapper { + grid-row: 1; + display: grid; + grid-template-columns: minmax(0, max-content) 1fr; + background-color: #fff; + padding-bottom: var(--wpds-dimension-gap-sm); + width: calc(60 * var(--wpds-dimension-base)); + padding-right: var(--wpds-dimension-padding-sm); +} + +.date-filters-panel-button { + background-color: #fff; // ToDo: handle this upstream. +} + +.date-range-calendar-wrapper { + --wca-calendar-button-width: 32px; + --wca-calendar-button-height: 32px; + --wca-calendar-button-gap: 1rem; // consistent with automattic/ui style + --wca-calendar-padding: var(--wca-popover-padding); + --wca-calendar-width: calc(var(--wca-calendar-button-width) * 7 + var(--wca-calendar-padding) * 2); + --wca-calendar-width-wide: calc(var(--wca-calendar-button-width) * 14 + var(--wca-calendar-padding) * 2 + var(--wca-calendar-button-gap)); + + grid-row: 1; + padding: var(--wca-calendar-padding); + width: var(--wca-calendar-width); + background-color: #fff; + + .date-range-calendar { + --a8c-calendar-button-width: var(--wca-calendar-button-width); + --a8c-calendar-button-height: var(--wca-calendar-button-height); + } + + &__wide { + width: var(--wca-calendar-width-wide); + } +} + +.date-range-popover-actions { + grid-column: 1 / -1; + padding: calc(var(--wca-popover-padding) / 2) var(--wca-popover-padding); + background-color: #fff; +} + +/* disable animation for the date range popover */ +/* stylelint-disable property-no-unknown, selector-pseudo-element-no-unknown */ +@media not ( prefers-reduced-motion: reduce ) { + + .date-filters-panel__popover { + view-transition-name: next-admin--date-range-popover; + } +} + +/* ensure it's above the canvas/stage during the transition */ +::view-transition-group(next-admin--date-range-popover) { + z-index: 3000; +} + +/* no animation for the snapshot (avoid "flashing") */ +::view-transition-new(next-admin--date-range-popover), +::view-transition-old(next-admin--date-range-popover) { + animation: none; +} +/* stylelint-enable property-no-unknown, selector-pseudo-element-no-unknown */ diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx new file mode 100644 index 000000000000..926074092897 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/date-range-filter.tsx @@ -0,0 +1,373 @@ +/** + * External dependencies + */ +import { DateRangeCalendar } from '@automattic/ui'; +import { + getPresetLabel, + getDefaultDateRangePresets, + PRESET_CUSTOM, + type PrimaryPresetId, + type DateRangePreset, +} from '@jetpack-premium-analytics/datetime'; +import { formatDateRange } from '@jetpack-premium-analytics/formatters'; +import { + Dropdown, + SelectControl, + privateApis as componentsPrivateApis, +} from '@wordpress/components'; +import { useResizeObserver } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; +import { calendar } from '@wordpress/icons'; +import { Badge, Button, Stack } from '@wordpress/ui'; +import clsx from 'clsx'; +import { useState, useCallback, useMemo, useEffect } from 'react'; +import '@automattic/ui/style.css'; +/** + * Internal dependencies + */ +import { DateRangeInput } from '../date-range-input'; +import { DateRangePresets } from '../date-range-presets'; +import { unlock } from '../lock/unlock'; +import './date-range-filter.scss'; + +const { Menu } = unlock( componentsPrivateApis ); + +/** + * Threshold width (in pixels) below which we consider the layout "mobile". + * This is based on the container width, not the viewport. + */ +const MOBILE_CONTAINER_WIDTH_THRESHOLD = 480; + +/** + * Date range type from @automattic/ui. + * Represents a range with `from` and `to` Date objects. + */ +export type DateRange = NonNullable< Parameters< typeof DateRangeCalendar >[ 0 ][ 'selected' ] >; + +/** + * Props for DateRangePopoverContent component. + */ +type DateRangePopoverContentProps = { + /** + * Currently selected preset identifier + */ + presetId?: PrimaryPresetId; + + /** + * The selected date range + */ + range: DateRange; + + /** + * Callback when range or preset changes + */ + onChange: ( range?: DateRange, preset?: PrimaryPresetId ) => void; + + /** + * Callback when user applies the selection + */ + onApply: () => void; + + /** + * Callback when user cancels the selection + */ + onCancel: () => void; + + /** + * Whether the Apply button should be enabled + */ + canApply: boolean; + + /** + * Whether to show wide screen layout (2 months) + */ + isWideScreen?: boolean; + + /** + * Whether to show mobile layout (dropdown presets instead of sidebar) + */ + isMobile?: boolean; + + /** + * IANA timezone string (e.g., 'America/New_York', 'Europe/London'). + * Required for proper date/time handling. + */ + timeZone: string; +}; + +/** + * Props for DateRangePresetsDropdown component. + */ +type DateRangePresetsDropdownProps = { + value: PrimaryPresetId | null; + onRangeChange: ( range: DateRange, id: PrimaryPresetId ) => void; + presets?: DateRangePreset[]; + timeZone: string; +}; + +function getDisplayedMonth( range: DateRange ): Date { + return range?.from ?? new Date(); +} + +/** + * Action buttons for the date range popover (Cancel/Apply). + */ +function DateRangePopoverActions( { + onCancel, + onApply, + canApply, +}: Pick< DateRangePopoverContentProps, 'onCancel' | 'onApply' | 'canApply' > ) { + return ( + + + + + ); +} + +/** + * Dropdown version of DateRangePresets for mobile layout. + * Displays presets as a SelectControl instead of a menu list. + */ +function DateRangePresetsDropdown( { + value, + onRangeChange, + presets: presetsProp, + timeZone, +}: DateRangePresetsDropdownProps ) { + const defaultPresets = useMemo( + () => ( presetsProp ? [] : getDefaultDateRangePresets( timeZone ) ), + [ presetsProp, timeZone ] + ); + const presets = presetsProp || defaultPresets; + + const options = useMemo( + () => [ + ...presets.map( ( { id, label } ) => ( { + value: id, + label, + } ) ), + { + value: PRESET_CUSTOM, + label: __( 'Custom range', 'jetpack-premium-analytics' ), + }, + ], + [ presets ] + ); + + const handleChange = useCallback( + ( selectedValue: string ) => { + const preset = presets.find( p => p.id === selectedValue ); + if ( preset ) { + onRangeChange( preset.range, preset.id ); + } + }, + [ presets, onRangeChange ] + ); + + return ( + + ); +} + +/** + * Content of the DateRangePopover, extracted for Storybook visualization. + * This component is exported for internal use only (stories, testing). + */ +export function DateRangePopoverContent( { + presetId, + range, + onChange, + onApply, + onCancel, + canApply, + isWideScreen = false, + isMobile = false, + timeZone, +}: DateRangePopoverContentProps ) { + const [ displayedMonth, setDisplayedMonth ] = useState( getDisplayedMonth( range ) ); + + const handleChange = ( nextRange?: DateRange, nextPrimaryPresetId?: PrimaryPresetId ) => { + if ( nextRange ) { + setDisplayedMonth( getDisplayedMonth( nextRange ) ); + } + + // If nextPrimaryPresetId is undefined, the user manually changed the dates + // (via calendar or input fields), so we switch to PRESET_CUSTOM + const effectivePrimaryPresetId = nextPrimaryPresetId ?? PRESET_CUSTOM; + + onChange( nextRange, effectivePrimaryPresetId ); + }; + + // Mobile layout: single column with dropdown presets + if ( isMobile ) { + return ( +
+ + + + + handleChange( nextRange ) } + numberOfMonths={ 1 } + month={ displayedMonth } + onMonthChange={ setDisplayedMonth } + timeZone={ timeZone } + /> + + +
+ ); + } + + // Desktop layout: grid with sidebar presets + return ( +
+
+ + + +
+ + + + + handleChange( nextRange ) } + numberOfMonths={ isWideScreen ? 2 : 1 } + month={ displayedMonth } + onMonthChange={ setDisplayedMonth } + timeZone={ timeZone } + /> + + + +
+ ); +} + +type DateRangePopoverProps = Omit< DateRangePopoverContentProps, 'isWideScreen' | 'isMobile' > & { + /** + * Optional external container element for responsive calculations. + * When provided, the component will measure this container's width + * instead of its own wrapper to determine mobile/wide layouts. + */ + containerElement?: HTMLElement | null; +}; + +/** + * Threshold width (in pixels) for showing 2 months in calendar. + * Based on CSS: --wca-calendar-width-wide (~500px for 2 months + presets sidebar) + */ +const WIDE_CONTAINER_THRESHOLD = 780; + +export function DateRangePopover( { + presetId, + range, + onChange, + onApply, + onCancel, + canApply, + timeZone, + containerElement, +}: DateRangePopoverProps ) { + const [ containerWidth, setContainerWidth ] = useState< number | null >( null ); + + // Callback to update container width + const handleResize = useCallback( ( entries: ResizeObserverEntry[] ) => { + const entry = entries[ 0 ]; + if ( entry ) { + setContainerWidth( entry.contentRect.width ); + } + }, [] ); + + // ResizeObserver for the reference container + const setObserverRef = useResizeObserver< HTMLElement >( handleResize ); + + // Attach observer to containerElement if provided, otherwise use document.body + useEffect( () => { + const element = containerElement ?? document.body; + setObserverRef( element ); + }, [ containerElement, setObserverRef ] ); + + // Determine layout based on container width + const isMobile = containerWidth !== null && containerWidth < MOBILE_CONTAINER_WIDTH_THRESHOLD; + + const isWideScreen = containerWidth !== null && containerWidth >= WIDE_CONTAINER_THRESHOLD; + + const presetLabel = getPresetLabel( presetId ); + + return ( + ( + + ) } + renderContent={ ( { onClose } ) => ( + { + onApply(); + onClose(); + } } + onCancel={ () => { + onCancel(); + onClose(); + } } + canApply={ canApply } + isWideScreen={ isWideScreen } + isMobile={ isMobile } + timeZone={ timeZone } + /> + ) } + /> + ); +} diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-popover/index.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/index.ts new file mode 100644 index 000000000000..9d4c1f786baf --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-popover/index.ts @@ -0,0 +1,2 @@ +export { DateRangePopover, DateRangePopoverContent } from './date-range-filter'; +export type { DateRange } from './date-range-filter'; diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.scss b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.scss new file mode 100644 index 000000000000..446ea4a41439 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.scss @@ -0,0 +1,29 @@ +@use "@wordpress/base-styles/variables" as vars; +@use "@wordpress/base-styles/colors" as colors; + +.date-range-presets { + max-width: 240px; +} + +.date-range-presets, +.date-range-presets__custom-group { + + .date-range-presets__item { + min-height: var(--wpds-typography-line-height-2xl); + } +} + +.date-range-presets__custom-group { + // Custom button acts as a label, not an interactive element. + // Override disabled styles to show selection state visually. + .date-range-presets__custom { + + &[aria-disabled="true"] { + color: var(--wpds-color-fg-content-neutral-weak); + } + + &[aria-checked="true"] { + color: var(--wpds-color-fg-content-neutral); + } + } +} diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.tsx b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.tsx new file mode 100644 index 000000000000..f27ac51d264c --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/date-range-presets.tsx @@ -0,0 +1,139 @@ +/** + * External dependencies + */ +import { + PRESET_CUSTOM, + getDefaultDateRangePresets, + type PrimaryPresetId, + type DateRangePreset, +} from '@jetpack-premium-analytics/datetime'; +import { privateApis as componentsPrivateApis } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import { DateRangePopover } from '../date-range-popover/date-range-filter'; +import { unlock } from '../lock/unlock'; +import './date-range-presets.scss'; + +const { Menu } = unlock( componentsPrivateApis ); + +type DateRange = Parameters< typeof DateRangePopover >[ 0 ][ 'range' ]; + +/** + * Props for the DateRangePresets component. + */ +type DateRangePresetsProps = { + /** + * Callback fired when a preset is selected + */ + onRangeChange: ( range: DateRange, id: PrimaryPresetId ) => void; + + /** + * Currently selected preset ID, or null if none + */ + value: PrimaryPresetId | null; + + /** + * IANA timezone string (e.g., 'America/New_York'). + * Required when using default presets. Optional if explicit presets are provided. + */ + timeZone?: string; + + /** + * Custom presets to display instead of defaults + */ + presets?: DateRangePreset[]; + + /** + * Whether to show the custom date option + */ + supportCustom?: boolean; + + /** + * Optional callback to clear/remove comparison. + * When provided, shows a "No comparison" option. + */ + onClear?: () => void; + + /** + * Whether clicking a preset item should close the parent popover. + * Defaults to undefined (Ariakit default: checkbox items stay open). + */ + hideOnClick?: boolean; +}; + +export function DateRangePresets( { + onRangeChange, + value, + timeZone, + presets: presetsProp, + onClear, + hideOnClick, +}: DateRangePresetsProps ) { + const defaultPresets = useMemo( () => { + if ( presetsProp ) { + return []; + } + + if ( ! timeZone ) { + throw new Error( + 'DateRangePresets: `timeZone` is required when `presets` are not provided.' + ); + } + + return getDefaultDateRangePresets( timeZone ); + }, [ presetsProp, timeZone ] ); + + const presets = useMemo( () => presetsProp || defaultPresets, [ presetsProp, defaultPresets ] ); + + return ( + <> + + { presets.map( ( { id, label, range: presetRange } ) => ( + onRangeChange( presetRange, id ) } + hideOnClick={ hideOnClick } + > + { label } + + ) ) } + + + + + + + { __( 'Custom', 'jetpack-premium-analytics' ) } + + + { onClear && ( + + { __( 'No comparison', 'jetpack-premium-analytics' ) } + + ) } + + + ); +} diff --git a/projects/packages/premium-analytics/packages/ui/src/date-range-presets/index.ts b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/index.ts new file mode 100644 index 000000000000..ed2d27e2e31b --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/date-range-presets/index.ts @@ -0,0 +1,29 @@ +export { DateRangePresets } from './date-range-presets'; + +/** + * Re-export types, constants, and guards from datetime + * so existing consumers of this barrel continue to work. + */ +export { + getDefaultDateRangePresets, + getPresetLabel, + isSelectablePreset, + isPrimaryPreset, + // Preset constants + PRESET_TODAY, + PRESET_YESTERDAY, + PRESET_LAST_7_DAYS, + PRESET_LAST_30_DAYS, + PRESET_LAST_90_DAYS, + PRESET_LAST_365_DAYS, + PRESET_LAST_MONTH, + PRESET_LAST_12_MONTHS, + PRESET_LAST_YEAR, + PRESET_CUSTOM, +} from '@jetpack-premium-analytics/datetime'; + +export type { + PrimaryPresetId, + SelectablePresetId, + DateRangePreset, +} from '@jetpack-premium-analytics/datetime'; diff --git a/projects/packages/premium-analytics/packages/ui/src/index.ts b/projects/packages/premium-analytics/packages/ui/src/index.ts new file mode 100644 index 000000000000..9a1d3322329e --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/index.ts @@ -0,0 +1 @@ +export { DateFiltersPanel } from './date-filters-panel'; diff --git a/projects/packages/premium-analytics/packages/ui/src/lock/unlock.ts b/projects/packages/premium-analytics/packages/ui/src/lock/unlock.ts new file mode 100644 index 000000000000..ca799e25b34c --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/lock/unlock.ts @@ -0,0 +1,22 @@ +/** + * Local unlock helper for reaching the private `Menu` component that + * `@wordpress/components` exposes through `@wordpress/private-apis`. + * + * Upstream reached `Menu` via `@automattic/admin-toolkit`'s `unlock`, which is + * not available in this monorepo. This mirrors existing Jetpack precedent that + * opts in to the private APIs directly: + * + * - `projects/packages/jetpack-mu-wpcom/src/common/utils.ts` (`getUnlock()`) + * - `projects/js-packages/charts/src/stories/unlock.ts` + * + * The opt-in module name only needs to be an allow-listed core module; the + * returned `unlock` reads private data bound to any object, so it resolves the + * private APIs locked onto `@wordpress/components`' `privateApis`. + */ + +import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis'; + +export const { unlock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.', + '@wordpress/components' +); diff --git a/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/index.ts b/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/index.ts new file mode 100644 index 000000000000..945c2ce54278 --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/index.ts @@ -0,0 +1,2 @@ +export { useComparisonDatePresets } from './use-comparison-date-presets'; +export type { ComparisonDateRangePreset } from './use-comparison-date-presets'; diff --git a/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/use-comparison-date-presets.ts b/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/use-comparison-date-presets.ts new file mode 100644 index 000000000000..1a018083db1c --- /dev/null +++ b/projects/packages/premium-analytics/packages/ui/src/use-comparison-date-presets/use-comparison-date-presets.ts @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { + getComparisonRangeFromPreset, + getComparisonPresetConfigs, + type ComparisonPresetId, +} from '@jetpack-premium-analytics/datetime'; +import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import type { DateRange } from '../date-range-popover/date-range-filter'; + +/** + * A comparison-specific date range preset. + * Similar to DateRangePreset but with a strongly-typed ComparisonPresetId. + */ +export type ComparisonDateRangePreset = { + id: ComparisonPresetId; + label: string; + range: DateRange; +}; + +/** + * Custom hook that generates comparison date presets + * based on a reference date range. + * + * @param referenceRange - The primary date range to compare against + * @return Array of comparison presets with strongly-typed IDs + */ +export function useComparisonDatePresets( referenceRange: DateRange ): ComparisonDateRangePreset[] { + return useMemo( () => { + if ( ! referenceRange.from || ! referenceRange.to ) { + return []; + } + + return getComparisonPresetConfigs() + .map( ( { id, label } ) => { + const range = getComparisonRangeFromPreset( referenceRange, id ); + return range ? { id, label, range } : null; + } ) + .filter( ( preset ): preset is ComparisonDateRangePreset => preset !== null ); + }, [ referenceRange ] ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/README.md new file mode 100644 index 000000000000..d4bf7888775d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/README.md @@ -0,0 +1,403 @@ +# @automattic/jetpack-premium-analytics-widgets-toolkit + +A collection of focused, single-responsibility components for building analytics widgets. +Each component has a clear API and specific purpose, making them easy to understand, test, and compose. + +## Installation + +This is an internal package of Jetpack Premium Analytics — it is never +published to npm and is resolved entirely in-tree. It's automatically +available to routes and other internal packages within +`@automattic/jetpack-premium-analytics`. + +## Components + +### MetricValue + +Displays a formatted numeric value. Does NOT handle comparisons or deltas. + +**Props:** + +- `value` (number) - The numeric value to display +- `format` ('number' | 'currency' | 'percentage') - How to format the value (default: 'number') +- `formatter` ((value: number) => string) - Custom formatter function (overrides format) +- `size` ('small' | 'medium' | 'large') - Size variant (default: 'medium') +- `color` ('neutral' | 'positive' | 'negative') - Color variant (default: 'neutral') +- `className` (string) - CSS class for styling + +**Examples:** + +```tsx +import { MetricValue } from '@jetpack-premium-analytics/widgets-toolkit'; + +// Simple number + + +// Currency + + +// Custom formatter + `${ v } items` } /> + +// Large positive value + +``` + +--- + +### MetricDelta + +Displays the change between two values (as percentage or absolute). + +**Props:** + +- `current` (number) - The current/new value +- `previous` (number) - The previous/comparison value +- `fallback` (string) - Display when calculation fails (default: '—') +- `hideZero` (boolean) - Hide when delta is zero (default: false) +- `invertColors` (boolean) - For metrics where decrease is improvement (default: false) +- `showAbsolute` (boolean) - Show absolute change instead of percentage (default: false) +- `absoluteFormat` ('number' | 'currency') - Format for absolute values (default: 'number') +- `className` (string) - CSS class for styling + +**Examples:** + +```tsx +import { MetricDelta } from '@jetpack-premium-analytics/widgets-toolkit'; + +// Percentage change: +50% + + +// Absolute change: +50 + + +// Inverted colors (for metrics where lower is better) +// Shows -33% in green + + +// Hide when no change + +``` + +**Delta Calculation:** + +- Returns percentage change: `( ( current - previous ) / |previous| ) * 100` +- Returns `null` if inputs are invalid or previous is zero (displays fallback) +- Returns `0` if both current and previous are zero + +--- + +### MetricWithComparison + +Composite component that combines MetricValue and MetricDelta. + +**Props:** + +- `value` (number) - The current value +- `previousValue` (number | null) - Previous value for comparison (no delta if null) +- `format` ('number' | 'currency' | 'percentage') - How to format the value (default: 'number') +- `formatter` ((value: number) => string) - Custom formatter for the value +- `direction` ('row' | 'column') - Layout direction (default: 'row') +- `size` ('small' | 'medium' | 'large') - Size of the main value (default: 'medium') +- `invertDeltaColors` (boolean) - Invert delta colors (default: false) +- `hideDeltaOnZero` (boolean) - Hide delta when zero (default: false) +- `showAbsoluteDelta` (boolean) - Show absolute change (default: false) +- `deltaFallback` (string) - Delta fallback text +- `className` (string) - Container CSS class + +**Examples:** + +```tsx +import { MetricWithComparison } from '@jetpack-premium-analytics/widgets-toolkit'; + +// Simple metric with comparison + +// Renders: $1,250 +25% + +// Metric where lower is better (e.g., bounce rate) + +// Renders: 15% -25% (in green) + +// Vertical layout + + +// No comparison + +// Renders: $1,250 (no delta) +``` + +--- + +### ComparativeLineChart + +Responsive line chart wrapper for displaying time-series data with comparison support. +Handles automatic resizing and provides sensible defaults for analytics visualizations. + +**Props:** + +- `series` (SeriesData[]) - Array of series data to display in the chart +- `dataFormat` (DataFormat) - Format configuration for tooltips (required) +- `className` (string) - CSS class for the chart container (optional) + +**Note:** Y-axis ticks are automatically formatted using the `dataFormat.type` with multipliers and zero decimals for concise labels (e.g., "1K", "2.5M"). Tooltips display full precision values according to `dataFormat` configuration. + +**DataFormat Type:** + +```tsx +type DataFormat = { + type: 'number' | 'currency' | 'percentage' | 'average'; + options?: { + useMultipliers?: boolean; + decimals?: number; + }; +}; +``` + +**Examples:** + +```tsx +import { ComparativeLineChart, getFormatByMetricKey } from '@jetpack-premium-analytics/widgets-toolkit'; + +// Simple line chart with currency formatting + + +// Chart with comparison data and number format + + +// Chart with multipliers (for large numbers like visitors) + + +// Using helper for predefined metric formats + +``` + +--- + +### ChartTooltip + +Internal chart tooltip component used by `ComparativeLineChart`. Displays formatted values and dates for primary and comparison series. + +**Props:** + +- `tooltipData` - Tooltip data from chart (provided by LineChart) +- `colorScale` - Function to get color for series keys +- `dataFormat` (DataFormat) - Format configuration for values +- `shape` ('line' | 'circle' | 'rect') - Legend shape type (default: 'line') +- `shapeSize` (number) - Size of legend shape in pixels (default: 16) + +**Note:** This component is typically used internally by `ComparativeLineChart` and doesn't need to be used directly. + +--- + +## Helpers + +### getFormatByMetricKey + +Returns the appropriate `DataFormat` configuration for a given metric key. + +**Signature:** + +```tsx +function getFormatByMetricKey( metricKey: MetricKey ): DataFormat; +``` + +**Supported Metrics:** + +- `orders_no` - Number format +- `total_sales` - Currency format +- `average_order_value` - Currency format +- `avg_items` - Average format +- `orders_value_net` - Currency format +- `orders_value_gross` - Currency format +- `coupons` - Currency format +- `profit_margin` - Currency format +- `visitors` - Number format with multipliers + +**Example:** + +```tsx +import { getFormatByMetricKey, ComparativeLineChart } from '@jetpack-premium-analytics/widgets-toolkit'; + + +// Returns: { type: 'currency' } + + +// Returns: { type: 'number', options: { useMultipliers: true, decimals: 0 } } +``` + +--- + +### applyThemeStylesToSeries + +Injects theme styles into chart series, so each series has everything it needs to render correctly (stroke color, strokeDasharray, strokeWidth, etc.) without depending on the theme context at render time. + +**Signature:** + +```tsx +function applyThemeStylesToSeries( + series: SeriesData[], + chartTheme: ReturnType< typeof useChartTheme > +): SeriesData[]; +``` + +**Example:** + +```tsx +import { + applyThemeStylesToSeries, + useChartTheme, + ComparativeLineChart, +} from '@jetpack-premium-analytics/widgets-toolkit'; + +const chartTheme = useChartTheme(); +const styledSeries = applyThemeStylesToSeries( series, chartTheme ); + +; +``` + +**What it does:** + +- Maps `chartTheme.seriesLineStyles` to each series +- Sets `options.stroke` from `chartTheme.colors[ 0 ]` +- Sets `options.seriesLineStyle` with strokeWidth, strokeDasharray, etc. +- Returns original series unchanged if no theme styles available + +--- + +### formatOrderMetric + +Creates a formatter function for a specific order metric. + +**Signature:** + +```tsx +function formatOrderMetric( + metricKey: MetricKey, + options?: FormatMetricValueOptions +): ( value: number ) => string; +``` + +**Example:** + +```tsx +import { formatOrderMetric } from '@jetpack-premium-analytics/widgets-toolkit'; + +const formatter = formatOrderMetric( 'total_sales' ); +formatter( 1234.56 ); // Returns: "$1,234.56" + +const visitorFormatter = formatOrderMetric( 'visitors' ); +visitorFormatter( 15000 ); // Returns: "15K" +``` + +--- + +## Types + +### DataFormat + +Configuration object for formatting chart values and tooltips. + +```tsx +type DataFormat = { + type: 'number' | 'currency' | 'percentage' | 'average'; + options?: { + useMultipliers?: boolean; // Use K, M, B suffixes for large numbers + decimals?: number; // Number of decimal places + }; +}; +``` + +### MetricKey + +Union type of all supported metric keys. + +```tsx +type OrderMetricKey = + | 'orders_no' + | 'total_sales' + | 'average_order_value' + | 'avg_items' + | 'orders_value_net' + | 'orders_value_gross' + | 'coupons' + | 'profit_margin'; + +type VisitorsMetricKey = 'visitors'; + +type MetricKey = OrderMetricKey | VisitorsMetricKey; +``` + +--- + +## Styling + +Components use CSS Modules for styling. You can customize appearance by: + +1. **Using className props**: Pass custom classes to any component +2. **CSS variables**: Components respect design system tokens +3. **Overriding styles**: Use CSS Modules or styled-components + +Example: + +```tsx + +``` diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/package.json b/projects/packages/premium-analytics/packages/widgets-toolkit/package.json new file mode 100644 index 000000000000..958f54b7b530 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/package.json @@ -0,0 +1,31 @@ +{ + "name": "@automattic/jetpack-premium-analytics-widgets-toolkit", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "sideEffects": [ + "*.scss" + ], + "dependencies": { + "@automattic/charts": "workspace:*", + "@jetpack-premium-analytics/data": "workspace:*", + "@jetpack-premium-analytics/datetime": "workspace:*", + "@jetpack-premium-analytics/formatters": "workspace:*", + "@jetpack-premium-analytics/icons": "workspace:*", + "@jetpack-premium-analytics/routing": "workspace:*", + "@jetpack-premium-analytics/ui": "workspace:*", + "@wordpress/components": "33.1.0", + "@wordpress/compose": "7.46.0", + "@wordpress/dataviews": "14.3.0", + "@wordpress/i18n": "^6.9.0", + "@wordpress/icons": "^13.0.0", + "@wordpress/route": "0.12.0", + "@wordpress/theme": "0.13.0", + "@wordpress/ui": "0.13.0", + "clsx": "2.1.1", + "date-fns": "4.1.0", + "react": "18.3.1" + } +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/README.md new file mode 100644 index 000000000000..fa1a8bc72dd3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/README.md @@ -0,0 +1,201 @@ +# BarChart + +A **pure** vertical bar chart component for displaying categorical data. Built on top of `@automattic/charts` with support for negative values, making it ideal for monetary widgets showing refunds, returns, or discounts. + +## Pure Component Design + +This component is **pure and self-contained**—it receives all styling via props and has no external dependencies on context providers or themes. + +```tsx +import { BarChart } from '@jetpack-premium-analytics/widgets-toolkit'; + +; +``` + +**Why this matters:** + +- Predictable rendering — same props always produce the same output +- Easy to test in isolation +- No implicit dependencies to track + +## Basic Usage + +### With `styles` prop (recommended) + +The cleanest approach is to pass styles as a separate prop. Styles are applied to series by index: + +```tsx +import { BarChart, type BarChartStyle } from '@jetpack-premium-analytics/widgets-toolkit'; + +const styles: BarChartStyle[] = [ { stroke: '#3858E9' } ]; + +const chartData = [ + { + label: 'Sales', + data: [ + { label: 'SUMMER20', value: 4500 }, + { label: 'WELCOME10', value: 3200 }, + { label: 'FLASH25', value: 2800 }, + ], + }, +]; + +; +``` + +### With styles in chartData (fallback) + +Alternatively, define styles directly in each series via `options`. +This is useful when styles are dynamically generated per-series: + +```tsx +const chartData = [ + { + label: 'Sales', + data: [ ... ], + options: { stroke: '#10B981' }, + }, +]; + +; +``` + +**Style resolution priority:** `styles` prop > `chartData[].options.stroke` fallback + +## Handling Negative Values + +The component supports negative values, making it ideal for showing refunds, returns, or discounts: + +```tsx +const revenueData = [ + { + label: 'Revenue', + data: [ + { label: 'Product Sales', value: 15000 }, + { label: 'Shipping', value: 2500 }, + { label: 'Refunds', value: -3200 }, + { label: 'Discounts', value: -1500 }, + ], + }, +]; + +; +``` + +## Comparison Mode + +Display multiple series to compare periods: + +```tsx +const comparisonData = [ + { + label: 'Current Period', + data: [ + { label: 'SUMMER20', value: 4500 }, + { label: 'WELCOME10', value: 3200 }, + ], + }, + { + label: 'Previous Period', + data: [ + { label: 'SUMMER20', value: 3800 }, + { label: 'WELCOME10', value: 2900 }, + ], + }, +]; + +const styles: BarChartStyle[] = [ + { stroke: '#3858E9' }, // Primary - Blueberry + { stroke: '#66BDFF' }, // Comparison - Blue 30 +]; + +; +``` + +## Using with Theme Providers + +Widgets wrapped in `GlobalChartsProvider` can use `getElementStyles` from the context to resolve theme colors: + +```tsx +import { BarChart } from '@jetpack-premium-analytics/widgets-toolkit'; +import { useGlobalChartsContext } from '@automattic/charts'; + +function MyWidget( { chartData } ) { + const { getElementStyles } = useGlobalChartsContext(); + + const barStyles = chartData.map( ( seriesData, index ) => { + const { color } = getElementStyles( { + data: seriesData, + index, + } ); + return { stroke: color }; + } ); + + return ( + + ); +} +``` + +## Props + +| Prop | Type | Required | Description | +| ------------ | ----------------- | -------- | ---------------------------------------------------------- | +| `chartData` | `BarChartData` | Yes | Array of series with categorical data points | +| `dataFormat` | `DataFormat` | Yes | Format for values (tooltips): currency, number, percentage | +| `styles` | `BarChartStyle[]` | No | Styles for each series (by index) | +| `className` | `string` | No | CSS class for the chart container | + +## BarChartStyle Type + +```typescript +type BarChartStyle = { + /** Bar fill color */ + stroke: string; +}; +``` + +## DataFormat Type + +```typescript +type DataFormat = { + type: 'currency' | 'number' | 'percentage'; +}; +``` + +## Empty State + +When all values are zero, the chart: + +1. **Disables tooltips** — no meaningless "0" tooltips on hover +2. **Shows a fixed Y-axis domain** — so 0 appears at the bottom with meaningful tick values + +Default domains by data format: + +- `currency`: 0 - 4K +- `number`: 0 - 80 +- `percentage`: 0% - 100% + +## Internal Components + +### ChartTooltip + +The tooltip displays data points when hovering over bars. It uses: + +- Rectangle indicators (matching bar shape) +- WPDS design tokens for consistent styling +- `MetricValue` component for formatted values + +## Features + +- **Responsive sizing**: Automatically adapts to container dimensions +- **Pure component**: No context dependencies - all data flows through props +- **Negative value support**: Can display both positive and negative values +- **Multiple series**: Support for comparison periods +- **Tooltips**: Built-in tooltip support with formatted values +- **Empty state handling**: Fixed Y-axis domain when data is empty +- **Custom styling**: Apply custom colors via `styles` prop or `className` diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/bar-chart.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/bar-chart.module.scss new file mode 100644 index 000000000000..8dfc1a5d1860 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/bar-chart.module.scss @@ -0,0 +1,41 @@ +.chart { + // Override visx-bar default styles that break the layout + // Todo: address upstream in Charts package. + /* stylelint-disable-next-line selector-pseudo-class-no-unknown */ + :global(.visx-bar) { + // All corners rounded to handle both positive and negative bar + // values consistently. + clip-path: inset(0 round 4px); + } + + .legend { + height: var(--wpds-typography-line-height-lg); + min-height: var(--wpds-typography-line-height-lg); + flex-wrap: nowrap; + + // Vertically center the legend shape. + // The Charts package applies a non-zero transform to legend circles, + // which offsets them from the label baseline in this layout. Reset + // the transform here so the circle is aligned with the accompanying + // text. TODO: address this default upstream in the Charts package. + circle { + transform: translate(0, 0) !important; + } + } + + .legendItem { + min-width: 0; + gap: var(--wpds-dimension-gap-sm); + padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm); // 4px 6px->8px + } + + .legendLabel { + min-width: 0; + flex: 1 1 0 !important; // Override the 0 0 auto default value + } + + .emptyState { + flex: 1; + min-height: 200px; + } +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/bar-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/bar-chart.tsx new file mode 100644 index 000000000000..345f84a11a76 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/bar-chart.tsx @@ -0,0 +1,263 @@ +/** + * External dependencies + */ +import { BarChart as BarChartBase } from '@automattic/charts'; +import { Icon } from '@wordpress/ui'; +import clsx from 'clsx'; +import { useCallback, useMemo, useId } from 'react'; +/** + * Internal dependencies + */ +import { RESIZE_DEBOUNCE_MS } from '../../constants'; +import { isEmptyChartData, getEmptyChartDomain } from '../../helpers'; +import { ChartEmptyState } from '../chart-empty-state'; +import { ChartTooltip } from '../chart-tooltip'; +import styles from './bar-chart.module.scss'; +import type { DataFormat } from '../../types'; +import type { ComponentProps } from 'react'; + +export type BarChartData = ComponentProps< typeof BarChartBase >[ 'data' ]; + +/** + * Inferred types from BarChart (BarChartBase) + */ +type BarChartBaseProps = ComponentProps< typeof BarChartBase >; +type RenderTooltipParams = Parameters< NonNullable< BarChartBaseProps[ 'renderTooltip' ] > >[ 0 ]; + +/** + * Style configuration for bar chart. + */ +export type BarChartStyle = { + /** Bar fill color */ + stroke: string; +}; + +export type BarChartProps = { + /** + * Chart data (series with data points). + * Colors can be provided via chartData[].options.stroke. + */ + chartData: BarChartData; + + /** + * Format configuration for chart values (tooltips) + */ + dataFormat: DataFormat; + + /** + * Explicit styles for bars. When provided, these take priority + * over styles defined in chartData[].options.stroke. + */ + styles?: BarChartStyle[]; + + /** + * Optional className for the container + */ + className?: string; + + /** + * Icon to display in the empty state + */ + emptyStateIcon?: React.ComponentProps< typeof Icon >[ 'icon' ]; + + /** + * Text to display in the empty state + */ + emptyStateText?: string; + + /** + * Whether to show a thin bar for zero values when the chart is rendered. + * When true and the data is not considered empty, zero-value bars render + * with a small visible height so users have something to hover over for + * tooltips. When all values are 0 or null and the chart is treated as + * empty, an empty state is shown instead and this option has no effect. + * @default true + */ + showZeroValues?: boolean; +}; + +/** + * Resolves bar styles from either the explicit styles prop or series options. + * Priority: styles prop > chartData[].options.stroke fallback + * + * @param stylesFromProp - Explicit styles from component prop + * @param chartData - Chart data with optional stroke colors + * @return Array of resolved styles, one per series + */ +function resolveSeriesStyles( + stylesFromProp: BarChartStyle[] | undefined, + chartData: BarChartData +): BarChartStyle[] { + // If styles prop is provided, use it directly + if ( stylesFromProp?.length ) { + return stylesFromProp; + } + + // Fallback: extract styles from chartData options + return ( + chartData?.map( series => ( { + stroke: series.options?.stroke ?? 'currentColor', + } ) ) ?? [ { stroke: 'currentColor' } ] + ); +} + +/** + * Applies resolved styles to chart data for the internal BarChart. + * Sets options.stroke on each series. + * + * @param chartData - Original chart data + * @param resolvedStyles - Styles to apply + * @return Chart data with styles applied to options + */ +function applyStylesToSeries( + chartData: BarChartData, + resolvedStyles: BarChartStyle[] +): BarChartData { + return chartData.map( ( seriesItem, index ) => { + const style = resolvedStyles[ index ] ?? resolvedStyles[ 0 ]; + + if ( ! style?.stroke ) { + return seriesItem; + } + + return { + ...seriesItem, + options: { + ...( seriesItem.options ?? {} ), + stroke: style.stroke, + }, + }; + } ); +} + +/** + * Pure BarChart component. + * Does not depend on any context provider - all data flows through props. + * + * Colors can be provided via chartData[].options.stroke or via styles prop. + * Uses RectShape from chart library for tooltip indicators. + */ +export function BarChart( { + chartData, + dataFormat, + styles: stylesProp, + className, + emptyStateIcon, + emptyStateText, + showZeroValues = true, +}: BarChartProps ) { + const chartId = useId(); + + /** + * Resolve styles: prop takes priority, fallback to chartData options. + * This array is used for tooltip styling and to decorate chart data. + */ + const resolvedStyles = useMemo< BarChartStyle[] >( + () => resolveSeriesStyles( stylesProp, chartData ), + [ stylesProp, chartData ] + ); + + /** + * Apply resolved styles to chart data for the internal BarChart. + * Only needed when styles come from prop; otherwise chartData already has styles. + */ + const styledChartData = useMemo( () => { + // If no styles prop, chartData already has its styles in options + if ( ! stylesProp?.length ) { + return chartData; + } + return applyStylesToSeries( chartData, resolvedStyles ); + }, [ stylesProp, chartData, resolvedStyles ] ); + + /** + * Detect if chart data is empty (all values are 0). + * Used to disable tooltips when there's no meaningful data to display. + */ + const isEmptyData = useMemo( () => isEmptyChartData( styledChartData ), [ styledChartData ] ); + + /** + * Chart options for empty data state. + * Sets a fixed Y-axis domain so the chart shows 0 at the bottom + * with meaningful tick values instead of a flat line. + */ + const chartOptions = useMemo( () => { + if ( ! isEmptyData ) { + return { + // Apply ellipsis to x-axis labels when they overflow. + axis: { + x: { + labelOverflow: 'ellipsis' as const, + }, + }, + }; + } + + const domain = getEmptyChartDomain( dataFormat.type ); + return { + yScale: { domain }, + }; + }, [ isEmptyData, dataFormat.type ] ); + + const getTooltipLabel = useCallback( + ( datum: { label: string }, _index: number, key: string ): string => { + if ( key ) { + // Show the key (typically the date range label) in the tooltip if available, + // since the bar's label is already shown on the x-axis. This helps distinguish + // between current period and comparison period bars in tooltips. + return key; + } + return datum.label; + }, + [] + ); + + const renderTooltip = useCallback( + ( params: RenderTooltipParams ) => { + return ( + + ); + }, + [ dataFormat, resolvedStyles, getTooltipLabel ] + ); + + if ( isEmptyData ) { + return ; + } + + return ( + + + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/index.ts new file mode 100644 index 000000000000..189c42822bf7 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/index.ts @@ -0,0 +1 @@ +export { BarChart, type BarChartProps, type BarChartData, type BarChartStyle } from './bar-chart'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/README.md new file mode 100644 index 000000000000..9f048c37a9bb --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/README.md @@ -0,0 +1,162 @@ +# ComparativeLineChart + +A **pure** line chart component for comparing time series data across different periods. Built on top of `@automattic/charts` with automatic date alignment for comparison series. + +## Pure Component Design + +This component is **pure and self-contained**—it receives all styling via props and has no external dependencies on context providers or themes. + +```tsx +import { ComparativeLineChart } from '@jetpack-premium-analytics/widgets-toolkit'; + +; +``` + +**Why this matters:** + +- Predictable rendering — same props always produce the same output +- Easy to test in isolation +- No implicit dependencies to track + +## Basic Usage + +### With `styles` prop (recommended) + +The cleanest approach is to pass styles as a separate prop. Styles are applied to series by index: + +```tsx +import { ComparativeLineChart, type SeriesStyle } from '@jetpack-premium-analytics/widgets-toolkit'; + +const styles: SeriesStyle[] = [ + { stroke: '#3858E9', strokeWidth: 2 }, + { stroke: '#3858E9', strokeDasharray: '4 4', strokeWidth: 1.5 }, +]; + +const series = [ + { + label: 'Jan 1-7, 2024', + group: 'primary', + options: {}, + data: [ + { date: new Date( '2024-01-01' ), value: 1000 }, + { date: new Date( '2024-01-02' ), value: 1200 }, + ], + }, + { + label: 'Dec 25-31, 2023', + group: 'primary', + options: { type: 'comparison' }, + data: [ + { date: new Date( '2023-12-25' ), value: 900 }, + { date: new Date( '2023-12-26' ), value: 1100 }, + ], + }, +]; + +; +``` + +### With styles in series (fallback) + +Alternatively, define styles directly in each series via `options`. +This is useful when each series needs different colors or when styles are dynamically generated per-series: + +```tsx +const series = [ + { + label: 'Jan 1-7, 2024', + group: 'primary', + data: [ ... ], + options: { + stroke: '#10B981', + seriesLineStyle: { strokeWidth: 2 }, + }, + }, + { + label: 'Dec 25-31, 2023', + group: 'comparison', + data: [ ... ], + options: { + stroke: '#F59E0B', + seriesLineStyle: { strokeDasharray: '4 4', strokeWidth: 1.5 }, + }, + }, +]; + +; +``` + +**Style resolution priority:** `styles` prop > `series[].options` fallback + +## Using with Theme Providers + +Widgets wrapped in `GlobalChartsProvider` can use `getElementStyles` from the context to resolve theme colors: + +```tsx +import { ComparativeLineChart } from '@jetpack-premium-analytics/widgets-toolkit'; +import { useGlobalChartsContext } from '@automattic/charts'; + +function MyWidget( { series } ) { + const { getElementStyles } = useGlobalChartsContext(); + + const seriesStyles = series.map( ( seriesData, index ) => { + const { color, lineStyles } = getElementStyles( { + data: seriesData, + index, + } ); + return { + stroke: color, + ...lineStyles, + }; + } ); + + return ( + + ); +} +``` + +## Props + +| Prop | Type | Required | Description | +| ------------ | ------------------------------ | -------- | -------------------------------------------------- | +| `series` | `ComparativeLineChartSeries[]` | Yes | Array of series with data | +| `styles` | `SeriesStyle[]` | No | Styles for each series (by index) | +| `dataFormat` | `DataFormat` | Yes | Format for values (Y-axis ticks and tooltips) | +| `tickFormat` | `string` | No | Custom X-axis date format (date-fns format string) | +| `className` | `string` | No | CSS class for the chart container | + +## SeriesStyle Type + +```typescript +type SeriesStyle = { + stroke: string; + strokeWidth?: number | string; + strokeDasharray?: string | number; + strokeLinecap?: 'butt' | 'round' | 'square' | 'inherit'; + strokeLinejoin?: 'miter' | 'round' | 'bevel' | 'inherit'; + opacity?: number | string; +}; +``` + +## Date Alignment + +The component automatically aligns comparison series to the primary series for X-axis display: + +1. First series (`series[0]`) is the reference +2. Comparison series dates are shifted to align with the primary +3. Original dates are preserved for tooltip display + +**Example**: A comparison series with Dec 25-31 dates will visually align to Jan 1-7 on the X-axis, but tooltips show the real Dec 25-31 dates. + +## Empty State + +When all values are zero, the chart shows a fixed Y-axis domain: + +- `currency`: 0 - 4K +- `number`: 0 - 80 +- `percentage`: 0% - 100% diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.module.scss new file mode 100644 index 000000000000..2f4c511d9c7b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.module.scss @@ -0,0 +1,26 @@ +.chart { + height: 100%; + + .legend { + flex: 0 0 auto; + height: var(--wpds-typography-line-height-lg); + min-height: var(--wpds-typography-line-height-lg); + flex-wrap: nowrap; + } + + .legendItem { + min-width: 0; + gap: var(--wpds-dimension-gap-sm); + padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-sm); // 4px 6px->8px + } + + .legendLabel { + // font-size and color come from chartTheme.legendLabelStyles + min-width: 0; + flex: 1 1 0 !important; // Override the 0 0 auto default value + + span { + display: block; + } + } +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx new file mode 100644 index 000000000000..b65f1f95780c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/comparative-line-chart.tsx @@ -0,0 +1,351 @@ +/** + * External dependencies + */ +import { LineChart } from '@automattic/charts'; +import { formatDate, formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import clsx from 'clsx'; +import { useCallback, useMemo } from 'react'; +import { type ComponentProps } from 'react'; +/** + * Internal dependencies + */ +import { RESIZE_DEBOUNCE_MS } from '../../constants'; +import { isEmptyChartData, getEmptyChartDomain } from '../../helpers'; +import { ChartTooltip } from '../chart-tooltip'; +import styles from './comparative-line-chart.module.scss'; +import { alignSeriesDates } from './utils'; +import type { ComparativeLineChartSeries, SeriesStyle } from './types'; +import type { DataFormat } from '../../types'; + +/** + * Resolves series styles from either the explicit styles prop or series options. + * Priority: styles prop > series[].options fallback + * + * @param stylesFromProp - Explicit styles passed as component prop + * @param series - Series data (may contain options with styles) + * @return Array of resolved styles, one per series + */ +function resolveSeriesStyles( + stylesFromProp: SeriesStyle[] | undefined, + series: ComparativeLineChartSeries[] +): SeriesStyle[] { + // If styles prop is provided, use it directly + if ( stylesFromProp?.length ) { + return stylesFromProp; + } + + // Fallback: extract styles from series options + return series.map( s => { + const lineStyle = s.options?.seriesLineStyle; + + return { + stroke: s.options?.stroke ?? '', + strokeWidth: lineStyle?.strokeWidth, + strokeDasharray: lineStyle?.strokeDasharray, + strokeLinecap: lineStyle?.strokeLinecap, + strokeLinejoin: lineStyle?.strokeLinejoin, + opacity: lineStyle?.opacity, + }; + } ); +} + +/** + * Default margin for charts. + * Y-axis is on the left, so right margin is always 0. + */ +const DEFAULT_MARGIN = { right: 0 }; + +/** + * Applies resolved styles to series data for the internal LineChart. + * Sets options.stroke and options.seriesLineStyle on each series. + * + * @param series - Original series data + * @param resolvedStyles - Styles to apply + * @return Series with styles applied to options + */ +function applyStylesToSeries( + series: ComparativeLineChartSeries[], + resolvedStyles: SeriesStyle[] +): ComparativeLineChartSeries[] { + return series.map( ( seriesItem, index ) => { + const style = resolvedStyles[ index ] ?? resolvedStyles[ 0 ]; + + if ( ! style?.stroke ) { + return seriesItem; + } + + const { stroke, ...lineStyleProps } = style; + + return { + ...seriesItem, + options: { + ...( seriesItem.options ?? {} ), + stroke, + seriesLineStyle: lineStyleProps, + }, + }; + } ); +} + +/** + * Inferred types + */ +type LineChartProps = ComponentProps< typeof LineChart >; +type RenderTooltipParams = Parameters< NonNullable< LineChartProps[ 'renderTooltip' ] > >[ 0 ]; + +/** + * Props for the ComparativeLineChart component. + * + * Combines series data with chart options, formatting, and responsive behavior. + * Wraps @automattic/charts LineChart with sensible defaults for comparative data visualization. + * + * Note: The chart defaults to margin.right = 0 since the Y-axis is positioned on the left. + */ +export type ComparativeLineChartProps = { + /** + * Array of series data to display in the chart. + * Series can include styling via options.stroke and options.seriesLineStyle + * as a fallback when styles prop is not provided. + */ + series: ComparativeLineChartSeries[]; + + /** + * Explicit styles for each series. When provided, these take priority + * over any styles defined in series[].options. + * Array index corresponds to series index. + */ + styles?: SeriesStyle[]; + + /** + * CSS class for the chart container + */ + className?: string; + + /** + * Format configuration for chart values (Y-axis ticks and tooltips) + */ + dataFormat: DataFormat; + + tickFormat?: string; +} & Omit< + ComponentProps< typeof LineChart >, + | 'data' + | 'options' + | 'withLegendGlyph' + | 'smoothing' + | 'showLegend' + | 'withGradientFill' + | 'resizeDebounceTime' + | 'withTooltips' + | 'renderTooltip' +>; + +export function ComparativeLineChart( { + series, + styles: stylesProp, + className, + dataFormat, + tickFormat: xTickFormatType, + maxWidth = Infinity, +}: ComparativeLineChartProps ) { + /** + * Resolve styles: prop takes priority, fallback to series options. + * This array is used for tooltip styling and to decorate series data. + */ + const resolvedStyles = useMemo< SeriesStyle[] >( + () => resolveSeriesStyles( stylesProp, series ), + [ stylesProp, series ] + ); + + /** + * Custom label extractor for line chart datum. + * Uses realDate for comparison series to show the actual date. + * + * @param datum - The data point with date information + * @param index - Index of this entry in the tooltip + */ + const getTooltipLabel = useCallback( + ( datum: { date: Date; realDate?: Date }, index: number ): string => { + const isComparison = index > 0; + const displayDate = isComparison ? datum.realDate ?? datum.date : datum.date; + return formatDate( displayDate ); + }, + [] + ); + + const renderTooltip = useCallback( + ( params: RenderTooltipParams ) => { + return ( + + ); + }, + [ dataFormat, resolvedStyles, getTooltipLabel ] + ); + + /** + * Y-axis formatter using dataFormat configuration, + * but using multipliers and 0 decimals to keep strings short and concise. + */ + const yTickFormat = useMemo( + () => ( value: number ) => + formatMetricValue( value, dataFormat.type, { + useMultipliers: true, + decimals: 0, + } ), + [ dataFormat ] + ); + + /** + * Creates margin object for fixed domain charts. + * The chart library doesn't auto-adjust left margin for fixed domains, + * so we estimate based on the formatted max value length. + */ + const createDomainMargin = useCallback( + ( maxValue: number ) => ( { + ...DEFAULT_MARGIN, + left: yTickFormat( maxValue ).length * 10, + } ), + [ yTickFormat ] + ); + + /** + * Align comparison series dates to primary series for X-axis display. + * Original dates are preserved in realDate for tooltip display. + */ + const alignedSeries = useMemo( () => alignSeriesDates( series ), [ series ] ); + + /** + * Apply resolved styles to series data for the internal LineChart. + * Only needed when styles come from prop; otherwise series already have styles. + */ + const styledSeries = useMemo( () => { + // If no styles prop, series already have their styles in options + if ( ! stylesProp?.length ) { + return alignedSeries; + } + return applyStylesToSeries( alignedSeries, resolvedStyles ); + }, [ stylesProp, alignedSeries, resolvedStyles ] ); + + /** + * Detect if chart data is empty and apply special props for empty state + */ + const isEmptyData = useMemo( () => isEmptyChartData( styledSeries ), [ styledSeries ] ); + + /** + * For percentage metrics, always use a fixed domain [0, 1.0] (0% to 100%) + * regardless of actual data values or empty state + */ + const percentageDomain: [ number, number ] | null = useMemo( () => { + return dataFormat.type === 'percentage' ? [ 0, 1.0 ] : null; + }, [ dataFormat.type ] ); + + const emptyChartProps = useMemo( () => { + if ( ! isEmptyData ) { + return {}; + } + + const domain = getEmptyChartDomain( dataFormat.type ); + + return { + chartOptions: { yScale: { domain } }, + margin: createDomainMargin( domain[ 1 ] ), + }; + }, [ isEmptyData, dataFormat.type, createDomainMargin ] ); + + /** + * Calculate margin for percentage charts + */ + const percentageMargin = useMemo( () => { + if ( ! percentageDomain ) { + return undefined; + } + return createDomainMargin( percentageDomain[ 1 ] ); + }, [ percentageDomain, createDomainMargin ] ); + + const xTickFormat = useCallback( + ( date: number ) => formatDate( date, xTickFormatType ?? 'short' ), + [ xTickFormatType ] + ); + + /** + * Merge chart options with empty chart options if data is empty + * For percentage metrics, always apply fixed domain + */ + const chartOptions = useMemo( () => { + const baseOptions = { + axis: { + x: { + // Use the chart library's default behavior for 'custom' presets + tickFormat: xTickFormatType ? xTickFormat : undefined, + }, + y: { + tickFormat: yTickFormat, + }, + }, + }; + + // Apply percentage domain if applicable + if ( percentageDomain ) { + return { + ...baseOptions, + yScale: { domain: percentageDomain }, + }; + } + + if ( ! isEmptyData ) { + return baseOptions; + } + + // Merge with empty chart options + return { + ...baseOptions, + ...emptyChartProps.chartOptions, + }; + }, [ + xTickFormat, + xTickFormatType, + yTickFormat, + percentageDomain, + isEmptyData, + emptyChartProps.chartOptions, + ] ); + + return ( + + + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/index.ts new file mode 100644 index 000000000000..a5bae53a6c11 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/index.ts @@ -0,0 +1,3 @@ +export { ComparativeLineChart } from './comparative-line-chart'; +export type { ComparativeLineChartProps } from './comparative-line-chart'; +export type { SeriesStyle } from './types'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/types.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/types.ts new file mode 100644 index 000000000000..b7a121e1811c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/types.ts @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { type SeriesData, type DataPointDate, type LineStyles } from '@automattic/charts'; + +/** + * Types + */ +export type ComparativeDatePointDate = DataPointDate & { + date: Date; // <- date is required by the comparative line chart. + realDate?: Date; +}; + +export type ComparativeLineChartSeries = SeriesData & { + // We expect SeriesData.data to be an array of DataPointDate. + data: ComparativeDatePointDate[]; +}; + +/** + * Style configuration for a single series. + * Derived from LineStyles (SVG line attributes) with required stroke. + */ +export type SeriesStyle = LineStyles & { + /** Line stroke color (required) */ + stroke: string; +}; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/utils/align-series-dates.test.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/utils/align-series-dates.test.ts new file mode 100644 index 000000000000..8b64b6779abf --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/utils/align-series-dates.test.ts @@ -0,0 +1,289 @@ +/** + * Internal dependencies + */ +import { alignSeriesDates } from './align-series-dates'; +import type { ComparativeLineChartSeries } from '../types'; + +/** + * Helper to create a series with dates. + */ +function createSeries( + label: string, + dates: Date[], + values?: number[] +): ComparativeLineChartSeries { + return { + label, + data: dates.map( ( date, i ) => ( { + date, + value: values?.[ i ] ?? i * 10, + } ) ), + }; +} + +describe( 'alignSeriesDates', () => { + describe( 'edge cases', () => { + it( 'returns empty array as-is', () => { + const result = alignSeriesDates( [] ); + expect( result ).toEqual( [] ); + } ); + + it( 'returns single series unchanged', () => { + const series = [ + createSeries( 'Primary', [ new Date( '2024-01-01' ), new Date( '2024-01-02' ) ] ), + ]; + + const result = alignSeriesDates( series ); + + expect( result ).toBe( series ); // Same reference + expect( result[ 0 ].data[ 0 ].date ).toEqual( new Date( '2024-01-01' ) ); + } ); + + it( 'handles series with empty data arrays', () => { + const series: ComparativeLineChartSeries[] = [ + { label: 'Primary', data: [] }, + { label: 'Comparison', data: [] }, + ]; + + const result = alignSeriesDates( series ); + + expect( result ).toBe( series ); // Returns original when primary has no data + } ); + + it( 'handles comparison series with empty data', () => { + const primary = createSeries( 'Primary', [ + new Date( '2024-01-01' ), + new Date( '2024-01-02' ), + ] ); + const comparison: ComparativeLineChartSeries = { + label: 'Comparison', + data: [], + }; + + const result = alignSeriesDates( [ primary, comparison ] ); + + expect( result[ 0 ] ).toBe( primary ); // Primary unchanged + expect( result[ 1 ] ).toBe( comparison ); // Empty comparison returned as-is + } ); + } ); + + describe( 'index-based date alignment', () => { + it( 'aligns comparison dates to corresponding primary dates by index', () => { + const primary = createSeries( 'This Week', [ + new Date( '2024-01-08' ), // Monday of this week + new Date( '2024-01-09' ), + new Date( '2024-01-10' ), + ] ); + + const comparison = createSeries( 'Last Week', [ + new Date( '2024-01-01' ), // Monday of last week + new Date( '2024-01-02' ), + new Date( '2024-01-03' ), + ] ); + + const result = alignSeriesDates( [ primary, comparison ] ); + + // Primary should be unchanged + expect( result[ 0 ].data[ 0 ].date ).toEqual( new Date( '2024-01-08' ) ); + + // Comparison dates should match primary dates by index + expect( result[ 1 ].data[ 0 ].date ).toEqual( new Date( '2024-01-08' ) ); + expect( result[ 1 ].data[ 1 ].date ).toEqual( new Date( '2024-01-09' ) ); + expect( result[ 1 ].data[ 2 ].date ).toEqual( new Date( '2024-01-10' ) ); + } ); + + it( 'handles weekly intervals with different start days', () => { + // This is the key scenario: weeks that don't start on the same day + // Primary: Sep 12 (Thu) - period starts mid-week + // Comparison: Jun 14 (Sat) - period starts on different day + const primary = createSeries( 'Current Period', [ + new Date( '2024-09-12' ), // Week 1 starts Thu + new Date( '2024-09-16' ), // Week 2 starts Mon + new Date( '2024-09-23' ), // Week 3 + ] ); + + const comparison = createSeries( 'Previous Period', [ + new Date( '2024-06-14' ), // Week 1 starts Sat + new Date( '2024-06-17' ), // Week 2 starts Mon + new Date( '2024-06-24' ), // Week 3 + ] ); + + const result = alignSeriesDates( [ primary, comparison ] ); + + // Comparison should get primary's dates for perfect alignment + expect( result[ 1 ].data[ 0 ].date ).toEqual( new Date( '2024-09-12' ) ); + expect( result[ 1 ].data[ 1 ].date ).toEqual( new Date( '2024-09-16' ) ); + expect( result[ 1 ].data[ 2 ].date ).toEqual( new Date( '2024-09-23' ) ); + + // Original dates preserved for tooltip + expect( result[ 1 ].data[ 0 ].realDate ).toEqual( new Date( '2024-06-14' ) ); + } ); + + it( 'preserves original dates in realDate property', () => { + const primary = createSeries( 'This Week', [ + new Date( '2024-01-08' ), + new Date( '2024-01-09' ), + ] ); + + const comparison = createSeries( 'Last Week', [ + new Date( '2024-01-01' ), + new Date( '2024-01-02' ), + ] ); + + const result = alignSeriesDates( [ primary, comparison ] ); + + // Original dates preserved in realDate + expect( result[ 1 ].data[ 0 ].realDate ).toEqual( new Date( '2024-01-01' ) ); + expect( result[ 1 ].data[ 1 ].realDate ).toEqual( new Date( '2024-01-02' ) ); + } ); + + it( 'does not add realDate to primary series', () => { + const primary = createSeries( 'This Week', [ new Date( '2024-01-08' ) ] ); + const comparison = createSeries( 'Last Week', [ new Date( '2024-01-01' ) ] ); + + const result = alignSeriesDates( [ primary, comparison ] ); + + expect( result[ 0 ].data[ 0 ] ).not.toHaveProperty( 'realDate' ); + } ); + + it( 'returns series unchanged when dates already align', () => { + const primary = createSeries( 'Series A', [ + new Date( '2024-01-01' ), + new Date( '2024-01-02' ), + ] ); + + const comparison = createSeries( 'Series B', [ + new Date( '2024-01-01' ), // Same start date + new Date( '2024-01-02' ), + ] ); + + const result = alignSeriesDates( [ primary, comparison ] ); + + // When dates already align, comparison should be returned as-is + expect( result[ 1 ] ).toBe( comparison ); + } ); + } ); + + describe( 'series with different lengths', () => { + it( 'handles comparison with more points than primary', () => { + const primary = createSeries( 'Primary', [ + new Date( '2024-01-08' ), + new Date( '2024-01-09' ), + ] ); + + const comparison = createSeries( 'Comparison', [ + new Date( '2024-01-01' ), + new Date( '2024-01-02' ), + new Date( '2024-01-03' ), // Extra point + ] ); + + const result = alignSeriesDates( [ primary, comparison ] ); + + // First two points align by index + expect( result[ 1 ].data[ 0 ].date ).toEqual( new Date( '2024-01-08' ) ); + expect( result[ 1 ].data[ 1 ].date ).toEqual( new Date( '2024-01-09' ) ); + // Extra point gets last primary date + expect( result[ 1 ].data[ 2 ].date ).toEqual( new Date( '2024-01-09' ) ); + } ); + + it( 'handles comparison with fewer points than primary', () => { + const primary = createSeries( 'Primary', [ + new Date( '2024-01-08' ), + new Date( '2024-01-09' ), + new Date( '2024-01-10' ), + ] ); + + const comparison = createSeries( 'Comparison', [ + new Date( '2024-01-01' ), + new Date( '2024-01-02' ), + ] ); + + const result = alignSeriesDates( [ primary, comparison ] ); + + // Both comparison points align to their corresponding primary dates + expect( result[ 1 ].data[ 0 ].date ).toEqual( new Date( '2024-01-08' ) ); + expect( result[ 1 ].data[ 1 ].date ).toEqual( new Date( '2024-01-09' ) ); + } ); + } ); + + describe( 'multiple comparison series', () => { + it( 'aligns all comparison series to primary', () => { + const primary = createSeries( 'Current', [ + new Date( '2024-03-01' ), + new Date( '2024-03-02' ), + ] ); + + const lastMonth = createSeries( 'Last Month', [ + new Date( '2024-02-01' ), + new Date( '2024-02-02' ), + ] ); + + const lastYear = createSeries( 'Last Year', [ + new Date( '2023-03-01' ), + new Date( '2023-03-02' ), + ] ); + + const result = alignSeriesDates( [ primary, lastMonth, lastYear ] ); + + // All series should now use primary's dates + expect( result[ 0 ].data[ 0 ].date ).toEqual( new Date( '2024-03-01' ) ); + expect( result[ 1 ].data[ 0 ].date ).toEqual( new Date( '2024-03-01' ) ); + expect( result[ 2 ].data[ 0 ].date ).toEqual( new Date( '2024-03-01' ) ); + + // Original dates preserved + expect( result[ 1 ].data[ 0 ].realDate ).toEqual( new Date( '2024-02-01' ) ); + expect( result[ 2 ].data[ 0 ].realDate ).toEqual( new Date( '2023-03-01' ) ); + } ); + } ); + + describe( 'data preservation', () => { + it( 'preserves all other data point properties', () => { + const primary: ComparativeLineChartSeries = { + label: 'Primary', + data: [ + { date: new Date( '2024-01-08' ), value: 100 }, + { date: new Date( '2024-01-09' ), value: 200 }, + ], + }; + + const comparison: ComparativeLineChartSeries = { + label: 'Comparison', + data: [ + { date: new Date( '2024-01-01' ), value: 50 }, + { date: new Date( '2024-01-02' ), value: 75 }, + ], + }; + + const result = alignSeriesDates( [ primary, comparison ] ); + + // Values should be preserved + expect( result[ 1 ].data[ 0 ].value ).toBe( 50 ); + expect( result[ 1 ].data[ 1 ].value ).toBe( 75 ); + } ); + + it( 'preserves series options and other properties', () => { + const primary: ComparativeLineChartSeries = { + label: 'Primary', + data: [ { date: new Date( '2024-01-08' ), value: 100 } ], + options: { stroke: '#ff0000' }, + }; + + const comparison: ComparativeLineChartSeries = { + label: 'Comparison', + data: [ { date: new Date( '2024-01-01' ), value: 50 } ], + options: { + stroke: '#0000ff', + seriesLineStyle: { opacity: 0.5 }, + }, + }; + + const result = alignSeriesDates( [ primary, comparison ] ); + + expect( result[ 1 ].label ).toBe( 'Comparison' ); + expect( result[ 1 ].options ).toEqual( { + stroke: '#0000ff', + seriesLineStyle: { opacity: 0.5 }, + } ); + } ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/utils/align-series-dates.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/utils/align-series-dates.ts new file mode 100644 index 000000000000..05a2ecf65e33 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/utils/align-series-dates.ts @@ -0,0 +1,74 @@ +/** + * Internal dependencies + */ +import type { ComparativeLineChartSeries } from '../types'; + +/** + * Aligns comparison series dates to primary series dates by index. + * + * Each comparison point gets assigned the date of the corresponding primary point + * (same index), ensuring both series align perfectly on the X-axis regardless of + * their original date intervals. Original dates are preserved in realDate for tooltips. + * + * This approach handles: + * - Different period lengths (e.g., weeks starting on different days) + * - Partial intervals at period boundaries + * - Any time granularity (daily, weekly, monthly) + * + * @param series - Array of series data where index 0 is primary and index 1+ are comparison + * @return New array with aligned series (comparison dates match primary, originals in realDate) + */ +export function alignSeriesDates( + series: ComparativeLineChartSeries[] +): ComparativeLineChartSeries[] { + if ( series.length < 2 ) { + return series; + } + + const [ primary, ...rest ] = series; + + if ( ! primary.data.length ) { + return series; + } + + const alignedRest = rest.map( comparisonSeries => { + if ( ! comparisonSeries.data.length ) { + return comparisonSeries; + } + + // Check if alignment is needed by comparing first dates + const primaryFirstDate = primary.data[ 0 ]?.date; + const comparisonFirstDate = comparisonSeries.data[ 0 ]?.date; + + const primaryFirstMs = + primaryFirstDate instanceof Date ? primaryFirstDate.getTime() : primaryFirstDate; + + const comparisonFirstMs = + comparisonFirstDate instanceof Date ? comparisonFirstDate.getTime() : comparisonFirstDate; + + // If dates already align, return as-is + if ( primaryFirstMs === comparisonFirstMs ) { + return comparisonSeries; + } + + // Align by index: each comparison point gets the primary point's date + return { + ...comparisonSeries, + data: comparisonSeries.data.map( ( point, index ) => { + // Use corresponding primary date, or last primary date if comparison has more points + const primaryDate = + primary.data[ index ]?.date ?? primary.data[ primary.data.length - 1 ]?.date; + + return { + ...point, + // Use primary's date for X-axis alignment + date: primaryDate, + // Preserve original date for tooltip display + realDate: point.date, + }; + } ), + }; + } ); + + return [ primary, ...alignedRest ]; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/utils/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/utils/index.ts new file mode 100644 index 000000000000..0ab92737831e --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/utils/index.ts @@ -0,0 +1 @@ +export { alignSeriesDates } from './align-series-dates'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/README.md new file mode 100644 index 000000000000..c4c940363158 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/README.md @@ -0,0 +1,157 @@ +# DonutChart + +A responsive donut (pie) chart component that automatically adapts to its container size. + +## Features + +- **Auto-resize**: Automatically scales to fit the parent container +- **Pure component**: No context dependencies - all data flows through props +- **Theme support**: Colors can be provided via `styles` prop or inline in `chartData` +- **Comparison mode**: Shows delta percentage when `comparisonValue` is provided +- **Legend integration**: Optional legend with comparison deltas +- **Validation**: Falls back to metric-only display when data is invalid + +## Usage + +```tsx +import { DonutChart } from '@jetpack-premium-analytics/widgets-toolkit'; + +const chartData = [ + { label: 'Completed', value: 45, percentage: 56.25 }, + { label: 'Pending', value: 25, percentage: 31.25 }, + { label: 'Cancelled', value: 10, percentage: 12.5 }, +]; + +const styles = [ { color: '#3858E9' }, { color: '#66BDFF' }, { color: '#A77EFF' } ]; + +; +``` + +## Props + +| Prop | Type | Default | Description | +| ----------------- | ---------------- | -------------------- | ------------------------------------------------------------------ | +| `chartData` | `DonutChartData` | required | Array of segments with `label`, `value`, and `percentage` | +| `styles` | `SegmentStyle[]` | - | Explicit colors per segment (takes priority over chartData colors) | +| `value` | `number` | required | Primary metric value displayed in center | +| `comparisonValue` | `number \| null` | - | Previous period value for delta calculation | +| `dataFormat` | `DataFormat` | `{ type: 'number' }` | Format configuration for the metric display | +| `legendData` | `LegendItem[]` | - | Legend items with labels and comparison values | +| `showLegend` | `boolean` | `true` | Whether to show the legend below the chart | +| `thickness` | `number` | `0.3` | Arc thickness as ratio (0-1) | + +## Data Validation + +The component validates chart data before rendering: + +1. **No negative values**: Both `value` and `percentage` must be >= 0 +2. **100% total**: Percentages must sum to approximately 100% (within 0.01 tolerance) + +When validation fails, the component displays a fallback view showing only the metric and legend without the chart. + +## Responsive Layout + +The component uses a reference/wrapper pattern to achieve fluid sizing: + +``` +┌─────────────────────────────┐ +│ .reference (relative) │ ← Takes 100% width from parent +│ ┌─────────────────────────┐ │ +│ │ .wrapper (absolute) │ │ ← Fills reference, observed by ResizeObserver +│ │ ┌─────────────────────┐ │ │ +│ │ │ Stack (content) │ │ │ ← Chart + Legend +│ │ └─────────────────────┘ │ │ +│ └─────────────────────────┘ │ +└─────────────────────────────┘ +``` + +### How it works + +1. **`.reference`** - Outer container with `position: relative` and `width: 100%`. Sets initial height from content or defaults to 164px. + +2. **`.wrapper`** - Absolutely positioned to fill the reference. The `ResizeObserver` attached to the inner `Stack` captures available dimensions. + +3. **Dynamic sizing** - The chart size is calculated as the minimum of container width, height, and the default size (164px). + +4. **SVG scaling** - The `PieChart` receives the calculated size and renders proportionally. + +### Default dimensions + +Before the first resize observation, the chart uses sensible defaults: + +- Size: 164px (width and height) + +## Storybook + +Run `pnpm storybook` and navigate to **Widgets Toolkit / Components / DonutChart** to see: + +- **Default** - Basic chart without legend +- **WithLegend** - Chart with legend items +- **WithComparison** - Shows positive delta +- **NegativeComparison** - Shows negative delta +- **CurrencyFormat** - Currency formatted values +- **Resizable** - Drag container edges to test auto-resize +- **SmallContainer** - 200px narrow container +- **LargeContainer** - 400px wide container with 5 segments +- **BookingsByStatus** - Real-world booking status example +- **NewVsReturning** - Customer segmentation example +- **InvalidData** - Shows fallback when data is invalid + +## Providing Colors + +Colors can be provided in two ways: + +### 1. Via `styles` prop (recommended) + +```tsx +const styles = [ + { color: '#3858E9' }, + { color: '#66BDFF' }, + { color: '#A77EFF' }, +]; + + +``` + +### 2. Inline in chartData + +```tsx +const chartData = [ + { label: 'Completed', value: 45, percentage: 56, color: '#3858E9' }, + { label: 'Pending', value: 25, percentage: 31, color: '#66BDFF' }, +]; +``` + +The `styles` prop takes priority when both are provided. + +## Integration with Theme + +For widgets using `GlobalChartsProvider`, obtain colors via `getElementStyles`: + +```tsx +const { getElementStyles } = useGlobalChartsContext(); + +const segmentStyles = chartData.map( ( segment, index ) => { + const { color } = getElementStyles( { data: segment, index } ); + return { color }; +} ); + + +``` + +## Comparison with SemiCircleChart + +| Feature | DonutChart | SemiCircleChart | +| --------------- | ------------------- | ---------------------- | +| Shape | Full circle | Half circle | +| Use case | Status distribution | Two-segment comparison | +| Default size | 164px | 220x100px | +| Metric position | Center | Bottom center | diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/donut-chart.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/donut-chart.module.scss new file mode 100644 index 000000000000..36d8d6262c04 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/donut-chart.module.scss @@ -0,0 +1,36 @@ +.reference { + width: 100%; + height: 100%; + position: relative; +} + +.wrapper { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; +} + +.chart { + position: relative; + display: flex; + justify-content: center; + align-items: center; + min-width: 96px; + max-width: 192px; + width: 100%; +} + +.metricContainer { + position: absolute; + pointer-events: none; +} + +.noChart { + height: 100%; +} + +.legendContainer { + width: 200px; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/donut-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/donut-chart.tsx new file mode 100644 index 000000000000..be0a260ef93c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/donut-chart.tsx @@ -0,0 +1,247 @@ +/** + * External dependencies + */ +import { PieChartUnresponsive as PieChart } from '@automattic/charts'; +import { useResizeObserver } from '@wordpress/compose'; +import { Icon, Stack } from '@wordpress/ui'; +import { useMemo, useState } from 'react'; +/** + * Internal dependencies + */ +import { + resolveSegmentStyles, + applyStylesToItems, + isEmptyPieChartData, + type SegmentStyle, +} from '../../helpers'; +import { ChartEmptyState } from '../chart-empty-state'; +import { PieChartTooltip } from '../chart-tooltip'; +import { Legend as LegendPure } from '../legend/legend'; +import { MetricWithComparison } from '../metric-with-comparison'; +import styles from './donut-chart.module.scss'; +import type { DataFormat } from '../../types'; +import type { LegendItem } from '../legend/legend'; +import type { ComponentProps } from 'react'; + +// Default chart configuration +const DEFAULT_THICKNESS = 0.3; +const DEFAULT_CORNER_SCALE = 0.03; +const DEFAULT_GAP_SCALE = 0.01; + +export type DonutChartData = ComponentProps< typeof PieChart >[ 'data' ]; + +const DEFAULT_SIZE = 164; + +export type DonutChartProps = { + /** + * Chart segment data (label, value, percentage). + * Colors can be provided here or via styles prop. + */ + chartData: DonutChartData; + + /** + * Explicit styles for each segment. When provided, these take priority + * over colors defined in chartData[].color. + * Array index corresponds to segment index. + */ + styles?: SegmentStyle[]; + + /** + * Primary metric value (total) + */ + value: number; + + /** + * Optional comparison value (previous period) + */ + comparisonValue?: number | null; + + /** + * Format for displaying values + */ + dataFormat?: DataFormat; + + /** + * Legend items. Colors will be applied from styles prop if provided. + */ + legendData?: LegendItem[]; + + /** + * Show legend below chart + */ + showLegend?: boolean; + + /** + * Thickness of the arc (0-1). + * @default 0.3 + */ + thickness?: number; + + /** + * Icon to display in the empty state + */ + emptyStateIcon?: React.ComponentProps< typeof Icon >[ 'icon' ]; + + /** + * Text to display in the empty state + */ + emptyStateText?: string; + + /** + * Enable tooltips on pie chart hover. + * @default false + */ + withTooltips?: boolean; + + /** + * Horizontal offset for tooltip positioning. + */ + tooltipOffsetX?: number; + + /** + * Vertical offset for tooltip positioning. + */ + tooltipOffsetY?: number; + + /** + * Format for tooltip segment values. Use when the segment values have a + * different format than the center metric's `dataFormat` (e.g. center shows + * percentage but segments are currency). Falls back to `dataFormat`. + */ + tooltipDataFormat?: DataFormat; +}; + +/** + * Pure DonutChart component. + * Does not depend on any context provider - all data flows through props. + * + * Colors can be provided via: + * 1. `styles` prop (takes priority) - array of { color } per segment + * 2. `chartData[].color` - inline color per segment + */ +export function DonutChart( { + chartData, + styles: stylesProp, + value, + comparisonValue, + dataFormat = { + type: 'number', + options: { useMultipliers: true, decimals: 0 }, + }, + legendData, + showLegend = true, + thickness = DEFAULT_THICKNESS, + emptyStateIcon, + emptyStateText, + withTooltips = false, + tooltipOffsetX, + tooltipOffsetY, + tooltipDataFormat, +}: DonutChartProps ) { + const hasComparison = comparisonValue !== null && comparisonValue !== undefined; + + const [ widgetHeight, setWidgetHeight ] = useState< number >( 0 ); + + /** + * Chart width will pick the width of the chart element + * via CSS, following the `chart` class name + */ + const [ chartWidth, setChartWidth ] = useState< number >( 0 ); + + const ref = useResizeObserver( entries => { + const entry = entries?.[ 0 ]; + if ( ! entry?.contentRect ) { + return; + } + + setWidgetHeight( entry.contentRect.height ); + + const chartElement = entry.target.children[ 0 ]; + if ( chartElement ) { + setChartWidth( chartElement.clientWidth ); + } + } ); + + /** + * Resolve styles: prop takes priority, fallback to chartData colors. + */ + const resolvedStyles = useMemo( + () => resolveSegmentStyles( stylesProp, chartData ), + [ stylesProp, chartData ] + ); + + /** + * Apply styles to chart data + */ + const styledChartData = useMemo( () => { + if ( ! stylesProp?.length ) { + return chartData; + } + return applyStylesToItems( chartData, resolvedStyles ); + }, [ stylesProp, chartData, resolvedStyles ] ); + + /** + * Apply styles to legend data + */ + const styledLegendData = useMemo( () => { + if ( ! legendData ) { + return undefined; + } + return applyStylesToItems( legendData, resolvedStyles ); + }, [ legendData, resolvedStyles ] ); + + const isEmptyData = isEmptyPieChartData( chartData ); + + // Render empty state when no data is available + if ( isEmptyData ) { + return ; + } + + return ( +
+
+ + ( + + ) } + showLabels={ false } + > + + + + { showLegend && styledLegendData && ( +
+ +
+ ) } +
+
+
+ ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/index.ts new file mode 100644 index 000000000000..dca9e0328af7 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/index.ts @@ -0,0 +1 @@ +export { DonutChart, type DonutChartProps, type DonutChartData } from './donut-chart'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-empty-state/chart-empty-state.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-empty-state/chart-empty-state.module.scss new file mode 100644 index 000000000000..60786aa66322 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-empty-state/chart-empty-state.module.scss @@ -0,0 +1,12 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + gap: var(--wpds-dimension-gap-lg); +} + +.icon { + color: var(--wpds-color-stroke-surface-neutral-weak, #e0e0e0); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-empty-state/chart-empty-state.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-empty-state/chart-empty-state.tsx new file mode 100644 index 000000000000..3abe51d04d43 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-empty-state/chart-empty-state.tsx @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { cautionFilled } from '@wordpress/icons'; +import { EmptyState, Icon } from '@wordpress/ui'; +/** + * Internal dependencies + */ +import styles from './chart-empty-state.module.scss'; + +export type ChartEmptyStateProps = { + /** + * Icon to display in the empty state. + * Should be a ReactNode (typically an SVG icon). + * Defaults to cautionFilled if not provided. + */ + icon?: React.ComponentProps< typeof Icon >[ 'icon' ]; + + /** + * Text to display in the empty state. + * @default "No data found for this date range." + */ + text?: string; +}; + +/** + * ChartEmptyState component. + * + * A reusable empty state component for charts that displays an icon and text + * when no data is available. Designed to be used by chart wrapper components. + * + * @example + * ```tsx + * import { customer } from '@jetpack-premium-analytics/icons'; + * + * // With custom icon + * + * + * // With custom text + * + * ``` + */ +export function ChartEmptyState( { + icon = cautionFilled, + text = __( 'No data found for this date range.', 'jetpack-premium-analytics' ), +}: ChartEmptyStateProps ) { + return ( + + { icon && } + { text } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-empty-state/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-empty-state/index.ts new file mode 100644 index 000000000000..9e6519d64689 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-empty-state/index.ts @@ -0,0 +1 @@ +export { ChartEmptyState, type ChartEmptyStateProps } from './chart-empty-state'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/README.md new file mode 100644 index 000000000000..0ca44ba61216 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/README.md @@ -0,0 +1,308 @@ +# LeaderboardChart + +A responsive leaderboard (horizontal bar) chart component for displaying ranking and "top X by Y" data visualizations. + +## Features + +- **Context-aware styling**: Integrates with GlobalChartsProvider for consistent theming +- **Comparison mode**: Shows current vs. previous period data with delta indicators +- **Flexible formatting**: Supports currency, number, percentage, and custom formats +- **Empty state handling**: Built-in empty state with customizable content +- **Legend support**: Optional legend with customizable labels +- **Overlay labels**: Alternative styling with labels on top of bars +- **Loading states**: Skeleton loaders during data fetch +- **Long label handling**: Automatic truncation and tooltips for long labels + +## Requirements + +**Important**: This component must be rendered within a `GlobalChartsProvider` context to access chart styling (colors, themes, element styles). + +```tsx +import { GlobalChartsProvider } from '@automattic/charts'; + + + +; +``` + +## Usage + +```tsx +import { LeaderboardChart } from '@jetpack-premium-analytics/widgets-toolkit'; + +const data = [ + { + id: '1', + label: 'Direct traffic', + currentValue: 125000, + previousValue: 98000, + currentShare: 42, + previousShare: 35, + delta: 27.55, + }, + { + id: '2', + label: 'Google Ads', + currentValue: 87500, + previousValue: 92000, + currentShare: 29, + previousShare: 33, + delta: -4.89, + }, + { + id: '3', + label: 'Email campaign', + currentValue: 53000, + previousValue: 61000, + currentShare: 18, + previousShare: 22, + delta: -13.11, + }, +]; + +; +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------ | ---------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `data` | `LeaderboardChartData` | required | Array of leaderboard items with label, values, shares, and deltas | +| `className` | `string` | - | Additional CSS classes for container | +| `loading` | `boolean` | `false` | Shows loading skeleton when true | +| `withComparison` | `boolean` | `false` | Enables comparison mode with previous period data | +| `withOverlayLabel` | `boolean` | `false` | Places labels on top of bars instead of beside them | +| `legendLabels` | `LegendLabels` | `{ primary: 'Current period', comparison: 'Previous period' }` | Custom legend labels | +| `showLegend` | `boolean` | `true` | Whether to show the legend | +| `dataFormat` | `DataFormat` | `{ type: 'currency', options: { useMultipliers: true, decimals: 2 } }` | Value formatting configuration | +| `emptyState` | `ReactNode` | - | Custom empty state content (overrides default) | +| `emptyStateIcon` | `ReactNode` | - | Icon to display in default empty state | +| `emptyStateText` | `string` | `'No data available'` | Text for default empty state | + +### LeaderboardChartData Type + +```tsx +type LeaderboardChartData = Array< { + id: string; + label: string; + currentValue: number; + previousValue: number; + currentShare: number; // Percentage (0-100) + previousShare: number; // Percentage (0-100) + delta: number; // Percentage change +} >; +``` + +### DataFormat Type + +```tsx +type DataFormat = { + type: 'currency' | 'number' | 'percentage' | 'average'; + options?: { + useMultipliers?: boolean; // Show 1K, 1M, etc. + decimals?: number; // Number of decimal places + signDisplay?: 'auto' | 'never' | 'always' | 'exceptZero'; // Sign display for numbers + // ... other format-specific options + }; +}; +``` + +## Common Use Cases + +### Basic Leaderboard (No Comparison) + +```tsx + +``` + +### With Comparison Period + +```tsx + +``` + +### Number Format (Not Currency) + +```tsx + +``` + +### Percentage Values + +```tsx + +``` + +### With Overlay Labels + +```tsx + +``` + +### Custom Empty State + +```tsx + } + emptyStateText="No results found for this period" +/> +``` + +Or with fully custom empty state: + +```tsx + + +

No Data Yet

+

Start tracking your metrics to see insights here

+ + } +/> +``` + +## Integration with GlobalChartsProvider + +The component automatically retrieves colors from the GlobalChartsProvider context: + +```tsx +import { GlobalChartsProvider } from '@automattic/charts'; +import { LeaderboardChart } from '@jetpack-premium-analytics/widgets-toolkit'; + +function MyWidget() { + return ( + + + + ); +} +``` + +The component uses `getElementStyles()` from the context to: + +- Retrieve primary and secondary colors for bars +- Apply consistent theming across all charts +- Support both current period (index 0) and comparison period (index 1) colors + +## Empty State Behavior + +The component handles empty data gracefully: + +1. **No data + custom `emptyState` prop**: Renders your custom empty state component +2. **No data + `emptyStateIcon` and/or `emptyStateText`**: Renders default empty state with your customizations +3. **No data + no customization**: Renders default empty state with "No data available" message + +## Loading State + +When `loading={true}`, the component displays skeleton loaders that match the structure of the actual chart, providing visual feedback during data fetch operations. + +## Responsive Behavior + +The LeaderboardChart automatically adapts to its container width. For optimal display: + +- **Minimum width**: 280px recommended +- **Ideal width**: 400px+ for comfortable reading +- **Label truncation**: Long labels automatically truncate with ellipsis +- **Bar scaling**: Bars scale proportionally to container width + +## Storybook + +Run `pnpm storybook` and navigate to **Widgets Toolkit / Components / LeaderboardChart** to see: + +- **Default** - Basic leaderboard without comparison +- **WithComparison** - Current vs. previous period +- **Loading** - Loading skeleton state +- **EmptyState** - No data handling +- **WithOverlayLabel** - Labels on top of bars +- **WithoutLegend** - Chart without legend +- **LongLabels** - Label truncation handling +- **NumberFormat** - Number formatting (not currency) +- **PercentageFormat** - Percentage values +- **Container size variants** - Small (280px), Medium (400px), Large (600px) + +## Comparison with Other Chart Components + +| Feature | LeaderboardChart | DonutChart | SemiCircleChart | +| ------------------ | -------------------------- | ------------------ | ---------------------- | +| Shape | Horizontal bars | Full circle | Half circle | +| Use case | Rankings, top N | Distribution | Two-segment comparison | +| Context dependency | Yes (GlobalChartsProvider) | No (pure) | No (pure) | +| Comparison mode | Yes | Yes | Yes | +| Data items | Unlimited | Unlimited segments | 2-5 segments typical | + +## Common Patterns + +### Sales by Traffic Source + +```tsx + +``` + +### Top Products by Revenue + +```tsx + +``` + +### Conversion Rates by Campaign + +```tsx + +``` + +### Sales by Device Type + +```tsx + +``` diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/index.ts new file mode 100644 index 000000000000..443db065d04b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/index.ts @@ -0,0 +1,9 @@ +export { LeaderboardChart } from './leaderboard-chart'; +export type { + LeaderboardChartProps, + LeaderboardChartData, + LegendLabels, +} from './leaderboard-chart'; + +export { LeaderboardLabel } from './leaderboard-label'; +export type { LeaderboardLabelProps } from './leaderboard-label'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.module.scss new file mode 100644 index 000000000000..31919b013c4b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.module.scss @@ -0,0 +1,42 @@ +.container { + height: 100%; +} + +.chart { + height: 100%; + justify-content: space-between; + + .legend { + flex-wrap: nowrap; + } + + .legendItem { + min-width: 0; + } + + .legendLabel { + min-width: 0; + flex: 1 1 0 !important; // Override the 0 0 auto default value + + span { + display: block; + } + } +} + +.emptyState { + padding: 48px 24px; + min-height: 200px; +} + +.emptyStateIcon { + color: var(--wpds-color-fg-content-neutral-weak); + opacity: 0.5; +} + +.emptyStateText { + margin: 0; + color: var(--wpds-color-fg-content-neutral); + font-size: 14px; + text-align: center; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.tsx new file mode 100644 index 000000000000..099a44db2d0d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-chart.tsx @@ -0,0 +1,211 @@ +/** + * External dependencies + */ +import { + LeaderboardChartUnresponsive as BaseLeaderboardChart, + useGlobalChartsContext, + Legend, + hexToRgba, +} from '@automattic/charts'; +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { Icon, Stack } from '@wordpress/ui'; +import clsx from 'clsx'; +import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import { ChartEmptyState } from '../chart-empty-state'; +import styles from './leaderboard-chart.module.scss'; +import type { WooChartTheme } from '../../hooks/use-chart-theme'; +import type { DataFormat } from '../../types'; +import type { ComponentProps, ReactNode } from 'react'; + +type LeaderboardChartData = ComponentProps< typeof BaseLeaderboardChart >[ 'data' ]; + +export type { LeaderboardChartData }; + +export type LegendLabels = { + primary: string; + comparison: string; +}; + +export type LeaderboardChartProps = { + /** + * Card container styles + */ + className?: string; + + /** + * Leaderboard data (label, currentValue, previousValue, currentShare, previousShare, delta) + */ + data: LeaderboardChartData; + + /** + * Whether the widget is in a loading state + */ + loading?: boolean; + + /** + * Whether to show comparison data + */ + withComparison?: boolean; + + /** + * Whether to show overlay label on bars + */ + withOverlayLabel?: boolean; + + /** + * Custom legend labels + */ + legendLabels?: LegendLabels; + + /** + * Format for displaying values + */ + dataFormat?: DataFormat; + + /** + * Whether to show the legend + */ + showLegend?: boolean; + + /** + * Custom empty state content to display when no data is available + */ + emptyState?: ReactNode; + + /** + * Icon to display in the empty state + */ + emptyStateIcon?: React.ComponentProps< typeof Icon >[ 'icon' ]; + + /** + * Text to display in the empty state + */ + emptyStateText?: string; + + /** + * Custom styling for the chart container + */ + style?: React.CSSProperties & { + '--a8c--charts--leaderboard--bar--border-radius'?: string; + }; +}; + +/** + * Generic LeaderboardChart component for displaying ranking/leaderboard data. + * Used for "top X by Y" type visualizations (e.g., sales by source, by channel, by campaign). + * + * This component wraps @automattic/charts LeaderboardChartUnresponsive with standardized formatting and styling. + * + * **Requirements:** + * - Must be rendered within a GlobalChartsProvider context to access chart styling (colors, themes, element styles) + * + * Features: + * - Automatic empty state handling + * - Configurable value formatting (currency, number, percentage, etc.) + * - Comparison mode support + * - Customizable legend labels + * - Overlay label support for alternative styling + */ +export function LeaderboardChart( { + className, + data, + loading = false, + withComparison = false, + withOverlayLabel = false, + showLegend = true, + legendLabels, + dataFormat = { + type: 'currency', + options: { useMultipliers: true, decimals: 2 }, + }, + emptyStateIcon, + emptyStateText, + style, +}: LeaderboardChartProps ) { + const { getElementStyles, theme } = useGlobalChartsContext(); + + /** + * Create value formatter from dataFormat configuration + */ + const valueFormatter = useMemo( + () => ( value: number ) => formatMetricValue( value, dataFormat.type, dataFormat.options ), + [ dataFormat ] + ); + + /** + * Get chart colors for legend + */ + const chartColors = useMemo( () => { + const { color: primaryColor } = getElementStyles( { index: 0 } ); + if ( ! withComparison ) { + return { primaryColor }; + } + const { color: secondaryColor } = getElementStyles( { index: 1 } ); + return { primaryColor, secondaryColor }; + }, [ withComparison, getElementStyles ] ); + + /** + * Merge theme bar border radius with style prop. + * Style prop takes precedence for per-widget overrides. + */ + const chartStyle = useMemo( () => { + const wooTheme = theme as WooChartTheme | undefined; + const barBorderRadius = wooTheme?.leaderboardChart?.barBorderRadius; + if ( ! barBorderRadius && ! style ) { + return undefined; + } + return { + '--a8c--charts--leaderboard--bar--border-radius': barBorderRadius, + ...style, + } as React.CSSProperties; + }, [ theme, style ] ); + + // Check if we have valid data + const isEmptyData = ! data || data.length === 0; + + if ( isEmptyData ) { + return ; + } + + return ( + + + { showLegend && ( + + ) } + + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-label.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-label.module.scss new file mode 100644 index 000000000000..b13be4a837cf --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-label.module.scss @@ -0,0 +1,15 @@ +.container { + padding: var(--wpds-dimension-padding-sm); +} + +.label { + font-size: var(--wpds-typography-font-size-sm); +} + +.labelImage { + width: 28px; + height: 28px; + vertical-align: middle; + border-radius: var(--wpds-border-radius-md); + object-fit: cover; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-label.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-label.tsx new file mode 100644 index 000000000000..e8bdad3d41cb --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/leaderboard-label.tsx @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import { Stack } from '@wordpress/ui'; +import clsx from 'clsx'; +/** + * Internal dependencies + */ +import styles from './leaderboard-label.module.scss'; + +export type LeaderboardLabelProps = { + /** + * Label text + */ + label: string; + /** + * Image URL + */ + imageUrl?: string; + /** + * Alt text for the image + */ + imageAlt?: string; + /** + * Class name for the image + */ + imageClassName?: string; +}; + +// Simple default image for when the image is not available. +const DEFAULT_IMAGE_URL = + 'data:image/svg+xml;utf8,'; + +/** + * Leaderboard Label Component + * + * Renders a label with an optional image thumbnail for use in leaderboard charts. + * Displays image (if available) alongside the label. + * + * Features: + * - Image thumbnail with fallback + * - Error handling for failed image loads + * - Responsive layout with consistent spacing + * + * @param props - Component props + * @param props.label - Label text + * @param props.imageUrl - Optional image URL + * @param props.imageAlt - Alt text for the image + * @param props.imageClassName - Class name for the image + */ +export function LeaderboardLabel( { + label, + imageUrl, + imageAlt, + imageClassName, +}: LeaderboardLabelProps ) { + // Use default if undefined OR empty string to prevent broken image flash + const finalImageUrl = imageUrl || DEFAULT_IMAGE_URL; + + return ( + + ) => { + e.currentTarget.src = DEFAULT_IMAGE_URL; + } } + alt={ imageAlt || label } + className={ clsx( styles.labelImage, imageClassName ) } + /> + { label } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/README.md new file mode 100644 index 000000000000..b044f4399632 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/README.md @@ -0,0 +1,123 @@ +# SemiCircleChart + +A responsive semi-circle (half-donut) chart component that fills its parent container. + +## Features + +- **Responsive**: Uses `@automattic/charts` responsive `PieSemiCircleChart` to fill the parent container +- **Pure component**: No context dependencies - all data flows through props +- **Theme support**: Colors can be provided via `styles` prop or inline in `chartData` +- **Comparison mode**: Shows delta percentage when `comparisonValue` is provided +- **Legend integration**: Optional legend with comparison deltas +- **Tooltips**: Optional hover tooltips with configurable formatting + +## Usage + +```tsx +import { SemiCircleChart } from '@jetpack-premium-analytics/widgets-toolkit'; + +const chartData = [ + { label: 'Mobile', value: 4500 }, + { label: 'Desktop', value: 2500 }, + { label: 'Tablet', value: 1000 }, +]; + +const styles = [ { color: '#3858E9' }, { color: '#66BDFF' }, { color: '#A77EFF' } ]; + +; +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------- | --------------------- | -------------------- | -------------------------------------------------------------------------- | +| `chartData` | `SemiCircleChartData` | required | Array of segments with `label` and `value` (percentage is auto-calculated) | +| `styles` | `SegmentStyle[]` | - | Explicit colors per segment (takes priority over chartData colors) | +| `value` | `number` | required | Primary metric value displayed in center | +| `comparisonValue` | `number \| null` | - | Previous period value for delta calculation | +| `dataFormat` | `DataFormat` | `{ type: 'number' }` | Format configuration for the metric display | +| `legendData` | `LegendItem[]` | - | Legend items with labels and comparison values | +| `showLegend` | `boolean` | `true` | Whether to show the legend below the chart | +| `thickness` | `number` | `0.3` | Arc thickness as ratio (0-1) | +| `maxWidth` | `number` | `Infinity` | Maximum width constraint for the chart | +| `withTooltips` | `boolean` | `false` | Enable tooltips on hover | +| `tooltipOffsetX` | `number` | - | Horizontal offset for tooltip positioning | +| `tooltipOffsetY` | `number` | - | Vertical offset for tooltip positioning | +| `tooltipDataFormat` | `DataFormat` | - | Format for tooltip values (falls back to `dataFormat`) | +| `emptyStateIcon` | `IconProps['icon']` | - | Icon for empty state | +| `emptyStateText` | `string` | - | Text for empty state | + +## Responsive Layout + +The chart fills its parent container automatically using the responsive `PieSemiCircleChart` from `@automattic/charts`. Use `maxWidth` to constrain the size when needed: + +```tsx +// Fills parent container + + +// Constrained to 220px max + +``` + +## Storybook + +Run `pnpm storybook` and navigate to **Widgets Toolkit / Components / SemiCircleChart** to see: + +- **Default** - Basic chart without legend +- **WithLegend** - Chart with legend items +- **WithComparison** - Shows positive delta +- **NegativeComparison** - Shows negative delta +- **Resizable** - Drag container edges to test auto-resize +- **SmallContainer** - 200px narrow container +- **LargeContainer** - 400px wide container with 5 segments + +## Providing Colors + +Colors can be provided in two ways: + +### 1. Via `styles` prop (recommended) + +```tsx +const styles = [ + { color: '#3858E9' }, + { color: '#66BDFF' }, + { color: '#A77EFF' }, +]; + + +``` + +### 2. Inline in chartData + +```tsx +const chartData = [ + { label: 'Mobile', value: 4500, color: '#3858E9' }, + { label: 'Desktop', value: 2500, color: '#66BDFF' }, +]; +``` + +The `styles` prop takes priority when both are provided. + +## Integration with Theme + +For widgets using `GlobalChartsProvider`, obtain colors via `getElementStyles`: + +```tsx +const { getElementStyles } = useGlobalChartsContext(); + +const segmentStyles = chartData.map( ( segment, index ) => { + const { color } = getElementStyles( { data: segment, index } ); + return { color }; +} ); + + +``` diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/index.ts new file mode 100644 index 000000000000..ed9c059697de --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/index.ts @@ -0,0 +1,5 @@ +export { + SemiCircleChart, + type SemiCircleChartProps, + type SemiCircleChartData, +} from './semi-circle-chart'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/semi-circle-chart.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/semi-circle-chart.module.scss new file mode 100644 index 000000000000..767f0251d757 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/semi-circle-chart.module.scss @@ -0,0 +1,16 @@ +.container, +.wrapper { + width: 100%; +} + +.chart { + position: relative; +} + +.metricContainer { + position: absolute; + left: 0; + bottom: 0; + right: 0; + pointer-events: none; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/semi-circle-chart.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/semi-circle-chart.tsx new file mode 100644 index 000000000000..c27a2fd23f4d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/semi-circle-chart.tsx @@ -0,0 +1,225 @@ +/** + * External dependencies + */ +import { PieSemiCircleChart } from '@automattic/charts'; +import { Icon, Stack } from '@wordpress/ui'; +import { useMemo } from 'react'; +import { RESIZE_DEBOUNCE_MS } from '../../constants'; +import { + resolveSegmentStyles, + applyStylesToItems, + isEmptyPieChartData, + type SegmentStyle, +} from '../../helpers'; +import { ChartEmptyState } from '../chart-empty-state'; +import { PieChartTooltip } from '../chart-tooltip'; +/** + * Internal dependencies + */ +import { Legend as LegendPure } from '../legend/legend'; +import { MetricWithComparison } from '../metric-with-comparison'; +import styles from './semi-circle-chart.module.scss'; +import type { DataFormat } from '../../types'; +import type { LegendItem } from '../legend/legend'; +import type { ComponentProps } from 'react'; + +// Default chart configuration +const DEFAULT_THICKNESS = 0.3; + +export type SemiCircleChartData = ComponentProps< typeof PieSemiCircleChart >[ 'data' ]; + +export type SemiCircleChartProps = { + /** + * Chart segment data (label, value). + * Colors can be provided here or via styles prop. + */ + chartData: SemiCircleChartData; + + /** + * Explicit styles for each segment. When provided, these take priority + * over colors defined in chartData[].color. + * Array index corresponds to segment index. + */ + styles?: SegmentStyle[]; + + /** + * Primary metric value (total) + */ + value: number; + + /** + * Optional comparison value (previous period) + */ + comparisonValue?: number | null; + + /** + * Format for displaying values + */ + dataFormat?: DataFormat; + + /** + * Legend items. Colors will be applied from styles prop if provided. + */ + legendData?: LegendItem[]; + + /** + * Show legend below chart + */ + showLegend?: boolean; + + /** + * Thickness of the arc (0-1). + * @default 0.3 + */ + thickness?: number; + + /** + * Width of the chart. + * @default Infinity + */ + maxWidth?: number; + + /** + * Icon to display in the empty state + */ + emptyStateIcon?: React.ComponentProps< typeof Icon >[ 'icon' ]; + + /** + * Text to display in the empty state + */ + emptyStateText?: string; + + /** + * Enable tooltips on pie chart hover. + * @default false + */ + withTooltips?: boolean; + + /** + * Horizontal offset for tooltip positioning. + */ + tooltipOffsetX?: number; + + /** + * Vertical offset for tooltip positioning. + */ + tooltipOffsetY?: number; + + /** + * Format for tooltip segment values. Use when the segment values have a + * different format than the center metric's `dataFormat` (e.g. center shows + * percentage but segments are currency). Falls back to `dataFormat`. + */ + tooltipDataFormat?: DataFormat; +}; + +/** + * Pure SemiCircleChart component. + * Does not depend on any context provider - all data flows through props. + * + * Colors can be provided via: + * 1. `styles` prop (takes priority) - array of { color } per segment + * 2. `chartData[].color` - inline color per segment + */ +export function SemiCircleChart( { + chartData, + styles: stylesProp, + value, + comparisonValue, + dataFormat = { + type: 'number', + options: { useMultipliers: true, decimals: 0 }, + }, + legendData, + showLegend = true, + thickness = DEFAULT_THICKNESS, + maxWidth = Infinity, + emptyStateIcon, + emptyStateText, + withTooltips = false, + tooltipOffsetX, + tooltipOffsetY, + tooltipDataFormat, +}: SemiCircleChartProps ) { + const hasComparison = comparisonValue !== null && comparisonValue !== undefined; + + /** + * Resolve styles: prop takes priority, fallback to chartData colors. + */ + const resolvedStyles = useMemo( + () => resolveSegmentStyles( stylesProp, chartData ), + [ stylesProp, chartData ] + ); + + /** + * Apply styles to chart data + */ + const styledChartData = useMemo( () => { + if ( ! stylesProp?.length ) { + return chartData; + } + return applyStylesToItems( chartData, resolvedStyles ); + }, [ stylesProp, chartData, resolvedStyles ] ); + + /** + * Apply styles to legend data + */ + const styledLegendData = useMemo( () => { + if ( ! legendData ) { + return undefined; + } + return applyStylesToItems( legendData, resolvedStyles ); + }, [ legendData, resolvedStyles ] ); + + const isEmptyData = isEmptyPieChartData( chartData ); + + // Render empty state when no data is available + if ( isEmptyData ) { + return ; + } + + return ( + + + ( + + ) } + resizeDebounceTime={ RESIZE_DEBOUNCE_MS } + > + + + + { showLegend && styledLegendData && ( + + ) } + + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/README.md new file mode 100644 index 000000000000..9f3bd613f552 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/README.md @@ -0,0 +1,203 @@ +# ChartTooltip + +A **shared** tooltip component for chart visualizations. Supports both line charts and bar charts with configurable indicator types and value formatting. + +## Features + +- **Dual indicator types**: `line` for line charts, `rect` for bar charts +- **Configurable extractors**: Custom `getLabel` and `getValue` functions +- **Sensible defaults**: Works with `datum.label` and `datum.value` out of the box +- **WPDS styling**: Uses design tokens for consistent appearance +- **MetricValue integration**: Formatted values with currency, number, or percentage + +## Basic Usage + +### With Line Charts + +```tsx +import { ChartTooltip } from '../chart-tooltip'; + +const renderTooltip = params => ( + formatDate( datum.date ) } + /> +); +``` + +### With Bar Charts + +```tsx +import { ChartTooltip } from '../chart-tooltip'; + +const renderTooltip = params => ( + +); +``` + +## Props + +| Prop | Type | Required | Description | +| --------------- | ------------------------------------------ | -------- | ------------------------------------------------------------------------------ | +| `tooltipData` | `{ datumByKey?: Record }` | No | Tooltip data from visx chart | +| `dataFormat` | `DataFormat` | Yes | Format for values: currency, number, percentage | +| `seriesStyles` | `TooltipStyle[]` | Yes | Styles for each series (color, stroke properties) | +| `indicatorType` | `'line' \| 'rect'` | Yes | Shape indicator: line for line charts, rect for bars | +| `getLabel` | `(datum, index, key) => string` | No | Custom label extractor. `key` is the series key/label (default: `datum.label`) | +| `getValue` | `(datum) => number` | No | Custom value extractor (default: `datum.value`) | + +## TooltipStyle Type + +```typescript +type TooltipStyle = { + /** Color for the indicator */ + stroke: string; + /** Stroke width (for line indicator) */ + strokeWidth?: string | number; + /** Stroke dash array (for line indicator) */ + strokeDasharray?: string | number; +}; +``` + +## Default Extractors + +The component provides sensible defaults that work with common chart data patterns: + +```typescript +// Default label extractor - uses datum.label +// The key parameter contains the series key (e.g., date range for bar charts) +function defaultGetLabel( datum: unknown, _index: number, _key: string ): string { + return ( datum as { label: string } ).label ?? ''; +} + +// Default value extractor - uses datum.value +function defaultGetValue( datum: unknown ): number { + return ( datum as { value: number } ).value; +} +``` + +### When to Use Custom Extractors + +**Line charts with dates**: Pass a custom `getLabel` to format dates: + +```tsx +const getLabel = ( datum, index, _key ) => { + const isComparison = index > 0; + const displayDate = isComparison ? datum.realDate ?? datum.date : datum.date; + return formatDate( displayDate ); +}; +``` + +**Bar charts with label-value data**: Use defaults (no custom extractors needed): + +```tsx +// Data format: { label: 'Category A', value: 1000 } +// Default extractors work automatically +``` + +## Indicator Types + +### Line Indicator (`indicatorType="line"`) + +Uses `LineShape` from the chart library. Supports: + +- `stroke` - Line color +- `strokeWidth` - Line thickness +- `strokeDasharray` - Dashed line pattern (e.g., `'4 4'`) + +### Rectangle Indicator (`indicatorType="rect"`) + +Uses `RectShape` from the chart library. Supports: + +- `stroke` - Fill color (8x8 pixel rectangle) + +## Styling + +The tooltip uses WPDS design tokens: + +- `--wpds-color-fg-content-neutral` - Text color +- `--wpds-elevation-sm` - Box shadow +- `--wpds-dimension-padding-sm` - Padding + +Global visx-tooltip overrides are applied to ensure consistent layout. + +## Used By + +- `ComparativeLineChart` - With `indicatorType="line"` and custom date label +- `BarChart` - With `indicatorType="rect"` and default label/value extractors + +--- + +# PieChartTooltip + +A tooltip component for **pie** and **semi-circle** charts. Renders a single row with a color indicator, label, and formatted value. + +Reuses the same SCSS module as `ChartTooltip` so styling (box-shadow, padding, visx-tooltip override) is shared. + +## Basic Usage + +```tsx +import { PieChartTooltip } from '../chart-tooltip'; + +const renderTooltip = ( { tooltipData } ) => ( + +); +``` + +## Props + +| Prop | Type | Required | Description | +| ------------- | --------------------- | -------- | ------------------------------------------------------- | +| `tooltipData` | `DataPointPercentage` | Yes | Tooltip data from pie chart hover (label, value, color) | +| `dataFormat` | `DataFormat` | Yes | Format for values: currency, number, percentage | + +## Used By + +- `DonutChart` - Pie chart tooltip with color indicators +- `SemiCircleChart` - Half-pie chart tooltip with color indicators + +--- + +# TooltipRow + +A shared building-block component that renders a single tooltip row: **indicator + label + formatted value**. Used internally by both `ChartTooltip` and `PieChartTooltip`. + +## Basic Usage + +```tsx +import { TooltipRow } from '../chart-tooltip'; +import { RectShape } from '@automattic/charts/visx/legend'; + + } + label="Revenue" + value={ 1234.56 } + dataFormat={ { type: 'currency' } } +/>; +``` + +## Props + +| Prop | Type | Required | Description | +| ------------ | ----------------- | -------- | ----------------------------------------------------------- | +| `indicator` | `React.ReactNode` | Yes | Pre-rendered indicator element (LineShape, RectShape, etc.) | +| `label` | `string` | Yes | Row label text | +| `value` | `number` | Yes | Numeric value to format | +| `dataFormat` | `DataFormat` | Yes | Format configuration (currency, number, percentage) | + +## Used By + +- `ChartTooltip` - For line and bar chart tooltip rows +- `PieChartTooltip` - For pie and semi-circle chart tooltip rows diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/chart-tooltip.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/chart-tooltip.module.scss new file mode 100644 index 000000000000..10fd837b1b6a --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/chart-tooltip.module.scss @@ -0,0 +1,27 @@ +.tooltip { + color: var(--wpds-color-fg-content-neutral); + padding: var(--wpds-dimension-padding-sm); + margin: 0; + box-shadow: var(--wpds-elevation-sm); + min-width: 200px; +} + +.item { + font-weight: 400; + line-height: var(--wpds-typography-line-height-xs); +} + +.label { + flex: 1; +} + +// Override visx-tooltip ONLY when our custom tooltip components are used. +// This applies to ChartTooltip (line/bar) and PieChartTooltip +// (pie/semi-circle). +/* stylelint-disable-next-line selector-pseudo-class-no-unknown */ +:global(.visx-tooltip):has(.tooltip) { + max-width: none !important; + box-shadow: none !important; + margin: 0 !important; + padding: 0 !important; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/chart-tooltip.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/chart-tooltip.tsx new file mode 100644 index 000000000000..27b973b85191 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/chart-tooltip.tsx @@ -0,0 +1,156 @@ +/** + * External dependencies + */ +import { LineShape, RectShape } from '@automattic/charts/visx/legend'; +import { Stack } from '@wordpress/ui'; +/** + * Internal dependencies + */ +import styles from './chart-tooltip.module.scss'; +import { TooltipRow } from './tooltip-row'; +import { isChartDatumEntry } from './utils'; +import type { DataFormat } from '../../types'; + +/** + * Style configuration for tooltip indicators. + * Matches SeriesStyle pattern from chart components. + */ +export type TooltipStyle = { + /** Color for the indicator */ + stroke: string; + + /** Stroke width (for line indicator) */ + strokeWidth?: string | number; + + /** Stroke dash array (for line indicator) */ + strokeDasharray?: string | number; + + /** Stroke dash offset (for line indicator) */ + strokeDashoffset?: string | number; +}; + +/** + * Common datum shape with label and value properties. + * Used by default extractors. + */ +type DatumWithLabel = { label: string }; +type DatumWithValue = { value: number }; + +/** + * Default label extractor - assumes datum has a 'label' property. + * Override for custom label formatting (e.g., date formatting for line charts). + * + * @param datum - The data point + */ +function defaultGetLabel( datum: unknown ): string { + return ( datum as DatumWithLabel ).label ?? ''; +} + +/** + * Default value extractor - assumes datum has a 'value' property. + */ +function defaultGetValue( datum: unknown ): number { + return ( datum as DatumWithValue ).value; +} + +export type ChartTooltipProps< TDatum = unknown > = { + /** + * Tooltip data from visx chart + */ + tooltipData?: { + datumByKey?: Record< string, unknown >; + }; + + /** + * Format configuration for chart values + */ + dataFormat: DataFormat; + + /** + * Array of styles for each series (required). + * Index corresponds to series index. + */ + seriesStyles: TooltipStyle[]; + + /** + * Indicator type: 'line' for line charts, 'rect' for bar charts + * Uses chart library's LineShape and RectShape components. + */ + indicatorType: 'line' | 'rect'; + + /** + * Function to extract label from datum. + * Defaults to extracting 'label' property. + */ + getLabel?: ( datum: TDatum, index: number, key: string ) => string; + + /** + * Function to extract value from datum. + * Defaults to extracting 'value' property. + */ + getValue?: ( datum: TDatum ) => number; +}; + +/** + * Self-contained tooltip component for charts. + * Handles rendering of tooltip rows with configurable indicators. + * + * Uses chart library's shape components (LineShape, RectShape) for visual consistency. + * + * Provides sensible defaults for common chart data patterns: + * - getLabel: Extracts 'label' property from datum + * - getValue: Extracts 'value' property from datum + */ +export function ChartTooltip< TDatum >( { + tooltipData, + dataFormat, + seriesStyles, + indicatorType, + getLabel = defaultGetLabel, + getValue = defaultGetValue, +}: ChartTooltipProps< TDatum > ) { + if ( ! tooltipData?.datumByKey ) { + return null; + } + + const datumEntries = Object.values( tooltipData.datumByKey ); + + if ( datumEntries.length === 0 ) { + return null; + } + + return ( + + { datumEntries.map( ( entry, index ) => { + if ( ! isChartDatumEntry< TDatum >( entry ) ) { + return null; + } + + const { stroke, ...lineShapeStyle } = seriesStyles[ index ] || seriesStyles[ 0 ]; + const label = getLabel( entry.datum, index, entry.key ); + const value = getValue( entry.datum ); + + return ( + + ) : ( + + ) + } + label={ label } + value={ value } + dataFormat={ dataFormat } + /> + ); + } ) } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/index.ts new file mode 100644 index 000000000000..9f4d39696756 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/index.ts @@ -0,0 +1,4 @@ +export { ChartTooltip, type ChartTooltipProps, type TooltipStyle } from './chart-tooltip'; +export { PieChartTooltip, type PieChartTooltipProps } from './pie-chart-tooltip'; +export { TooltipRow, type TooltipRowProps } from './tooltip-row'; +export { isChartDatumEntry, type ChartDatumEntry } from './utils'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/pie-chart-tooltip.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/pie-chart-tooltip.tsx new file mode 100644 index 000000000000..c8a57451139c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/pie-chart-tooltip.tsx @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import { RectShape } from '@automattic/charts/visx/legend'; +import { Stack } from '@wordpress/ui'; +import styles from './chart-tooltip.module.scss'; +import { TooltipRow } from './tooltip-row'; +import type { DataFormat } from '../../types'; +import type { DataPointPercentage } from '@automattic/charts'; + +/** + * Internal dependencies + */ + +export type PieChartTooltipProps = { + /** + * Tooltip data from pie chart hover — a single DataPointPercentage. + */ + tooltipData: DataPointPercentage; + + /** + * Format configuration for the value display. + */ + dataFormat: DataFormat; +}; + +/** + * Tooltip component for pie and semi-circle charts. + * Renders a single row with a color indicator, label, and formatted value. + * + * Reuses the same SCSS module as ChartTooltip so styling (box-shadow, padding, + * the `:global(.visx-tooltip):has(.tooltip)` override) is shared. + */ +export function PieChartTooltip( { tooltipData, dataFormat }: PieChartTooltipProps ) { + return ( + + + } + label={ tooltipData.label } + value={ tooltipData.value } + dataFormat={ dataFormat } + /> + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/tooltip-row.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/tooltip-row.tsx new file mode 100644 index 000000000000..624387ee46c3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/tooltip-row.tsx @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { Stack } from '@wordpress/ui'; +/** + * Internal dependencies + */ +import { MetricValue } from '../metric-value'; +import styles from './chart-tooltip.module.scss'; +import type { DataFormat } from '../../types'; + +export type TooltipRowProps = { + /** Pre-rendered indicator element (LineShape, RectShape, etc.) */ + indicator: React.ReactNode; + /** Row label text */ + label: string; + /** Numeric value to format */ + value: number; + /** Format configuration */ + dataFormat: DataFormat; +}; + +export function TooltipRow( { indicator, label, value, dataFormat }: TooltipRowProps ) { + return ( + + { indicator } + +
{ label }
+ + +
+ ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/utils.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/utils.ts new file mode 100644 index 000000000000..6f55b73c2f39 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/utils.ts @@ -0,0 +1,25 @@ +/** + * Generic chart datum entry type from visx tooltip data. + * Both line and bar charts use this structure. + */ +export type ChartDatumEntry< T = unknown > = { + datum: T; + index: number; + key: string; +}; + +/** + * Type guard to check if an entry is a valid chart datum entry. + * + * @param entry - The entry to check. + * @return True if the entry has the expected structure. + */ +export const isChartDatumEntry = < T >( entry: unknown ): entry is ChartDatumEntry< T > => { + return ( + typeof entry === 'object' && + entry !== null && + 'datum' in entry && + 'index' in entry && + 'key' in entry + ); +}; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts new file mode 100644 index 000000000000..40aa6c4178e4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts @@ -0,0 +1,21 @@ +export { MetricDelta } from './metric-delta'; +export { MetricValue } from './metric-value'; +export { MetricWithComparison } from './metric-with-comparison'; +export { ComparativeLineChart, type SeriesStyle } from './chart-comparative-line'; +export { Legend, type LegendItem } from './legend'; +export { WidgetRoot, useWidgetRootContext } from './widget-root'; + +export { SemiCircleChart } from './chart-semi-circle'; +export { DonutChart, type DonutChartData } from './chart-donut'; +export { ReportMetricWidget } from './report-metric'; +export { + LeaderboardChart, + type LeaderboardChartProps, + type LeaderboardChartData, + type LegendLabels, + LeaderboardLabel, + type LeaderboardLabelProps, +} from './chart-leaderboard'; +export { BarChart, type BarChartProps, type BarChartData, type BarChartStyle } from './chart-bar'; +export { ChartEmptyState, type ChartEmptyStateProps } from './chart-empty-state'; +export { WidgetLoadingOverlay } from './widget-loading-overlay'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/README.md new file mode 100644 index 000000000000..243cc32d174b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/README.md @@ -0,0 +1,84 @@ +# Legend + +A pure component for rendering chart legends with optional comparison deltas. + +## Usage + +```tsx +import { Legend } from '@jetpack-premium-analytics/widgets-toolkit'; + +const items = [ + { label: 'Mobile', value: 241950, displayValue: '$241.95K', color: '#3858E9' }, + { label: 'Desktop', value: 148130, displayValue: '$148.13K', color: '#66BDFF' }, + { label: 'Tablet', value: 44740, displayValue: '$44.74K', color: '#A77EFF' }, +]; + +; +``` + +## Props + +| Prop | Type | Default | Description | +| ---------------- | -------------- | -------- | -------------------------------- | +| `items` | `LegendItem[]` | required | Array of legend items to display | +| `withComparison` | `boolean` | `false` | Show comparison deltas | + +### LegendItem + +| Property | Type | Required | Description | +| -------------- | -------- | -------- | ------------------------------------ | +| `label` | `string` | yes | Item label text | +| `value` | `number` | yes | Current numeric value | +| `displayValue` | `string` | yes | Display-ready formatted value | +| `color` | `string` | no | Bullet color (hex, rgb, etc.) | +| `comparison` | `number` | no | Previous value for delta calculation | + +## With Comparison + +```tsx +const items = [ + { + label: 'Mobile', + value: 241950, + displayValue: '$241.95K', + color: '#3858E9', + comparison: 200000, + }, + { + label: 'Desktop', + value: 148130, + displayValue: '$148.13K', + color: '#66BDFF', + comparison: 160000, + }, +]; + +; +``` + +## Theme Integration + +For widgets inside `GlobalChartsProvider`, use `LegendWithTheme` instead. It automatically resolves colors from the chart theme: + +```tsx +import { LegendWithTheme as Legend } from '@jetpack-premium-analytics/widgets-toolkit'; + +// Colors are injected from theme - no need to specify them + } +/>; +``` + +## Architecture + +``` +Legend (pure) +├── Receives items with colors already resolved +├── Renders Grid with LegendRow components +└── No context dependencies + +LegendWithTheme (wrapper) +├── Resolves colors: item.color → chartItems → theme +├── Passes items with colors to Legend +└── Requires GlobalChartsProvider +``` diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/index.ts new file mode 100644 index 000000000000..50fbad1f0beb --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/index.ts @@ -0,0 +1,2 @@ +export { type LegendItem } from './legend'; +export { LegendWithTheme as Legend } from './legend-with-theme'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/legend-with-theme.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/legend-with-theme.tsx new file mode 100644 index 000000000000..92d58b474e33 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/legend-with-theme.tsx @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import { type BaseLegendItem, useGlobalChartsContext } from '@automattic/charts'; +/** + * Internal dependencies + */ +import { Legend, type LegendItem } from './legend'; + +type LegendWithThemeProps = { + chartItems?: BaseLegendItem[]; + items: LegendItem[]; + withComparison?: boolean; +}; + +/** + * Resolves the color for a legend item using the following priority: + * 1. item.color (explicit per-item) + * 2. chartItems color (matched by label) + * 3. theme color (from GlobalChartsProvider) + */ +function resolveItemColor( + item: LegendItem, + index: number, + chartItems: BaseLegendItem[] | undefined, + getElementStyles: ( opts: { index: number } ) => { color: string } +): string { + if ( item.color ) { + return item.color; + } + + const correspondingChartItem = chartItems?.find( chartItem => chartItem.label === item.label ); + + if ( correspondingChartItem?.color ) { + return correspondingChartItem.color; + } + + return getElementStyles( { index } ).color; +} + +/** + * Legend wrapper that injects theme colors from GlobalChartsProvider. + * Use this for widgets that render inside a GlobalChartsProvider context. + * + * For standalone usage, use the pure Legend component instead. + * + * @deprecated Prefer using the pure Legend component with explicit colors. + * This wrapper will be removed once all widgets are migrated. + */ +export function LegendWithTheme( { + chartItems, + items, + withComparison = false, +}: LegendWithThemeProps ) { + const { getElementStyles } = useGlobalChartsContext(); + + // Resolve all colors before passing to Legend + const itemsWithColors = items.map( ( item, index ) => ( { + ...item, + color: resolveItemColor( item, index, chartItems, getElementStyles ), + } ) ); + + return ; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/legend.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/legend.module.scss new file mode 100644 index 000000000000..b0b708366c43 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/legend.module.scss @@ -0,0 +1,28 @@ +.legend { + width: 100%; +} + +.labelContainer { + overflow: hidden; + min-width: 0; +} + +.bullet { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.value { + text-align: right; + font-weight: 600; + white-space: nowrap; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/legend.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/legend.tsx new file mode 100644 index 000000000000..3121846c256c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/legend.tsx @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import { __experimentalGrid as Grid } from '@wordpress/components'; +/** + * Internal dependencies + */ +import { MetricDelta } from '../metric-delta'; +import styles from './legend.module.scss'; +import { LegendRow } from './row'; + +export type LegendItem = { + label: string; + value: number; + displayValue: string; + /** + * Color for the legend item bullet. + */ + color?: string; + comparison?: number; +}; + +type LegendProps = { + items: LegendItem[]; + /** + * Show comparison deltas. + * @default false + */ + withComparison?: boolean; + /** + * Hide the displayValue column. + * Useful when only showing labels and comparison deltas. + * @default false + */ + hideValue?: boolean; +}; + +/** + * Pure Legend component that renders a grid of legend items. + * Does not depend on any context provider - all data flows through props. + * + * For widgets using GlobalChartsProvider, use LegendWithTheme instead. + */ +/** + * Determines the number of grid columns based on visibility options. + */ +function getTemplateColumns( hideValue: boolean, withComparison: boolean ): string { + if ( hideValue ) { + return withComparison ? '1fr auto' : '1fr'; + } + return withComparison ? '1fr auto auto' : '1fr auto'; +} + +export function Legend( { items, withComparison = false, hideValue = false }: LegendProps ) { + return ( + + { items.map( item => ( + + ) : null + } + color={ item.color } + title={ item.label } + > + { item.label } + + ) ) } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/row/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/row/index.ts new file mode 100644 index 000000000000..f1a30908b036 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/row/index.ts @@ -0,0 +1,2 @@ +export { LegendRow } from './legend-row'; +export type { LegendRowProps } from './legend-row'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/row/legend-row.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/row/legend-row.tsx new file mode 100644 index 000000000000..1f8de76e005a --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/row/legend-row.tsx @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { Stack } from '@wordpress/ui'; +import styles from '../legend.module.scss'; +import type { ReactNode } from 'react'; + +/** + * Internal dependencies + */ + +export type LegendRowProps = { + /** + * The label content (usually text) + */ + children: ReactNode; + + /** + * Formatted value to display. + * When false, the value column is not rendered. + */ + value: string | false; + + /** + * Comparison display (can be MetricDelta component) + */ + comparison?: ReactNode; + + /** + * Color for the bullet indicator + */ + color?: string; + + /** + * Title for the label (shown on hover, useful when text is truncated) + */ + title?: string; +}; + +export function LegendRow( { children, value, comparison, color, title }: LegendRowProps ) { + return ( + <> + +
+ + { children } + + + { value !== false && { value } } + { comparison } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-delta/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-delta/index.ts new file mode 100644 index 000000000000..32a713c5bf4e --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-delta/index.ts @@ -0,0 +1,2 @@ +export { MetricDelta } from './metric-delta'; +export type { MetricDeltaProps } from './metric-delta'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-delta/metric-delta.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-delta/metric-delta.module.scss new file mode 100644 index 000000000000..0c3bc7d9e000 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-delta/metric-delta.module.scss @@ -0,0 +1,18 @@ +.delta { + font-size: var(--wpds-typography-font-size-md); + font-weight: 400; + line-height: var(--wpds-typography-font-size-lg); + + &.invalid, + &.neutral { + color: var(--wpds-color-fg-content-neutral-weak); + } + + &.positive { + color: var(--wpds-color-stroke-surface-success-strong); + } + + &.negative { + color: var(--wpds-color-stroke-surface-error-strong); + } +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-delta/metric-delta.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-delta/metric-delta.tsx new file mode 100644 index 000000000000..cf3974e7b52c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-delta/metric-delta.tsx @@ -0,0 +1,142 @@ +/** + * External dependencies + */ +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { Stack } from '@wordpress/ui'; +import clsx from 'clsx'; +/** + * Internal dependencies + */ +import styles from './metric-delta.module.scss'; +import type { ComponentProps } from 'react'; + +export type MetricDeltaProps = { + /** + * The current/new value + */ + current: number; + + /** + * The previous/comparison value + */ + previous: number; + + /** + * What to display when calculation is not possible + * @default '—' + */ + fallback?: string; + + /** + * Whether to hide when delta is zero + * @default false + */ + hideZero?: boolean; + + /** + * For metrics where decrease is improvement (e.g., bounce rate, returns) + * @default false + */ + invertColors?: boolean; + + /** + * CSS class for styling + */ + className?: string; + + /** + * Text alignment + * @default 'center' + */ + justify?: ComponentProps< typeof Stack >[ 'justify' ]; + + /** + * Show absolute change instead of percentage + * @default false + */ + showAbsolute?: boolean; + + /** + * Format for absolute values + * @default 'number' + */ + absoluteFormat?: 'number' | 'currency'; +}; + +function calculatePercentageChange( current: number, previous: number ): number | null { + // Handle invalid inputs + if ( ! Number.isFinite( current ) || ! Number.isFinite( previous ) ) { + return null; + } + + // Handle zero previous value + if ( previous === 0 ) { + return current === 0 ? 0 : null; + } + + // Calculate percentage change, rounded to integer + return Math.round( ( ( current - previous ) / Math.abs( previous ) ) * 100 ); +} + +export function MetricDelta( { + current, + previous, + fallback = '—', + hideZero = false, + invertColors = false, + className, + justify = 'center', + showAbsolute = false, + absoluteFormat = 'number', +}: MetricDeltaProps ) { + // Calculate the change + const absoluteChange = current - previous; + const percentageChange = calculatePercentageChange( current, previous ); + + // Handle edge cases + if ( percentageChange === null ) { + return ( + + { fallback } + + ); + } + + if ( hideZero && percentageChange === 0 ) { + return null; + } + + // Determine display value + let displayValue: string; + if ( showAbsolute ) { + displayValue = formatMetricValue( absoluteChange, absoluteFormat ); + if ( absoluteChange > 0 ) { + displayValue = `+${ displayValue }`; + } + } else { + displayValue = formatMetricValue( percentageChange / 100, 'percentage' ); + } + + // Determine color based on direction and inversion + const isPositive = + ( percentageChange > 0 && ! invertColors ) || ( percentageChange < 0 && invertColors ); + const isNegative = + ( percentageChange < 0 && ! invertColors ) || ( percentageChange > 0 && invertColors ); + + return ( + + { displayValue } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-value/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-value/index.ts new file mode 100644 index 000000000000..1c84a4c29630 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-value/index.ts @@ -0,0 +1,2 @@ +export { MetricValue } from './metric-value'; +export type { MetricValueProps } from './metric-value'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-value/metric-value.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-value/metric-value.module.scss new file mode 100644 index 000000000000..b312580c1e0e --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-value/metric-value.module.scss @@ -0,0 +1,18 @@ +.metricValue { + font-weight: 500; + font-size: var(--wp-ui-metric-font-size); + line-height: var(--wpds-typography-line-height-sm); + + // Color variants + &.color--neutral { + color: var(--wpds-color-fg-content-neutral); + } + + &.color--positive { + color: var(--wpds-color-stroke-surface-success-strong); + } + + &.color--negative { + color: var(--wpds-color-stroke-surface-error-strong); + } +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-value/metric-value.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-value/metric-value.tsx new file mode 100644 index 000000000000..bc79f5685077 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-value/metric-value.tsx @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import clsx from 'clsx'; +import { type CSSProperties, useMemo } from 'react'; +/** + * Internal dependencies + */ +import styles from './metric-value.module.scss'; +import type { DataFormat } from '../../types'; +import type { FontSize } from '@wordpress/theme'; + +export type MetricValueProps = { + /** + * The numeric value to display + */ + value: number; + + /** + * Format configuration for value display + * @default { type: 'number' } + */ + dataFormat?: DataFormat; + + /** + * ISO 4217 currency code (e.g. `'USD'`, `'EUR'`). + */ + currencyCode?: string; + + /** + * CSS class for styling + */ + className?: string; + + /** + * Font size token from the WordPress Design System. + * Maps directly to `--wpds-typography-font-size-{value}`. + * @default 'lg' + */ + fontSize?: FontSize; + + /** + * Color variant + * @default 'neutral' + */ + color?: 'neutral' | 'positive' | 'negative'; +}; + +export function MetricValue( { + value, + dataFormat = { type: 'number' }, + currencyCode, + className, + fontSize = 'lg', + color = 'neutral', +}: MetricValueProps ) { + /** + * Create display value using dataFormat configuration + */ + const displayValue = useMemo( + () => + formatMetricValue( value, dataFormat.type, { + ...dataFormat.options, + currencyCode, + } ), + [ value, dataFormat, currencyCode ] + ); + + const style = { + '--wp-ui-metric-font-size': `var( --wpds-typography-font-size-${ fontSize } )`, + } as CSSProperties; + + return ( + + { displayValue } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-with-comparison/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-with-comparison/index.ts new file mode 100644 index 000000000000..037dddb1fff6 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-with-comparison/index.ts @@ -0,0 +1 @@ +export { MetricWithComparison } from './metric-with-comparison'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-with-comparison/metric-with-comparison.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-with-comparison/metric-with-comparison.tsx new file mode 100644 index 000000000000..648690364706 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-with-comparison/metric-with-comparison.tsx @@ -0,0 +1,115 @@ +/** + * External dependencies + */ +import { Stack } from '@wordpress/ui'; +import { ComponentProps } from 'react'; +/** + * Internal dependencies + */ +import { MetricDelta } from '../metric-delta'; +import { MetricValue } from '../metric-value'; +import type { DataFormat } from '../../types'; +import type { MetricValueProps } from '../metric-value'; + +export type MetricWithComparisonProps = { + /** + * The current value to display + */ + value: number; + + /** + * The previous value for comparison. If null/undefined, delta won't be shown. + */ + previousValue?: number | null; + + /** + * Format configuration for value and delta display + * @default { type: 'number' } + */ + dataFormat?: DataFormat; + + /** + * Layout direction + * @default 'row' + */ + direction?: ComponentProps< typeof Stack >[ 'direction' ]; + + /** + * Alignment of items + * @default 'flex-end' + */ + align?: ComponentProps< typeof Stack >[ 'align' ]; + + /** + * Font size token for the primary value + * @default 'xl' + */ + fontSize?: MetricValueProps[ 'fontSize' ]; + + /** + * For metrics where decrease is improvement (e.g., bounce rate) + * @default false + */ + invertDeltaColors?: boolean; + + /** + * Hide delta when it's zero + * @default false + */ + hideDeltaOnZero?: boolean; + + /** + * CSS class for the container + */ + className?: string; + + /** + * What to display for delta when calculation is not possible + */ + deltaFallback?: string; + + /** + * Show absolute change instead of percentage in delta + * @default false + */ + showAbsoluteDelta?: boolean; +}; + +export function MetricWithComparison( { + value, + previousValue, + dataFormat = { type: 'number' }, + direction = 'row', + align = 'baseline', + fontSize = 'xl', + invertDeltaColors = false, + hideDeltaOnZero = false, + className, + deltaFallback, + showAbsoluteDelta = false, +}: MetricWithComparisonProps ) { + const showDelta = previousValue !== null && previousValue !== undefined; + + /** + * Determine absolute format for delta based on data type + */ + const absoluteFormat = dataFormat.type === 'currency' ? 'currency' : 'number'; + + return ( + + + + { showDelta && ( + + ) } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-metric/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-metric/index.ts new file mode 100644 index 000000000000..679ee591c93f --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-metric/index.ts @@ -0,0 +1,2 @@ +export { ReportMetricWidget } from './report-metric'; +export type { ReportMetricWidgetProps } from './report-metric'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-metric/report-metric.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-metric/report-metric.tsx new file mode 100644 index 000000000000..82ab16e08b09 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-metric/report-metric.tsx @@ -0,0 +1,139 @@ +/** + * External dependencies + */ +import { useGlobalChartsContext } from '@automattic/charts'; +import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import { buildTimeSeriesChartData } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { MetricComparisonWidget } from '../../widgets/metric-comparison'; +import { WidgetLoadingOverlay } from '../widget-loading-overlay'; +import type { DataFormat } from '../../types'; + +/** + * Generic type for report data with time series + */ +type ReportData = { + summary: { + date_start: string; + date_end: string; + [ key: string ]: string | number; + }; + data: Array< { + date_start: string; + [ key: string ]: string | number; + } >; +}; + +/** + * Type for the data prop - the result from useReport hooks + */ +type ReportHookResult = { + primary: { data?: ReportData }; + comparison: { data?: ReportData }; + isLoading: boolean; + isFetching: boolean; + hasData: boolean; + isError: boolean; + error: Error | null | undefined; + refetch: () => void; +}; + +export type ReportMetricWidgetProps = { + /** + * The metric key to display from the data + */ + metricKey: string; + + /** + * The report data from useReport hooks (e.g., useReportOrders, useReportVisitors) + */ + data: ReportHookResult; + + /** + * The format configuration for the metric + */ + dataFormat: DataFormat; +}; + +/** + * Report Metric Widget - Internal Component + * + * @param {ReportMetricWidgetProps} props - The component props + * + * @internal + */ +export function ReportMetricWidget( { metricKey, data, dataFormat }: ReportMetricWidgetProps ) { + const { getElementStyles } = useGlobalChartsContext(); + + const primaryData = data.primary.data; + const comparisonData = data.comparison.data; + const { isLoading, isFetching, hasData, isError, error, refetch } = data; + + // Compute unified loading states (same logic as useWidgetLoading in dashboard v1) + const isInitialLoading = isLoading && ! hasData; + const isRefetching = ( isLoading || isFetching ) && hasData; + + // Build series[] data. + const series = buildTimeSeriesChartData( { + primary: primaryData ?? { + summary: { + date_start: '', + date_end: '', + [ metricKey ]: 0, + }, + data: [], + }, + comparison: comparisonData, + metricKey, + emptyDataFallback: 'empty-array', + } ); + + // Build seriesStyles[] data. + const seriesStyles = useMemo( + () => + series.map( ( seriesData, index ) => { + const { color, lineStyles } = getElementStyles( { + data: seriesData, + index, + } ); + + return { + stroke: color, + ...lineStyles, + }; + } ), + [ series, getElementStyles ] + ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; // Dashboard shows error UI via WidgetErrorBoundary + } + + // No data and not loading = nothing to show + if ( ! primaryData && ! isInitialLoading ) { + return null; + } + + // metricKey always refers to a numeric metric field (e.g., "visitors", "orders_no"), + // never to date fields (e.g., "date_start"). The summary type includes both for flexibility, + // but we know the actual value will be a number at runtime. + const primaryValue = ( primaryData?.summary[ metricKey ] as number ) ?? 0; + const comparisonValue = comparisonData?.summary[ metricKey ] as number | undefined; + + return ( + <> + + { ( isInitialLoading || isRefetching ) && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-loading-overlay/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-loading-overlay/index.ts new file mode 100644 index 000000000000..5b5ee9f5c947 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-loading-overlay/index.ts @@ -0,0 +1 @@ +export { WidgetLoadingOverlay } from './widget-loading-overlay'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-loading-overlay/widget-loading-overlay.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-loading-overlay/widget-loading-overlay.module.scss new file mode 100644 index 000000000000..ff81e242d1ab --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-loading-overlay/widget-loading-overlay.module.scss @@ -0,0 +1,7 @@ +.overlay { + position: absolute; + inset: 0; + z-index: 1; + height: 100%; + background: color-mix(in sRGB, var(--wpds-color-bg-surface-neutral-strong) 60%, transparent); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-loading-overlay/widget-loading-overlay.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-loading-overlay/widget-loading-overlay.tsx new file mode 100644 index 000000000000..914183478196 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-loading-overlay/widget-loading-overlay.tsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { Spinner } from '@wordpress/components'; +import { Stack } from '@wordpress/ui'; +/** + * Internal dependencies + */ +import styles from './widget-loading-overlay.module.scss'; + +/** + * Local stand-in for `WidgetLoadingOverlay` from `@automattic/dashboard` + * (CIAB Admin), which is not published to npm. Renders a centered spinner + * that overlays the widget content while data is loading or refetching. + * + * TODO: Replace with the `@automattic/dashboard` component once it is + * available in the monorepo or published to npm. + */ +export function WidgetLoadingOverlay() { + return ( + + + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/README.md new file mode 100644 index 000000000000..532fb64175ea --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/README.md @@ -0,0 +1,153 @@ +# WidgetRoot + +A wrapper component that encapsulates all infrastructure a lazy-loaded dashboard widget needs. + +## Problem + +Dashboard widgets are ES Modules loaded asynchronously via lazy-load. This means they don't share context with other widgets—providers **must** be instantiated per widget. + +`WidgetRoot` centralizes this "bootstrap" logic instead of scattering it across multiple widget files. + +## What WidgetRoot Provides + +- **AnalyticsQueryClientProvider** - React Query client for data fetching +- **GlobalChartsProvider** - Chart theming via `useChartTheme()` +- **Report params resolution** - From widget attributes or URL fallback +- **Context provider** - Child widgets access resolved params via `useWidgetRootContext()` + +## Usage + +### In dashboard-widgets (consumer) + +```tsx +// dashboard-widgets/my-widget/render.tsx +import { WidgetRoot, MyWidget } from '@jetpack-premium-analytics/widgets-toolkit'; + +export default function MyWidgetRender( { attributes } ) { + return ( + + + + ); +} +``` + +### In widgets-toolkit (internal widget) + +```tsx +// widgets-toolkit/widgets/my-widget/widget-my-widget.tsx +import { useWidgetRootContext } from '../../components/widget-root'; + +export function MyWidget() { + const { reportParams } = useWidgetRootContext(); + + // Use reportParams for data fetching + const { data } = useReportOrders( reportParams ); + + return
{ /* render widget */ }
; +} +``` + +## API + +### WidgetRoot Props + +| Prop | Type | Description | +| -------------- | -------------------------------------- | --------------------------------------------------------------- | +| `attributes` | `Partial` | Widget attributes, may include `reportParams` | +| `children` | `ReactNode` | Child components (widgets) | +| `options.from` | `string` | Router path for URL params (default: `/wc-analytics/dashboard`) | + +### useWidgetRootContext + +Returns the resolved context value: + +```typescript +type WidgetRootContextValue = { + reportParams: ReportParams; +}; +``` + +**Important**: Must be called within a `WidgetRoot` component. Throws an error otherwise. + +## Report Params Resolution + +`WidgetRoot` resolves `reportParams` with the following priority: + +1. **From attributes** - If `attributes.reportParams` is provided and non-empty +2. **From URL** - Falls back to URL search params via `@wordpress/route` + +This allows widgets to work both: + +- In the Analytics dashboard (params from URL) +- Other contexts (params from attributes) + +## Architecture + +``` +WidgetRoot +├── AnalyticsQueryClientProvider (shared React Query client) +│ └── GlobalChartsProvider (chart theme) +│ └── WidgetRootContext.Provider (reportParams) +│ └── children (widget components) +``` + +## Responsive Widgets with Container Queries + +`WidgetRoot` wraps children in a container query context, enabling widgets to adapt their layout based on their own size (not viewport). + +### Why Container Queries? + +Dashboard widgets live in a resizable grid. Users can change tile sizes, so widgets must adapt to their container—not the viewport. CSS Container Queries solve this. + +### Available Breakpoints + +Aligned with [Tailwind container query defaults](https://tailwindcss.com/docs/responsive-design#container-size-reference) and [ARC-464](https://linear.app/a8c/issue/ARC-464). + +| Token | Size | Use Case | +| ----- | ------------- | ----------------------- | +| `xxs` | 256px (16rem) | Extra extra small tiles | +| `xs` | 320px (20rem) | Extra small tiles | +| `sm` | 384px (24rem) | Small tiles | +| `md` | 448px (28rem) | Standard tile size | +| `lg` | 512px (32rem) | Large tiles | +| `xl` | 576px (36rem) | Extra large tiles | +| `2xl` | 672px (42rem) | Full-width widgets | + +### Usage in Widget SCSS + +```scss +@use '../../styles/widget-container' as *; + +.myWidget { + // Mobile-first: vertical layout for small containers + flex-direction: column; + + // >= 448px: switch to horizontal layout + @include widget-query( md ) { + flex-direction: row; + } + + // >= 576px: add more spacing + @include widget-query( xl ) { + gap: var( --wpds-dimension-base ); + } +} +``` + +### How It Works + +1. `WidgetRoot` wraps children in a div with `container-type: inline-size` +2. Child widgets use `@container` queries via the `widget-query()` mixin +3. Styles apply based on the widget's actual width, not the viewport + +### Files + +- `../../styles/_widget-container.scss` - Breakpoints and mixin definitions + +## Files + +- `widget-root.tsx` - Main component +- `widget-root.module.scss` - Container query setup +- `context.tsx` - React context and `useWidgetRootContext` hook +- `index.ts` - Public exports diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/context.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/context.tsx new file mode 100644 index 000000000000..a27238ad1a72 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/context.tsx @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import { createContext, useContext } from 'react'; +import type { WidgetErrorConfig } from '../../types'; +import type { ReportParams } from '@jetpack-premium-analytics/data'; + +export type WidgetRootContextValue = { + /** + * Normalized report parameters resolved from widget attributes or URL. + */ + reportParams: ReportParams; + + /** + * Function to report an error state in the widget. + * Pass `true` for default error, a config object for custom error, or `null` to clear. + * + * @example + * ```tsx + * // Show error with retry action + * setError( { + * message: 'Failed to load data', + * action: { label: 'Retry', onClick: handleRetry } + * } ); + * + * // Clear error state + * setError( null ); + * ``` + */ + setError?: ( error: WidgetErrorConfig | true | null ) => void; +}; + +const WidgetRootContext = createContext< WidgetRootContextValue | null >( null ); + +/** + * Hook to access the WidgetRoot context. + * + * Must be used within a WidgetRoot component. + * + * @throws {Error} If used outside of WidgetRoot + * @return {WidgetRootContextValue} The widget root context value + * + * @example + * ```tsx + * function MyWidget() { + * const { reportParams } = useWidgetRootContext(); + * // Use reportParams for data fetching + * } + * ``` + */ +export function useWidgetRootContext(): WidgetRootContextValue { + const context = useContext( WidgetRootContext ); + + if ( ! context ) { + throw new Error( 'useWidgetRootContext must be used within a WidgetRoot component' ); + } + + return context; +} + +export { WidgetRootContext }; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/index.ts new file mode 100644 index 000000000000..71308d9f23f4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/index.ts @@ -0,0 +1,3 @@ +export { WidgetRoot } from './widget-root'; +export { useWidgetRootContext } from './context'; +export type { WidgetRootContextValue } from './context'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/widget-root.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/widget-root.module.scss new file mode 100644 index 000000000000..465c6c663baf --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/widget-root.module.scss @@ -0,0 +1,6 @@ +@use "../../styles/widget-container" as *; + +.root { + + @extend %widget-container; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/widget-root.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/widget-root.tsx new file mode 100644 index 000000000000..a3cc938644ad --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/widget-root/widget-root.tsx @@ -0,0 +1,129 @@ +/** + * External dependencies + */ +import { GlobalChartsProvider } from '@automattic/charts'; +import { + AnalyticsQueryClientProvider, + getDefaultPreset, + normalizeReportParams, +} from '@jetpack-premium-analytics/data'; +import { useSearch } from '@wordpress/route'; +import { useMemo, type ReactNode } from 'react'; +import { getStoreInfo } from '../../helpers/store-info'; +import '@automattic/charts/style.css'; +/** + * Internal dependencies + */ +import { useChartTheme } from '../../hooks'; +import { WidgetRootContext } from './context'; +import styles from './widget-root.module.scss'; +import type { ReportParamsFieldAttributes } from '../../fields'; +import type { WidgetErrorConfig } from '../../types'; + +type WidgetRootProps = { + /** + * The attributes for the widget. + */ + attributes?: Partial< ReportParamsFieldAttributes >; + + /** + * The children of the widget root. + */ + children: ReactNode; + + /** + * Function to report an error state in the widget. + * Passed from the dashboard's WidgetRenderProps. + */ + setError?: ( error: WidgetErrorConfig | true | null ) => void; + + /** + * The options for the widget root. + */ + options?: { + /** + * The source of the search params. + * @default '/wc-analytics/dashboard' + */ + from?: string; + }; +}; + +const DEFAULT_SEARCH_FROM = '/wc-analytics/dashboard'; + +/** + * Hook that resolves widget attributes: + * - `reportParams`: with URL search params when it's not provided + */ +function useResolveReportParams( + attributes?: Partial< ReportParamsFieldAttributes >, + from?: string +) { + let search: Record< string, unknown > = {}; + + try { + // eslint-disable-next-line react-hooks/rules-of-hooks -- useSearch may throw outside a matched route + search = useSearch( { from: from ?? DEFAULT_SEARCH_FROM } ); + } catch { + // Do nothing + } + + /* + * Check if reportParams exists and is not empty. + * If it exists, use the provided reportParams. + * Otherwise, use URL search params as reportParams. + */ + const hasReportParams = + !! attributes?.reportParams && Object.keys( attributes.reportParams ).length > 0; + + return hasReportParams ? attributes.reportParams : search; +} + +/** + * WidgetRoot + * + * A wrapper component that encapsulates all the infrastructure a lazy-loaded + * dashboard widget needs: + * - AnalyticsQueryClientProvider for data fetching + * - GlobalChartsProvider with chart theme + * - Report params resolution (from attributes or URL fallback) + * - Context provider for child widgets to access resolved params + * + * @example + * ```tsx + * // In dashboard-widgets/my-widget/render.tsx + * + * + * + * + * // In widgets-toolkit/widgets/my-widget.tsx + * function MyWidget() { + * const { reportParams } = useWidgetRootContext(); + * // Use reportParams for data fetching + * } + * ``` + */ +export function WidgetRoot( { attributes, children, setError, options }: WidgetRootProps ) { + const chartTheme = useChartTheme(); + const rawReportParams = useResolveReportParams( attributes, options?.from ); + + const { launchedDate } = getStoreInfo(); + const defaultPreset = getDefaultPreset( launchedDate ); + + const reportParams = useMemo( + () => normalizeReportParams( rawReportParams, defaultPreset ), + [ rawReportParams, defaultPreset ] + ); + + const contextValue = useMemo( () => ( { reportParams, setError } ), [ reportParams, setError ] ); + + return ( + + + +
{ children }
+
+
+
+ ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/constants/chart.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/constants/chart.ts new file mode 100644 index 000000000000..b08d6832b0af --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/constants/chart.ts @@ -0,0 +1,4 @@ +/** + * Override the @automattic/charts default (300ms) for snappier resize response. + */ +export const RESIZE_DEBOUNCE_MS = 50; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/constants/color-palette.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/constants/color-palette.ts new file mode 100644 index 000000000000..6c3a9e8f8238 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/constants/color-palette.ts @@ -0,0 +1,15 @@ +// Base colors +const COLOR_BLUEBERRY = '#3858E9'; + +const COLOR_PURPLE_30 = '#A77EFF'; + +const COLOR_BLUE_30 = '#66BDFF'; + +export const COLOR_GRAY_100 = '#F0F0F0'; + +// Semantic colors +const COLOR_PRIMARY = COLOR_BLUEBERRY; +const COLOR_SECONDARY = COLOR_BLUE_30; + +// Theme +export const WOO_COLORS = [ COLOR_PRIMARY, COLOR_SECONDARY, COLOR_PURPLE_30, '#7B90FF', '#EB6594' ]; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/constants/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/constants/index.ts new file mode 100644 index 000000000000..e3293cad3ba2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/constants/index.ts @@ -0,0 +1,2 @@ +export * from './chart'; +export * from './color-palette'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/README.md new file mode 100644 index 000000000000..35fbbed8293c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/README.md @@ -0,0 +1,34 @@ +# ReportParamsField + +Form control for editing a widget's date-range parameters +(preset, from/to, comparison range). + +## Data coupling + +This field depends on two external data providers: + +| Provider | Package | Purpose | +| -------------------- | ------------------------------------------------------------------ | ----------------------------------------------------- | +| `getStoreInfo()` | `helpers/store-info` (local stand-in for `@woocommerce-next/data`) | Reads `launchedDate` from the store profile | +| `getDefaultPreset()` | `@jetpack-premium-analytics/data` | Resolves a smart date-range preset based on store age | + +### Why the coupling exists + +The field renders inside a `@wordpress/components` Modal, which is a +**sibling** of the widget render tree — not a child. That means it has +no access to `WidgetRootContext` or any provider that lives inside +`WidgetRoot`. + +Without this coupling, fresh widgets (no saved `reportParams`) would +always fall back to `last-30-days`, even when the widget itself uses a +dynamic preset like `today` or `last-7-days`. The settings modal would +show dates that don't match the widget's actual data range. + +### Alternatives considered + +| Approach | Why we didn't use it | +| ------------------------- | -------------------------------------------------------------------- | +| WidgetRoot context | Modal renders outside the widget tree — context not accessible | +| Prop via attribute config | `@ciab/dataviews` `DataFormControlProps` doesn't support extra props | +| Global/singleton | Adds indirection for a problem scoped to one component | +| Attribute initialization | Side-effect on render, risk of re-render loops | diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/date-report-params-field.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/date-report-params-field.tsx new file mode 100644 index 000000000000..2cc63fad2139 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/date-report-params-field.tsx @@ -0,0 +1,149 @@ +/** + * External dependencies + */ +import { + getDefaultPreset, + normalizeReportParams, + localTZDate, + getSiteTimezone, +} from '@jetpack-premium-analytics/data'; +import { + type ComparisonPresetId, + isPrimaryPreset, + type DateRange, +} from '@jetpack-premium-analytics/datetime'; +import { deriveComparisonRange, encodeDateToSearchParam } from '@jetpack-premium-analytics/routing'; +import { DateFiltersPanel } from '@jetpack-premium-analytics/ui'; +import { Stack } from '@wordpress/ui'; +import { endOfDay } from 'date-fns'; +import { useCallback, useMemo, useState, useEffect } from 'react'; +import { getStoreInfo } from '../../helpers/store-info'; +import type { DataFormControlProps } from '@wordpress/dataviews'; + +/** + * Inferred types + */ +type ReportParams = NonNullable< Parameters< typeof normalizeReportParams >[ 0 ] >; + +export type ReportParamsFieldAttributes = { + reportParams: ReportParams; +}; + +export function ReportParamsField( { + data: attributes, + onChange, +}: DataFormControlProps< ReportParamsFieldAttributes > ) { + const [ stagedReportParams, setStagedReportParams ] = useState< ReportParams >( + attributes?.reportParams + ); + + const { launchedDate } = getStoreInfo(); + const defaultPreset = getDefaultPreset( launchedDate ); + + const reportParams = normalizeReportParams( stagedReportParams, defaultPreset ); + + const range = { + from: localTZDate( reportParams.from ), + to: localTZDate( reportParams.to ), + }; + + const stageDateRange = useCallback( + ( nextRange?: DateRange, nextPresetId?: string ) => { + const nextReportParams = { ...stagedReportParams }; + + if ( nextRange?.from && nextRange?.to ) { + nextReportParams.from = encodeDateToSearchParam( nextRange.from ); + nextReportParams.to = encodeDateToSearchParam( endOfDay( nextRange.to ) ); + } + + if ( nextPresetId && isPrimaryPreset( nextPresetId ) ) { + nextReportParams.preset = nextPresetId; + } else if ( nextPresetId ) { + delete nextReportParams.preset; + } + + /* + * Derive comparison range from primary range and preset, + * when comparison is enabled. + */ + if ( reportParams.comp === '1' ) { + const derived = deriveComparisonRange( nextReportParams ); + if ( derived ) { + nextReportParams.compare_from = derived.compare_from; + nextReportParams.compare_to = derived.compare_to; + } + } + + setStagedReportParams( nextReportParams ); + }, + [ stagedReportParams, reportParams.comp ] + ); + + // Basic check if the date range has been changed. + const isDateRangeDirty = useMemo( () => { + return ( + attributes?.reportParams?.from !== stagedReportParams?.from || + attributes?.reportParams?.to !== stagedReportParams?.to || + attributes?.reportParams?.preset !== stagedReportParams?.preset + ); + }, [ + attributes?.reportParams?.from, + attributes?.reportParams?.to, + attributes?.reportParams?.preset, + stagedReportParams?.from, + stagedReportParams?.to, + stagedReportParams?.preset, + ] ); + + const commitComparisonRange = useCallback( + ( nextComparisonRange?: DateRange, nextComparisonPresetId?: ComparisonPresetId ) => { + onChange( { + reportParams: { + ...reportParams, + compare_from: encodeDateToSearchParam( nextComparisonRange?.from ), + compare_to: encodeDateToSearchParam( nextComparisonRange?.to ), + compare_preset: nextComparisonPresetId, + comp: '1' as const, + }, + } ); + }, + [ onChange, reportParams ] + ); + + const commit = useCallback( () => { + onChange( { reportParams: stagedReportParams } ); + }, [ onChange, stagedReportParams ] ); + + const clear = useCallback( () => { + setStagedReportParams( attributes?.reportParams ); + }, [ setStagedReportParams, attributes ] ); + + /* + * Get the dashboard layout surface for responsive calculations. + * This is a temporary workaround until @automattic/dashboard exposes + * a Context provider. See WOOA7S-1008 for the upstream solution. + */ + const [ containerElement, setContainerElement ] = useState< HTMLElement | null >( null ); + + useEffect( () => { + const node = document.querySelector< HTMLElement >( '.next-admin-layout__surface' ); + setContainerElement( node ); + }, [] ); + + return ( + + + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/index.ts new file mode 100644 index 000000000000..8cc8a10736f0 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/date-report-params-field/index.ts @@ -0,0 +1 @@ +export { ReportParamsField, type ReportParamsFieldAttributes } from './date-report-params-field'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/index.ts new file mode 100644 index 000000000000..c048f329edde --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/index.ts @@ -0,0 +1,5 @@ +/** + * Widget edit fields + */ +export { ReportParamsField, type ReportParamsFieldAttributes } from './date-report-params-field'; +export { MetricsField, DEFAULT_METRICS } from './metrics-field'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/metrics-field/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/metrics-field/index.ts new file mode 100644 index 000000000000..9b3b326c12fe --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/metrics-field/index.ts @@ -0,0 +1,2 @@ +export { MetricsField } from './metrics-field'; +export { DEFAULT_METRICS, type Metric } from './metrics'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/metrics-field/metrics-field.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/metrics-field/metrics-field.tsx new file mode 100644 index 000000000000..97dd1126a26b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/metrics-field/metrics-field.tsx @@ -0,0 +1,68 @@ +/** + * External dependencies + */ +import { CheckboxControl } from '@wordpress/components'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { Fieldset, Stack } from '@wordpress/ui'; +import { useCallback, useEffect } from 'react'; +/** + * Internal dependencies + */ +import { DEFAULT_METRICS, type Metric } from './metrics'; +import type { DataFormControlProps } from '@wordpress/dataviews'; + +type MetricsAttributes = { + metrics: Metric[]; +}; + +export function MetricsField( { + data: attributes, + onChange, +}: DataFormControlProps< MetricsAttributes > ) { + // Store the metrics in the attributes. + useEffect( () => { + if ( attributes?.metrics?.length ) { + return; + } + + onChange( { metrics: DEFAULT_METRICS } ); + }, [ onChange, attributes ] ); + + const updateMetrics = useCallback( + ( id: string ) => + onChange( { + metrics: attributes.metrics.map( m => { + return m.id === id ? { ...m, enabled: ! m.enabled } : m; + } ), + } ), + [ onChange, attributes ] + ); + + const help = sprintf( + /* translators: %d: number of metrics */ + _n( + 'Choose up to %d metric', + 'Choose up to %d metrics', + attributes.metrics?.length ?? 1, + 'jetpack-premium-analytics' + ), + attributes.metrics?.length ?? 1 + ); + + return ( + + { __( 'Metrics', 'jetpack-premium-analytics' ) } + { help } + + { attributes?.metrics?.map( metric => ( + updateMetrics( metric.id ) } + /> + ) ) } + + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/metrics-field/metrics.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/metrics-field/metrics.ts new file mode 100644 index 000000000000..0988d96b7e0f --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/fields/metrics-field/metrics.ts @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import { FilterCondition } from '@jetpack-premium-analytics/data'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import type { MetricKey } from '../../types'; + +export type Metric = { + id: string; + label: string; + description?: string; + category?: 'Finances' | 'Orders' | 'Sales' | 'Inventory'; + metricType: 'general' | 'product' | 'booking' | 'visitors' | 'conversion' | 'customers'; + metricKey: MetricKey; + filters?: FilterCondition[]; + enabled: boolean; +}; + +const METRIC_NET_SALES: Metric = { + id: 'general-orders_value_net', + label: __( 'Net sales', 'jetpack-premium-analytics' ), + description: __( + 'Monitor your total revenue — after any discounts, returns, or adjustments — over a set period of time.', + 'jetpack-premium-analytics' + ), + category: 'Finances', + metricType: 'general', + metricKey: 'orders_value_net', + enabled: true, +}; + +const METRIC_ORDERS: Metric = { + id: 'general-orders_no', + label: __( 'Orders', 'jetpack-premium-analytics' ), + description: __( + 'See a breakdown of when orders are placed to identify peak selling periods.', + 'jetpack-premium-analytics' + ), + category: 'Orders', + metricType: 'general', + metricKey: 'orders_no', + enabled: true, +}; + +const METRIC_BOOKINGS: Metric = { + id: 'booking-orders_no', + label: __( 'Bookings', 'jetpack-premium-analytics' ), + description: __( + 'See a breakdown of when bookings are placed to identify peak selling periods.', + 'jetpack-premium-analytics' + ), + category: 'Orders', + metricKey: 'orders_no', + metricType: 'booking', + filters: [ + { + compare: 'IN', + key: 'product_type', + value: [ 'booking', 'bookable-event', 'bookable-service' ], + }, + ], + enabled: true, +}; + +const METRIC_VISITORS: Metric = { + id: 'visitors-visitors', + label: __( 'Visitors', 'jetpack-premium-analytics' ), + description: __( + 'Track website visitor trends and monitor traffic patterns over time.', + 'jetpack-premium-analytics' + ), + category: 'Orders', + metricType: 'visitors', + metricKey: 'visitors', + enabled: true, +}; + +const METRIC_CONVERSION_RATE: Metric = { + id: 'conversion-conversion_rate', + label: __( 'Store conversion rate', 'jetpack-premium-analytics' ), + description: __( + "Track your store's conversion funnel from sessions to completed orders.", + 'jetpack-premium-analytics' + ), + category: 'Sales', + metricType: 'conversion', + metricKey: 'conversion_rate', + enabled: true, +}; + +const METRIC_CUSTOMERS: Metric = { + id: 'customers-customers', + label: __( 'Customers', 'jetpack-premium-analytics' ), + description: __( + 'Track the total number of customers (new and returning) who placed orders during the selected time period.', + 'jetpack-premium-analytics' + ), + category: 'Orders', + metricType: 'customers', + metricKey: 'customers', + enabled: true, +}; + +export const DEFAULT_METRICS = [ + METRIC_NET_SALES, + METRIC_ORDERS, + METRIC_BOOKINGS, + METRIC_VISITORS, + METRIC_CONVERSION_RATE, + METRIC_CUSTOMERS, +]; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-coupon-use-data.test.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-coupon-use-data.test.ts new file mode 100644 index 000000000000..075a2a317dde --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-coupon-use-data.test.ts @@ -0,0 +1,164 @@ +/** + * Mock formatMetricValue to avoid pulling in heavy transitive deps. + */ +jest.mock( '@jetpack-premium-analytics/formatters', () => ( { + formatMetricValue: ( value: number ) => `$${ value }`, +} ) ); +/** + * Internal dependencies + */ +import { buildCouponUseData } from '../build-coupon-use-data'; + +type CouponsByDateSummary = { + total_orders: number; + orders_with_coupon: number; + orders_without_coupon: number; + total_sales: number; + sales_with_coupon: number; + sales_without_coupon: number; + total_discount_amount: number; + net_sales_after_discount: number; + coupon_usage_percentage: number; +}; + +function makeCouponsByDateData( summary: Partial< CouponsByDateSummary > ) { + return { + data: [], + summary: { + date_start: '2024-01-01', + date_end: '2024-01-31', + total_orders: 0, + orders_with_coupon: 0, + orders_without_coupon: 0, + total_sales: 0, + sales_with_coupon: 0, + sales_without_coupon: 0, + total_discount_amount: 0, + net_sales_after_discount: 0, + coupon_usage_percentage: 0, + ...summary, + }, + }; +} + +describe( 'buildCouponUseData', () => { + it( 'returns empty state when coupons is undefined', () => { + const result = buildCouponUseData( undefined, undefined ); + + expect( result ).toEqual( { + chartData: [], + total: 0, + comparisonTotal: 0, + legendData: [], + } ); + } ); + + it( 'returns empty state when coupons is null', () => { + const result = buildCouponUseData( null, null ); + + expect( result ).toEqual( { + chartData: [], + total: 0, + comparisonTotal: 0, + legendData: [], + } ); + } ); + + it( 'returns empty state when total sales is zero', () => { + const coupons = makeCouponsByDateData( { + total_sales: 0, + sales_with_coupon: 0, + sales_without_coupon: 0, + } ); + + const result = buildCouponUseData( coupons, undefined ); + + expect( result.chartData ).toEqual( [] ); + expect( result.total ).toBe( 0 ); + expect( result.legendData ).toEqual( [] ); + } ); + + it( 'builds donut data from sales with and without coupons', () => { + const coupons = makeCouponsByDateData( { + total_sales: 300, + sales_with_coupon: 200, + sales_without_coupon: 100, + } ); + + const result = buildCouponUseData( coupons, undefined ); + + expect( result.chartData ).toHaveLength( 2 ); + expect( result.chartData[ 0 ].value ).toBe( 200 ); + expect( result.chartData[ 0 ].label ).toBe( 'With coupons' ); + expect( result.chartData[ 1 ].value ).toBe( 100 ); + expect( result.chartData[ 1 ].label ).toBe( 'No coupons' ); + expect( result.total ).toBe( 300 ); + } ); + + it( 'includes comparison values in legend when hasComparison is true', () => { + const coupons = makeCouponsByDateData( { + total_sales: 300, + sales_with_coupon: 200, + sales_without_coupon: 100, + } ); + + const comparison = makeCouponsByDateData( { + total_sales: 250, + sales_with_coupon: 150, + sales_without_coupon: 100, + } ); + + const result = buildCouponUseData( coupons, comparison, true ); + + expect( result.comparisonTotal ).toBe( 250 ); + expect( result.legendData[ 0 ].comparison ).toBe( 150 ); + expect( result.legendData[ 1 ].comparison ).toBe( 100 ); + } ); + + it( 'excludes comparison values from legend when hasComparison is false', () => { + const coupons = makeCouponsByDateData( { + total_sales: 300, + sales_with_coupon: 200, + sales_without_coupon: 100, + } ); + + const comparison = makeCouponsByDateData( { + total_sales: 250, + sales_with_coupon: 150, + sales_without_coupon: 100, + } ); + + const result = buildCouponUseData( coupons, comparison, false ); + + expect( result.legendData[ 0 ].comparison ).toBeUndefined(); + expect( result.legendData[ 1 ].comparison ).toBeUndefined(); + } ); + + it( 'handles case where all sales use coupons', () => { + const coupons = makeCouponsByDateData( { + total_sales: 500, + sales_with_coupon: 500, + sales_without_coupon: 0, + } ); + + const result = buildCouponUseData( coupons, undefined ); + + expect( result.chartData[ 0 ].value ).toBe( 500 ); + expect( result.chartData[ 1 ].value ).toBe( 0 ); + expect( result.total ).toBe( 500 ); + } ); + + it( 'defaults comparison totals to 0 when comparison data is missing', () => { + const coupons = makeCouponsByDateData( { + total_sales: 300, + sales_with_coupon: 200, + sales_without_coupon: 100, + } ); + + const result = buildCouponUseData( coupons, undefined, true ); + + expect( result.comparisonTotal ).toBe( 0 ); + expect( result.legendData[ 0 ].comparison ).toBe( 0 ); + expect( result.legendData[ 1 ].comparison ).toBe( 0 ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-sales-by-coupon-data.test.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-sales-by-coupon-data.test.ts new file mode 100644 index 000000000000..1b1a595bd1db --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/__tests__/build-sales-by-coupon-data.test.ts @@ -0,0 +1,189 @@ +/** + * Mock formatters to avoid pulling in heavy transitive deps. + */ +jest.mock( '@jetpack-premium-analytics/formatters', () => ( { + formatDateRange: () => 'Jan 1 – 31, 2024', +} ) ); +/** + * Internal dependencies + */ +import { buildSalesByCouponData } from '../build-sales-by-coupon-data'; + +const defaultReportParams = { + from: '2024-01-01', + to: '2024-01-31', + compare_from: '2023-12-01', + compare_to: '2023-12-31', + interval: 'day' as const, +}; + +function makeCouponsData( + items: Array< { + coupon_code: string; + total_sales: number; + discount_amount: number; + } >, + summary: Record< string, unknown > = {} +) { + return { + data: items.map( item => ( { + ...item, + coupon_id: 1, + orders_count: 1, + } ) ), + summary: { + total_sales: 0, + total_discount_amount: 0, + total_orders: 0, + ...summary, + }, + }; +} + +describe( 'buildSalesByCouponData', () => { + it( 'returns empty chartData when coupons is undefined', () => { + const result = buildSalesByCouponData( undefined, undefined, defaultReportParams ); + + expect( result.chartData ).toEqual( [] ); + } ); + + it( 'returns empty chartData when coupons has no summary', () => { + const result = buildSalesByCouponData( + { data: [], summary: undefined } as any, + undefined, + defaultReportParams + ); + + expect( result.chartData ).toEqual( [] ); + } ); + + it( 'builds current period data from top coupons', () => { + const coupons = makeCouponsData( + [ + { + coupon_code: 'SAVE10', + total_sales: 100, + discount_amount: 10, + }, + { + coupon_code: 'SAVE20', + total_sales: 200, + discount_amount: 20, + }, + ], + { total_sales: 300 } + ); + + const result = buildSalesByCouponData( coupons as any, undefined, defaultReportParams ); + + expect( result.chartData ).toHaveLength( 1 ); + expect( result.chartData[ 0 ].data ).toEqual( [ + { label: 'SAVE10', value: 100 }, + { label: 'SAVE20', value: 200 }, + ] ); + } ); + + it( 'aggregates remaining coupons into "Other" segment', () => { + const coupons = makeCouponsData( + [ + { coupon_code: 'A', total_sales: 100, discount_amount: 5 }, + { coupon_code: 'B', total_sales: 200, discount_amount: 10 }, + { coupon_code: 'C', total_sales: 300, discount_amount: 15 }, + { coupon_code: 'D', total_sales: 50, discount_amount: 2 }, + { coupon_code: 'E', total_sales: 75, discount_amount: 3 }, + ], + { total_sales: 725 } + ); + + const result = buildSalesByCouponData( coupons as any, undefined, defaultReportParams, 3 ); + + const currentPeriod = result.chartData[ 0 ].data; + expect( currentPeriod ).toHaveLength( 4 ); // 3 top + Other + expect( currentPeriod[ 3 ] ).toEqual( { + label: 'Other', + value: 125, // 50 + 75 + } ); + } ); + + it( 'includes comparison period data when provided', () => { + const coupons = makeCouponsData( + [ + { + coupon_code: 'SAVE10', + total_sales: 100, + discount_amount: 10, + }, + ], + { total_sales: 100 } + ); + + const comparisonCoupons = makeCouponsData( + [ { coupon_code: 'SAVE10', total_sales: 80, discount_amount: 8 } ], + { total_sales: 80 } + ); + + const result = buildSalesByCouponData( + coupons as any, + comparisonCoupons as any, + defaultReportParams + ); + + expect( result.chartData ).toHaveLength( 2 ); + expect( result.chartData[ 1 ].data[ 0 ] ).toEqual( { + label: 'SAVE10', + value: 80, + } ); + } ); + + it( 'uses total_sales not discount_amount for values', () => { + const coupons = makeCouponsData( + [ { coupon_code: 'BIG', total_sales: 500, discount_amount: 50 } ], + { total_sales: 500 } + ); + + const result = buildSalesByCouponData( coupons as any, undefined, defaultReportParams ); + + expect( result.chartData[ 0 ].data[ 0 ].value ).toBe( 500 ); + } ); + + it( 'respects custom totalSegments parameter', () => { + const coupons = makeCouponsData( + [ + { coupon_code: 'A', total_sales: 100, discount_amount: 5 }, + { coupon_code: 'B', total_sales: 200, discount_amount: 10 }, + { coupon_code: 'C', total_sales: 300, discount_amount: 15 }, + ], + { total_sales: 600 } + ); + + const result = buildSalesByCouponData( coupons as any, undefined, defaultReportParams, 2 ); + + const currentPeriod = result.chartData[ 0 ].data; + expect( currentPeriod ).toHaveLength( 3 ); // 2 top + Other + expect( currentPeriod[ 2 ] ).toEqual( { + label: 'Other', + value: 300, + } ); + } ); + + it( 'returns 0 for missing comparison coupon codes', () => { + const coupons = makeCouponsData( + [ { coupon_code: 'NEW', total_sales: 100, discount_amount: 10 } ], + { total_sales: 100 } + ); + + const comparisonCoupons = makeCouponsData( + [ { coupon_code: 'OLD', total_sales: 50, discount_amount: 5 } ], + { total_sales: 50 } + ); + + const result = buildSalesByCouponData( + coupons as any, + comparisonCoupons as any, + defaultReportParams + ); + + // "NEW" didn't exist in comparison period + expect( result.chartData[ 1 ].data[ 0 ].value ).toBe( 0 ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-bookings-by-attendance-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-bookings-by-attendance-data.ts new file mode 100644 index 000000000000..0579a7e8bbad --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-bookings-by-attendance-data.ts @@ -0,0 +1,134 @@ +/** + * External dependencies + */ +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import type { LegendItem } from '../components'; +import type { DonutChartData } from '../components/chart-donut/donut-chart'; +import type { ReportDataMap } from '@jetpack-premium-analytics/data'; + +// Color for cancelled status +const CANCELLED_COLOR = 'rgb(240, 240, 240)'; + +export interface BookingsByAttendanceData { + chartData: DonutChartData; + total: number; + comparisonTotal: number; + legendData: LegendItem[]; +} + +/** + * Builds chart and legend data for the Bookings by Status widget. + * + * @param bookings - Primary period bookings data + * @param comparisonBookings - Comparison period bookings data + */ +export function buildBookingsByAttendanceData( + bookings: ReportDataMap[ 'bookings' ] | undefined, + comparisonBookings: ReportDataMap[ 'bookings' ] | undefined +): BookingsByAttendanceData { + if ( ! bookings?.summary ) { + return { + chartData: [], + total: 0, + comparisonTotal: 0, + legendData: [], + }; + } + + const { summary } = bookings; + const comparisonSummary = comparisonBookings?.summary; + + // Attendance status keys from the bookings summary + type AttendanceStatusKey = + | 'attendance_status_booked' + | 'attendance_status_checked_in' + | 'attendance_status_no_show' + | 'status_cancelled'; + + // Define status mapping with user-friendly labels + const statusMap: Array< { key: AttendanceStatusKey; label: string } > = [ + { + key: 'attendance_status_booked', + label: __( 'Booked', 'jetpack-premium-analytics' ), + }, + { + key: 'attendance_status_checked_in', + label: __( 'Checked In', 'jetpack-premium-analytics' ), + }, + { + key: 'attendance_status_no_show', + label: __( 'No Show', 'jetpack-premium-analytics' ), + }, + { + key: 'status_cancelled', + label: __( 'Cancelled', 'jetpack-premium-analytics' ), + }, + ]; + + // Calculate values for each status + const statusValues = statusMap.map( status => { + const value = summary[ status.key ] || 0; + const comparisonValue = comparisonSummary ? comparisonSummary[ status.key ] || 0 : 0; + + return { + ...status, + value, + comparisonValue, + }; + } ); + + // Calculate total bookings across all statuses + const totalBookings = statusValues.reduce( ( sum, status ) => sum + status.value, 0 ); + + const comparisonTotalBookings = statusValues.reduce( + ( sum, status ) => sum + status.comparisonValue, + 0 + ); + + // If there are no bookings, return empty state + if ( totalBookings === 0 ) { + return { + chartData: [], + total: 0, + comparisonTotal: comparisonTotalBookings, + legendData: [], + }; + } + + // Filter out statuses with zero bookings + const statusesWithData = statusValues.filter( status => status.value > 0 ); + + // Build chart data + const chartData: DonutChartData = statusesWithData.map( status => ( { + label: status.label, + value: status.value, + valueDisplay: formatMetricValue( status.value, 'number', { + useMultipliers: false, + decimals: 0, + } ), + ...( status.key === 'status_cancelled' && { color: CANCELLED_COLOR } ), + } ) ); + + // Build legend data + const legendData: LegendItem[] = statusesWithData.map( status => ( { + label: status.label, + value: status.value, + displayValue: formatMetricValue( status.value, 'number', { + useMultipliers: false, + decimals: 0, + } ), + comparison: comparisonBookings ? status.comparisonValue : undefined, + ...( status.key === 'status_cancelled' && { color: CANCELLED_COLOR } ), + } ) ); + + return { + chartData, + total: totalBookings, + comparisonTotal: comparisonTotalBookings, + legendData, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-coupon-use-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-coupon-use-data.ts new file mode 100644 index 000000000000..eb1ecdc42d55 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-coupon-use-data.ts @@ -0,0 +1,112 @@ +/** + * External dependencies + */ +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import type { LegendItem } from '../components'; +import type { DonutChartData } from '../components/chart-donut/donut-chart'; +import type { ReportDataMap } from '@jetpack-premium-analytics/data'; + +export interface CouponUseData { + chartData: DonutChartData; + total: number; + comparisonTotal: number; + legendData: LegendItem[]; +} + +/** + * Builds chart and legend data for the Coupon Use widget. + * + * Uses pre-computed sales_with_coupon / sales_without_coupon from the + * coupons/by-date endpoint so the donut chart shows the correct breakdown + * of sales with vs without coupons across all orders. + * + * @param coupons - Primary period coupon-by-date data + * @param comparisonCoupons - Comparison period coupon-by-date data + * @param hasComparison - Whether comparison period should be included + */ +export function buildCouponUseData( + coupons: ReportDataMap[ 'couponsByDate' ] | null | undefined, + comparisonCoupons: ReportDataMap[ 'couponsByDate' ] | null | undefined, + hasComparison = true +): CouponUseData { + if ( ! coupons?.summary ) { + return { + chartData: [], + total: 0, + comparisonTotal: 0, + legendData: [], + }; + } + + const salesWithCoupon = coupons.summary.sales_with_coupon; + const salesWithoutCoupon = coupons.summary.sales_without_coupon; + const totalSales = coupons.summary.total_sales; + + // Pick comparison totals + const comparisonTotalSales = comparisonCoupons?.summary.total_sales || 0; + const comparisonSalesWithCoupon = comparisonCoupons?.summary.sales_with_coupon || 0; + const comparisonSalesWithoutCoupon = comparisonCoupons?.summary.sales_without_coupon || 0; + + // If there are no sales, return empty state + if ( totalSales === 0 ) { + return { + chartData: [], + total: 0, + comparisonTotal: comparisonTotalSales, + legendData: [], + }; + } + + // Build chart data showing sales breakdown + const chartData: DonutChartData = [ + { + label: __( 'With coupons', 'jetpack-premium-analytics' ), + value: salesWithCoupon, + valueDisplay: formatMetricValue( salesWithCoupon, 'currency', { + useMultipliers: true, + decimals: 0, + } ), + }, + { + label: __( 'No coupons', 'jetpack-premium-analytics' ), + value: salesWithoutCoupon, + valueDisplay: formatMetricValue( salesWithoutCoupon, 'currency', { + useMultipliers: true, + decimals: 0, + } ), + }, + ]; + + // Build legend data + const legendData: LegendItem[] = [ + { + label: __( 'With coupons', 'jetpack-premium-analytics' ), + value: salesWithCoupon, + displayValue: formatMetricValue( salesWithCoupon, 'currency', { + useMultipliers: true, + decimals: 0, + } ), + comparison: hasComparison ? comparisonSalesWithCoupon : undefined, + }, + { + label: __( 'No coupons', 'jetpack-premium-analytics' ), + value: salesWithoutCoupon, + displayValue: formatMetricValue( salesWithoutCoupon, 'currency', { + useMultipliers: true, + decimals: 0, + } ), + comparison: hasComparison ? comparisonSalesWithoutCoupon : undefined, + }, + ]; + + return { + chartData, + total: totalSales, + comparisonTotal: comparisonTotalSales, + legendData, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-new-vs-returning-customer-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-new-vs-returning-customer-data.ts new file mode 100644 index 000000000000..3647c61300b2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-new-vs-returning-customer-data.ts @@ -0,0 +1,110 @@ +/** + * External dependencies + */ +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import type { LegendItem } from '../components'; +import type { DonutChartData } from '../components/chart-donut/donut-chart'; +import type { ReportDataMap } from '@jetpack-premium-analytics/data'; + +export interface NewVsReturningCustomerData { + chartData: DonutChartData; + total: number; + comparisonTotal: number; + legendData: LegendItem[]; +} + +/** + * Builds chart and legend data for the New vs Returning Customer widget. + * Shows unique customer counts (not revenue) broken down by new vs returning. + * + * @param customers - Primary period customers by date data + * @param comparisonCustomers - Comparison period customers by date data + * @param hasComparison - Whether comparison period should be included + */ +export function buildNewVsReturningCustomerData( + customers: ReportDataMap[ 'customersByDate' ] | null | undefined, + comparisonCustomers: ReportDataMap[ 'customersByDate' ] | null | undefined, + hasComparison = true +): NewVsReturningCustomerData { + if ( ! customers?.summary ) { + return { + chartData: [], + total: 0, + comparisonTotal: 0, + legendData: [], + }; + } + + const totalCustomers = customers.summary.total_customers; + const newCustomers = customers.summary.new_customers; + const returningCustomers = customers.summary.returning_customers; + + // Pick comparison totals + const comparisonTotalCustomers = comparisonCustomers?.summary?.total_customers || 0; + const comparisonNewCustomers = comparisonCustomers?.summary?.new_customers || 0; + const comparisonReturningCustomers = comparisonCustomers?.summary?.returning_customers || 0; + + // If there are no customers, return empty state + if ( totalCustomers === 0 ) { + return { + chartData: [], + total: 0, + comparisonTotal: comparisonTotalCustomers, + legendData: [], + }; + } + + // Build chart data showing customer counts + // Note: Returning customers first to match design (larger segment first) + const chartData: DonutChartData = [ + { + label: __( 'Returning', 'jetpack-premium-analytics' ), + value: returningCustomers, + valueDisplay: formatMetricValue( returningCustomers, 'number', { + useMultipliers: true, + decimals: 0, + } ), + }, + { + label: __( 'New', 'jetpack-premium-analytics' ), + value: newCustomers, + valueDisplay: formatMetricValue( newCustomers, 'number', { + useMultipliers: true, + decimals: 0, + } ), + }, + ]; + + // Build legend data (same order as chart) + const legendData: LegendItem[] = [ + { + label: __( 'Returning', 'jetpack-premium-analytics' ), + value: returningCustomers, + displayValue: formatMetricValue( returningCustomers, 'number', { + useMultipliers: true, + decimals: 0, + } ), + comparison: hasComparison ? comparisonReturningCustomers : undefined, + }, + { + label: __( 'New', 'jetpack-premium-analytics' ), + value: newCustomers, + displayValue: formatMetricValue( newCustomers, 'number', { + useMultipliers: true, + decimals: 0, + } ), + comparison: hasComparison ? comparisonNewCustomers : undefined, + }, + ]; + + return { + chartData, + total: totalCustomers, + comparisonTotal: comparisonTotalCustomers, + legendData, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-orders-fulfillment-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-orders-fulfillment-data.ts new file mode 100644 index 000000000000..87e38ca5908f --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-orders-fulfillment-data.ts @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import type { LegendItem } from '../components'; +import type { DonutChartData } from '../components/chart-donut/donut-chart'; +import type { ReportDataMap } from '@jetpack-premium-analytics/data'; + +export interface OrdersFulfillmentData { + chartData: DonutChartData; + total: number; + comparisonTotal: number; + legendData: LegendItem[]; +} + +/** + * Builds chart and legend data for the Orders Fulfillment widget. + * + * Takes separate responses from filtered API calls (one for fulfilled + * orders, one for unfulfilled orders) and combines them into donut chart data. + * + * @param fulfilledOrders - Primary period fulfilled orders data + * @param unfulfilledOrders - Primary period unfulfilled orders data + * @param comparisonFulfilledOrders - Comparison period fulfilled orders data + * @param comparisonUnfulfilledOrders - Comparison period unfulfilled orders data + */ +export function buildOrdersFulfillmentData( + fulfilledOrders: ReportDataMap[ 'orders' ] | undefined, + unfulfilledOrders: ReportDataMap[ 'orders' ] | undefined, + comparisonFulfilledOrders: ReportDataMap[ 'orders' ] | undefined, + comparisonUnfulfilledOrders: ReportDataMap[ 'orders' ] | undefined +): OrdersFulfillmentData { + const fulfilledCount = fulfilledOrders?.summary?.orders_no ?? 0; + const unfulfilledCount = unfulfilledOrders?.summary?.orders_no ?? 0; + const totalOrders = fulfilledCount + unfulfilledCount; + + const comparisonFulfilledCount = comparisonFulfilledOrders?.summary?.orders_no ?? 0; + const comparisonUnfulfilledCount = comparisonUnfulfilledOrders?.summary?.orders_no ?? 0; + const comparisonTotalOrders = comparisonFulfilledCount + comparisonUnfulfilledCount; + + if ( totalOrders === 0 ) { + return { + chartData: [], + total: 0, + comparisonTotal: comparisonTotalOrders, + legendData: [], + }; + } + + const formatCount = ( value: number ) => + formatMetricValue( value, 'number', { + useMultipliers: true, + decimals: 0, + } ); + + const chartData: DonutChartData = [ + { + label: __( 'Fulfilled', 'jetpack-premium-analytics' ), + value: fulfilledCount, + valueDisplay: formatCount( fulfilledCount ), + }, + { + label: __( 'Unfulfilled', 'jetpack-premium-analytics' ), + value: unfulfilledCount, + valueDisplay: formatCount( unfulfilledCount ), + }, + ]; + + const legendData: LegendItem[] = [ + { + label: __( 'Fulfilled', 'jetpack-premium-analytics' ), + value: fulfilledCount, + displayValue: formatCount( fulfilledCount ), + comparison: comparisonFulfilledOrders ? comparisonFulfilledCount : undefined, + }, + { + label: __( 'Unfulfilled', 'jetpack-premium-analytics' ), + value: unfulfilledCount, + displayValue: formatCount( unfulfilledCount ), + comparison: comparisonUnfulfilledOrders ? comparisonUnfulfilledCount : undefined, + }, + ]; + + return { + chartData, + total: totalOrders, + comparisonTotal: comparisonTotalOrders, + legendData, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-payment-status-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-payment-status-data.ts new file mode 100644 index 000000000000..9b4e1878a005 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-payment-status-data.ts @@ -0,0 +1,107 @@ +/** + * External dependencies + */ +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import type { LegendItem } from '../components'; +import type { DonutChartData } from '../components/chart-donut/donut-chart'; +import type { ReportDataMap } from '@jetpack-premium-analytics/data'; + +export interface PaymentStatusData { + chartData: DonutChartData; + total: number; + comparisonTotal: number; + legendData: LegendItem[]; +} + +/** + * Builds chart and legend data for the Payment Status widget. + * + * @param orders - Primary period order data + * @param comparisonOrders - Comparison period order data + */ +export function buildPaymentStatusData( + orders: ReportDataMap[ 'orders' ] | undefined, + comparisonOrders: ReportDataMap[ 'orders' ] | undefined +): PaymentStatusData { + if ( ! orders?.summary ) { + return { + chartData: [], + total: 0, + comparisonTotal: 0, + legendData: [], + }; + } + + const { summary } = orders; + const paidNetSales = summary.paid_net_sales; + const unpaidNetSales = summary.unpaid_net_sales; + const totalSales = paidNetSales + unpaidNetSales; + + // Calculate comparison totals + const comparisonPaidNetSales = comparisonOrders?.summary?.paid_net_sales || 0; + const comparisonUnpaidNetSales = comparisonOrders?.summary?.unpaid_net_sales || 0; + const comparisonTotalSales = comparisonPaidNetSales + comparisonUnpaidNetSales; + + // If there are no sales, return empty state + if ( totalSales === 0 ) { + return { + chartData: [], + total: 0, + comparisonTotal: comparisonTotalSales, + legendData: [], + }; + } + + // Build chart data + const chartData: DonutChartData = [ + { + label: __( 'Paid', 'jetpack-premium-analytics' ), + value: paidNetSales, + valueDisplay: formatMetricValue( paidNetSales, 'currency', { + useMultipliers: true, + decimals: 0, + } ), + }, + { + label: __( 'Unpaid', 'jetpack-premium-analytics' ), + value: unpaidNetSales, + valueDisplay: formatMetricValue( unpaidNetSales, 'currency', { + useMultipliers: true, + decimals: 0, + } ), + }, + ]; + + // Build legend data + const legendData: LegendItem[] = [ + { + label: __( 'Paid', 'jetpack-premium-analytics' ), + value: paidNetSales, + displayValue: formatMetricValue( paidNetSales, 'currency', { + useMultipliers: true, + decimals: 0, + } ), + comparison: comparisonOrders ? comparisonPaidNetSales : undefined, + }, + { + label: __( 'Unpaid', 'jetpack-premium-analytics' ), + value: unpaidNetSales, + displayValue: formatMetricValue( unpaidNetSales, 'currency', { + useMultipliers: true, + decimals: 0, + } ), + comparison: comparisonOrders ? comparisonUnpaidNetSales : undefined, + }, + ]; + + return { + chartData, + total: totalSales, + comparisonTotal: comparisonTotalSales, + legendData, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-revenue-by-customer-type-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-revenue-by-customer-type-data.ts new file mode 100644 index 000000000000..3de41d93e279 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-revenue-by-customer-type-data.ts @@ -0,0 +1,78 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { formatLegendLabels } from './format-legend-labels'; +import type { SeriesData } from '@automattic/charts'; +import type { ReportDataMap, ReportParams } from '@jetpack-premium-analytics/data'; + +/** + * Internal dependencies + */ + +export interface RevenueByCustomerTypeData { + chartData: SeriesData[]; +} + +/** + * Builds bar chart data for the Revenue by Customer Type widget. + * + * Shows revenue split between new and returning customers. + * + * @param customers - Primary period customer data + * @param comparisonCustomers - Comparison period customer data + * @param reportParams - Report parameters for generating date range labels + */ +export function buildRevenueByCustomerTypeData( + customers: ReportDataMap[ 'customers' ] | undefined, + comparisonCustomers: ReportDataMap[ 'customers' ] | undefined, + reportParams: ReportParams +): RevenueByCustomerTypeData { + if ( ! customers?.summary ) { + return { + chartData: [], + }; + } + + const { primary: primaryLabel, comparison: comparisonLabel } = formatLegendLabels( reportParams ); + + const { summary } = customers; + const newCustomerSales = summary.new_customer_sales; + const returningCustomerSales = summary.returning_customer_sales; + + // Build bar chart data - each category is a bar + const chartData: SeriesData[] = [ + { + label: primaryLabel, + data: [ + { + label: __( 'Returning', 'jetpack-premium-analytics' ), + value: returningCustomerSales, + }, + { + label: __( 'New', 'jetpack-premium-analytics' ), + value: newCustomerSales, + }, + ], + }, + ]; + + // Add comparison period if available + if ( comparisonCustomers?.summary ) { + const comparisonNewCustomerSales = comparisonCustomers.summary.new_customer_sales || 0; + const comparisonReturningCustomerSales = + comparisonCustomers.summary.returning_customer_sales || 0; + + chartData.push( { + label: comparisonLabel, + data: [ + { label: 'Returning', value: comparisonReturningCustomerSales }, + { label: 'New', value: comparisonNewCustomerSales }, + ], + } ); + } + + return { + chartData, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-coupon-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-coupon-data.ts new file mode 100644 index 000000000000..eb595ca2f305 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-coupon-data.ts @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { formatLegendLabels } from './format-legend-labels'; +import type { SeriesData } from '@automattic/charts'; +import type { ReportDataMap, ReportParams } from '@jetpack-premium-analytics/data'; + +/** + * Internal dependencies + */ + +export interface SalesByCouponData { + chartData: SeriesData[]; +} + +/** + * Builds bar chart data for the Sales by Coupon widget. + * + * Shows revenue distribution by coupon with top coupons plus "Other" segment. + * + * @param coupons - Primary period coupon data + * @param comparisonCoupons - Comparison period coupon data + * @param reportParams - Report parameters for generating date range labels + * @param totalSegments - Number of top coupons to show (rest goes to "Other") + */ +export function buildSalesByCouponData( + coupons: ReportDataMap[ 'coupons' ] | undefined, + comparisonCoupons: ReportDataMap[ 'coupons' ] | undefined, + reportParams: ReportParams, + totalSegments = 3 +): SalesByCouponData { + if ( ! coupons?.summary ) { + return { + chartData: [], + }; + } + + const { primary: primaryLabel, comparison: comparisonLabel } = formatLegendLabels( reportParams ); + + const { data: items } = coupons; + + // Process coupons and limit to totalSegments + const topCoupons = items.slice( 0, totalSegments ); + + // Create a map of comparison data by coupon code + const comparisonMap = new Map< string, number >(); + if ( comparisonCoupons ) { + comparisonCoupons.data.forEach( item => { + comparisonMap.set( item.coupon_code, item.total_sales ); + } ); + } + + // Build current period data points + const currentPeriodData = topCoupons.map( item => ( { + label: item.coupon_code, + value: item.total_sales, + } ) ); + + // Add "Other" segment if there are more coupons than shown + if ( items.length > totalSegments ) { + const otherSales = items + .slice( totalSegments ) + .reduce( ( sum, item ) => sum + item.total_sales, 0 ); + + currentPeriodData.push( { + label: __( 'Other', 'jetpack-premium-analytics' ), + value: otherSales, + } ); + } + + // Build bar chart data - current period + const chartData: SeriesData[] = [ + { + label: primaryLabel, + data: currentPeriodData, + }, + ]; + + // Add comparison period if available + if ( comparisonCoupons?.summary ) { + const comparisonPeriodData = topCoupons.map( item => ( { + label: item.coupon_code, + value: comparisonMap.get( item.coupon_code ) || 0, + } ) ); + + // Add "Other" segment for comparison + if ( items.length > totalSegments ) { + const otherComparison = comparisonCoupons.data + .slice( totalSegments ) + .reduce( ( sum, item ) => sum + item.total_sales, 0 ); + + comparisonPeriodData.push( { + label: __( 'Other', 'jetpack-premium-analytics' ), + value: otherComparison, + } ); + } + + chartData.push( { + label: comparisonLabel, + data: comparisonPeriodData, + } ); + } + + return { + chartData, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-device-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-device-data.ts new file mode 100644 index 000000000000..b37056adc5bf --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-device-data.ts @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { formatLegendLabels } from './format-legend-labels'; +import type { SeriesData } from '@automattic/charts'; +import type { ReportDataMap, ReportParams } from '@jetpack-premium-analytics/data'; + +/** + * Internal dependencies + */ + +export interface SalesByDeviceData { + chartData: SeriesData[]; +} + +/** + * Builds bar chart data for the Sales by Device widget. + * + * Shows sales breakdown by device type (Desktop, Mobile, Tablet). + * + * @param orderAttribution - Primary period order attribution data + * @param hasComparison - Whether comparison period should be included + * @param reportParams - Report parameters for generating date range labels + */ +export function buildSalesByDeviceData( + orderAttribution: ReportDataMap[ 'order-attribution' ] | undefined, + hasComparison: boolean, + reportParams: ReportParams +): SalesByDeviceData { + if ( ! orderAttribution?.data || orderAttribution.data.length === 0 ) { + return { + chartData: [], + }; + } + + const { primary: primaryLabel, comparison: comparisonLabel } = formatLegendLabels( reportParams ); + + const { data } = orderAttribution; + + // Build bar chart data - current period + const chartData: SeriesData[] = [ + { + label: primaryLabel, + data: data.map( item => ( { + label: item.item || __( 'Unassigned', 'jetpack-premium-analytics' ), + value: item.current_period?.value ?? 0, + } ) ), + }, + ]; + + // Add comparison period if available + if ( hasComparison ) { + chartData.push( { + label: comparisonLabel, + data: data.map( item => ( { + label: item.item || __( 'Unassigned', 'jetpack-premium-analytics' ), + value: item.previous_period?.value ?? 0, + } ) ), + } ); + } + + return { + chartData, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-utm-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-utm-data.ts new file mode 100644 index 000000000000..b55b32de46d5 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sales-by-utm-data.ts @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { calculateDelta } from './calculate-delta'; +import type { LeaderboardChartData } from '../components/chart-leaderboard'; +import type { ReportDataMap } from '@jetpack-premium-analytics/data'; + +/** + * Internal dependencies + */ + +/** + * Builds leaderboard chart data for the Sales by UTM widget. + * + * Transforms order attribution data into the format required by LeaderboardChart. + * + * @param orderAttribution - Primary period order attribution data + * @param maxEntries - Maximum number of entries to include in the leaderboard + * @return Processed data ready for LeaderboardChart component + */ +export function buildSalesByUtmData( + orderAttribution: ReportDataMap[ 'order-attribution' ] | undefined, + maxEntries = 4 +): LeaderboardChartData { + if ( ! orderAttribution?.data || orderAttribution.data.length === 0 ) { + return []; + } + + const { data } = orderAttribution; + + // Find the max value for share calculation + const maxValue = Math.max( + ...data.map( item => + Math.max( item.current_period.value || 0, item.previous_period?.value || 0 ) + ), + 1 // Prevent division by zero + ); + + return data.slice( 0, maxEntries ).map( ( item, idx ) => { + const currentValue = item.current_period.value || 0; + const previousValue = item.previous_period?.value ?? 0; + const delta = calculateDelta( currentValue, previousValue ); + + return { + id: item.item ? String( item.item ) : String( idx ), + label: item.item || __( 'Unassigned', 'jetpack-premium-analytics' ), + currentValue, + previousValue, + currentShare: ( currentValue / maxValue ) * 100, + previousShare: ( previousValue / maxValue ) * 100, + delta, + }; + } ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sessions-by-device-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sessions-by-device-data.ts new file mode 100644 index 000000000000..4973191065a9 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-sessions-by-device-data.ts @@ -0,0 +1,115 @@ +/** + * External dependencies + */ +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import type { LegendItem } from '../components'; +import type { SemiCircleChartData } from '../components/chart-semi-circle/semi-circle-chart'; +import type { ReportDataMap } from '@jetpack-premium-analytics/data'; + +export interface SessionsByDeviceData { + chartData: SemiCircleChartData; + total: number; + comparisonTotal: number; + legendData: LegendItem[]; +} + +/** + * Device type display labels. + * Maps API device_type values to user-friendly labels. + */ +const DEVICE_LABELS: Record< string, string > = { + mobile: __( 'Mobile', 'jetpack-premium-analytics' ), + desktop: __( 'Desktop', 'jetpack-premium-analytics' ), + tablet: __( 'Tablet', 'jetpack-premium-analytics' ), +}; + +/** + * Get the display label for a device type. + * + * @param deviceType - The device type from the API + */ +function getDeviceLabel( deviceType: string ): string { + const normalized = deviceType.toLowerCase(); + return DEVICE_LABELS[ normalized ] || deviceType; +} + +/** + * Builds chart and legend data for the Sessions by Device widget. + * + * @param sessionsByDevice - Primary period sessions by device data + * @param comparisonSessionsByDevice - Comparison period sessions by device data + */ +export function buildSessionsByDeviceData( + sessionsByDevice: ReportDataMap[ 'sessionsByDevice' ] | undefined, + comparisonSessionsByDevice?: ReportDataMap[ 'sessionsByDevice' ] | undefined +): SessionsByDeviceData { + if ( ! sessionsByDevice?.data || sessionsByDevice.data.length === 0 ) { + return { + chartData: [], + total: 0, + comparisonTotal: 0, + legendData: [], + }; + } + + const { data, summary } = sessionsByDevice; + const total = summary.total_sessions; + const comparisonTotal = comparisonSessionsByDevice?.summary?.total_sessions || 0; + + // If there are no sessions, return empty state + if ( total === 0 ) { + return { + chartData: [], + total: 0, + comparisonTotal, + legendData: [], + }; + } + + // Create a map of comparison data by device type + const comparisonMap = new Map< string, number >(); + if ( comparisonSessionsByDevice?.data ) { + comparisonSessionsByDevice.data.forEach( item => { + comparisonMap.set( item.device_type.toLowerCase(), item.active_sessions ); + } ); + } + + // Build chart data + const chartData: SemiCircleChartData = data.map( item => ( { + label: getDeviceLabel( item.device_type ), + value: item.active_sessions, + valueDisplay: formatMetricValue( item.active_sessions, 'number', { + useMultipliers: true, + decimals: 0, + } ), + } ) ); + + // Build legend data + const legendData: LegendItem[] = data.map( item => { + const normalizedType = item.device_type.toLowerCase(); + const comparisonValue = comparisonSessionsByDevice + ? comparisonMap.get( normalizedType ) || 0 + : undefined; + + return { + label: getDeviceLabel( item.device_type ), + value: item.active_sessions, + displayValue: formatMetricValue( item.active_sessions, 'number', { + useMultipliers: true, + decimals: 0, + } ), + comparison: comparisonValue, + }; + } ); + + return { + chartData, + total, + comparisonTotal, + legendData, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-time-series-chart-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-time-series-chart-data.ts new file mode 100644 index 000000000000..b18c4c04eb55 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-time-series-chart-data.ts @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import { localTZDate } from '@jetpack-premium-analytics/data'; +import { formatDateRange } from '@jetpack-premium-analytics/formatters'; +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import type { + ComparativeLineChartSeries, + ComparativeDatePointDate, +} from '../components/chart-comparative-line/types'; + +/** + * Generic type for time series data that has date_start and metric values + */ +export type TimeSeriesData = { + date_start: string; + [ key: string ]: string | number; +}; + +/** + * Generic type for time series response. + * The summary only needs date_start and date_end for chart labels, + * so we use a loose constraint that accepts any summary with those fields. + */ +type TimeSeriesResponse< T extends TimeSeriesData > = { + data: T[]; + summary: { date_start: string; date_end: string }; +}; + +/** + * Map time series items array into chart series data. + */ +function mapTimeSeriesToLineChartData< T extends TimeSeriesData >( + data: T[], + metricKey: keyof T +): ComparativeDatePointDate[] { + if ( ! data ) { + return []; + } + + return data.map( item => ( { + date: localTZDate( item.date_start ), + value: Number( item[ metricKey ] ), + } ) ); +} + +type BuildTimeSeriesChartOptions< T extends TimeSeriesData > = { + primary: TimeSeriesResponse< T >; + comparison?: TimeSeriesResponse< T >; + metricKey: keyof T; + emptyDataFallback?: 'empty-array' | 'no-data-series'; +}; + +/** + * Generic function to build line chart series from time series data + */ +export function buildTimeSeriesChartData< T extends TimeSeriesData >( { + primary, + comparison, + metricKey, + emptyDataFallback = 'empty-array', +}: BuildTimeSeriesChartOptions< T > ): ComparativeLineChartSeries[] { + if ( ! primary.data?.length ) { + if ( emptyDataFallback === 'no-data-series' ) { + return [ + { + label: __( 'No data available', 'jetpack-premium-analytics' ), + data: [], + }, + ]; + } + return []; + } + + const primarySeries: ComparativeLineChartSeries = { + label: formatDateRange( { + from: localTZDate( primary.summary.date_start ), + to: localTZDate( primary.summary.date_end ), + } ), + data: mapTimeSeriesToLineChartData( primary.data, metricKey ), + group: 'primary', + options: {}, + }; + + if ( ! comparison?.data?.length ) { + return [ primarySeries ]; + } + + const comparisonSeries: ComparativeLineChartSeries = { + label: formatDateRange( { + from: localTZDate( comparison.summary.date_start ), + to: localTZDate( comparison.summary.date_end ), + } ), + data: mapTimeSeriesToLineChartData( comparison.data, metricKey ), + group: 'primary', + options: { + type: 'comparison', + }, + }; + + return [ primarySeries, comparisonSeries ]; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-total-returns-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-total-returns-data.ts new file mode 100644 index 000000000000..e4937bb25b4c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-total-returns-data.ts @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { formatLegendLabels } from './format-legend-labels'; +import type { SeriesData } from '@automattic/charts'; +import type { ReportDataMap, ReportParams } from '@jetpack-premium-analytics/data'; + +/** + * Internal dependencies + */ + +export interface TotalReturnsData { + chartData: SeriesData[]; +} + +/** + * Builds bar chart data for the Total Returns widget. + * + * Shows refunds and net sales as a bar chart, which properly + * supports negative values for refunds visualization. + * + * @param orders - Primary period orders data + * @param comparisonOrders - Comparison period orders data + * @param reportParams - Report parameters for generating date range labels + */ +export function buildTotalReturnsData( + orders: ReportDataMap[ 'orders' ] | null | undefined, + comparisonOrders: ReportDataMap[ 'orders' ] | null | undefined, + reportParams: ReportParams +): TotalReturnsData { + if ( ! orders?.data || ! orders?.summary ) { + return { + chartData: [], + }; + } + + const refundsAmount = orders.summary.refunds ?? 0; + const comparisonRefundsAmount = comparisonOrders?.summary?.refunds ?? 0; + + // When there are no refunds in either period, return empty + // data so the widget shows an empty state instead of + // misleadingly displaying total sales as "returns". + if ( refundsAmount === 0 && comparisonRefundsAmount === 0 ) { + return { + chartData: [], + }; + } + + const { primary: primaryLabel, comparison: comparisonLabel } = formatLegendLabels( reportParams ); + const totalSales = orders.summary.total_sales ?? 0; + + // Net sales (total sales minus refunds) + const salesAmount = Math.max( 0, totalSales - refundsAmount ); + + // Build bar chart data - each category is a bar + const chartData: SeriesData[] = [ + { + label: primaryLabel, + data: [ + { label: 'Total sales', value: salesAmount }, + { + label: __( 'Refunds', 'jetpack-premium-analytics' ), + value: refundsAmount, + }, + ], + }, + ]; + + // Add comparison period if available + if ( comparisonOrders?.summary ) { + const comparisonTotalRefunds = comparisonOrders.summary.refunds || 0; + const comparisonTotalSales = comparisonOrders.summary.total_sales || 0; + const comparisonSalesAmount = Math.max( 0, comparisonTotalSales - comparisonTotalRefunds ); + + chartData.push( { + label: comparisonLabel, + data: [ + { + label: __( 'Total sales', 'jetpack-premium-analytics' ), + value: comparisonSalesAmount, + }, + { + label: __( 'Refunds', 'jetpack-premium-analytics' ), + value: comparisonTotalRefunds, + }, + ], + } ); + } + + return { + chartData, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-visitors-by-location-data.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-visitors-by-location-data.ts new file mode 100644 index 000000000000..068271bdc6ec --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/build-visitors-by-location-data.ts @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import type { LeaderboardChartData } from '../components/chart-leaderboard/leaderboard-chart'; +import type { GeoData } from '@automattic/charts'; + +export type Region = 'US' | 'world'; + +export type VisitorsByLocationData = { + geoData: GeoData; + leaderboardData: LeaderboardChartData; +}; + +export type LocationDataEntry = { + id: string; + label: string; + value: number; +}; + +type BuildVisitorsByLocationDataParams = { + primaryData: LocationDataEntry[]; + comparisonData?: LocationDataEntry[]; + region: Region; + limit?: number; +}; + +/** + * Build geo chart and leaderboard data from raw location data. + * + * @param params - Build parameters + * @param params.primaryData - Primary period data + * @param params.comparisonData - Comparison period data (optional) + * @param params.region - The region ('US' or 'world') + * @param params.limit - Maximum number of items for leaderboard (default: 5) + * @return Geo chart data and leaderboard data + */ +export function buildVisitorsByLocationData( { + primaryData, + comparisonData, + region, + limit = 5, +}: BuildVisitorsByLocationDataParams ): VisitorsByLocationData { + const headerLabel = + region === 'US' + ? __( 'State', 'jetpack-premium-analytics' ) + : __( 'Country', 'jetpack-premium-analytics' ); + + // Build geo chart data + const geoData: GeoData = [ + [ headerLabel, 'Visitors' ], + ...primaryData.map( item => [ item.label, item.value ] as [ string, number ] ), + ]; + + // Find max values for bar width scaling (largest value = 100% width) + const maxPrimaryValue = Math.max( ...primaryData.map( d => d.value ), 0 ); + const maxComparisonValue = comparisonData + ? Math.max( ...comparisonData.map( d => d.value ), 0 ) + : 0; + + // Build leaderboard data (top N items) + const leaderboardData: LeaderboardChartData = primaryData.slice( 0, limit ).map( item => { + const comparisonItem = comparisonData?.find( c => c.id === item.id ); + const previousValue = comparisonItem?.value ?? 0; + const currentShare = maxPrimaryValue > 0 ? ( item.value / maxPrimaryValue ) * 100 : 0; + const previousShare = maxComparisonValue > 0 ? ( previousValue / maxComparisonValue ) * 100 : 0; + const delta = previousValue > 0 ? ( ( item.value - previousValue ) / previousValue ) * 100 : 0; + + return { + id: item.id, + label: item.label, + currentValue: item.value, + previousValue, + currentShare, + previousShare, + delta, + }; + } ); + + return { geoData, leaderboardData }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/calculate-delta.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/calculate-delta.ts new file mode 100644 index 000000000000..899000fcdd17 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/calculate-delta.ts @@ -0,0 +1,30 @@ +/** + * Calculates the percentage change (delta) between two values. + * + * Handles edge cases where the previous value is zero: + * - 0 → 0: Returns 0% (no change) + * - 0 → positive: Returns 100% (instead of infinity, representing "new/appeared") + * - 0 → negative: Returns 0% (no meaningful decrease from zero) + * + * @param currentValue - Current period value + * @param previousValue - Previous period value + * @return Percentage change as a number (e.g., 50 for 50% increase, -25 for 25% decrease) + * + * @example + * calculateDelta(150, 100) // Returns 50 (50% increase) + * calculateDelta(75, 100) // Returns -25 (25% decrease) + * calculateDelta(100, 0) // Returns 100 (new item, instead of infinity) + * calculateDelta(0, 0) // Returns 0 (no change) + * calculateDelta(0, 100) // Returns -100 (complete disappearance) + */ +export function calculateDelta( currentValue: number, previousValue: number ): number { + // Handle the case where previous value is zero + if ( previousValue === 0 ) { + // If previous was 0 and current is positive, show 100% increase + // If both are 0, show 0% change + return currentValue > 0 ? 100 : 0; + } + + // Standard percentage change calculation + return ( ( currentValue - previousValue ) / previousValue ) * 100; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/chart-empty-state.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/chart-empty-state.ts new file mode 100644 index 000000000000..1094b9b81a3d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/chart-empty-state.ts @@ -0,0 +1,64 @@ +/** + * Shared utilities for handling empty chart data states. + * Used by BarChart, ComparativeLineChart, DonutChart, and SemiCircleChart. + */ + +/** + * External dependencies + */ +import type { DataPointPercentage } from '@automattic/charts'; + +/** + * Series data shape for bar and line charts (nested array format). + */ +type SeriesWithData = { + data: Array< { value: number | null } >; +}; + +/** + * Checks if chart data is empty (all values are 0 or null). + * Used to disable tooltips and apply fixed Y-axis domains when there's no meaningful data. + * + * @param series - Array of series data to check + * @return True if all values across all series are 0 or null + */ +export function isEmptyChartData( series: SeriesWithData[] ): boolean { + return series.every( s => s.data.every( point => point.value === 0 || point.value === null ) ); +} + +/** + * Checks if pie chart data is empty (all values are 0). + * Used for DonutChart and SemiCircleChart. + * + * @param data - Array of DataPointPercentage to check + * @return True if data is empty or all values are 0 + */ +export function isEmptyPieChartData( data: DataPointPercentage[] | undefined | null ): boolean { + if ( ! data || data.length === 0 ) { + return true; + } + return data.every( item => item.value === 0 ); +} + +/** + * Returns a sensible Y-axis domain for empty chart data based on metric type. + * Each domain is chosen to produce evenly spaced, readable tick values: + * - currency: 0-4K (ticks: 0, 1K, 2K, 3K, 4K) + * - percentage: 0-1.0 (ticks: 0%, 25%, 50%, 75%, 100%) + * - number: 0-80 (ticks: 0, 20, 40, 60, 80) + * + * @param metricType - The type of data format (currency, number, percentage) + * @return Y-axis domain tuple [min, max] + */ +export function getEmptyChartDomain( metricType: string ): [ number, number ] { + if ( metricType === 'currency' ) { + return [ 0, 4000 ]; + } + + if ( metricType === 'percentage' ) { + return [ 0, 1.0 ]; + } + + // Default for 'number' and other types + return [ 0, 80 ]; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/flag-url.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/flag-url.ts new file mode 100644 index 000000000000..4c6744d7cf59 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/flag-url.ts @@ -0,0 +1,13 @@ +/** + * Given a country code, return a flag SVG URL from CDN. + * @param countryCode - A two-letter ISO 3166-1 country code (lowercase) + * @return Flag SVG URL + */ +export function flagUrl( countryCode: string ): string | null { + if ( ! countryCode || countryCode.length !== 2 ) { + return null; + } + + // Use jsDelivr CDN to serve flag-icons package SVGs + return `https://cdn.jsdelivr.net/npm/flag-icons@7.5.0/flags/4x3/${ countryCode.toLowerCase() }.svg`; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/format-legend-labels.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/format-legend-labels.ts new file mode 100644 index 000000000000..cdd97c486a8f --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/format-legend-labels.ts @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { formatDateRange } from '@jetpack-premium-analytics/formatters'; +import { __ } from '@wordpress/i18n'; +import type { LegendLabels } from '../components/chart-leaderboard'; +import type { ReportParams } from '@jetpack-premium-analytics/data'; + +/** + * Internal dependencies + */ + +/** + * Formats legend labels from report parameters. + * + * Creates human-readable legend labels for chart comparisons based on the + * date ranges in the report parameters. If date ranges are not available, + * returns default period labels. + * + * @param reportParams - Report parameters containing date ranges + * @return Object with primary and comparison legend labels + * + * @example + * ```ts + * const labels = formatLegendLabels({ + * from: '2024-01-01', + * to: '2024-01-31', + * compare_from: '2023-12-01', + * compare_to: '2023-12-31', + * interval: 'day' + * }); + * // Returns: { primary: 'Jan 1 - 31, 2024', comparison: 'Dec 1 - 31, 2023' } + * ``` + */ +export function formatLegendLabels( reportParams: ReportParams ): LegendLabels { + const primaryLabel = formatDateRange( { + from: new Date( reportParams.from ), + to: new Date( reportParams.to ), + } ); + + const comparisonLabel = + reportParams.compare_from && reportParams.compare_to + ? formatDateRange( { + from: new Date( reportParams.compare_from ), + to: new Date( reportParams.compare_to ), + } ) + : __( 'Previous period', 'jetpack-premium-analytics' ); + + return { + primary: primaryLabel, + comparison: comparisonLabel, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/format-orders-metrics.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/format-orders-metrics.ts new file mode 100644 index 000000000000..ac6be5ed936d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/format-orders-metrics.ts @@ -0,0 +1,104 @@ +/** + * External dependencies + */ +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +/** + * Internal dependencies + */ +import type { MetricKey } from '../types'; + +type FormatMetricOptions = NonNullable< Parameters< typeof formatMetricValue >[ 2 ] >; + +type MetricType = NonNullable< Parameters< typeof formatMetricValue >[ 1 ] >; + +const metricFormatMap: Record< + MetricKey, + { metricType: MetricType; format?: FormatMetricOptions } +> = { + orders_no: { + metricType: 'number', + }, + total_sales: { + metricType: 'currency', + }, + average_order_value: { + metricType: 'currency', + }, + avg_items: { + metricType: 'average', + }, + orders_value_net: { + metricType: 'currency', + }, + orders_value_gross: { + metricType: 'currency', + }, + coupons: { + metricType: 'currency', + }, + profit_margin: { + metricType: 'currency', + }, + visitors: { + metricType: 'number', + format: { + useMultipliers: true, + decimals: 0, + }, + }, + conversion_rate: { + metricType: 'percentage', + format: { + decimals: 1, + }, + }, + customers: { + metricType: 'number', + format: { + useMultipliers: true, + decimals: 0, + }, + }, + // Booking status metrics + status_unpaid: { + metricType: 'number', + }, + status_pending_confirmation: { + metricType: 'number', + }, + status_confirmed: { + metricType: 'number', + }, + status_paid: { + metricType: 'number', + }, + status_cancelled: { + metricType: 'number', + }, + status_complete: { + metricType: 'number', + }, + // Booking attendance metrics + attendance_status_booked: { + metricType: 'number', + }, + attendance_status_no_show: { + metricType: 'number', + }, + attendance_status_checked_in: { + metricType: 'number', + }, +}; + +export function formatOrderMetric( metricKey: MetricKey, options?: FormatMetricOptions ) { + return ( value: number ) => + formatMetricValue( value, metricFormatMap[ metricKey ].metricType, options ?? {} ); +} + +export function getFormatByMetricKey( metricKey: MetricKey ) { + const config = metricFormatMap[ metricKey ]; + return { + type: config.metricType, + options: config.format, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/fulfillment-filters.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/fulfillment-filters.ts new file mode 100644 index 000000000000..0b01177c8622 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/fulfillment-filters.ts @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import type { FilterCondition } from '@jetpack-premium-analytics/data'; + +/** + * Filter for fulfilled orders only. + */ +export const FULFILLED_ORDERS_FILTER: FilterCondition = { + key: 'fulfillment_status', + value: 'fulfilled', + compare: '=', +}; + +/** + * Filter for unfulfilled orders (includes orders with no fulfillments). + */ +export const UNFULFILLED_ORDERS_FILTER: FilterCondition = { + key: 'fulfillment_status', + value: [ 'unfulfilled', 'no_fulfillments' ], + compare: 'IN', +}; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts new file mode 100644 index 000000000000..547a4b0dfd99 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/index.ts @@ -0,0 +1,47 @@ +export { formatOrderMetric, getFormatByMetricKey } from './format-orders-metrics'; +export { buildTimeSeriesChartData, type TimeSeriesData } from './build-time-series-chart-data'; +export { buildSalesByCouponData, type SalesByCouponData } from './build-sales-by-coupon-data'; +export { PHYSICAL_PRODUCTS_FILTER, BOOKINGS_FILTER } from './product-type-filters'; +export { FULFILLED_ORDERS_FILTER, UNFULFILLED_ORDERS_FILTER } from './fulfillment-filters'; +export { PAYMENT_STATUS_FILTERS } from './payment-status-filters'; +export { + buildRevenueByCustomerTypeData, + type RevenueByCustomerTypeData, +} from './build-revenue-by-customer-type-data'; +export { + buildNewVsReturningCustomerData, + type NewVsReturningCustomerData, +} from './build-new-vs-returning-customer-data'; +export { + resolveSegmentStyles, + applyStylesToItems, + type SegmentStyle, + type ColorableItem, +} from './segment-styles'; +export { buildSalesByDeviceData, type SalesByDeviceData } from './build-sales-by-device-data'; +export { + buildSessionsByDeviceData, + type SessionsByDeviceData, +} from './build-sessions-by-device-data'; +export { + buildBookingsByAttendanceData, + type BookingsByAttendanceData, +} from './build-bookings-by-attendance-data'; +export { buildTotalReturnsData, type TotalReturnsData } from './build-total-returns-data'; +export { buildSalesByUtmData } from './build-sales-by-utm-data'; +export { formatLegendLabels } from './format-legend-labels'; +export { calculateDelta } from './calculate-delta'; +export { buildCouponUseData, type CouponUseData } from './build-coupon-use-data'; +export { buildPaymentStatusData, type PaymentStatusData } from './build-payment-status-data'; +export { + buildOrdersFulfillmentData, + type OrdersFulfillmentData, +} from './build-orders-fulfillment-data'; +export { + buildVisitorsByLocationData, + type VisitorsByLocationData, + type LocationDataEntry, + type Region, +} from './build-visitors-by-location-data'; +export { flagUrl } from './flag-url'; +export { isEmptyChartData, isEmptyPieChartData, getEmptyChartDomain } from './chart-empty-state'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/payment-status-filters.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/payment-status-filters.ts new file mode 100644 index 000000000000..6fdd6c085cd4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/payment-status-filters.ts @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import type { FilterCondition } from '@jetpack-premium-analytics/data'; + +/** + * Filter for order statuses relevant to payment tracking. + * + * The Orders API excludes pending/failed/cancelled by default + * (via woocommerce_excluded_report_order_statuses). The payment + * status widget needs pending orders for unpaid_net_sales, so we + * pass an explicit status filter to override the default exclusion. + * + * Includes statuses that represent the payment lifecycle: pending + * (unpaid), processing, on-hold, completed (paid), and refunded. + * Failed, cancelled, and checkout-draft are excluded because they + * don't represent meaningful payment states. + */ +export const PAYMENT_STATUS_FILTERS: FilterCondition[] = [ + { + key: 'status', + value: [ 'wc-pending', 'wc-processing', 'wc-on-hold', 'wc-completed', 'wc-refunded' ], + compare: 'IN', + }, +]; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/product-type-filters.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/product-type-filters.ts new file mode 100644 index 000000000000..88f5241dcc22 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/product-type-filters.ts @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import type { FilterCondition } from '@jetpack-premium-analytics/data'; + +/** + * Product type filter constants for coupon-based widgets. + * + * These filters are used to segment coupon sales data by product type. + * Each widget instance should specify which filter to use based on + * the product category it targets. + * + * @see https://github.com/woocommerce/woocommerce-analytics/blob/develop/src/Utilities/OrderProductTypeTracker.php + */ + +/** + * Filter for physical products only. + * Includes: simple, variable, and variation product types. + * Excludes: digital/downloadable products and bookings. + */ +export const PHYSICAL_PRODUCTS_FILTER: FilterCondition = { + key: 'product_type', + value: [ 'simple', 'variable', 'variation' ], + compare: 'IN', +}; + +/** + * Filter for booking products only. + * Includes: booking, bookable-event, and bookable-service product types. + * Used by WooCommerce Bookings extension. + * + * @see OrderProductTypeTracker::BOOKINGS_TYPES + */ +export const BOOKINGS_FILTER: FilterCondition = { + key: 'product_type', + value: [ 'booking', 'bookable-event', 'bookable-service' ], + compare: 'IN', +}; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/segment-styles.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/segment-styles.ts new file mode 100644 index 000000000000..80dcb6d9eba3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/segment-styles.ts @@ -0,0 +1,59 @@ +/** + * Style configuration for a single segment. + */ +export type SegmentStyle = { + /** Segment fill color */ + color: string; +}; + +/** + * Item with optional color property. + */ +export type ColorableItem = { color?: string }; + +/** + * Segment data with optional color property. + */ +type SegmentData = { color?: string }; + +/** + * Resolves segment styles from either the explicit styles prop or chartData. + * Priority: styles prop > chartData[].color + * + * @param stylesProp - Explicit styles passed as component prop + * @param chartData - Chart data (may contain color per segment) + * @return Array of resolved styles, one per segment + */ +export function resolveSegmentStyles( + stylesProp: SegmentStyle[] | undefined, + chartData: SegmentData[] +): SegmentStyle[] { + if ( stylesProp?.length ) { + return stylesProp; + } + + return chartData.map( segment => ( { + color: segment.color ?? '', + } ) ); +} + +/** + * Applies resolved styles (colors) to an array of items. + * Works with any item type that has an optional color property. + * + * @param items - Array of items to style + * @param resolvedStyles - Styles to apply + * @return Items with styles applied + */ +export function applyStylesToItems< T extends ColorableItem >( + items: T[], + resolvedStyles: SegmentStyle[] +): T[] { + return items.map( ( item, index ) => { + const style = resolvedStyles[ index ] ?? resolvedStyles[ 0 ]; + return { + ...item, + color: style?.color || item.color, + }; + } ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/store-info.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/store-info.ts new file mode 100644 index 000000000000..ff5103005ee7 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/helpers/store-info.ts @@ -0,0 +1,21 @@ +type StoreInfo = { + /** + * ISO 8601 date string of when the store was launched, if known. + */ + launchedDate?: string; +}; + +/** + * Local stand-in for `getStoreInfo` from `@woocommerce-next/data` (next-admin), + * which is not published to npm. Only `launchedDate` is consumed by this + * package, where it feeds `getDefaultPreset( launchedDate )` — that helper + * falls back to its default preset when `launchedDate` is undefined, so + * returning an empty object keeps the behavior correct until real store info + * is available. + * + * TODO: Source store info from the analytics boot/localized settings once the + * host exposes it. + */ +export function getStoreInfo(): StoreInfo { + return {}; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/index.ts new file mode 100644 index 000000000000..6151d15e74ff --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/index.ts @@ -0,0 +1,4 @@ +export { useAttributesWithSearchFallback } from './use-attributes-with-search-fallback'; +export { useChartTheme, type WooChartTheme } from './use-chart-theme'; +export { useSeriesStyles } from './use-series-styles'; +export { useWidgetError } from './use-widget-error'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-attributes-with-search-fallback.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-attributes-with-search-fallback.ts new file mode 100644 index 000000000000..6ce75cf2295f --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-attributes-with-search-fallback.ts @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import { useSearch } from '@wordpress/route'; +/** + * Internal dependencies + */ +import type { ReportParamsFieldAttributes } from '../fields'; + +/** + * Hook that provides widget attributes with URL search params as fallback. + * + * When attributes don't contain reportParams (empty or missing), this hook + * will attempt to get them from the URL using useSearch(). This is useful + * for dashboard widgets that can work in two contexts: + * - Dashboard-v2: No attributes, needs URL params + * - Post-Launch: Has attributes, ignores URL + * + * @param { Partial< ReportParamsFieldAttributes > } attributes - The widget attributes (may be empty or partial) + * @return { ReportParamsFieldAttributes } Effective attributes with reportParams guaranteed + * + * @example + * ```typescript + * function MyWidgetRender( { attributes } ) { + * const effectiveAttributes = useAttributesWithSearchFallback( attributes ); + * return ; + * } + * ``` + */ +export function useAttributesWithSearchFallback( + attributes: Partial< ReportParamsFieldAttributes > +): ReportParamsFieldAttributes { + /* + * Try to get search params from router. + * This may fail in contexts without router (e.g., Post-Launch). + * We declare the variable and use try/catch to handle both cases. + */ + let search: Record< string, any >; + + try { + // eslint-disable-next-line react-hooks/rules-of-hooks + search = useSearch( { + from: '/wc-analytics/dashboard', + } ); + } catch { + /* + * Not in router context or route doesn't exist. + * This can happen in Post-Launch where widgets are rendered + * outside the Analytics dashboard context. + */ + search = {}; + } + + /* + * Check if reportParams exists and is not empty. + * If it exists, use the provided attributes. + * Otherwise, build attributes from URL search params. + */ + const hasReportParams = + !! attributes?.reportParams && Object.keys( attributes.reportParams ).length > 0; + + return hasReportParams ? ( attributes as ReportParamsFieldAttributes ) : { reportParams: search }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-chart-theme.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-chart-theme.ts new file mode 100644 index 000000000000..b0e60a135051 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-chart-theme.ts @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import { useMemo } from 'react'; +import { WOO_COLORS } from '../constants'; +import { useColorPreference } from './use-color-preference'; +import type { ChartTheme } from '@automattic/charts'; + +/** + * Internal dependencies + */ + +/** + * Extended chart theme with WooCommerce-specific properties. + * Extends the base ChartTheme from @automattic/charts. + */ +export type WooChartTheme = ChartTheme & { + leaderboardChart: ChartTheme[ 'leaderboardChart' ] & { + barBorderRadius: string; + }; +}; + +export function useChartTheme(): WooChartTheme { + const { preferences } = useColorPreference(); + + return useMemo( () => { + // If the user is using a custom color theme, use colors generated from the design system accent + // color token, otherwise use the default Woo theme colors. + const colors = + preferences.interfaceTheme === 'custom' + ? [ '--wpds-color-fg-interactive-brand' ] + : WOO_COLORS; + + return { + backgroundColor: 'var(--wpds-color-bg-surface-neutral-strong)', + labelBackgroundColor: 'var(--wpds-color-bg-interactive-neutral-weak)', + labelTextColor: 'var(--wpds-color-fg-interactive-neutral-strong)', + colors, + gridStyles: { + stroke: 'var(--wpds-color-stroke-surface-neutral)', + strokeWidth: 1, + }, + tickLength: 4, + gridColor: '', + gridColorDark: '', + svgLabelSmall: { + fill: 'var(--wpds-color-fg-content-neutral-weak)', + }, + xTickLineStyles: { stroke: '' }, + xAxisLineStyles: { + stroke: 'var(--wpds-color-stroke-surface-neutral)', + strokeWidth: 1, + }, + legend: { + labelStyles: { + fontSize: 'var(--wpds-typography-font-size-sm)', + fontWeight: 400, + color: 'var(--wpds-color-fg-content-neutral)', + }, + containerStyles: { + rowGap: 'var( --wpds-dimension-padding-sm )', + columnGap: 'var( --wpds-dimension-padding-sm )', + }, + shapeStyles: [ + { + transform: 'translate(0, 1px)', + }, + { + transform: 'translate(0, 1px)', + strokeDasharray: '2, 2, 3, 2, 3, 2, 2', + }, + ], + }, + leaderboardChart: { + rowGap: 12, + columnGap: 4, + labelSpacing: 1.5, + barBorderRadius: 'var(--wpds-border-radius-md)', + deltaColors: [ + 'var(--wpds-color-fg-content-error-weak)', + 'var(--wpds-color-fg-content-neutral)', + 'var(--wpds-color-fg-content-success-weak)', + ] as [ string, string, string ], // [ negative, neutral, positive ] + }, + conversionFunnelChart: { + backgroundColor: 'var(--wpds-color-bg-surface-brand)', + positiveChangeColor: 'var(--wpds-color-fg-content-success-weak)', + negativeChangeColor: 'var(--wpds-color-fg-content-error-weak)', + }, + lineChart: { + lineStyles: { + comparison: { + strokeDasharray: '4 4', + strokeWidth: 1.5, + strokeLinecap: 'square' as const, + strokeOpacity: 0.8, + strokeDashoffset: 2, + }, + }, + }, + seriesLineStyles: [ + { + strokeWidth: 2, + }, + { + strokeDasharray: '4 4', + strokeWidth: 1.5, + strokeLinecap: 'square' as const, + strokeDashoffset: 2, + }, + ], + }; + }, [ preferences.interfaceTheme ] ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-color-preference.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-color-preference.ts new file mode 100644 index 000000000000..a3905f43c667 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-color-preference.ts @@ -0,0 +1,24 @@ +type ColorPreference = { + preferences: { + interfaceTheme: 'default' | 'custom'; + }; +}; + +/** + * Local stand-in for `useColorPreference` from `@automattic/admin-toolkit` + * (CIAB Admin), which is not published to npm. Jetpack Premium Analytics has + * no interface-theme preference yet, so this always reports the default + * theme, which maps to the standard WOO_COLORS chart palette in + * `useChartTheme`. + * + * TODO: Wire this to a real interface-theme preference once the host + * dashboard exposes one (or replace with the `@automattic/admin-toolkit` + * hook if it becomes available). + */ +export function useColorPreference(): ColorPreference { + return { + preferences: { + interfaceTheme: 'default', + }, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-series-styles.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-series-styles.ts new file mode 100644 index 000000000000..6a024c2cae09 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-series-styles.ts @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { useGlobalChartsContext } from '@automattic/charts'; +import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import type { + ComparativeLineChartSeries, + SeriesStyle, +} from '../components/chart-comparative-line/types'; + +/** + * Hook to build series styles from theme. + * Maps each chart series to its color and line styles from the theme provider. + * + * @param series - Array of chart series data + * @return Array of series styles with stroke color and line properties + * + * @example + * ```tsx + * const seriesStyles = useSeriesStyles( chartSeries ); + * return ; + * ``` + */ +export function useSeriesStyles( series: ComparativeLineChartSeries[] ): SeriesStyle[] { + const { getElementStyles } = useGlobalChartsContext(); + + return useMemo( + () => + series.map( ( seriesData, index ) => { + const { color, lineStyles } = getElementStyles( { + data: seriesData, + index, + } ); + + return { + stroke: color, + ...lineStyles, + }; + } ), + [ series, getElementStyles ] + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-widget-error.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-widget-error.ts new file mode 100644 index 000000000000..66d82ede71ba --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/hooks/use-widget-error.ts @@ -0,0 +1,97 @@ +/** + * External dependencies + */ +import { useGlobalError } from '@jetpack-premium-analytics/data'; +import { __ } from '@wordpress/i18n'; +import { useEffect } from 'react'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../components/widget-root'; + +/** + * Hook to report widget errors to the dashboard's error boundary. + * + * This hook manages the error lifecycle: + * - When an error occurs, it logs the error and reports to the dashboard via setError + * - When the error clears, it clears the error state + * - Provides a retry action that clears the error and refetches data + * - Cleans up error state when the widget unmounts + * + * @param isError - Whether the widget is in an error state + * @param error - The error object (used for logging) + * @param refetch - Function to refetch the data (for retry action) + * + * @return true if widget is in error state, false otherwise + * + * @example + * ```tsx + * function MyWidget() { + * const { isError, error, refetch } = useMyData(); + * const hasError = useWidgetError( isError, error, refetch ); + * + * if ( hasError ) { + * return null; // Dashboard shows error UI via WidgetErrorBoundary + * } + * + * return
Widget content
; + * } + * ``` + */ +export function useWidgetError( + isError: boolean, + error: Error | null | undefined, + refetch?: () => void +): boolean { + const { setError } = useWidgetRootContext(); + const { isGlobalError } = useGlobalError(); + + useEffect( () => { + if ( ! isError ) { + setError?.( null ); + return; + } + + if ( ! setError ) { + // Fallback: Log when setError is unavailable (widget outside dashboard context) + // eslint-disable-next-line no-console + console.warn( '[useWidgetError] setError is undefined - error UI cannot be displayed' ); + return; + } + + if ( isGlobalError ) { + // Global error: show illustration only + setError( { + message: '', + } ); + return; + } + + // Log error for debugging - captures API errors, network failures, etc. + if ( error ) { + // eslint-disable-next-line no-console + console.error( '[Widget Error]', error.message, error ); + } + + // Widget-specific error: show message + retry + setError( { + message: __( + "We couldn't load this data. Please try again in a moment.", + 'jetpack-premium-analytics' + ), + action: { + label: __( 'Retry', 'jetpack-premium-analytics' ), + onClick: () => { + setError?.( null ); + refetch?.(); + }, + }, + } ); + + // No cleanup function needed: error UI is shown by WidgetErrorBoundary, which unmounts this widget. + // Calling setError(null) in a cleanup would wrongly clear the error. + // Error state is handled and cleared by SingleDashboardWidget as needed. + }, [ isError, error, isGlobalError, setError, refetch ] ); + + return isError; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts new file mode 100644 index 000000000000..87259b94517c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -0,0 +1,102 @@ +/** + * Components + */ +export { + MetricDelta, + MetricWithComparison, + ComparativeLineChart, + Legend, + WidgetRoot, + useWidgetRootContext, + type LegendItem, + type SeriesStyle, + LeaderboardChart, + type LeaderboardChartProps, + type LeaderboardChartData, + type LegendLabels, + LeaderboardLabel, + type LeaderboardLabelProps, + BarChart, + type BarChartProps, + type BarChartData, + type BarChartStyle, + WidgetLoadingOverlay, +} from './components'; + +/** + * Constants + */ +export { WOO_COLORS, COLOR_GRAY_100 } from './constants'; + +/** + * Widget edit fields + */ +export { + ReportParamsField, + type ReportParamsFieldAttributes, + MetricsField, + DEFAULT_METRICS, +} from './fields'; + +/** + * Helpers and utilities + */ +export { + formatOrderMetric, + getFormatByMetricKey, + buildTimeSeriesChartData, + type TimeSeriesData, + calculateDelta, + BOOKINGS_FILTER, + PHYSICAL_PRODUCTS_FILTER, + FULFILLED_ORDERS_FILTER, + UNFULFILLED_ORDERS_FILTER, + PAYMENT_STATUS_FILTERS, +} from './helpers'; + +/** + * Hooks + */ +export { + useAttributesWithSearchFallback, + useChartTheme, + useSeriesStyles, + useWidgetError, +} from './hooks'; + +/** + * Widget components + */ +export { + BookingOrderMetricWidget, + BookingsByAttendanceWidget, + BookingsRevenueByCustomerTypeWidget, + BookingConversionRateWidget, + ConversionRateWidget, + CouponUseWidget, + MetricComparisonWidget, + RevenueByCustomerTypeWidget, + NewVsReturningCustomerWidget, + OrderMetricWidget, + PaymentStatusWidget, + OrdersFulfillmentWidget, + SalesByCouponWidget, + TotalReturnsWidget, + VisitorMetricWidget, + VisitorsByLocationWidget, + SalesByDeviceWidget, + BookingsByDeviceWidget, + SessionsByDeviceWidget, + SalesByUtmWidget, + TopPerformingProductLeaderboardWidget, + type TopPerformingProductLeaderboardWidgetProps, + TopPerformingProductsWidget, + type TopPerformingProductsWidgetProps, + TopPerformingBookingsWidget, + type TopPerformingBookingsWidgetProps, +} from './widgets'; + +/** + * Types + */ +export type { OrderMetricKey, OrderMetrics, OrdersSummary, DataFormat } from './types'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/styles/_widget-container.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/styles/_widget-container.scss new file mode 100644 index 000000000000..7172a7f25be7 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/styles/_widget-container.scss @@ -0,0 +1,82 @@ +@use "sass:map"; + +/** + * Widget Container Queries + * + * Provides CSS Container Query support for responsive widgets. + * Breakpoints aligned with Tailwind defaults for consistency with the + * Design System. + * + * @see https://linear.app/a8c/issue/ARC-464/design-system-wide-responsiveness + * @see https://tailwindcss.com/docs/responsive-design#container-size-reference + * + * Usage: + * ```scss + * @use '../styles/widget-container' as *; + * + * .myWidget { + * flex-direction: column; // Mobile-first default + * + * @include widget-query( md ) { + * flex-direction: row; // >= 448px + * } + * } + * ``` + */ + +// Container Query Breakpoints (Tailwind-aligned) +// These are for element-based queries, not viewport +$widget-breakpoints: ( + xxs: 16rem, + // 256px - Extra extra small widgets + xs: 20rem, + // 320px - Extra small widgets + sm: 24rem, + // 384px - Small widgets + md: 28rem, + // 448px - Medium widgets (common tile size) + lg: 32rem, + // 512px - Large widgets + xl: 36rem, + // 576px - Extra large widgets + 2xl: 42rem, + // 672px - Full-width widgets +) !default; + +/** + * Container query mixin for widgets. + * + * @param {string} $breakpoint - Breakpoint name (xs, sm, md, lg, xl, 2xl) + * @param {string} $type - Query type (min-width or max-width), + * default: min-width + * + * @example + * // Min-width query (mobile-first) + * @include widget-query( md ) { ... } + * + * // Max-width query + * @include widget-query( sm, max-width ) { ... } + */ +@mixin widget-query( $breakpoint, $type: min-width ) { + $size: map.get($widget-breakpoints, $breakpoint); + + @if not $size { + + @error "Unknown breakpoint: #{$breakpoint}. Valid: xs, sm, md, lg, xl, 2xl"; + } + + @container widget ( #{ $type }: #{ $size } ) { + @content; + } +} + +/** + * Widget container base placeholder. + * Extend from the widget wrapper element to enable container queries. + */ +%widget-container { + container-type: inline-size; + container-name: widget; + width: 100%; + height: 100%; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/types.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/types.ts new file mode 100644 index 000000000000..e9a1ac8c80fa --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/types.ts @@ -0,0 +1,79 @@ +/** + * External dependencies + */ +import { formatMetricValue } from '@jetpack-premium-analytics/formatters'; +import type { ReportDataMap } from '@jetpack-premium-analytics/data'; + +export type OrdersSummary = ReportDataMap[ 'orders' ][ 'summary' ]; + +export type OrderMetrics = Pick< + OrdersSummary, + | 'orders_no' + | 'total_sales' + | 'average_order_value' + | 'avg_items' + | 'orders_value_net' + | 'orders_value_gross' + | 'coupons' + | 'profit_margin' +>; + +export type OrderMetricKey = keyof OrderMetrics; + +type BookingsSummary = ReportDataMap[ 'bookings' ][ 'summary' ]; + +type BookingMetrics = Pick< + BookingsSummary, + | 'status_unpaid' + | 'status_pending_confirmation' + | 'status_confirmed' + | 'status_paid' + | 'status_cancelled' + | 'status_complete' + | 'attendance_status_booked' + | 'attendance_status_no_show' + | 'attendance_status_checked_in' +>; + +export type BookingMetricKey = keyof BookingMetrics; + +export type VisitorsMetricKey = 'visitors'; + +export type ConversionMetricKey = 'conversion_rate'; + +export type CustomersMetricKey = 'customers'; + +export type MetricKey = + | OrderMetricKey + | BookingMetricKey + | VisitorsMetricKey + | ConversionMetricKey + | CustomersMetricKey; + +/* + * Inferred types + */ +type MetricFormat = NonNullable< Parameters< typeof formatMetricValue >[ 1 ] >; + +type FormatMetricValueOptions = NonNullable< Parameters< typeof formatMetricValue >[ 2 ] >; + +export type DataFormat = { + type: MetricFormat; + options?: FormatMetricValueOptions; +}; + +/** + * Local stand-in for the `WidgetErrorConfig` type from `@automattic/dashboard` + * (CIAB Admin), which is not published to npm. Mirrors the documented shape of + * the dashboard's widget error contract: a message plus an optional action + * (e.g. a retry button). + * + * TODO: Replace with the `@automattic/dashboard` type once it is available. + */ +export type WidgetErrorConfig = { + message: string; + action?: { + label: string; + onClick: () => void; + }; +}; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/README.md b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/README.md new file mode 100644 index 000000000000..794b5705764b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/README.md @@ -0,0 +1,43 @@ +# Widgets + +Dashboard widget components for WooCommerce Analytics. + +## Available Widgets + +| Widget | Chart Component | Description | +| ------------------------------ | ----------------------------------------------- | ------------------------------------------------- | +| `ConversionRateWidget` | `MetricWithComparison` | Funnel conversion rate metric | +| `MetricComparisonWidget` | `MetricWithComparison` + `ComparativeLineChart` | Generic metric with time series | +| `RevenueByCustomerTypeWidget` | `BarChart` | Revenue breakdown by customer type | +| `NewVsReturningCustomerWidget` | `DonutChart` | Customer counts by new vs returning | +| `OrderMetricWidget` | `ReportMetricWidget` | Order-based metrics (revenue, orders, AOV) | +| `SalesByCouponWidget` | `SemiCircleChart` | Coupon sales for all product types | +| `SalesByDeviceWidget` | `DonutChart` | Sales breakdown by device type | +| `SalesByUtmWidget` | `LeaderboardChart` | Sales by UTM parameters (source/channel/campaign) | +| `TotalReturnsWidget` | `DonutChart` | Returns/refunds for all product types | +| `VisitorMetricWidget` | `ReportMetricWidget` | Visitor-based metrics | +| `TopPerformingProductsWidget` | `LeaderboardChart` | Top products by revenue | +| `TopPerformingBookingsWidget` | `LeaderboardChart` | Top bookings by revenue | + +## Chart Components + +| Component | Type | Use Case | +| ---------------------- | ----------- | ----------------------------------- | +| `DonutChart` | Pie/Donut | Category breakdowns (2-4 segments) | +| `SemiCircleChart` | Half-pie | Top N rankings with "Other" segment | +| `ComparativeLineChart` | Line | Time series with comparison periods | +| `MetricWithComparison` | Metric | Single value with delta indicator | +| `ReportMetricWidget` | Metric | Report-based metrics with sparkline | +| `LeaderboardChart` | Leaderboard | Top N items with bars and labels | + +## Common Utilities + +Shared code is located in `common/`: + +### Styles + +- `donut-widget.module.scss` - Container styles for DonutChart widgets + +### Hooks + +- `useSegmentStyles( chartData )` - Builds segment colors from theme provider diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/bookings-by-attendance/bookings-by-attendance-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/bookings-by-attendance/bookings-by-attendance-widget.tsx new file mode 100644 index 000000000000..809bcfbd0237 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/bookings-by-attendance/bookings-by-attendance-widget.tsx @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import { useReportBookings } from '@jetpack-premium-analytics/data'; +import { calendar } from '@jetpack-premium-analytics/icons'; +import { Stack } from '@wordpress/ui'; +import { useMemo } from 'react'; +import { DonutChart } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildBookingsByAttendanceData } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useSegmentStyles } from '../common'; +import styles from '../common/donut-widget.module.scss'; + +/** + * Bookings by Status Widget Component + * + * Displays a donut chart showing bookings breakdown by status. + * Shows the total bookings count in the center with a breakdown in the legend. + * + * Statuses include: Booked, Checked In, No Show, and Cancelled. + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function BookingsByAttendanceWidget() { + const { reportParams } = useWidgetRootContext(); + + const { + primary, + comparison, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + error, + refetch, + } = useReportBookings( reportParams ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { chartData, total, comparisonTotal, legendData } = useMemo( + () => buildBookingsByAttendanceData( primary.data, comparison.data ), + [ primary.data, comparison.data ] + ); + + const segmentStyles = useSegmentStyles( chartData ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/bookings-by-attendance/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/bookings-by-attendance/index.ts new file mode 100644 index 000000000000..3215a0b51402 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/bookings-by-attendance/index.ts @@ -0,0 +1 @@ +export { BookingsByAttendanceWidget } from './bookings-by-attendance-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/donut-widget.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/donut-widget.module.scss new file mode 100644 index 000000000000..3c4b0cbde9be --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/donut-widget.module.scss @@ -0,0 +1,10 @@ +/** + * Shared styles for DonutChart-based widgets. + * Used by: CouponUseWidget, PaymentStatusWidget, BookingsByAttendanceWidget + */ +.container { + min-width: 120px; + max-width: 240px; + height: 100%; + margin: 0 auto; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/index.ts new file mode 100644 index 000000000000..94c7a827b260 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/index.ts @@ -0,0 +1,2 @@ +export { useSegmentStyles } from './use-segment-styles'; +export { useBarStyles } from './use-bar-styles'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/use-bar-styles.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/use-bar-styles.ts new file mode 100644 index 000000000000..cdf0fa4eda9b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/use-bar-styles.ts @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import { useGlobalChartsContext } from '@automattic/charts'; +import { useMemo } from 'react'; +import type { BarChartStyle } from '../../components'; +import type { SeriesData } from '@automattic/charts'; + +/** + * Internal dependencies + */ + +/** + * Hook to build bar chart styles from theme. + * Maps each series to its color from the theme provider. + * + * @param chartData - Array of series data (SeriesData[]) + * @return Array of bar styles with stroke color for each series + * + * @example + * ```tsx + * const barStyles = useBarStyles( chartData ); + * return ; + * ``` + */ +export function useBarStyles( chartData: SeriesData[] ): BarChartStyle[] { + const { getElementStyles } = useGlobalChartsContext(); + + return useMemo( + () => + chartData.map( ( seriesData, index ) => { + const { color } = getElementStyles( { + data: seriesData, + index, + } ); + + return { stroke: color }; + } ), + [ chartData, getElementStyles ] + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/use-segment-styles.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/use-segment-styles.ts new file mode 100644 index 000000000000..159b65a6603f --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/common/use-segment-styles.ts @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { useGlobalChartsContext } from '@automattic/charts'; +import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import type { SegmentStyle } from '../../helpers'; + +type ChartSegment = { + label: string; + value: number; + percentage?: number; +}; + +/** + * Hook to build segment styles from theme. + * Maps each chart segment to its color from the theme provider. + * + * @param chartData - Array of chart segments with label and value + * @return Array of segment styles with color for each segment + * + * @example + * ```tsx + * const segmentStyles = useSegmentStyles( chartData ); + * return ; + * ``` + */ +export function useSegmentStyles( chartData: ChartSegment[] ): SegmentStyle[] { + const { getElementStyles } = useGlobalChartsContext(); + + return useMemo( + () => + chartData.map( ( segment, index ) => { + const { color } = getElementStyles( { + data: { + ...segment, + group: segment.label, // Use label as group for stable color assignment + } as Parameters< typeof getElementStyles >[ 0 ][ 'data' ], + index, + } ); + + return { color }; + } ), + [ chartData, getElementStyles ] + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/conversion-rate/conversion-rate-widget.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/conversion-rate/conversion-rate-widget.module.scss new file mode 100644 index 000000000000..47a7ea065ed1 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/conversion-rate/conversion-rate-widget.module.scss @@ -0,0 +1,11 @@ +.container { + height: 100%; + min-height: 0; +} + +.conversionFunnelChart { + --funnel-font-family: var(--wpds-typography-font-family-body); + --step-font-family: var(--wpds-typography-font-family-body); + flex: 1; + min-height: 0; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/conversion-rate/conversion-rate-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/conversion-rate/conversion-rate-widget.tsx new file mode 100644 index 000000000000..41bcdbfaa786 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/conversion-rate/conversion-rate-widget.tsx @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import { ConversionFunnelChart } from '@automattic/charts'; +import { FilterCondition, useReportConversionRate } from '@jetpack-premium-analytics/data'; +import { goal } from '@jetpack-premium-analytics/icons'; +import { Icon, Stack } from '@wordpress/ui'; +import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import { MetricWithComparison, ChartEmptyState } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +import { useWidgetRootContext } from '../../components/widget-root'; +import { BOOKINGS_FILTER } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import styles from './conversion-rate-widget.module.scss'; + +/** + * ConversionRateWidget Component + * + * Displays a conversion funnel visualization showing the path from + * visitors to completed orders. Shows steps with conversion percentages + * and comparison delta when available. + */ +export function ConversionRateWidget( { + filters = [], + emptyStateIcon = goal, + emptyStateText, +}: { + filters?: FilterCondition[]; + emptyStateIcon?: React.ComponentProps< typeof Icon >[ 'icon' ]; + emptyStateText?: string; +} ) { + const { reportParams } = useWidgetRootContext(); + + const { + primary, + comparison, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + error, + refetch, + } = useReportConversionRate( { + ...reportParams, + filters, + } ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { data: conversionData } = primary; + const { data: comparisonData } = comparison; + + const { steps, overallRate, comparisonRate } = useMemo( () => { + if ( ! conversionData || conversionData.summary.active_sessions === 0 ) { + return { + steps: [], + overallRate: 0, + comparisonRate: null, + }; + } + + return { + steps: conversionData.steps || [], + // overallRate is a decimal (e.g., 0.0476 for 4.76%) + overallRate: conversionData.overallRate || 0, + // Get comparison rate as decimal + comparisonRate: + hasComparison && comparisonData?.summary ? comparisonData.summary.conversion_rate : null, + }; + }, [ conversionData, comparisonData, hasComparison ] ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + // Don't render if no steps data + if ( steps.length === 0 ) { + return ( + <> + + { isRefetching && } + + ); + } + + // Convert to percentage for ConversionFunnelChart (expects 0-100 scale) + const overallRatePercent = overallRate * 100; + + return ( + <> + + + + null } + className={ styles.conversionFunnelChart } + /> + + { isRefetching && } + + ); +} + +/** + * Booking Conversion Rate Widget Component + * + * A widget that displays a conversion funnel visualization showing the path from + * visitors to completed orders for booking products only. + * + * This component automatically filters data to show only booking product types + * (booking, bookable-event, bookable-service). + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function BookingConversionRateWidget() { + return ; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/conversion-rate/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/conversion-rate/index.ts new file mode 100644 index 000000000000..ba8e8f3c81f6 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/conversion-rate/index.ts @@ -0,0 +1 @@ +export { ConversionRateWidget, BookingConversionRateWidget } from './conversion-rate-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/coupon-use/coupon-use-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/coupon-use/coupon-use-widget.tsx new file mode 100644 index 000000000000..d2d333fc9e7f --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/coupon-use/coupon-use-widget.tsx @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import { useReportCouponsByDate } from '@jetpack-premium-analytics/data'; +import { coupon } from '@jetpack-premium-analytics/icons'; +import { Stack } from '@wordpress/ui'; +import { useMemo } from 'react'; +import { DonutChart } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildCouponUseData } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useSegmentStyles } from '../common'; +import styles from '../common/donut-widget.module.scss'; + +/** + * Coupon Use Widget Component + * + * Displays a donut chart showing total sales with a coupon vs net sales breakdown. + * Shows the total sales in the center with slices in the legend. + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function CouponUseWidget() { + const { reportParams } = useWidgetRootContext(); + + const { + primary, + comparison, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + error, + refetch, + } = useReportCouponsByDate( reportParams ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { chartData, total, comparisonTotal, legendData } = useMemo( + () => buildCouponUseData( primary.data, comparison.data, hasComparison ), + [ primary.data, comparison.data, hasComparison ] + ); + + const segmentStyles = useSegmentStyles( chartData ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/coupon-use/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/coupon-use/index.ts new file mode 100644 index 000000000000..cb365cd60944 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/coupon-use/index.ts @@ -0,0 +1 @@ +export { CouponUseWidget } from './coupon-use-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts new file mode 100644 index 000000000000..02734ce2b232 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/index.ts @@ -0,0 +1,27 @@ +export { MetricComparisonWidget } from './metric-comparison'; +export { OrderMetricWidget, BookingOrderMetricWidget } from './order-metric'; +export { VisitorMetricWidget } from './visitor-metric'; +export { SalesByCouponWidget } from './sales-by-coupon'; +export { ConversionRateWidget, BookingConversionRateWidget } from './conversion-rate'; +export { + RevenueByCustomerTypeWidget, + BookingsRevenueByCustomerTypeWidget, +} from './revenue-by-customer-type'; +export { NewVsReturningCustomerWidget } from './new-vs-returning-customer'; +export { SalesByDeviceWidget, BookingsByDeviceWidget } from './sales-by-device'; +export { SessionsByDeviceWidget } from './sessions-by-device'; +export { BookingsByAttendanceWidget } from './bookings-by-attendance'; +export { TotalReturnsWidget } from './total-returns'; +export { SalesByUtmWidget } from './sales-by-utm'; +export { + TopPerformingProductLeaderboardWidget, + type TopPerformingProductLeaderboardWidgetProps, + TopPerformingProductsWidget, + type TopPerformingProductsWidgetProps, + TopPerformingBookingsWidget, + type TopPerformingBookingsWidgetProps, +} from './product-leaderboard'; +export { CouponUseWidget } from './coupon-use'; +export { PaymentStatusWidget } from './payment-status'; +export { OrdersFulfillmentWidget } from './orders-fulfillment'; +export { VisitorsByLocationWidget } from './visitors-by-location'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/index.ts new file mode 100644 index 000000000000..2a1f751be5f3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/index.ts @@ -0,0 +1 @@ +export { MetricComparisonWidget } from './metric-comparison-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/metric-comparison-widget.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/metric-comparison-widget.module.scss new file mode 100644 index 000000000000..8c2e3cfd586a --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/metric-comparison-widget.module.scss @@ -0,0 +1,4 @@ +.container { + height: 100%; + min-height: 0; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/metric-comparison-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/metric-comparison-widget.tsx new file mode 100644 index 000000000000..c48fdc2158bf --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/metric-comparison/metric-comparison-widget.tsx @@ -0,0 +1,68 @@ +/** + * External dependencies + */ +import { Stack } from '@wordpress/ui'; +/** + * Internal dependencies + */ +import { MetricWithComparison, ComparativeLineChart } from '../../components'; +import styles from './metric-comparison-widget.module.scss'; +import type { + ComparativeLineChartSeries, + SeriesStyle, +} from '../../components/chart-comparative-line/types'; +import type { DataFormat } from '../../types'; + +export type MetricComparisonWidgetProps = { + /** + * Primary metric value + */ + value: number; + + /** + * Optional comparison metric (previous period, target, etc.) + */ + comparisonValue?: number | null; + + /** + * Chart display props + */ + series: ComparativeLineChartSeries[]; + + /** + * Explicit styles for chart series. When provided, takes priority + * over styles defined in series[].options. + */ + seriesStyles?: SeriesStyle[]; + + dataFormat: DataFormat; + tickFormat?: string; +}; + +export function MetricComparisonWidget( { + value, + comparisonValue, + series, + seriesStyles, + dataFormat, + tickFormat, +}: MetricComparisonWidgetProps ) { + return ( + + + + + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/new-vs-returning-customer/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/new-vs-returning-customer/index.ts new file mode 100644 index 000000000000..67942ea055f2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/new-vs-returning-customer/index.ts @@ -0,0 +1 @@ +export { NewVsReturningCustomerWidget } from './new-vs-returning-customer-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/new-vs-returning-customer/new-vs-returning-customer-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/new-vs-returning-customer/new-vs-returning-customer-widget.tsx new file mode 100644 index 000000000000..83ede41bdf82 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/new-vs-returning-customer/new-vs-returning-customer-widget.tsx @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import { useReportCustomersByDate } from '@jetpack-premium-analytics/data'; +import { customer } from '@jetpack-premium-analytics/icons'; +import { Stack } from '@wordpress/ui'; +import { useMemo } from 'react'; +import { DonutChart } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildNewVsReturningCustomerData } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useSegmentStyles } from '../common'; +import styles from '../common/donut-widget.module.scss'; + +/** + * New vs Returning Customer Widget Component + * + * Displays a donut chart showing the breakdown of unique customers + * by type (new vs returning) over the selected time period. + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function NewVsReturningCustomerWidget() { + const { reportParams } = useWidgetRootContext(); + + const { + primary, + comparison, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + error, + refetch, + } = useReportCustomersByDate( reportParams ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { chartData, total, comparisonTotal, legendData } = useMemo( + () => buildNewVsReturningCustomerData( primary.data, comparison.data, hasComparison ), + [ primary.data, comparison.data, hasComparison ] + ); + + const segmentStyles = useSegmentStyles( chartData ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/order-metric/booking-order-metric-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/order-metric/booking-order-metric-widget.tsx new file mode 100644 index 000000000000..f0049a46254a --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/order-metric/booking-order-metric-widget.tsx @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { useReportOrders } from '@jetpack-premium-analytics/data'; +/** + * Internal dependencies + */ +import { ReportMetricWidget } from '../../components/report-metric'; +import { useWidgetRootContext } from '../../components/widget-root'; +import { getFormatByMetricKey, BOOKINGS_FILTER } from '../../helpers'; +import type { OrderMetricKey } from '../../types'; + +export type BookingOrderMetricWidgetProps = { + /** + * The metric key to display from the data + */ + metricKey: OrderMetricKey; +}; + +/** + * Booking Order Metric Widget Component + * + * A widget that displays booking order-related metrics over time with comparison support. + * This component automatically filters data to show only booking product types + * (booking, bookable-event, bookable-service). + * + * This component must be used within a WidgetRoot which provides reportParams + * via context. + * + * @param {object} props - Component props + * @param {OrderMetricKey} props.metricKey - The metric key to display + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function BookingOrderMetricWidget( { metricKey }: BookingOrderMetricWidgetProps ) { + const { reportParams } = useWidgetRootContext(); + + return ( + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/order-metric/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/order-metric/index.ts new file mode 100644 index 000000000000..b12b5ef51038 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/order-metric/index.ts @@ -0,0 +1,2 @@ +export { OrderMetricWidget } from './widget-order-metric'; +export { BookingOrderMetricWidget } from './booking-order-metric-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/order-metric/widget-order-metric.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/order-metric/widget-order-metric.tsx new file mode 100644 index 000000000000..ed67900c19bc --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/order-metric/widget-order-metric.tsx @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import { useReportOrders } from '@jetpack-premium-analytics/data'; +/** + * Internal dependencies + */ +import { ReportMetricWidget } from '../../components/report-metric'; +import { useWidgetRootContext } from '../../components/widget-root'; +import { getFormatByMetricKey } from '../../helpers'; +import type { OrderMetricKey } from '../../types'; + +export type OrderMetricWidgetProps = { + /** + * The metric key to display from the data + */ + metricKey: OrderMetricKey; +}; + +/** + * Order Metric Widget Component + * + * A widget that displays order-related metrics over time with comparison support. + * This component must be used within a WidgetRoot which provides reportParams + * via context. + * + * @param {object} props - Component props + * @param {OrderMetricKey} props.metricKey - The metric key to display + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function OrderMetricWidget( { metricKey }: OrderMetricWidgetProps ) { + const { reportParams } = useWidgetRootContext(); + + return ( + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/orders-fulfillment/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/orders-fulfillment/index.ts new file mode 100644 index 000000000000..108292d321dd --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/orders-fulfillment/index.ts @@ -0,0 +1 @@ +export { OrdersFulfillmentWidget } from './orders-fulfillment-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/orders-fulfillment/orders-fulfillment-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/orders-fulfillment/orders-fulfillment-widget.tsx new file mode 100644 index 000000000000..d08eb01a72df --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/orders-fulfillment/orders-fulfillment-widget.tsx @@ -0,0 +1,125 @@ +/** + * External dependencies + */ +import { useReportOrders } from '@jetpack-premium-analytics/data'; +import { reports } from '@jetpack-premium-analytics/icons'; +import { Stack } from '@wordpress/ui'; +import { useMemo, useCallback } from 'react'; +import { DonutChart } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { + buildOrdersFulfillmentData, + FULFILLED_ORDERS_FILTER, + UNFULFILLED_ORDERS_FILTER, +} from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useSegmentStyles } from '../common'; +import styles from '../common/donut-widget.module.scss'; + +/** + * Orders Fulfillment Widget Component + * + * Displays a donut chart showing the breakdown of fulfilled vs unfulfilled + * order counts over the selected time period. + * + * Makes two separate API calls with different fulfillment status filters + * since fulfillment data is not pre-aggregated in the orders summary. + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function OrdersFulfillmentWidget() { + const { reportParams } = useWidgetRootContext(); + + const fulfilled = useReportOrders( { + ...reportParams, + filters: [ FULFILLED_ORDERS_FILTER ], + } ); + + const unfulfilled = useReportOrders( { + ...reportParams, + filters: [ UNFULFILLED_ORDERS_FILTER ], + } ); + + const isLoading = fulfilled.isLoading || unfulfilled.isLoading; + const isFetching = fulfilled.isFetching || unfulfilled.isFetching; + const hasData = fulfilled.hasData && unfulfilled.hasData; + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { chartData, total, comparisonTotal, legendData } = useMemo( + () => + isLoading + ? { + chartData: [], + total: 0, + comparisonTotal: 0, + legendData: [], + } + : buildOrdersFulfillmentData( + fulfilled.primary.data, + unfulfilled.primary.data, + fulfilled.comparison.data, + unfulfilled.comparison.data + ), + [ + isLoading, + fulfilled.primary.data, + unfulfilled.primary.data, + fulfilled.comparison.data, + unfulfilled.comparison.data, + ] + ); + + const segmentStyles = useSegmentStyles( chartData ); + const hasComparison = fulfilled.hasComparison; + + const isError = fulfilled.isError || unfulfilled.isError; + const error = fulfilled.error ?? unfulfilled.error; + const fulfilledRefetch = fulfilled.refetch; + const unfulfilledRefetch = unfulfilled.refetch; + const refetch = useCallback( async () => { + await Promise.all( [ fulfilledRefetch(), unfulfilledRefetch() ] ); + }, [ fulfilledRefetch, unfulfilledRefetch ] ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/index.ts new file mode 100644 index 000000000000..81f8a17dc1df --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/index.ts @@ -0,0 +1 @@ +export { PaymentStatusWidget } from './payment-status-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/payment-status-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/payment-status-widget.tsx new file mode 100644 index 000000000000..c0ac049cc06a --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/payment-status/payment-status-widget.tsx @@ -0,0 +1,92 @@ +/** + * External dependencies + */ +import { useReportOrders } from '@jetpack-premium-analytics/data'; +import { payment } from '@jetpack-premium-analytics/icons'; +import { Stack } from '@wordpress/ui'; +import { useMemo } from 'react'; +import { DonutChart } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildPaymentStatusData, PAYMENT_STATUS_FILTERS } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useSegmentStyles } from '../common'; +import styles from '../common/donut-widget.module.scss'; + +/** + * Payment Status Widget Component + * + * Displays a donut chart comparing revenue from paid orders vs unpaid orders. + * Shows the total revenue in the center with a breakdown in the legend. + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function PaymentStatusWidget() { + const { reportParams } = useWidgetRootContext(); + + const { + primary, + comparison, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + error, + refetch, + } = useReportOrders( { + ...reportParams, + filters: PAYMENT_STATUS_FILTERS, + } ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { chartData, total, comparisonTotal, legendData } = useMemo( + () => buildPaymentStatusData( primary.data, comparison.data ), + [ primary.data, comparison.data ] + ); + + const segmentStyles = useSegmentStyles( chartData ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/index.ts new file mode 100644 index 000000000000..599126e19cea --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/index.ts @@ -0,0 +1,12 @@ +export { + TopPerformingProductLeaderboardWidget, + type TopPerformingProductLeaderboardWidgetProps, +} from './top-performing-product-leaderboard-widget'; +export { + TopPerformingProductsWidget, + type TopPerformingProductsWidgetProps, +} from './top-performing-products-widget'; +export { + TopPerformingBookingsWidget, + type TopPerformingBookingsWidgetProps, +} from './top-performing-bookings-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/top-performing-bookings-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/top-performing-bookings-widget.tsx new file mode 100644 index 000000000000..086568a222ed --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/top-performing-bookings-widget.tsx @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { calendar } from '@jetpack-premium-analytics/icons'; +/** + * Internal dependencies + */ +import { BOOKINGS_FILTER } from '../../helpers'; +import { TopPerformingProductLeaderboardWidget } from './top-performing-product-leaderboard-widget'; + +export type TopPerformingBookingsWidgetProps = { + /** + * Maximum number of bookings to display + */ + limit?: number; +}; + +/** + * Top Performing Bookings Widget + * + * Displays the top-performing booking products by net revenue in a leaderboard format. + * Shows product images, names, and revenue with comparison to previous period. + * + * Filters to: booking, bookable-event, and bookable-service product types. + * + * Features: + * - Automatic booking product data fetching + * - Product image loading + * - Revenue-based ranking + * - Comparison support + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @param props - Component props + * @param props.limit - Maximum number of bookings to display (default: 5) + * + * @example + * + * + * + */ +export function TopPerformingBookingsWidget( { limit = 5 }: TopPerformingBookingsWidgetProps ) { + return ( + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/top-performing-product-leaderboard-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/top-performing-product-leaderboard-widget.tsx new file mode 100644 index 000000000000..622b201e0080 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/top-performing-product-leaderboard-widget.tsx @@ -0,0 +1,199 @@ +/** + * External dependencies + */ +import { + useReportProducts, + useProductImages, + type FilterCondition, +} from '@jetpack-premium-analytics/data'; +import { productBlouse } from '@jetpack-premium-analytics/icons'; +import { Icon } from '@wordpress/ui'; +import { useMemo } from 'react'; +import { LeaderboardChart, LeaderboardLabel } from '../../components/chart-leaderboard'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { formatLegendLabels, calculateDelta } from '../../helpers'; +import { useWidgetError } from '../../hooks'; + +export type TopPerformingProductLeaderboardWidgetProps = { + /** + * Maximum number of products to display + */ + limit?: number; + + /** + * Optional product type filter to apply when fetching product data. + * + * When provided, filters results to specific product types (e.g., bookings only). + * When omitted, shows data for all product types. + */ + filter?: FilterCondition; + + /** + * Icon to display in the empty state. + * Defaults to productBlouse icon. + */ + emptyStateIcon?: React.ComponentProps< typeof Icon >[ 'icon' ]; +}; + +/** + * Top Performing Product Leaderboard Widget + * + * Displays top-performing products by net revenue in a leaderboard format. + * Shows product images, names, and revenue with comparison to previous period. + * + * This is a reusable component that can be used for any product-based leaderboard + * (regular products, bookings, etc.). + * + * Features: + * - Automatic product data fetching + * - Product image loading + * - Revenue-based ranking + * - Comparison support + * - Product type filtering + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @param props - Component props + * @param props.limit - Maximum number of products to display (default: 5) + * @param props.filter - Optional product type filter + * @param props.emptyStateIcon - Icon to display in empty state (default: productBlouse) + * + * @example + * // All product types + * + * + * + * + * @example + * // Bookings only + * + * + * + */ +export function TopPerformingProductLeaderboardWidget( { + limit = 5, + filter, + emptyStateIcon = productBlouse, +}: TopPerformingProductLeaderboardWidgetProps ) { + const { reportParams } = useWidgetRootContext(); + + const params = useMemo( + () => ( { + ...reportParams, + ...( filter && { filters: [ filter ] } ), + } ), + [ reportParams, filter ] + ); + + const { + primary, + comparison, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + error, + refetch, + } = useReportProducts( params, limit ); + + const { data } = primary; + const { data: comparisonData } = comparison; + + // Extract product IDs for fetching images + const productIds = useMemo( + () => data?.data?.map( item => item.product_id ) || [], + [ data?.data ] + ); + + // Fetch product images + const { data: productImages, isLoading: imagesLoading } = useProductImages( { + productIds, + } ); + + const isInitialLoading = ( isLoading || imagesLoading ) && ! hasData; + const isRefetching = ( isFetching || imagesLoading ) && hasData; + + const chartData = useMemo( () => { + const comparisonItems = comparisonData?.data || []; + + // Create a map of product_id to comparison data for efficient lookup + const comparisonMap = new Map( comparisonItems.map( item => [ item.product_id, item ] ) ); + + // Calculate maxValue once outside the map + const maxCurrentValue = Math.max( + ...( data?.data?.map( p => p.product_net_revenue ?? 0 ) || [] ), + 1 // Prevent division by zero + ); + + // Calculate max previous value once outside the map + const maxPreviousValue = Math.max( + ...comparisonItems.map( p => p.product_net_revenue ?? 0 ), + 1 // Prevent division by zero + ); + + return ( + data?.data?.map( ( product, index: number ) => { + const currentValue = product.product_net_revenue ?? 0; + + const productImage = productImages ? productImages[ product.product_id ] : undefined; + + // Match by product_id instead of index + const comparisonProduct = comparisonMap.get( product.product_id ); + const previousValue = comparisonProduct?.product_net_revenue ?? 0; + + const previousShare = + comparisonItems.length > 0 && previousValue > 0 + ? ( previousValue / maxPreviousValue ) * 100 + : 0; + + const label = product.product_name; + const imageUrl = productImage?.imageUrl || ''; + const imageAlt = productImage?.imageAlt || label; + const delta = calculateDelta( currentValue, previousValue ); + + return { + id: String( product.product_id || index ), + label: , + currentValue, + currentShare: ( currentValue / maxCurrentValue ) * 100, + previousValue, + previousShare, + delta, + }; + } ) || [] + ); + }, [ data?.data, comparisonData?.data, productImages ] ); + + const legendLabels = useMemo( () => formatLegendLabels( reportParams ), [ reportParams ] ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/top-performing-products-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/top-performing-products-widget.tsx new file mode 100644 index 000000000000..1ccaa05741e3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/product-leaderboard/top-performing-products-widget.tsx @@ -0,0 +1,42 @@ +/** + * Internal dependencies + */ +import { PHYSICAL_PRODUCTS_FILTER } from '../../helpers'; +import { TopPerformingProductLeaderboardWidget } from './top-performing-product-leaderboard-widget'; + +export type TopPerformingProductsWidgetProps = { + /** + * Maximum number of products to display + */ + limit?: number; +}; + +/** + * Top Performing Products Widget + * + * Displays the top-performing physical products by net revenue in a leaderboard format. + * Shows product images, names, and revenue with comparison to previous period. + * + * Filters to: simple, variable, and variation product types (physical products only). + * + * Features: + * - Automatic product data fetching + * - Product image loading + * - Revenue-based ranking + * - Comparison support + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @param props - Component props + * @param props.limit - Maximum number of products to display (default: 5) + * + * @example + * + * + * + */ +export function TopPerformingProductsWidget( { limit = 5 }: TopPerformingProductsWidgetProps ) { + return ( + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/index.ts new file mode 100644 index 000000000000..5e8082e1ab2d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/index.ts @@ -0,0 +1,4 @@ +export { + RevenueByCustomerTypeWidget, + BookingsRevenueByCustomerTypeWidget, +} from './revenue-by-customer-type-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/revenue-by-customer-type-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/revenue-by-customer-type-widget.tsx new file mode 100644 index 000000000000..ece1fe954957 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/revenue-by-customer-type-widget.tsx @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import { useReportCustomers, type FilterCondition } from '@jetpack-premium-analytics/data'; +import { customer } from '@jetpack-premium-analytics/icons'; +import { useMemo } from 'react'; +import { BarChart } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildRevenueByCustomerTypeData, BOOKINGS_FILTER } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useBarStyles } from '../common'; + +type CustomerTypeRevenueWidgetProps = { + /** + * Optional product type filter to apply when fetching customer data. + * If not provided, will show data for all product types. + * + * @see PHYSICAL_PRODUCTS_FILTER for physical goods (simple, variable, variation) + * @see BOOKINGS_FILTER for booking products (booking, bookable-event, bookable-service) + */ + filter?: FilterCondition; +}; + +/** + * Customer Type Revenue Widget Component + * + * Displays a bar chart comparing revenue from new customers vs returning customers. + * Optionally supports filtering by product type. + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +function CustomerTypeRevenueWidget( { filter }: CustomerTypeRevenueWidgetProps ) { + const { reportParams } = useWidgetRootContext(); + + const { primary, comparison, isLoading, isFetching, hasData, isError, error, refetch } = + useReportCustomers( { + ...reportParams, + filters: filter ? [ filter ] : undefined, + } ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { chartData } = useMemo( + () => buildRevenueByCustomerTypeData( primary.data, comparison.data, reportParams ), + [ primary.data, comparison.data, reportParams ] + ); + + const barStyles = useBarStyles( chartData ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + { isRefetching && } + + ); +} + +/** + * Revenue by Customer Type Widget + * + * Displays customer revenue data for all product types. + * No product type filtering applied. + */ +export function RevenueByCustomerTypeWidget() { + return ; +} + +/** + * Bookings Revenue by Customer Type Widget + * + * Displays customer revenue data for booking products only. + * Filters to: booking, bookable-event, and bookable-service product types. + */ +export function BookingsRevenueByCustomerTypeWidget() { + return ; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/index.ts new file mode 100644 index 000000000000..231977195cfc --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/index.ts @@ -0,0 +1 @@ +export { SalesByCouponWidget } from './sales-by-coupon-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/sales-by-coupon-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/sales-by-coupon-widget.tsx new file mode 100644 index 000000000000..66f7572af684 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/sales-by-coupon-widget.tsx @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import { useReportCoupons } from '@jetpack-premium-analytics/data'; +import { coupon } from '@jetpack-premium-analytics/icons'; +import { useMemo } from 'react'; +import { BarChart } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildSalesByCouponData } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useBarStyles } from '../common'; + +/** + * Sales by Coupon Widget Component + * + * Displays a bar chart showing coupon discount distribution. + * Shows top 3 coupons plus "Other" segment. + * Displays data for all product types. + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function SalesByCouponWidget() { + const { reportParams } = useWidgetRootContext(); + + const { primary, comparison, isLoading, isFetching, hasData, isError, error, refetch } = + useReportCoupons( reportParams ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { chartData } = useMemo( + () => buildSalesByCouponData( primary.data, comparison.data, reportParams, 3 ), + [ primary.data, comparison.data, reportParams ] + ); + + const barStyles = useBarStyles( chartData ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/index.ts new file mode 100644 index 000000000000..ab1e276c454b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/index.ts @@ -0,0 +1 @@ +export { SalesByDeviceWidget, BookingsByDeviceWidget } from './sales-by-device-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/sales-by-device-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/sales-by-device-widget.tsx new file mode 100644 index 000000000000..6bffa2e8cfa2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/sales-by-device-widget.tsx @@ -0,0 +1,122 @@ +/** + * External dependencies + */ +import { useReportOrderAttribution, type FilterCondition } from '@jetpack-premium-analytics/data'; +import { device } from '@jetpack-premium-analytics/icons'; +import { useMemo } from 'react'; +import { BarChart } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildSalesByDeviceData, BOOKINGS_FILTER } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useBarStyles } from '../common'; + +type SalesByDeviceWidgetProps = { + /** + * Optional product type filter to apply when fetching order attribution data. + * + * When provided, filters results to specific product types (e.g., bookings only). + * When omitted, shows data for all product types. + */ + filter?: FilterCondition; +}; + +/** + * Sales by Device Widget Component + * + * Displays a bar chart showing sales breakdown by device type (Desktop, Mobile, Tablet). + * + * Features: + * - Optional product type filtering (e.g., bookings only) + * - Comparison support (current vs previous period) + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @param props - Component props + * @param props.filter - Optional product type filter + * + * @example + * // All product types + * + * + * + * + * @example + * // Bookings only + * + * + * + */ +export function SalesByDeviceWidget( { filter }: SalesByDeviceWidgetProps ) { + const { reportParams } = useWidgetRootContext(); + + // Add the device view to params + const paramsWithView = useMemo( + () => ( { + ...reportParams, + view: 'device' as const, + ...( filter && { filters: [ filter ] } ), + } ), + [ reportParams, filter ] + ); + + const { primary, hasComparison, isLoading, isFetching, hasData, isError, error, refetch } = + useReportOrderAttribution( paramsWithView ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { chartData } = useMemo( + () => buildSalesByDeviceData( primary.data, hasComparison, reportParams ), + [ primary.data, hasComparison, reportParams ] + ); + + const barStyles = useBarStyles( chartData ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + { isRefetching && } + + ); +} + +/** + * Bookings by Device Widget Component + * + * Displays device breakdown data for booking products only. + * This component automatically filters data to show only booking product types + * (booking, bookable-event, bookable-service). + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function BookingsByDeviceWidget() { + return ; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/index.ts new file mode 100644 index 000000000000..28a1e191a8dd --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/index.ts @@ -0,0 +1 @@ +export { SalesByUtmWidget } from './sales-by-utm-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/sales-by-utm-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/sales-by-utm-widget.tsx new file mode 100644 index 000000000000..eecabb0cad4b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/sales-by-utm-widget.tsx @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +import { + useReportOrderAttribution, + ORDER_ATTRIBUTION_VIEWS, +} from '@jetpack-premium-analytics/data'; +import { megaphone, search, channel } from '@jetpack-premium-analytics/icons'; +import { useMemo } from 'react'; +import { LeaderboardChart } from '../../components/chart-leaderboard'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildSalesByUtmData, formatLegendLabels } from '../../helpers'; +import { useWidgetError } from '../../hooks'; + +type OrderAttributionView = ( typeof ORDER_ATTRIBUTION_VIEWS )[ number ]; + +type SalesByUtmWidgetProps = { + /** + * The order attribution view to display (source, channel, campaign, etc.) + */ + view: OrderAttributionView; +}; + +/** + * Sales by UTM Widget Component + * + * Displays order attribution data in a leaderboard chart, showing how sales are + * distributed across different UTM parameters (source, channel, or campaign). + * + * Features: + * - Multiple views: source, channel, campaign + * - Displays data for all product types + * - Comparison support (current vs previous period) + * - Formatted legend labels with date ranges + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @param props - Component props + * @param props.view - The order attribution view (source, channel, campaign) + * + * @example + * + * + * + */ +export function SalesByUtmWidget( { view }: SalesByUtmWidgetProps ) { + const { reportParams } = useWidgetRootContext(); + + const params = useMemo( + () => ( { + ...reportParams, + view, + } ), + [ reportParams, view ] + ); + + const { primary, hasComparison, isLoading, isFetching, hasData, isError, error, refetch } = + useReportOrderAttribution( params ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const chartData = useMemo( () => buildSalesByUtmData( primary.data ), [ primary.data ] ); + + const legendLabels = useMemo( () => formatLegendLabels( reportParams ), [ reportParams ] ); + + const emptyStateIcon = useMemo( () => { + switch ( view ) { + case 'source': + return search; + case 'channel': + return channel; + case 'campaign': + return megaphone; + default: + return search; + } + }, [ view ] ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sessions-by-device/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sessions-by-device/index.ts new file mode 100644 index 000000000000..5c11324e2400 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sessions-by-device/index.ts @@ -0,0 +1 @@ +export { SessionsByDeviceWidget } from './sessions-by-device-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sessions-by-device/sessions-by-device-widget.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sessions-by-device/sessions-by-device-widget.module.scss new file mode 100644 index 000000000000..77f9c81fda33 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sessions-by-device/sessions-by-device-widget.module.scss @@ -0,0 +1,3 @@ +.container { + height: 100%; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sessions-by-device/sessions-by-device-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sessions-by-device/sessions-by-device-widget.tsx new file mode 100644 index 000000000000..90ac045400c3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sessions-by-device/sessions-by-device-widget.tsx @@ -0,0 +1,95 @@ +/** + * External dependencies + */ +import { useReportSessionsByDevice } from '@jetpack-premium-analytics/data'; +import { device } from '@jetpack-premium-analytics/icons'; +import { Stack } from '@wordpress/ui'; +import { useMemo } from 'react'; +import { SemiCircleChart } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildSessionsByDeviceData } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useSegmentStyles } from '../common'; +import styles from './sessions-by-device-widget.module.scss'; + +/** + * Sessions by Device Type Widget Component + * + * Displays a semi-circle chart showing the breakdown of website sessions + * by device category: Mobile, Desktop, and Tablet. + * + * Features: + * - Shows total sessions in the center with comparison delta + * - Legend with individual device counts and comparison deltas + * - Supports comparison periods + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function SessionsByDeviceWidget() { + const { reportParams } = useWidgetRootContext(); + + const { + primary, + comparison, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + error, + refetch, + } = useReportSessionsByDevice( reportParams ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { chartData, total, comparisonTotal, legendData } = useMemo( + () => buildSessionsByDeviceData( primary.data, comparison.data ), + [ primary.data, comparison.data ] + ); + + const segmentStyles = useSegmentStyles( chartData ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/index.ts new file mode 100644 index 000000000000..ea562eb4bb17 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/index.ts @@ -0,0 +1 @@ +export { TotalReturnsWidget } from './total-returns-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/total-returns-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/total-returns-widget.tsx new file mode 100644 index 000000000000..61b3f5fcb3df --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/total-returns-widget.tsx @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { useReportOrders } from '@jetpack-premium-analytics/data'; +import { paymentReturn } from '@jetpack-premium-analytics/icons'; +import { useMemo } from 'react'; +import { BarChart } from '../../components'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { buildTotalReturnsData } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useBarStyles } from '../common'; + +/** + * Total Returns Widget Component + * + * A widget that displays total returns (refunds) as a bar chart + * showing refunds and net sales side by side. + * + * Must be used within a WidgetRoot which provides reportParams via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function TotalReturnsWidget() { + const { reportParams } = useWidgetRootContext(); + + const { primary, comparison, isLoading, isFetching, hasData, isError, error, refetch } = + useReportOrders( reportParams ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const { chartData } = useMemo( + () => buildTotalReturnsData( primary.data, comparison.data, reportParams ), + [ primary.data, comparison.data, reportParams ] + ); + + const barStyles = useBarStyles( chartData ); + + const hasError = useWidgetError( isError, error, refetch ); + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + return ( + <> + + { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/index.ts new file mode 100644 index 000000000000..9ee968c1e3bc --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/index.ts @@ -0,0 +1 @@ +export { VisitorMetricWidget } from './widget-visitor-metric'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/widget-visitor-metric.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/widget-visitor-metric.tsx new file mode 100644 index 000000000000..84fbcee4627d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitor-metric/widget-visitor-metric.tsx @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import { useReportVisitors } from '@jetpack-premium-analytics/data'; +/** + * Internal dependencies + */ +import { ReportMetricWidget } from '../../components/report-metric'; +import { useWidgetRootContext } from '../../components/widget-root'; + +/** + * Visitor Metric Widget Component + * + * A widget that displays visitor metrics over time with comparison support. + * This component must be used within a WidgetRoot which provides reportParams + * via context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function VisitorMetricWidget() { + const { reportParams } = useWidgetRootContext(); + + return ( + + ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/index.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/index.tsx new file mode 100644 index 000000000000..84201cec7527 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/index.tsx @@ -0,0 +1 @@ +export { VisitorsByLocationWidget } from './visitors-by-location-widget'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/use-visitors-by-location.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/use-visitors-by-location.ts new file mode 100644 index 000000000000..75536863c30a --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/use-visitors-by-location.ts @@ -0,0 +1,102 @@ +/** + * External dependencies + */ +import { type ReportParams, useReportVisitorsByLocation } from '@jetpack-premium-analytics/data'; +import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import { + buildVisitorsByLocationData, + type Region, + type LocationDataEntry, +} from '../../helpers/build-visitors-by-location-data'; + +export type { Region }; + +type LocationRawData = { + primary: LocationDataEntry[]; + comparison: LocationDataEntry[]; +}; + +/** + * Hook to fetch and build visitors by location chart data. + * + * @param reportParams - Report parameters from widget context + * @param region - The region to get data for ('US' or 'world') + * @return Geo chart data and leaderboard data for the selected region + */ +export function useVisitorsByLocation( reportParams: ReportParams, region: Region ) { + const usReport = useReportVisitorsByLocation( reportParams, { + enabled: region === 'US', + groupBy: 'region', + countryCode: 'US', + limit: 100, + } ); + + const worldReport = useReportVisitorsByLocation( reportParams, { + enabled: region === 'world', + groupBy: 'country', + limit: 15, + } ); + + const activeReport = region === 'US' ? usReport : worldReport; + const hasComparison = activeReport.hasComparison; + + const rawData: LocationRawData = useMemo( () => { + const primaryItems = activeReport.primary.data?.data ?? []; + const comparisonItems = activeReport.comparison.data?.data ?? []; + + if ( region === 'US' ) { + const mapUsRegions = ( items: typeof primaryItems ) => + items + .filter( item => Boolean( item.region ) ) + .map( item => ( { + id: item.region as string, + label: item.region as string, + value: item.visitors, + } ) ); + + return { + primary: mapUsRegions( primaryItems ), + comparison: mapUsRegions( comparisonItems ), + }; + } + + return { + primary: primaryItems.map( item => ( { + id: item.country_code.toLowerCase(), + label: item.label, + value: item.visitors, + } ) ), + comparison: comparisonItems.map( item => ( { + id: item.country_code.toLowerCase(), + label: item.label, + value: item.visitors, + } ) ), + }; + }, [ region, activeReport.primary.data, activeReport.comparison.data ] ); + + const chartDataResult = useMemo( + () => + buildVisitorsByLocationData( { + primaryData: rawData.primary, + comparisonData: hasComparison ? rawData.comparison : undefined, + region, + } ), + [ rawData.primary, rawData.comparison, region, hasComparison ] + ); + + const { isLoading, isFetching, hasData, isError, error, refetch } = activeReport; + + return { + ...chartDataResult, + hasComparison, + isLoading, + isFetching, + hasData, + isError, + error, + refetch, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.module.scss b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.module.scss new file mode 100644 index 000000000000..40b03dafe03d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.module.scss @@ -0,0 +1,35 @@ +.root { + height: 100%; + overflow: hidden; +} + +.container { + height: 100%; +} + +.geoChart { + min-width: 0; + max-height: 250px; +} + +.toggleControl { + grid-column: 2; + + // The upstream widget content container + // (.next-admin-dashboard-widget__content) sets overflow: auto, which + // clips the ToggleGroupControl's outward focus ring. + // Add padding to create space for the focus indicator. + padding-block-start: var(--wpds-dimension-padding-xs, 4px); + padding-inline-end: var(--wpds-dimension-padding-xs, 4px); + padding-block-end: 0; + padding-inline-start: 0; +} + +.leaderboardChart { + // bar border-radius now comes from chartTheme.leaderboardChart.barBorderRadius + + .leaderboardImage { + height: 20px; + border-radius: var(--wpds-border-radius-sm, 2px); + } +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.tsx new file mode 100644 index 000000000000..b8cee6439f50 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/visitors-by-location-widget.tsx @@ -0,0 +1,263 @@ +/** + * External dependencies + */ +import { GeoChart } from '@automattic/charts'; +import { location } from '@jetpack-premium-analytics/icons'; +import { + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOption as ToggleGroupControlOption, + __experimentalGrid as Grid, +} from '@wordpress/components'; +import { useResizeObserver } from '@wordpress/compose'; +import { __, sprintf } from '@wordpress/i18n'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ChartEmptyState } from '../../components'; +import { LeaderboardChart, LeaderboardLabel } from '../../components/chart-leaderboard'; +import { WidgetLoadingOverlay } from '../../components/widget-loading-overlay'; +/** + * Internal dependencies + */ +import { useWidgetRootContext } from '../../components/widget-root'; +import { RESIZE_DEBOUNCE_MS } from '../../constants'; +import { flagUrl } from '../../helpers'; +import { useWidgetError } from '../../hooks'; +import { useVisitorsByLocation, type Region } from './use-visitors-by-location'; +import styles from './visitors-by-location-widget.module.scss'; + +function isRegion( value: unknown ): value is Region { + return value === 'US' || value === 'world'; +} + +function closestHTMLElement( + el: Element | null | undefined, + selector: string +): HTMLElement | null { + const match = el?.closest( selector ); + return match instanceof HTMLElement ? match : null; +} + +function isSingleColumnTileFromGridColumnEnd( gridColumnEnd: string ) { + const raw = ( gridColumnEnd || '' ).trim(); + const match = raw.match( /^span\s+(\d+)$/ ); + if ( ! match ) { + return false; + } + return Number( match[ 1 ] ) === 1; +} + +export function VisitorsByLocationWidget() { + const { reportParams } = useWidgetRootContext(); + const [ region, setRegion ] = useState< Region >( 'US' ); + const [ isMinimized, setIsMinimized ] = useState( false ); + const rootRef = useRef< HTMLDivElement | null >( null ); + const tileButtonRef = useRef< HTMLElement | null >( null ); + const resizeDebounceTimeoutRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + + const { + geoData, + leaderboardData, + isLoading, + isFetching, + hasData, + hasComparison, + isError, + error, + refetch, + } = useVisitorsByLocation( reportParams, region ); + + const isInitialLoading = isLoading && ! hasData; + const isRefetching = isFetching && hasData; + + const leaderboardDataWithImages = useMemo( + () => + leaderboardData.map( item => { + const imageUrl = flagUrl( region === 'US' ? 'us' : item.id ); + const labelText = typeof item.label === 'string' ? item.label : ''; + const imageAlt = + region === 'US' + ? __( 'United States flag', 'jetpack-premium-analytics' ) + : sprintf( + /* translators: %s is the country name */ + __( 'Flag of %s', 'jetpack-premium-analytics' ), + labelText + ); + + return { + ...item, + label: ( + + ), + }; + } ), + [ leaderboardData, region ] + ); + + const updateIsMinimized = useCallback( () => { + const tileButton = tileButtonRef.current; + if ( ! tileButton ) { + return; + } + + const nextIsMinimized = isSingleColumnTileFromGridColumnEnd( tileButton.style.gridColumnEnd ); + + // Avoid scheduling React state updates when nothing changes. + setIsMinimized( prev => ( prev === nextIsMinimized ? prev : nextIsMinimized ) ); + }, [] ); + + const debouncedResizeUpdate = useCallback( () => { + // ResizeObserver can fire very frequently while the tile is being resized. + // Debounce to reduce rerenders, mirroring GeoChart's internal resize debounce. + if ( resizeDebounceTimeoutRef.current ) { + clearTimeout( resizeDebounceTimeoutRef.current ); + } + resizeDebounceTimeoutRef.current = setTimeout( updateIsMinimized, RESIZE_DEBOUNCE_MS ); + }, [ updateIsMinimized ] ); + + const resizeObserverRef = useResizeObserver( () => { + debouncedResizeUpdate(); + } ); + + useEffect( () => { + const root = rootRef.current; + + // DataViews picker grid: always render the simplified (map-only) tile + // and avoid attaching any observers/listeners. + const dataViewsPickerGrid = closestHTMLElement( root, '.dataviews-view-picker-grid' ); + + if ( dataViewsPickerGrid ) { + tileButtonRef.current = null; + setIsMinimized( true ); + return; + } + + // Dashboard tile: react to changes in the tile's grid span. + const tileButton = closestHTMLElement( + root, + '[role="button"][aria-roledescription="sortable"]' + ); + + if ( ! tileButton ) { + tileButtonRef.current = null; + setIsMinimized( false ); + return; + } + + tileButtonRef.current = tileButton; + + updateIsMinimized(); + + const mutationObserver = new MutationObserver( updateIsMinimized ); + mutationObserver.observe( tileButton, { + attributes: true, + attributeFilter: [ 'style', 'class' ], + } ); + + // `useResizeObserver` returns a ref callback. We can attach it + // programmatically to `tileButton` even though it's outside this component's + // render tree. + resizeObserverRef( tileButton ); + + return () => { + mutationObserver.disconnect(); + resizeObserverRef( null ); + tileButtonRef.current = null; + if ( resizeDebounceTimeoutRef.current ) { + clearTimeout( resizeDebounceTimeoutRef.current ); + resizeDebounceTimeoutRef.current = null; + } + }; + }, [ resizeObserverRef, updateIsMinimized, leaderboardData ] ); + + const geoChartProps = + region === 'US' + ? ( { + region, + resolution: 'provinces', + } as const ) + : {}; + + const geoChart = ( + + ); + + const hasError = useWidgetError( isError, error, refetch ); + + if ( hasError ) { + return null; + } + + if ( isInitialLoading ) { + return ; + } + + if ( ! leaderboardData || leaderboardData.length === 0 ) { + return ( + <> + + { isRefetching && } + + ); + } + + return ( + <> +
+ { isMinimized ? ( +
{ geoChart }
+ ) : ( + +
+ { + if ( isRegion( value ) ) { + setRegion( value ); + } + } } + value={ region } + > + + + +
+ +
{ geoChart }
+ + +
+ ) } +
+ { isRefetching && } + + ); +} diff --git a/projects/packages/premium-analytics/src/class-analytics.php b/projects/packages/premium-analytics/src/class-analytics.php index e3cc93a370e7..62aece91526a 100644 --- a/projects/packages/premium-analytics/src/class-analytics.php +++ b/projects/packages/premium-analytics/src/class-analytics.php @@ -56,6 +56,10 @@ public static function init( $options = array() ) { require_once $build_entry; } + // Emit `window.jpaConfig` (site ID, REST root, nonce) ahead of the boot script. + require_once __DIR__ . '/class-config-data.php'; + Config_Data::init(); + add_action( 'admin_menu', array( static::class, 'register_admin_menu' ) ); add_action( 'jetpack-premium-analytics_init', array( static::class, 'register_sidebar_items' ) ); } diff --git a/projects/packages/premium-analytics/src/class-config-data.php b/projects/packages/premium-analytics/src/class-config-data.php new file mode 100644 index 000000000000..6ff39faa0685 --- /dev/null +++ b/projects/packages/premium-analytics/src/class-config-data.php @@ -0,0 +1,93 @@ + $site_id, + 'apiRoot' => esc_url_raw( rest_url() ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ); + } +} diff --git a/projects/packages/premium-analytics/tests/jest.config.cjs b/projects/packages/premium-analytics/tests/jest.config.cjs new file mode 100644 index 000000000000..ac15edcd2b5e --- /dev/null +++ b/projects/packages/premium-analytics/tests/jest.config.cjs @@ -0,0 +1,17 @@ +const path = require( 'path' ); +const baseConfig = require( 'jetpack-js-tools/jest/config.base.js' ); + +module.exports = { + ...baseConfig, + rootDir: path.join( __dirname, '..' ), + moduleNameMapper: { + ...baseConfig.moduleNameMapper, + // Resolve internal `packages/*` imports to their TypeScript source, + // mirroring the tsconfig `paths` alias (see README → "Internal packages"). + '^@jetpack-premium-analytics/(.*)$': path.join( __dirname, '..', 'packages', '$1', 'src' ), + // Plain CSS shipped by node_modules packages is not transformed by the + // base config (its asset stub only covers first-party files), so stub + // it out here. + '^@automattic/(ui|charts)/style\\.css$': path.join( __dirname, 'style-stub.cjs' ), + }, +}; diff --git a/projects/packages/premium-analytics/tests/style-stub.cjs b/projects/packages/premium-analytics/tests/style-stub.cjs new file mode 100644 index 000000000000..3b3cbb70317f --- /dev/null +++ b/projects/packages/premium-analytics/tests/style-stub.cjs @@ -0,0 +1,4 @@ +// Empty stand-in for plain CSS imports from node_modules packages +// (e.g. `@automattic/ui/style.css`), which jest would otherwise try to +// parse as JavaScript. +module.exports = {}; diff --git a/projects/packages/premium-analytics/tsconfig.json b/projects/packages/premium-analytics/tsconfig.json index ec28701d0d80..12425d0a415c 100644 --- a/projects/packages/premium-analytics/tsconfig.json +++ b/projects/packages/premium-analytics/tsconfig.json @@ -9,5 +9,5 @@ "@jetpack-premium-analytics/*": [ "./packages/*/src" ] } }, - "include": [ "routes/**/*", "packages/**/*" ] + "include": [ "routes/**/*", "packages/**/*", "widgets/**/*" ] } diff --git a/projects/packages/premium-analytics/widgets/top-posts/package.json b/projects/packages/premium-analytics/widgets/top-posts/package.json new file mode 100644 index 000000000000..702fd8b7d6b9 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/package.json @@ -0,0 +1,19 @@ +{ + "name": "@automattic/jetpack-premium-analytics-widget-top-posts", + "version": "0.1.0-alpha", + "private": true, + "type": "module", + "dependencies": { + "@jetpack-premium-analytics/data": "workspace:*", + "@jetpack-premium-analytics/widgets-toolkit": "workspace:*", + "@wordpress/i18n": "^6.9.0", + "@wordpress/icons": "^13.0.0", + "@wordpress/ui": "0.13.0", + "date-fns": "4.1.0", + "react": "18.3.1" + }, + "devDependencies": { + "@testing-library/react": "16.3.2", + "@wordpress/api-fetch": "7.46.0" + } +} diff --git a/projects/packages/premium-analytics/widgets/top-posts/render.tsx b/projects/packages/premium-analytics/widgets/top-posts/render.tsx new file mode 100644 index 000000000000..aa32d4d96ad4 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/render.tsx @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import { WidgetRoot } from '@jetpack-premium-analytics/widgets-toolkit'; +/** + * Internal dependencies + */ +import { TopPosts, type TopPostsAttributes } from './top-posts'; + +type TopPostsRenderProps = { + attributes?: TopPostsAttributes; +}; + +/** + * Widget render entry point. + * + * Attributes flow to the inner component via props rather than + * `WidgetRootContext` — the context's report params are WC-Analytics-shaped + * and do not fit stats queries. + * + * @param props - Render props. + * @param props.attributes - Widget attributes. + * @return The rendered widget. + */ +export default function TopPostsWidget( { attributes }: TopPostsRenderProps ) { + return ( + + + + ); +} diff --git a/projects/packages/premium-analytics/widgets/top-posts/style.module.css b/projects/packages/premium-analytics/widgets/top-posts/style.module.css new file mode 100644 index 000000000000..921015676957 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/style.module.css @@ -0,0 +1,13 @@ +.labelLink { + display: block; + overflow: hidden; + color: inherit; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; +} + +.labelLink:hover, +.labelLink:focus { + text-decoration: underline; +} diff --git a/projects/packages/premium-analytics/widgets/top-posts/tests/top-posts.test.tsx b/projects/packages/premium-analytics/widgets/top-posts/tests/top-posts.test.tsx new file mode 100644 index 000000000000..3db79aeb3d8c --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/tests/top-posts.test.tsx @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import { queryClient } from '@jetpack-premium-analytics/data'; +import { render, screen } from '@testing-library/react'; +import apiFetch from '@wordpress/api-fetch'; +/** + * Internal dependencies + */ +import TopPostsWidget from '../render'; + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +// WidgetRoot reads URL search params as a fallback for report params; outside +// a matched route the real hook warns and throws. +jest.mock( '@wordpress/route', () => ( { + useSearch: () => ( {} ), +} ) ); + +const mockApiFetch = apiFetch as unknown as jest.Mock; + +const TOP_POSTS_RESPONSE = { + date: '2026-06-10', + days: { + '2026-06-10': { + postviews: [ + { + id: 1, + href: 'https://example.com/hello-world/', + date: '2026-06-01', + title: 'Hello World Post', + type: 'post', + views: 42, + }, + { + id: 2, + href: 'https://example.com/about/', + date: null, + title: 'About Page', + type: 'page', + views: 7, + }, + ], + total_views: 49, + }, + }, +}; + +describe( 'TopPostsWidget', () => { + beforeEach( () => { + // The data package's query client is a module-level singleton; drop its + // cache so each test starts from a fresh fetch. + queryClient.clear(); + mockApiFetch.mockReset(); + mockApiFetch.mockResolvedValue( TOP_POSTS_RESPONSE ); + window.jpaConfig = { + siteId: 123, + apiRoot: 'https://example.com/wp-json/', + nonce: 'abc', + }; + } ); + + afterEach( () => { + delete window.jpaConfig; + } ); + + it( 'renders the fetched top posts as links', async () => { + render( ); + + const link = await screen.findByRole( 'link', { name: 'Hello World Post' } ); + expect( link ).toHaveAttribute( 'href', 'https://example.com/hello-world/' ); + expect( screen.getByText( 'About Page' ) ).toBeInTheDocument(); + } ); + + it( 'filters rows by post type when the name attribute is set', async () => { + render( + + ); + + await expect( screen.findByText( 'About Page' ) ).resolves.toBeInTheDocument(); + expect( screen.queryByText( 'Hello World Post' ) ).not.toBeInTheDocument(); + } ); + + it( 'renders the empty state when there are no views', async () => { + mockApiFetch.mockResolvedValue( { date: '2026-06-10', days: {} } ); + + render( ); + + await expect( screen.findByText( 'No views in this period.' ) ).resolves.toBeInTheDocument(); + } ); +} ); diff --git a/projects/packages/premium-analytics/widgets/top-posts/top-posts.tsx b/projects/packages/premium-analytics/widgets/top-posts/top-posts.tsx new file mode 100644 index 000000000000..151597b624a1 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/top-posts.tsx @@ -0,0 +1,124 @@ +/** + * External dependencies + */ +import { localTZDate, useReportTopPosts } from '@jetpack-premium-analytics/data'; +import { + LeaderboardChart, + WidgetLoadingOverlay, + type LeaderboardChartData, +} from '@jetpack-premium-analytics/widgets-toolkit'; +import { __ } from '@wordpress/i18n'; +import { Text } from '@wordpress/ui'; +import { format } from 'date-fns'; +import { useMemo } from 'react'; +/** + * Internal dependencies + */ +import styles from './style.module.css'; +import type { TopPostRow, UseReportTopPostsParams } from '@jetpack-premium-analytics/data'; + +export type TopPostsAttributes = { + period?: UseReportTopPostsParams[ 'period' ]; + /** + * Reference date, YYYY-MM-DD. Defaults to today in the site timezone. + */ + date?: string; + num?: number; + /** + * Post type(s) to keep. When undefined, all types are shown. + */ + name?: string | string[]; +}; + +type TopPostsProps = { + attributes?: TopPostsAttributes; +}; + +/** + * Leaderboard row label: the post title linking to the published post. + * + * @param props - Component props. + * @param props.label - Post title. + * @param props.href - Post URL. + * @return The label element. + */ +function TopPostLabel( { label, href }: { label: string; href: string } ) { + return ( + + { label } + + ); +} + +/** + * Map normalized top-posts rows to leaderboard entries. Bars scale relative + * to the most-viewed entry; there is no comparison period in v1. + * + * @param rows - Normalized top-posts rows. + * @return Leaderboard chart entries. + */ +function buildLeaderboardData( rows: TopPostRow[] ): LeaderboardChartData { + const maxViews = Math.max( ...rows.map( row => row.value ), 0 ); + + return rows.map( ( row, index ) => ( { + id: `${ index }-${ row.href }`, + label: , + currentValue: row.value, + previousValue: 0, + currentShare: maxViews > 0 ? ( row.value / maxViews ) * 100 : 0, + previousShare: 0, + delta: 0, + } ) ); +} + +/** + * Top posts & pages list. + * + * Attributes arrive via props, not WidgetRootContext — the context's report + * params are WC-Analytics-shaped and do not fit stats queries. + * + * @param props - Component props. + * @param props.attributes - Widget attributes. + * @return The widget content. + */ +export function TopPosts( { attributes }: TopPostsProps ) { + const period = attributes?.period ?? 'day'; + const date = attributes?.date ?? format( localTZDate(), 'yyyy-MM-dd' ); + const num = attributes?.num ?? 10; + + const { rows, isLoading, isError } = useReportTopPosts( { + period, + date, + num, + name: attributes?.name, + } ); + + const data = useMemo( () => buildLeaderboardData( rows ), [ rows ] ); + + if ( isError ) { + return { __( 'Unable to load top posts.', 'jetpack-premium-analytics' ) }; + } + + if ( isLoading ) { + return ; + } + + if ( data.length === 0 ) { + return { __( 'No views in this period.', 'jetpack-premium-analytics' ) }; + } + + return ( + + ); +} diff --git a/projects/packages/premium-analytics/widgets/top-posts/widget.json b/projects/packages/premium-analytics/widgets/top-posts/widget.json new file mode 100644 index 000000000000..f35d6ddf9d3e --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/widget.json @@ -0,0 +1,7 @@ +{ + "name": "jpa/stats-top-posts", + "title": "Top posts & pages", + "description": "Your most viewed posts and pages.", + "category": "stats", + "presentation": "framed" +} diff --git a/projects/packages/premium-analytics/widgets/top-posts/widget.ts b/projects/packages/premium-analytics/widgets/top-posts/widget.ts new file mode 100644 index 000000000000..d9ac36ce6c21 --- /dev/null +++ b/projects/packages/premium-analytics/widgets/top-posts/widget.ts @@ -0,0 +1,42 @@ +/** + * WordPress dependencies + */ +import { chartBar } from '@wordpress/icons'; + +/** + * Widget type definition. + * + * `example.attributes` doubles as the defaults applied to new instances: + * one day of stats, ten posts, all post types. The reference date is + * deliberately absent — it is computed at render time so the widget always + * shows "today" (see top-posts.tsx). + */ +export default { + name: 'jpa/stats-top-posts', + title: 'Top posts & pages', + icon: chartBar, + presentation: 'framed', + attributes: [ + { + id: 'period', + label: 'Period', + type: 'text', + }, + { + id: 'date', + label: 'Date', + type: 'text', + }, + { + id: 'num', + label: 'Number of results', + type: 'integer', + }, + ], + example: { + attributes: { + period: 'day', + num: 10, + }, + }, +}; diff --git a/projects/plugins/premium-analytics/changelog/wooa7s-1489-port-stats-top-posts-widget b/projects/plugins/premium-analytics/changelog/wooa7s-1489-port-stats-top-posts-widget new file mode 100644 index 000000000000..f432cece757a --- /dev/null +++ b/projects/plugins/premium-analytics/changelog/wooa7s-1489-port-stats-top-posts-widget @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Premium Analytics package version bump only; no plugin-side code changes. + +