diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9d8671c1478..952e2c54d443 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3862,6 +3862,9 @@ 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 @@ -3892,6 +3895,9 @@ importers: '@wordpress/data': specifier: 10.48.0 version: 10.48.0(react@18.3.1) + '@wordpress/dataviews': + specifier: 14.3.0 + version: 14.3.0(@date-fns/tz@1.4.1)(react@18.3.1) '@wordpress/i18n': specifier: ^6.9.0 version: 6.21.0 @@ -3907,6 +3913,9 @@ importers: '@wordpress/route': specifier: 0.13.1 version: 0.13.1(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(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3952,7 +3961,7 @@ importers: version: 8.0.0 '@wordpress/build': specifier: 0.14.0 - version: 0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.14.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.48.0)(@wordpress/route@0.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + version: 0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.14.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.48.0)(@wordpress/route@0.13.1(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 @@ -24759,7 +24768,7 @@ snapshots: '@wordpress/browserslist-config@6.48.0': {} - '@wordpress/build@0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.14.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.48.0)(@wordpress/route@0.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': + '@wordpress/build@0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.14.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/private-apis@1.48.0)(@wordpress/route@0.13.1(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 '@wordpress/style-runtime': 0.2.0 @@ -24781,6 +24790,7 @@ snapshots: '@wordpress/boot': 0.14.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/private-apis': 1.48.0 '@wordpress/route': 0.13.1(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) transitivePeerDependencies: - '@babel/core' - browserslist @@ -25437,6 +25447,57 @@ snapshots: rememo: 4.0.2 use-memo-one: 1.1.3(react@18.3.1) + '@wordpress/dataviews@14.3.0(@date-fns/tz@1.4.1)(react@18.3.1)': + dependencies: + '@ariakit/react': 0.4.29(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/base-styles': 8.0.0 + '@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.48.0(react@18.3.1) + '@wordpress/deprecated': 4.48.0 + '@wordpress/element': 6.46.0 + '@wordpress/i18n': 6.21.0 + '@wordpress/icons': 13.1.0(react@18.3.1) + '@wordpress/keycodes': 4.48.0 + '@wordpress/primitives': 4.48.0(react@18.3.1) + '@wordpress/private-apis': 1.48.0 + '@wordpress/ui': 0.13.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/warning': 3.48.0 + clsx: 2.1.1 + react: 18.3.1 + remove-accents: 0.5.0 + optionalDependencies: + '@base-ui/react': 1.5.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@emotion/cache': 11.14.0 + '@emotion/css': 11.13.5 + '@emotion/react': 11.14.0(react@18.3.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(react@18.3.1))(react@18.3.1) + '@emotion/utils': 1.4.2 + '@floating-ui/react-dom': 2.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@use-gesture/react': 10.3.1(react@18.3.1) + '@wordpress/date': 5.48.0 + '@wordpress/hooks': 4.48.0 + change-case: 4.1.2 + colord: 2.9.3 + date-fns: 4.1.0 + deepmerge: 4.3.1 + fast-deep-equal: 3.1.3 + framer-motion: 11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + highlight-words-core: 1.2.3 + is-plain-object: 5.0.0 + memize: 2.1.1 + react-colorful: 5.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-day-picker: 9.14.0(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) + use-memo-one: 1.1.3(react@18.3.1) + uuid: 14.0.0 + transitivePeerDependencies: + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@types/react' + - stylelint + - supports-color + '@wordpress/dataviews@14.3.0(@types/react@18.3.28)(react@18.3.1)': dependencies: '@ariakit/react': 0.4.29(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 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/eslint.config.mjs b/projects/packages/premium-analytics/eslint.config.mjs index d26a13974722..519ad4968d3b 100644 --- a/projects/packages/premium-analytics/eslint.config.mjs +++ b/projects/packages/premium-analytics/eslint.config.mjs @@ -76,5 +76,32 @@ export default defineConfig( 'jsdoc/check-indentation': 'off', 'import/no-extraneous-dependencies': '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 f0d30fddddb6..e0c33bcdeae1 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -31,6 +31,7 @@ } }, "dependencies": { + "@automattic/charts": "workspace:*", "@automattic/number-formatters": "workspace:*", "@automattic/ui": "1.0.2", "@date-fns/tz": "1.4.1", @@ -41,11 +42,13 @@ "@wordpress/compose": "8.1.0", "@wordpress/core-data": "7.48.0", "@wordpress/data": "10.48.0", + "@wordpress/dataviews": "14.3.0", "@wordpress/i18n": "^6.9.0", "@wordpress/icons": "^13.0.0", "@wordpress/primitives": "4.48.0", "@wordpress/private-apis": "1.48.0", "@wordpress/route": "0.13.1", + "@wordpress/theme": "0.13.0", "@wordpress/ui": "0.13.0", "@wordpress/url": "4.48.0", "clsx": "2.1.1", 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..ccbdfcbb0b34 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/package.json @@ -0,0 +1,35 @@ +{ + "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/api-fetch": "7.48.0", + "@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" + }, + "devDependencies": { + "@storybook/react": "10.3.6" + } +} 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-bar/stories/bar-chart.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/stories/bar-chart.stories.tsx new file mode 100644 index 000000000000..dba8a94fc010 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-bar/stories/bar-chart.stories.tsx @@ -0,0 +1,349 @@ +import { withChartTheme } from '../../../stories/with-chart-theme'; +import { BarChart, type BarChartStyle } from '../bar-chart'; +import type { SeriesData } from '@automattic/charts'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta< typeof BarChart > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/BarChart', + component: BarChart, + tags: [ 'autodocs' ], + parameters: { + layout: 'padded', + }, + decorators: [ + withChartTheme, + Story => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj< typeof BarChart >; + +/** + * Series styles using the styles prop (recommended approach). + * Clean API where all styling is defined in one place. + */ +const STYLES: BarChartStyle[] = [ + { stroke: '#3858E9' }, + { stroke: '#3858E9' }, + { stroke: '#3858E9' }, +]; + +/** + * Sample categorical chart data for sales by coupon + */ +const chartData: SeriesData[] = [ + { + label: 'Dec 16, 2025-Jan 14, 2026', + data: [ + { label: 'summer', value: 4500 }, + { label: 'welcome', value: 3200 }, + { label: 'flash', value: 2800 }, + ], + }, +]; + +/** + * Sample categorical chart data with negative values (refunds) + */ +const withNegativeValues: SeriesData[] = [ + { + label: 'Dec 16, 2025-Jan 14, 2026', + data: [ + { label: 'Product Sales', value: 15000 }, + { label: 'Shipping', value: 2500 }, + { label: 'Refunds', value: -3200 }, + { label: 'Discounts', value: -1500 }, + ], + }, +]; + +const longDataLabel: SeriesData[] = [ + { + label: 'Dec 16, 2025-Jan 14, 2026', + data: [ + { + label: 'Very long data label that might need to be truncated', + value: 15000, + }, + { + label: 'Another long data label that might need to be truncated', + value: 2500, + }, + ], + }, +]; + +/** + * Default: Vertical bar chart with categorical data + */ +export const Default: Story = { + args: { + chartData, + dataFormat: { type: 'number' }, + styles: STYLES, + }, +}; + +/** + * WithNegativeValues: Bar chart showing negative amounts (refunds) + * This demonstrates the key feature of using bar charts for monetary widgets + */ +export const WithNegativeValues: Story = { + args: { + chartData: withNegativeValues, + dataFormat: { type: 'currency' }, + styles: STYLES, + }, +}; + +/** + * AllNegativeValues: Example showing all negative values + */ +export const AllNegativeValues: Story = { + args: { + chartData: [ + { + label: 'Dec 16, 2025-Jan 14, 2026', + data: [ + { label: 'Defective', value: -5400 }, + { label: 'Wrong Item', value: -3200 }, + { label: 'Changed Mind', value: -2100 }, + ], + }, + ], + dataFormat: { type: 'currency' }, + styles: STYLES, + }, +}; + +/** + * LongDataLabel: Bar chart with long data labels + */ +export const LongDataLabel: Story = { + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + chartData: longDataLabel, + dataFormat: { type: 'number' }, + styles: STYLES, + }, +}; + +/** + * CurrencyFormat: Bar chart with currency formatted tooltip + */ +export const CurrencyFormat: Story = { + args: { + chartData, + dataFormat: { type: 'currency' }, + styles: STYLES, + }, +}; + +/** + * PercentageFormat: Bar chart with percentage formatted tooltip + */ +export const PercentageFormat: Story = { + args: { + chartData: [ + { + label: 'Dec 16, 2025-Jan 14, 2026', + data: [ + { label: 'Desktop', value: 0.045 }, + { label: 'Mobile', value: 0.032 }, + { label: 'Tablet', value: 0.028 }, + ], + }, + ], + dataFormat: { type: 'percentage' }, + styles: STYLES, + }, +}; + +/** + * WithComparison: Bar chart with primary and comparison periods + * Demonstrates multiple series with different colors + */ +export const WithComparison: Story = { + args: { + chartData: [ + { + label: 'Dec 16, 2025-Jan 14, 2026', + data: [ + { label: 'summer', value: 4500 }, + { label: 'welcome', value: 3200 }, + { label: 'flash', value: 2800 }, + ], + }, + { + label: 'Dec 16, 2024-Jan 14, 2025', + data: [ + { label: 'summer', value: 3800 }, + { label: 'welcome', value: 2900 }, + { label: 'flash', value: 3100 }, + ], + }, + ], + dataFormat: { type: 'currency' }, + styles: [ + { stroke: '#3858E9' }, // Primary - Blueberry + { stroke: '#66BDFF' }, // Comparison - Blue 30 + ], + }, + parameters: { + docs: { + description: { + story: + 'Bar chart with two series comparing current and previous periods. Uses different blue shades to distinguish between periods.', + }, + }, + }, +}; + +/** + * Resizable: Demonstrates auto-resize behavior + */ +export const Resizable: Story = { + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + ...WithComparison.args, + }, +}; + +/** + * CustomStyles: Bar chart with custom green color applied via the `styles` prop + */ +export const CustomStyles: Story = { + args: { + chartData, + dataFormat: { type: 'currency' }, + styles: [ { stroke: '#10B981' } ], + }, + parameters: { + docs: { + description: { + story: 'Bar chart with custom green color applied via the `styles` prop.', + }, + }, + }, +}; + +/** + * ZeroValues: Bar chart with all zero values (tooltips disabled) + * Demonstrates how the chart handles zero values gracefully by disabling tooltips + */ +export const ZeroValues: Story = { + args: { + chartData: [ + { + label: 'Dec 16, 2025-Jan 14, 2026', + data: [ + { label: 'summer', value: 0 }, + { label: 'welcome', value: 0 }, + { label: 'flash', value: 0 }, + ], + }, + ], + dataFormat: { type: 'currency' }, + styles: STYLES, + }, + parameters: { + docs: { + description: { + story: + 'When all data values are 0, tooltips are automatically disabled to avoid showing meaningless "0" tooltips on hover.', + }, + }, + }, +}; + +/** + * ComparisonWithZeroValues: Bar chart comparing current period with zero historical data + * Demonstrates how charts handle new stores with no prior data to compare against. + * Zero-value bars render with a small visible height for better UX. + */ +export const ComparisonWithZeroValues: Story = { + args: { + chartData: [ + { + label: 'Dec 16, 2025-Jan 14, 2026', + data: [ + { label: 'New customers', value: 4500 }, + { label: 'Returning', value: 3200 }, + ], + }, + { + label: 'Dec 16, 2024-Jan 14, 2025', + data: [ + { label: 'New customers', value: 0 }, + { label: 'Returning', value: 0 }, + ], + }, + ], + dataFormat: { type: 'currency' }, + styles: [ + { stroke: '#3858E9' }, // Primary - Blueberry + { stroke: '#66BDFF' }, // Comparison - Blue 30 + ], + showZeroValues: true, + }, + parameters: { + docs: { + description: { + story: + 'When comparison period has zero values (e.g., new store with no prior data), zero-value bars render with a small visible height. This provides a visual cue and allows users to hover for tooltip confirmation.', + }, + }, + }, +}; + +/** + * EmptyState: Bar chart with no data available + * Demonstrates the empty state message when there's no data to display + */ +export const EmptyState: Story = { + args: { + chartData: [], + dataFormat: { type: 'currency' }, + styles: STYLES, + }, + parameters: { + docs: { + description: { + story: + 'When no data is available, an empty state message is displayed instead of the 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/stories/comparative-line-chart.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/stories/comparative-line-chart.stories.tsx new file mode 100644 index 000000000000..e19b044fb59d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-comparative-line/stories/comparative-line-chart.stories.tsx @@ -0,0 +1,598 @@ +import { formatDate } from '@jetpack-premium-analytics/formatters'; +import { withChartTheme } from '../../../stories/with-chart-theme'; +import { ComparativeLineChart } from '../comparative-line-chart'; +import type { ComparativeLineChartSeries, SeriesStyle } from '../types'; +import type { Meta, StoryObj } from '@storybook/react'; + +/** + * Helper component to display series data as a table (for story documentation only) + */ +const SeriesDataTable = ( { series }: { series: ComparativeLineChartSeries[] } ) => { + const tableStyle: React.CSSProperties = { + width: '100%', + borderCollapse: 'collapse', + fontSize: '12px', + marginTop: '16px', + }; + + const cellStyle: React.CSSProperties = { + border: '1px solid #ddd', + padding: '6px 10px', + textAlign: 'left', + }; + + const headerStyle: React.CSSProperties = { + ...cellStyle, + backgroundColor: '#f5f5f5', + fontWeight: 600, + }; + + // Get max data points across all series + const maxLength = Math.max( ...series.map( s => s.data.length ) ); + + return ( + + + + + { series.map( ( s, i ) => ( + + ) ) } + + + + { series.map( ( _, i ) => ( + <> + + + + ) ) } + + + + { Array.from( { length: maxLength }, ( _, rowIndex ) => ( + + + { series.map( ( s, seriesIndex ) => { + const point = s.data[ rowIndex ] as { date: Date; value: number } | undefined; + return ( + <> + + + + ); + } ) } + + ) ) } + +
# + { s.label } +
+ Date + + Value +
{ rowIndex + 1 } + { point?.date ? formatDate( point.date, 'short' ) : '-' } + + { point?.value ?? '-' } +
+ ); +}; + +const meta: Meta< typeof ComparativeLineChart > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/ComparativeLineChart', + component: ComparativeLineChart, + tags: [ 'autodocs' ], + parameters: { + layout: 'padded', + }, + decorators: [ + withChartTheme, + Story => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj< typeof ComparativeLineChart >; + +/** + * Series styles using the styles prop (recommended approach). + * Clean API where all styling is defined in one place. + */ +const SERIES_STYLES: SeriesStyle[] = [ + { stroke: '#3858E9', strokeWidth: 2 }, + { + stroke: '#3858E9', + strokeDasharray: '4 4', + strokeWidth: 1.5, + strokeDashoffset: 2, + }, + { stroke: '#3858E9', strokeDasharray: '2 2', strokeWidth: 1.5 }, +]; + +/** + * Sample data - deterministic values for consistent snapshots + */ +const primaryDates = [ + new Date( '2024-01-01' ), + new Date( '2024-01-02' ), + new Date( '2024-01-03' ), + new Date( '2024-01-04' ), + new Date( '2024-01-05' ), + new Date( '2024-01-06' ), + new Date( '2024-01-07' ), +]; + +const comparisonDates = [ + new Date( '2023-12-25' ), + new Date( '2023-12-26' ), + new Date( '2023-12-27' ), + new Date( '2023-12-28' ), + new Date( '2023-12-29' ), + new Date( '2023-12-30' ), + new Date( '2023-12-31' ), +]; + +/** + * Default: Single series with currency format. + * Styles are passed via the `styles` prop (recommended approach). + */ +const singleSeries: ComparativeLineChartSeries[] = [ + { + label: 'Revenue', + group: 'primary', + data: [ + { date: primaryDates[ 0 ], value: 1200 }, + { date: primaryDates[ 1 ], value: 1800 }, + { date: primaryDates[ 2 ], value: 1400 }, + { date: primaryDates[ 3 ], value: 2200 }, + { date: primaryDates[ 4 ], value: 1900 }, + { date: primaryDates[ 5 ], value: 2400 }, + { date: primaryDates[ 6 ], value: 2100 }, + ], + }, +]; + +export const Default: Story = { + args: { + series: singleSeries, + styles: SERIES_STYLES, + dataFormat: { type: 'currency' }, + }, +}; + +/** + * WithComparison: Two series comparing current vs previous period. + * Note how the comparison dates (Dec 25-31) are automatically aligned + * to the primary dates (Jan 1-7) on the X-axis. + */ +const comparisonSeries: ComparativeLineChartSeries[] = [ + { + label: 'Jan 1-7, 2024', + group: 'primary', + data: [ + { date: primaryDates[ 0 ], value: 1200 }, + { date: primaryDates[ 1 ], value: 1800 }, + { date: primaryDates[ 2 ], value: 1400 }, + { date: primaryDates[ 3 ], value: 2200 }, + { date: primaryDates[ 4 ], value: 1900 }, + { date: primaryDates[ 5 ], value: 2400 }, + { date: primaryDates[ 6 ], value: 2100 }, + ], + }, + { + label: 'Dec 25-31, 2023', + group: 'comparison', + data: [ + { date: comparisonDates[ 0 ], value: 1000 }, + { date: comparisonDates[ 1 ], value: 1500 }, + { date: comparisonDates[ 2 ], value: 1300 }, + { date: comparisonDates[ 3 ], value: 1800 }, + { date: comparisonDates[ 4 ], value: 1600 }, + { date: comparisonDates[ 5 ], value: 2000 }, + { date: comparisonDates[ 6 ], value: 1700 }, + ], + }, +]; + +export const WithComparison: Story = { + args: { + series: comparisonSeries, + styles: SERIES_STYLES, + dataFormat: { type: 'currency' }, + }, + render: args => ( + <> + + + + ), + parameters: { + docs: { + source: { + code: ``, + }, + }, + }, +}; + +/** + * MultipleSeries: Three series comparing different periods. + * Each series is automatically aligned to the primary (first) series. + */ +const thirdPeriodDates = [ + new Date( '2023-12-18' ), + new Date( '2023-12-19' ), + new Date( '2023-12-20' ), + new Date( '2023-12-21' ), + new Date( '2023-12-22' ), + new Date( '2023-12-23' ), + new Date( '2023-12-24' ), +]; + +const multipleSeries: ComparativeLineChartSeries[] = [ + { + label: 'Jan 1-7, 2024', + group: 'primary', + data: [ + { date: primaryDates[ 0 ], value: 1200 }, + { date: primaryDates[ 1 ], value: 1800 }, + { date: primaryDates[ 2 ], value: 1400 }, + { date: primaryDates[ 3 ], value: 2200 }, + { date: primaryDates[ 4 ], value: 1900 }, + { date: primaryDates[ 5 ], value: 2400 }, + { date: primaryDates[ 6 ], value: 2100 }, + ], + }, + { + label: 'Dec 25-31, 2023', + group: 'comparison', + data: [ + { date: comparisonDates[ 0 ], value: 1000 }, + { date: comparisonDates[ 1 ], value: 1500 }, + { date: comparisonDates[ 2 ], value: 1300 }, + { date: comparisonDates[ 3 ], value: 1800 }, + { date: comparisonDates[ 4 ], value: 1600 }, + { date: comparisonDates[ 5 ], value: 2000 }, + { date: comparisonDates[ 6 ], value: 1700 }, + ], + }, + { + label: 'Dec 18-24, 2023', + group: 'comparison', + data: [ + { date: thirdPeriodDates[ 0 ], value: 800 }, + { date: thirdPeriodDates[ 1 ], value: 1200 }, + { date: thirdPeriodDates[ 2 ], value: 1100 }, + { date: thirdPeriodDates[ 3 ], value: 1400 }, + { date: thirdPeriodDates[ 4 ], value: 1300 }, + { date: thirdPeriodDates[ 5 ], value: 1600 }, + { date: thirdPeriodDates[ 6 ], value: 1500 }, + ], + }, +]; + +export const MultipleSeries: Story = { + args: { + series: multipleSeries, + styles: SERIES_STYLES, + dataFormat: { type: 'currency' }, + }, + render: args => ( + <> + + + + ), +}; + +/** + * WithStylesInSeries: Alternative approach where styles are defined + * directly in each series via the `options` property. + * This is useful when each series needs different colors or when + * styles are dynamically generated per-series. + */ +const seriesWithInlineStyles: ComparativeLineChartSeries[] = [ + { + label: 'Jan 1-7, 2024', + group: 'primary', + data: [ + { date: primaryDates[ 0 ], value: 1200 }, + { date: primaryDates[ 1 ], value: 1800 }, + { date: primaryDates[ 2 ], value: 1400 }, + { date: primaryDates[ 3 ], value: 2200 }, + { date: primaryDates[ 4 ], value: 1900 }, + { date: primaryDates[ 5 ], value: 2400 }, + { date: primaryDates[ 6 ], value: 2100 }, + ], + options: { + stroke: '#10B981', + seriesLineStyle: { strokeWidth: 2 }, + }, + }, + { + label: 'Dec 25-31, 2023', + group: 'comparison', + data: [ + { date: comparisonDates[ 0 ], value: 1000 }, + { date: comparisonDates[ 1 ], value: 1500 }, + { date: comparisonDates[ 2 ], value: 1300 }, + { date: comparisonDates[ 3 ], value: 1800 }, + { date: comparisonDates[ 4 ], value: 1600 }, + { date: comparisonDates[ 5 ], value: 2000 }, + { date: comparisonDates[ 6 ], value: 1700 }, + ], + options: { + stroke: '#F59E0B', + seriesLineStyle: { + strokeWidth: 1.5, + strokeDasharray: '4 4', + strokeDashoffset: 2, + }, + }, + }, +]; + +export const WithStylesInSeries: Story = { + args: { + series: seriesWithInlineStyles, + dataFormat: { type: 'currency' }, + }, + parameters: { + docs: { + description: { + story: `Styles can also be defined directly in each series via the \`options\` property. +This approach is useful when: +- Each series needs different colors (e.g., green vs orange) +- Styles are dynamically generated per-series +- You want styles co-located with series data + +The component will use these styles as a fallback when no \`styles\` prop is provided.`, + }, + source: { + code: `const seriesWithInlineStyles = [ + { + label: 'Current Period', + group: 'primary', + data: [...], + options: { + stroke: '#10B981', + seriesLineStyle: { strokeWidth: 2 }, + }, + }, + { + label: 'Previous Period', + group: 'comparison', + data: [...], + options: { + stroke: '#F59E0B', + seriesLineStyle: { strokeWidth: 1.5, strokeDasharray: '4 4', strokeDashoffset: 2 }, + }, + }, +]; + +`, + }, + }, + }, +}; + +/** + * WeeklyIntervalsAlignment: Demonstrates proper bullet alignment when comparing + * periods with weekly intervals that start on different days of the week. + * + * This is a key test case for the index-based alignment fix: + * - Primary period: Sep 12 (Thursday) - starts mid-week + * - Comparison period: Jun 14 (Saturday) - starts on a different day + * + * Without the fix, bullets would be misaligned because the offset-based approach + * didn't account for periods starting on different weekdays. + * With index-based alignment, each comparison point aligns perfectly with its + * corresponding primary point regardless of actual dates. + */ +const weeklyPrimaryDates = [ + new Date( '2024-09-12' ), // Week 1 - Thu (partial week) + new Date( '2024-09-16' ), // Week 2 - Mon + new Date( '2024-09-23' ), // Week 3 - Mon + new Date( '2024-09-30' ), // Week 4 - Mon + new Date( '2024-10-07' ), // Week 5 - Mon + new Date( '2024-10-14' ), // Week 6 - Mon +]; + +const weeklyComparisonDates = [ + new Date( '2024-06-14' ), // Week 1 - Sat (partial week, different day!) + new Date( '2024-06-17' ), // Week 2 - Mon + new Date( '2024-06-24' ), // Week 3 - Mon + new Date( '2024-06-30' ), // Week 4 - Sun (different day!) + new Date( '2024-07-08' ), // Week 5 - Mon + new Date( '2024-07-15' ), // Week 6 - Mon +]; + +const weeklyIntervalsSeries: ComparativeLineChartSeries[] = [ + { + label: 'Sep 12 - Oct 14, 2024', + group: 'primary', + data: [ + { date: weeklyPrimaryDates[ 0 ], value: 0 }, + { date: weeklyPrimaryDates[ 1 ], value: 15800 }, + { date: weeklyPrimaryDates[ 2 ], value: 47200 }, + { date: weeklyPrimaryDates[ 3 ], value: 40900 }, + { date: weeklyPrimaryDates[ 4 ], value: 36200 }, + { date: weeklyPrimaryDates[ 5 ], value: 43000 }, + ], + }, + { + label: 'Jun 14 - Aug 16, 2024', + group: 'comparison', + data: [ + { date: weeklyComparisonDates[ 0 ], value: 0 }, + { date: weeklyComparisonDates[ 1 ], value: 12000 }, + { date: weeklyComparisonDates[ 2 ], value: 38500 }, + { date: weeklyComparisonDates[ 3 ], value: 35200 }, + { date: weeklyComparisonDates[ 4 ], value: 29800 }, + { date: weeklyComparisonDates[ 5 ], value: 31500 }, + ], + }, +]; + +export const WeeklyIntervalsAlignment: Story = { + args: { + series: weeklyIntervalsSeries, + styles: SERIES_STYLES, + dataFormat: { type: 'currency' }, + }, + render: args => ( + <> + + +

+ Note: The primary period starts on Thursday (Sep 12) while the comparison + starts on Saturday (Jun 14). With index-based alignment, bullets align perfectly by position + regardless of actual dates. Hover to see both dates in tooltip. +

+ + ), + parameters: { + docs: { + description: { + story: `Demonstrates proper alignment when comparing weekly intervals +that start on different days of the week. This scenario previously caused +misaligned bullets because the offset-based approach didn't account for +periods starting on different weekdays. The fix uses index-based alignment +where each comparison point gets the date of its corresponding primary point.`, + }, + }, + }, +}; + +/** + * EmptyData: Shows the chart behavior when all values are zero. + * The chart displays a fixed Y-axis domain and adjusts margins accordingly. + */ +const emptyDataSeries: ComparativeLineChartSeries[] = [ + { + label: 'Jan 1-7, 2024', + group: 'primary', + data: [ + { date: primaryDates[ 0 ], value: 0 }, + { date: primaryDates[ 1 ], value: 0 }, + { date: primaryDates[ 2 ], value: 0 }, + { date: primaryDates[ 3 ], value: 0 }, + { date: primaryDates[ 4 ], value: 0 }, + { date: primaryDates[ 5 ], value: 0 }, + { date: primaryDates[ 6 ], value: 0 }, + ], + }, +]; + +export const EmptyState: Story = { + args: { + series: emptyDataSeries, + styles: SERIES_STYLES, + dataFormat: { type: 'currency' }, + }, + parameters: { + docs: { + description: { + story: `When all data points are zero, the chart shows a fixed Y-axis domain +(0-4000 for currency) to maintain visual consistency.`, + }, + }, + }, +}; + +/** + * EmptyDataNumber: Shows empty state behavior for number format. + * Uses a fixed Y-axis domain of 0-80 for number metrics. + */ +export const EmptyDataNumber: Story = { + args: { + series: emptyDataSeries, + styles: SERIES_STYLES, + dataFormat: { type: 'number' }, + }, + parameters: { + docs: { + description: { + story: `When using number format with empty data, the chart shows a fixed +Y-axis domain of 0-80 to maintain visual consistency.`, + }, + }, + }, +}; + +/** + * Long date labels for testing legend overflow. + */ +const longLabelSeries: ComparativeLineChartSeries[] = [ + { + label: 'January 1, 2024 - January 31, 2024', + group: 'primary', + data: [ + { date: primaryDates[ 0 ], value: 1200 }, + { date: primaryDates[ 1 ], value: 1800 }, + { date: primaryDates[ 2 ], value: 1400 }, + { date: primaryDates[ 3 ], value: 2200 }, + { date: primaryDates[ 4 ], value: 1900 }, + { date: primaryDates[ 5 ], value: 2400 }, + { date: primaryDates[ 6 ], value: 2100 }, + ], + }, + { + label: 'December 1, 2023 - December 31, 2023', + group: 'comparison', + data: [ + { date: comparisonDates[ 0 ], value: 1000 }, + { date: comparisonDates[ 1 ], value: 1500 }, + { date: comparisonDates[ 2 ], value: 1300 }, + { date: comparisonDates[ 3 ], value: 1800 }, + { date: comparisonDates[ 4 ], value: 1600 }, + { date: comparisonDates[ 5 ], value: 2000 }, + { date: comparisonDates[ 6 ], value: 1700 }, + ], + }, +]; + +/** + * Resizable: Demonstrates auto-resize behavior. + * Drag the container edges to see the chart adapt to different widths. + */ +export const Resizable: Story = { + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + series: longLabelSeries, + styles: SERIES_STYLES, + dataFormat: { type: 'currency' }, + }, + parameters: { + layout: 'padded', + }, +}; 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-donut/stories/donut-chart.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/stories/donut-chart.stories.tsx new file mode 100644 index 000000000000..bfcea43d9363 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-donut/stories/donut-chart.stories.tsx @@ -0,0 +1,405 @@ +import { withChartTheme } from '../../../stories/with-chart-theme'; +import { DonutChart } from '../donut-chart'; +import type { SegmentStyle } from '../../../helpers'; +import type { LegendItem } from '../../legend/legend'; +import type { DonutChartData } from '../donut-chart'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta< typeof DonutChart > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/DonutChart', + component: DonutChart, + tags: [ 'autodocs' ], + parameters: { + layout: 'padded', + }, + decorators: [ withChartTheme ], +}; + +export default meta; +type Story = StoryObj< typeof DonutChart >; + +/** + * Segment styles using the styles prop (recommended approach). + */ +const SEGMENT_STYLES: SegmentStyle[] = [ + { color: '#3858E9' }, // Blueberry + { color: '#66BDFF' }, // Blue 30 + { color: '#A77EFF' }, // Purple 30 +]; + +/** + * Extended segment styles for 5 items. + */ +const SEGMENT_STYLES_5: SegmentStyle[] = [ + { color: '#3858E9' }, // Blueberry + { color: '#66BDFF' }, // Blue 30 + { color: '#A77EFF' }, // Purple 30 + { color: '#F2D675' }, // Yellow 20 + { color: '#7BDCB5' }, // Green 30 +]; + +/** + * Sample chart data + */ +const chartData: DonutChartData = [ + { label: 'Completed', value: 45 }, + { label: 'Pending', value: 25 }, + { label: 'Cancelled', value: 10 }, +]; + +/** + * Sample legend data (without comparison) + */ +const legendData: LegendItem[] = [ + { label: 'Completed', value: 45, displayValue: '45' }, + { label: 'Pending', value: 25, displayValue: '25' }, + { label: 'Cancelled', value: 10, displayValue: '10' }, +]; + +/** + * Sample legend data with comparison values + */ +const legendDataWithComparison: LegendItem[] = [ + { label: 'Completed', value: 45, displayValue: '45', comparison: 42 }, + { label: 'Pending', value: 25, displayValue: '25', comparison: 30 }, + { label: 'Cancelled', value: 10, displayValue: '10', comparison: 8 }, +]; + +/** + * Default: Chart only, colors via styles prop. + */ +export const Default: Story = { + args: { + chartData, + styles: SEGMENT_STYLES, + value: 80, + dataFormat: { type: 'number' }, + }, +}; + +/** + * WithLegend: Chart with legend below. + */ +export const WithLegend: Story = { + args: { + chartData, + styles: SEGMENT_STYLES, + value: 80, + legendData, + showLegend: true, + dataFormat: { type: 'number' }, + }, +}; + +/** + * WithComparison: Chart with comparison value showing positive delta. + */ +export const WithComparison: Story = { + args: { + chartData, + styles: SEGMENT_STYLES, + value: 80, + comparisonValue: 72, + legendData: legendDataWithComparison, + showLegend: true, + dataFormat: { type: 'number' }, + }, +}; + +/** + * NegativeComparison: Chart with comparison value showing negative delta. + */ +export const NegativeComparison: Story = { + args: { + chartData: [ + { label: 'Completed', value: 35 }, + { label: 'Pending', value: 20 }, + { label: 'Cancelled', value: 15 }, + ], + styles: SEGMENT_STYLES, + value: 70, + comparisonValue: 80, + legendData: [ + { + label: 'Completed', + value: 35, + displayValue: '35', + comparison: 45, + }, + { + label: 'Pending', + value: 20, + displayValue: '20', + comparison: 25, + }, + { + label: 'Cancelled', + value: 15, + displayValue: '15', + comparison: 10, + }, + ], + showLegend: true, + dataFormat: { type: 'number' }, + }, +}; + +/** + * CurrencyFormat: Donut chart with currency formatted values. + */ +export const CurrencyFormat: Story = { + args: { + chartData: [ + { label: 'Online Sales', value: 45000 }, + { label: 'In-store Sales', value: 25000 }, + { label: 'Returns', value: 10000 }, + ], + styles: SEGMENT_STYLES, + value: 80000, + comparisonValue: 72000, + legendData: [ + { + label: 'Online Sales', + value: 45000, + displayValue: '$45K', + comparison: 42000, + }, + { + label: 'In-store Sales', + value: 25000, + displayValue: '$25K', + comparison: 22000, + }, + { + label: 'Returns', + value: 10000, + displayValue: '$10K', + comparison: 8000, + }, + ], + showLegend: true, + dataFormat: { + type: 'currency', + options: { useMultipliers: true, decimals: 0 }, + }, + }, +}; + +/** + * Resizable: Demonstrates auto-resize behavior. + * Drag the container edges to see the chart adapt to different widths. + */ +export const Resizable: Story = { + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + chartData, + styles: SEGMENT_STYLES, + value: 80, + comparisonValue: 72, + legendData: legendDataWithComparison, + showLegend: true, + dataFormat: { type: 'number' }, + }, +}; + +/** + * SmallContainer: Chart in a narrow 200px container. + */ +export const SmallContainer: Story = { + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + chartData, + styles: SEGMENT_STYLES, + value: 80, + comparisonValue: 72, + legendData: legendDataWithComparison, + showLegend: true, + dataFormat: { type: 'number' }, + }, +}; + +/** + * LargeContainer: Chart stretches to fill a 400px container. + */ +export const LargeContainer: Story = { + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + chartData: [ + { label: 'Completed', value: 45 }, + { label: 'Pending', value: 25 }, + { label: 'Cancelled', value: 10 }, + { label: 'Refunded', value: 8 }, + { label: 'On Hold', value: 2 }, + ], + styles: SEGMENT_STYLES_5, + value: 90, + comparisonValue: 82, + legendData: [ + { + label: 'Completed', + value: 45, + displayValue: '45', + comparison: 42, + }, + { + label: 'Pending', + value: 25, + displayValue: '25', + comparison: 21, + }, + { + label: 'Cancelled', + value: 10, + displayValue: '10', + comparison: 11, + }, + { + label: 'Refunded', + value: 8, + displayValue: '8', + comparison: 6, + }, + { + label: 'On Hold', + value: 2, + displayValue: '2', + comparison: 2, + }, + ], + showLegend: true, + dataFormat: { type: 'number' }, + }, +}; + +/** + * BookingsByStatus: Real-world example for booking status distribution. + */ +export const BookingsByStatus: Story = { + args: { + chartData: [ + { label: 'Confirmed', value: 120 }, + { label: 'Pending', value: 45 }, + { label: 'Cancelled', value: 15 }, + ], + styles: [ + { color: '#36B37E' }, // Green for confirmed + { color: '#FFAB00' }, // Yellow for pending + { color: '#FF5630' }, // Red for cancelled + ], + value: 180, + comparisonValue: 165, + legendData: [ + { + label: 'Confirmed', + value: 120, + displayValue: '120', + comparison: 110, + }, + { + label: 'Pending', + value: 45, + displayValue: '45', + comparison: 40, + }, + { + label: 'Cancelled', + value: 15, + displayValue: '15', + comparison: 15, + }, + ], + showLegend: true, + dataFormat: { type: 'number' }, + }, +}; + +/** + * NewVsReturning: Real-world example for customer segmentation. + */ +export const NewVsReturning: Story = { + args: { + chartData: [ + { label: 'New customers', value: 340 }, + { label: 'Returning', value: 660 }, + ], + styles: [ + { color: '#3858E9' }, // Blue for new + { color: '#A77EFF' }, // Purple for returning + ], + value: 1000, + comparisonValue: 920, + legendData: [ + { + label: 'New customers', + value: 340, + displayValue: '340', + comparison: 300, + }, + { + label: 'Returning', + value: 660, + displayValue: '660', + comparison: 620, + }, + ], + showLegend: true, + dataFormat: { type: 'number' }, + }, +}; + +/** + * EmptyState: Shows the empty state with default icon when no data is available. + */ +export const EmptyState: Story = { + args: { + chartData: [], + styles: SEGMENT_STYLES, + value: 0, + dataFormat: { type: 'number' }, + }, +}; + +/** + * WithTooltips: Chart with tooltips enabled on hover. + * Hover over segments to see tooltips with label and value. + */ +export const WithTooltips: Story = { + args: { + chartData, + styles: SEGMENT_STYLES, + value: 80, + comparisonValue: 72, + legendData: legendDataWithComparison, + showLegend: true, + dataFormat: { type: 'number' }, + withTooltips: true, + }, +}; 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-empty-state/stories/chart-empty-state.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-empty-state/stories/chart-empty-state.stories.tsx new file mode 100644 index 000000000000..764f116e8bd3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-empty-state/stories/chart-empty-state.stories.tsx @@ -0,0 +1,136 @@ +import { device, location, customer, payment } from '@jetpack-premium-analytics/icons'; +import { ChartEmptyState } from '../chart-empty-state'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta< typeof ChartEmptyState > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/ChartEmptyState', + component: ChartEmptyState, + tags: [ 'autodocs' ], + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'A reusable empty state component for charts. Uses cautionFilled from @wordpress/icons as the default icon, with support for custom illustrated icons from @jetpack-premium-analytics/icons.', + }, + }, + }, + argTypes: { + icon: { + control: false, + description: + 'Icon to display in the empty state. Defaults to cautionFilled from @wordpress/icons. Can be overridden with custom icons.', + }, + text: { + control: 'text', + description: 'Text to display in the empty state.', + }, + }, +}; + +export default meta; + +type Story = StoryObj< typeof ChartEmptyState >; + +/** + * Widget card wrapper component for Storybook stories. + * Simulates a widget container to demonstrate how ChartEmptyState + * appears within typical widget dimensions. + */ +const WidgetCard = ( { + title, + children, + width = '300px', + height = '280px', +}: { + title: string; + children: React.ReactNode; + width?: string; + height?: string; +} ) => ( +
+
+ { title } +
+
{ children }
+
+); + +/** + * Default empty state with cautionFilled icon from @wordpress/icons + */ +export const Default: Story = { + args: {}, + decorators: [ + Story => ( + + + + ), + ], +}; + +/** + * Empty state with custom icon and text + */ +export const Custom: Story = { + args: { + text: 'No payments found for this period.', + icon: payment, + }, + decorators: [ + Story => ( + + + + ), + ], +}; + +/** + * Different Container Sizes + * + * Shows how the empty state adapts to different widget sizes, + * featuring different domain icons (customer, device, location). + */ +export const ContainerSizes: Story = { + render: () => ( +
+ + + + + + + + + +
+ ), +}; 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-leaderboard/stories/leaderboard-chart.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/stories/leaderboard-chart.stories.tsx new file mode 100644 index 000000000000..7dc9004ff654 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-leaderboard/stories/leaderboard-chart.stories.tsx @@ -0,0 +1,320 @@ +import { withChartTheme } from '../../../stories/with-chart-theme'; +import { LeaderboardChart } from '../leaderboard-chart'; +import type { LeaderboardChartData } from '../leaderboard-chart'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof LeaderboardChart > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/LeaderboardChart', + component: LeaderboardChart, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + '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).', + }, + }, + }, + decorators: [ withChartTheme ], +}; + +export default meta; + +type Story = StoryObj< typeof LeaderboardChart >; + +// Mock data for stories +const mockLeaderboardData: LeaderboardChartData = [ + { + 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, + }, + { + id: '4', + label: 'Social media', + currentValue: 31500, + previousValue: 28000, + currentShare: 11, + previousShare: 10, + delta: 12.5, + }, +]; + +const mockLongLabelData: LeaderboardChartData = [ + { + id: '1', + label: 'Very Long Campaign Name That Might Need To Be Truncated', + currentValue: 125000, + previousValue: 98000, + currentShare: 45, + previousShare: 38, + delta: 27.55, + }, + { + id: '2', + label: 'Another Extremely Long Label For Testing', + currentValue: 87500, + previousValue: 92000, + currentShare: 32, + previousShare: 36, + delta: -4.89, + }, + { + id: '3', + label: 'Medium length label', + currentValue: 63000, + previousValue: 67000, + currentShare: 23, + previousShare: 26, + delta: -5.97, + }, +]; + +/** + * Default state showing leaderboard without comparison + */ +export const Default: Story = { + args: { + data: mockLeaderboardData, + withComparison: false, + }, +}; + +/** + * With comparison period enabled - shows delta and previous period data + */ +export const WithComparison: Story = { + args: { + data: mockLeaderboardData, + withComparison: true, + legendLabels: { + primary: 'Jan 1 – 31, 2025', + comparison: 'Dec 1 – 31, 2024', + }, + }, +}; + +/** + * Loading state + */ +export const Loading: Story = { + args: { + data: mockLeaderboardData, + loading: true, + withComparison: true, + }, +}; + +/** + * Empty state - no data available + */ +export const EmptyState: Story = { + args: { + data: [], + emptyStateText: 'No data available for this period', + }, +}; + +/** + * With overlay label - label displayed on top of bar + */ +export const WithOverlayLabel: Story = { + args: { + data: mockLeaderboardData, + withOverlayLabel: true, + withComparison: true, + }, +}; + +/** + * Without legend + */ +export const WithoutLegend: Story = { + args: { + data: mockLeaderboardData, + withComparison: true, + showLegend: false, + }, +}; + +/** + * With long labels to test truncation + */ +export const LongLabels: Story = { + args: { + data: mockLongLabelData, + withComparison: true, + }, +}; + +/** + * Number format (no currency) + */ +export const NumberFormat: Story = { + args: { + data: mockLeaderboardData, + withComparison: true, + dataFormat: { + type: 'number', + options: { useMultipliers: true, decimals: 0 }, + }, + }, +}; + +/** + * Percentage format - displays conversion rates as percentages + */ +export const PercentageFormat: Story = { + args: { + data: [ + { + id: '1', + label: 'Conversion rate A', + currentValue: 0.0435, + previousValue: 0.038, + currentShare: 48, + previousShare: 42, + delta: 14.47, + }, + { + id: '2', + label: 'Conversion rate B', + currentValue: 0.0312, + previousValue: 0.035, + currentShare: 34, + previousShare: 38, + delta: -10.86, + }, + { + id: '3', + label: 'Conversion rate C', + currentValue: 0.0163, + previousValue: 0.018, + currentShare: 18, + previousShare: 20, + delta: -9.44, + }, + ], + withComparison: true, + dataFormat: { + type: 'percentage', + options: { decimals: 2 }, + }, + }, +}; + +/* + * Container Size Stories + * + * These stories demonstrate how the widget adapts to different container sizes. + * Breakpoints aligned with Tailwind container query defaults. + */ + +/** + * Creates a decorator that wraps the story in a fixed-size container + */ +const createSizeDecorator = ( width: string, height = 'auto' ): Decorator => { + return Story => ( +
+ +
+ ); +}; + +/** + * Extra extra small container (256px / xxs breakpoint) + */ +export const SizeXXSmall: Story = { + args: { + data: mockLeaderboardData, + withComparison: true, + }, + decorators: [ createSizeDecorator( '256px' ) ], +}; + +/** + * Medium container (448px / md breakpoint) + */ +export const SizeMedium: Story = { + args: { + data: mockLeaderboardData, + withComparison: true, + }, + decorators: [ createSizeDecorator( '448px' ) ], +}; + +/** + * Large container (576px / xl breakpoint) + */ +export const SizeLarge: Story = { + args: { + data: mockLeaderboardData, + withComparison: true, + }, + decorators: [ createSizeDecorator( '576px' ) ], +}; + +/** + * Resizable: Demonstrates auto-resize behavior. + * Drag the container edges to see the chart adapt to different widths. + */ +export const Resizable: Story = { + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + data: mockLeaderboardData, + withComparison: true, + }, + parameters: { + layout: 'padded', + }, +}; 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-semi-circle/stories/semi-circle-chart.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/stories/semi-circle-chart.stories.tsx new file mode 100644 index 000000000000..a8c40c7ce31c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-semi-circle/stories/semi-circle-chart.stories.tsx @@ -0,0 +1,288 @@ +import { withChartTheme } from '../../../stories/with-chart-theme'; +import { SemiCircleChart } from '../semi-circle-chart'; +import type { SegmentStyle } from '../../../helpers'; +import type { LegendItem } from '../../legend/legend'; +import type { SemiCircleChartData } from '../semi-circle-chart'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta< typeof SemiCircleChart > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/SemiCircleChart', + component: SemiCircleChart, + tags: [ 'autodocs' ], + parameters: { + layout: 'padded', + }, + decorators: [ withChartTheme ], +}; + +export default meta; +type Story = StoryObj< typeof SemiCircleChart >; + +/** + * Segment styles using the styles prop (recommended approach). + */ +const SEGMENT_STYLES: SegmentStyle[] = [ + { color: '#3858E9' }, // Blueberry + { color: '#66BDFF' }, // Blue 30 + { color: '#A77EFF' }, // Purple 30 +]; + +/** + * Extended segment styles for 5 items. + */ +const SEGMENT_STYLES_5: SegmentStyle[] = [ + { color: '#3858E9' }, // Blueberry + { color: '#66BDFF' }, // Blue 30 + { color: '#A77EFF' }, // Purple 30 + { color: '#F2D675' }, // Yellow 20 + { color: '#7BDCB5' }, // Green 30 +]; + +/** + * Sample chart data + */ +const chartData: SemiCircleChartData = [ + { label: 'Mobile', value: 4500 }, + { label: 'Desktop', value: 2500 }, + { label: 'Tablet', value: 1000 }, +]; + +/** + * Sample legend data (without comparison) + */ +const legendData: LegendItem[] = [ + { label: 'Mobile', value: 4500, displayValue: '4.5K' }, + { label: 'Desktop', value: 2500, displayValue: '2.5K' }, + { label: 'Tablet', value: 1000, displayValue: '1K' }, +]; + +/** + * Sample legend data with comparison values + */ +const legendDataWithComparison: LegendItem[] = [ + { label: 'Mobile', value: 4500, displayValue: '4.5K', comparison: 4200 }, + { label: 'Desktop', value: 2500, displayValue: '2.5K', comparison: 2100 }, + { label: 'Tablet', value: 1000, displayValue: '1K', comparison: 1150 }, +]; + +/** + * Default: Chart only, colors via styles prop. + */ +export const Default: Story = { + args: { + chartData, + styles: SEGMENT_STYLES, + value: 8000, + dataFormat: { type: 'number', options: { useMultipliers: true } }, + }, +}; + +/** + * WithLegend: Chart with legend below. + */ +export const WithLegend: Story = { + args: { + chartData, + styles: SEGMENT_STYLES, + value: 8000, + legendData, + showLegend: true, + dataFormat: { type: 'number', options: { useMultipliers: true } }, + }, +}; + +/** + * WithComparison: Chart with comparison value showing positive delta. + */ +export const WithComparison: Story = { + args: { + chartData, + styles: SEGMENT_STYLES, + value: 8000, + comparisonValue: 7450, + legendData: legendDataWithComparison, + showLegend: true, + dataFormat: { type: 'number', options: { useMultipliers: true } }, + }, +}; + +/** + * NegativeComparison: Chart with comparison value showing negative delta. + */ +export const NegativeComparison: Story = { + args: { + chartData: [ + { label: 'Mobile', value: 3500 }, + { label: 'Desktop', value: 2000 }, + { label: 'Tablet', value: 1000 }, + ], + styles: SEGMENT_STYLES, + value: 6500, + comparisonValue: 8000, + legendData: [ + { + label: 'Mobile', + value: 3500, + displayValue: '3.5K', + comparison: 4500, + }, + { + label: 'Desktop', + value: 2000, + displayValue: '2K', + comparison: 2500, + }, + { + label: 'Tablet', + value: 1000, + displayValue: '1K', + comparison: 1000, + }, + ], + showLegend: true, + dataFormat: { type: 'number', options: { useMultipliers: true } }, + }, +}; + +/** + * Resizable: Demonstrates auto-resize behavior. + * Drag the container edges to see the chart adapt to different widths. + */ +export const Resizable: Story = { + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + chartData, + styles: SEGMENT_STYLES, + value: 8000, + comparisonValue: 7450, + legendData: legendDataWithComparison, + showLegend: true, + dataFormat: { type: 'number', options: { useMultipliers: true } }, + }, +}; + +/** + * SmallContainer: Chart in a narrow 200px container. + */ +export const SmallContainer: Story = { + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + chartData, + styles: SEGMENT_STYLES, + value: 8000, + comparisonValue: 7450, + legendData: legendDataWithComparison, + showLegend: true, + dataFormat: { type: 'number', options: { useMultipliers: true } }, + }, +}; + +/** + * LargeContainer: Chart stretches to fill a 400px container. + */ +export const LargeContainer: Story = { + decorators: [ + Story => ( +
+ +
+ ), + ], + args: { + chartData: [ + { label: 'Mobile', value: 4500 }, + { label: 'Desktop', value: 2500 }, + { label: 'Tablet', value: 1000 }, + { label: 'Smart TV', value: 800 }, + { label: 'Other', value: 200 }, + ], + styles: SEGMENT_STYLES_5, + value: 9000, + comparisonValue: 8200, + legendData: [ + { + label: 'Mobile', + value: 4500, + displayValue: '4.5K', + comparison: 4200, + }, + { + label: 'Desktop', + value: 2500, + displayValue: '2.5K', + comparison: 2100, + }, + { + label: 'Tablet', + value: 1000, + displayValue: '1K', + comparison: 1150, + }, + { + label: 'Smart TV', + value: 800, + displayValue: '800', + comparison: 600, + }, + { + label: 'Other', + value: 200, + displayValue: '200', + comparison: 150, + }, + ], + showLegend: true, + dataFormat: { type: 'number', options: { useMultipliers: true } }, + }, +}; + +/** + * EmptyState: Shows the empty state with default icon when no data is available. + */ +export const EmptyState: Story = { + args: { + chartData: [], + styles: SEGMENT_STYLES, + value: 0, + dataFormat: { type: 'number', options: { useMultipliers: true } }, + }, +}; + +/** + * WithTooltips: Chart with tooltips enabled on hover. + * Hover over segments to see tooltips with label and value. + */ +export const WithTooltips: Story = { + args: { + chartData, + styles: SEGMENT_STYLES, + value: 8000, + comparisonValue: 7450, + legendData: legendDataWithComparison, + showLegend: true, + dataFormat: { type: 'number', options: { useMultipliers: true } }, + withTooltips: true, + }, +}; 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/stories/chart-tooltip.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/stories/chart-tooltip.stories.tsx new file mode 100644 index 000000000000..9622f6cfabb3 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/stories/chart-tooltip.stories.tsx @@ -0,0 +1,419 @@ +import { formatDate } from '@jetpack-premium-analytics/formatters'; +import { ChartTooltip, type TooltipStyle } from '../chart-tooltip'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta< typeof ChartTooltip > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/ChartTooltip', + component: ChartTooltip, + tags: [ 'autodocs' ], + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj< typeof ChartTooltip >; + +/** + * Helper wrapper for tooltip stories with consistent background. + */ +const TooltipWrapper = ( { children }: { children: React.ReactNode } ) => ( +
+ { children } +
+); + +/** + * Line chart styles - solid and dashed lines + */ +const LINE_SERIES_STYLES: TooltipStyle[] = [ + { stroke: '#3858E9', strokeWidth: 2 }, + { + stroke: '#3858E9', + strokeDasharray: '4 4', + strokeWidth: 1.5, + strokeDashoffset: 2, + }, + { stroke: '#3858E9', strokeDasharray: '2 2', strokeWidth: 1.5 }, +]; + +/** + * Bar chart styles - solid colors + */ +const BAR_SERIES_STYLES: TooltipStyle[] = [ + { stroke: '#3858E9' }, + { stroke: '#66BDFF' }, + { stroke: '#A78BFA' }, +]; + +/** + * Custom label extractor for line chart datum (date-based). + * 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 + * @param _key - Series key (unused, date is extracted from datum) + */ +type LineDatum = { date: Date; realDate?: Date; value: number }; +const getDateLabel = ( datum: LineDatum, index: number ): string => { + const isComparison = index > 0; + const displayDate = isComparison ? datum.realDate ?? datum.date : datum.date; + return formatDate( displayDate ); +}; + +/** + * LineIndicatorTwoSeries: Line indicator with two series (primary + comparison). + */ +export const LineIndicatorTwoSeries: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: + 'Line indicator showing primary and comparison periods. The dashed line differentiates the comparison series.', + }, + }, + }, +}; + +/** + * LineIndicatorThreeSeries: Line indicator with three series. + */ +export const LineIndicatorThreeSeries: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Line indicator showing three periods with distinct dash patterns.', + }, + }, + }, +}; + +/** + * RectIndicatorTwoSeries: Rectangle indicator for bar charts with two series. + * Uses default getLabel which extracts datum.label. + */ +export const RectIndicatorTwoSeries: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Rectangle indicator for bar charts. Uses different colors for each series.', + }, + }, + }, +}; + +/** + * RectIndicatorSingleSeries: Rectangle indicator with single series. + * Uses default getLabel which extracts datum.label. + */ +export const RectIndicatorSingleSeries: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Single series with rectangle indicator and percentage formatting.', + }, + }, + }, +}; + +/** + * NumberFormat: Tooltip with number formatting. + */ +export const NumberFormat: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Tooltip with number formatting (no currency symbol).', + }, + }, + }, +}; + +/** + * PercentageFormat: Tooltip with percentage formatting. + */ +export const PercentageFormat: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Tooltip with percentage formatting.', + }, + }, + }, +}; + +/** + * CurrencyFormat: Tooltip with currency formatting. + */ +export const CurrencyFormat: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Single series tooltip with currency formatting.', + }, + }, + }, +}; + +/** + * CustomStyles: Tooltip with custom color styles. + */ +export const CustomStyles: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Tooltip with custom green and orange colors instead of the default blue.', + }, + }, + }, +}; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/stories/pie-chart-tooltip.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/stories/pie-chart-tooltip.stories.tsx new file mode 100644 index 000000000000..2ea7242364c2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/chart-tooltip/stories/pie-chart-tooltip.stories.tsx @@ -0,0 +1,134 @@ +import { PieChartTooltip } from '../pie-chart-tooltip'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { ReactNode } from 'react'; + +const meta: Meta< typeof PieChartTooltip > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/PieChartTooltip', + component: PieChartTooltip, + tags: [ 'autodocs' ], + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj< typeof PieChartTooltip >; + +/** + * Helper wrapper for tooltip stories with consistent background. + */ +const TooltipWrapper = ( { children }: { children: ReactNode } ) => ( +
+ { children } +
+); + +/** + * NumberFormat: Pie tooltip with number formatting. + */ +export const NumberFormat: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: + 'Pie chart tooltip with number formatting. Shows color indicator, label, and formatted value.', + }, + }, + }, +}; + +/** + * CurrencyFormat: Pie tooltip with currency formatting. + */ +export const CurrencyFormat: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Pie chart tooltip with currency formatting.', + }, + }, + }, +}; + +/** + * PercentageFormat: Pie tooltip with percentage formatting. + */ +export const PercentageFormat: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Pie chart tooltip with percentage formatting.', + }, + }, + }, +}; + +/** + * CustomColor: Pie tooltip with a custom segment color. + */ +export const CustomColor: Story = { + render: () => ( + + + + ), + parameters: { + docs: { + description: { + story: 'Pie chart tooltip showing a custom red color indicator.', + }, + }, + }, +}; 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..121c13e7227b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/index.ts @@ -0,0 +1,20 @@ +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'; 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/legend/stories/legend.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/stories/legend.stories.tsx new file mode 100644 index 000000000000..25fe41ba1b37 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/legend/stories/legend.stories.tsx @@ -0,0 +1,272 @@ +import { MetricDelta } from '../../metric-delta'; +import { Legend, type LegendItem } from '../legend'; +import { LegendRow } from '../row'; +import type { ReactNode } from 'react'; + +/** + * Default color palette for stories + */ +const STORY_COLORS = [ + '#3858E9', // Blueberry + '#66BDFF', // Blue 30 + '#A77EFF', // Purple 30 + '#7B90FF', + '#EB6594', +]; + +const sampleItems: LegendItem[] = [ + { + label: 'Mobile', + value: 241950, + displayValue: 'R$ 241.95K', + color: STORY_COLORS[ 0 ], + }, + { + label: 'Desktop', + value: 148130, + displayValue: 'R$ 148.13K', + color: STORY_COLORS[ 1 ], + }, + { + label: 'Tablet', + value: 44740, + displayValue: 'R$ 44.74K', + color: STORY_COLORS[ 2 ], + }, +]; + +const sampleItemsWithComparison: LegendItem[] = [ + { + label: 'Mobile', + value: 241950, + displayValue: 'R$ 241.95K', + color: STORY_COLORS[ 0 ], + comparison: 200000, + }, + { + label: 'Desktop', + value: 148130, + displayValue: 'R$ 148.13K', + color: STORY_COLORS[ 1 ], + comparison: 160000, + }, + { + label: 'Tablet', + value: 44740, + displayValue: 'R$ 44.74K', + color: STORY_COLORS[ 2 ], + comparison: 44740, + }, +]; + +const meta = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/Legend', + component: Legend, + tags: [ 'autodocs' ], +}; + +export default meta; + +/** + * Default legend with items + */ +export const Default = { + args: { + items: sampleItems, + }, +}; + +/** + * Legend with comparison deltas + */ +export const WithComparison = { + args: { + items: sampleItemsWithComparison, + withComparison: true, + }, +}; + +/** + * Legend with hidden values - shows only labels and comparison deltas. + * Useful for widgets like Sales by Coupon where absolute values + * are already shown in the chart. + */ +export const HiddenValues = { + args: { + items: sampleItemsWithComparison, + withComparison: true, + hideValue: true, + }, +}; + +/** + * Grid wrapper to properly display LegendRow (which returns a Fragment) + */ +function GridWrapper( { children }: { children: ReactNode } ) { + return ( +
+ { children } +
+ ); +} + +/** + * LegendRow: Basic row with label and value + */ +export const Row = { + render: () => ( + + + Item Label + + + ), +}; + +/** + * LegendRow: With positive comparison delta + */ +export const RowWithPositiveComparison = { + render: () => ( + + } + > + Revenue + + + ), +}; + +/** + * LegendRow: With negative comparison delta + */ +export const RowWithNegativeComparison = { + render: () => ( + + } + > + Returns + + + ), +}; + +/** + * Resizable container wrapper for testing responsive behavior. + */ +function ResizableWrapper( { children }: { children: ReactNode } ) { + return ( +
+ { children } +
+ ); +} + +/** + * Items with long labels to test text overflow behavior + */ +const longLabelItems: LegendItem[] = [ + { + label: 'Desktop Computer', + value: 85000, + displayValue: '$85.142,00', + color: STORY_COLORS[ 0 ], + comparison: 80000, + }, + { + label: 'Mobile Phone', + value: 45000, + displayValue: '$ 45.086,60', + color: STORY_COLORS[ 1 ], + comparison: 40000, + }, + { + label: 'Tablet Device', + value: 15000, + displayValue: '$ 15.023,10', + color: STORY_COLORS[ 2 ], + comparison: 18000, + }, +]; + +/** + * Resizable: Legend with comparison in narrow container. + * Tests how delta indicators behave when space is limited. + */ +export const Resizable = { + render: () => ( + + + + ), +}; + +/** + * Items with dash fallback - when previous is 0 and current is not, + * a dash is shown instead of percentage (can't calculate % from zero). + */ +const itemsWithDashFallback: LegendItem[] = [ + { + label: 'Mobile', + value: 104000, + displayValue: '$104K', + color: STORY_COLORS[ 0 ], + comparison: 5000, // Normal: +1980% + }, + { + label: 'Unassigned', + value: 69000, + displayValue: '$69K', + color: STORY_COLORS[ 1 ], + comparison: 12000, // Normal: +475% + }, + { + label: 'Desktop', + value: 28000, + displayValue: '$28K', + color: STORY_COLORS[ 2 ], + comparison: 0, // Dash fallback: previous is 0 + }, + { + label: 'Tablet', + value: 15000, + displayValue: '$15K', + color: STORY_COLORS[ 3 ], + comparison: 0, // Dash fallback: previous is 0 + }, +]; + +/** + * WithComparisonDashFallback: Tests dash alignment with percentage values. + * When previous period value is 0, a dash "—" is shown instead of percentage. + * The dash should be right-aligned with the other delta percentages. + */ +export const WithComparisonDashFallback = { + args: { + items: itemsWithDashFallback, + withComparison: true, + }, +}; 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-delta/stories/metric-delta.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-delta/stories/metric-delta.stories.tsx new file mode 100644 index 000000000000..bdeecea95fdf --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-delta/stories/metric-delta.stories.tsx @@ -0,0 +1,190 @@ +import { MetricDelta } from '../metric-delta'; + +const meta = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/MetricDelta', + component: MetricDelta, + tags: [ 'autodocs' ], + argTypes: { + current: { + control: 'number', + description: 'The current/new value', + }, + previous: { + control: 'number', + description: 'The previous/comparison value', + }, + invertColors: { + control: 'boolean', + description: 'For metrics where decrease is improvement (e.g., bounce rate)', + }, + hideZero: { + control: 'boolean', + description: 'Whether to hide when delta is zero', + }, + showAbsolute: { + control: 'boolean', + description: 'Show absolute change instead of percentage', + }, + absoluteFormat: { + control: 'select', + options: [ 'number', 'currency' ], + description: 'Format for absolute values', + }, + fallback: { + control: 'text', + description: 'What to display when calculation is not possible', + }, + }, +}; + +export default meta; + +/** + * Positive change (green, increase) + */ +export const Positive = { + args: { + current: 150, + previous: 100, + }, +}; + +/** + * Negative change (red, decrease) + */ +export const Negative = { + args: { + current: 80, + previous: 100, + }, +}; + +/** + * Zero change (neutral) + */ +export const Zero = { + args: { + current: 100, + previous: 100, + }, +}; + +/** + * Inverted colors for metrics where decrease is good (e.g., bounce rate, returns) + * Here a decrease shows as green (positive) + */ +export const InvertedColorsDecrease = { + args: { + current: 80, + previous: 100, + invertColors: true, + }, +}; + +/** + * Inverted colors: increase shows as red (negative) + */ +export const InvertedColorsIncrease = { + args: { + current: 120, + previous: 100, + invertColors: true, + }, +}; + +/** + * Large percentage change (1000%+) + */ +export const LargeChange = { + args: { + current: 1100, + previous: 100, + }, +}; + +/** + * Small percentage change (< 1%) + */ +export const SmallChange = { + args: { + current: 100.5, + previous: 100, + }, +}; + +/** + * Fallback when previous value is zero (can't calculate percentage) + */ +export const FallbackZeroPrevious = { + args: { + current: 100, + previous: 0, + fallback: 'N/A', + }, +}; + +/** + * Hide when delta is zero + */ +export const HideZero = { + args: { + current: 100, + previous: 100, + hideZero: true, + }, +}; + +/** + * Show absolute change instead of percentage + */ +export const AbsoluteChange = { + args: { + current: 150, + previous: 100, + showAbsolute: true, + }, +}; + +/** + * Show absolute change with currency format + */ +export const AbsoluteChangeCurrency = { + args: { + current: 1500, + previous: 1000, + showAbsolute: true, + absoluteFormat: 'currency', + }, +}; + +/** + * Negative absolute change + */ +export const AbsoluteChangeNegative = { + args: { + current: 800, + previous: 1000, + showAbsolute: true, + absoluteFormat: 'currency', + }, +}; + +/** + * Fallback with justify="flex-end" - tests alignment of dash character. + * Used in legend rows where the dash should align right with percentage values. + */ +export const FallbackJustifyEnd = { + args: { + current: 100, + previous: 0, + fallback: '—', + justify: 'flex-end', + }, + decorators: [ + ( Story: React.ComponentType ) => ( +
+ +
+ ), + ], +}; 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-value/stories/metric-value.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-value/stories/metric-value.stories.tsx new file mode 100644 index 000000000000..1cd784f62675 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-value/stories/metric-value.stories.tsx @@ -0,0 +1,206 @@ +import { MetricValue } from '../metric-value'; + +const currencyCodes = [ 'USD', 'EUR', 'GBP', 'JPY', 'INR', 'BRL' ]; + +const meta = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/MetricValue', + component: MetricValue, + tags: [ 'autodocs' ], + argTypes: { + fontSize: { + control: 'select', + options: [ 'xs', 'sm', 'md', 'lg', 'xl', '2xl' ], + }, + color: { + control: 'select', + options: [ 'neutral', 'positive', 'negative' ], + }, + }, +}; + +export default meta; + +/** + * Format value as a number + */ +export const Number = { + args: { + value: 1234567, + dataFormat: { type: 'number' }, + }, +}; + +/** + * Format value with decimal precision + */ +export const NumberWithDecimals = { + args: { + value: 1234.56, + dataFormat: { + type: 'number', + options: { decimals: 2 }, + }, + }, +}; + +/** + * Format currency with compact notation (K, M, B) across different locales + */ +export const CurrencyCompact = { + render: () => ( +
+ { currencyCodes.map( code => ( +
+ { code }: + +
+ ) ) } +
+ ), +}; + +/** + * Multiple currency formats showing different locales and symbol positions + */ +export const Currencies = { + render: () => ( +
+ { currencyCodes.map( code => ( +
+ { code }: + +
+ ) ) } +
+ ), +}; + +/** + * Format value as an average + */ +export const Average = { + args: { + value: 87.45, + dataFormat: { + type: 'average', + options: { decimals: 2 }, + }, + }, +}; + +/** + * Format value as a percentage + */ +export const Percentage = { + args: { + value: 0.2345, + dataFormat: { + type: 'percentage', + options: { decimals: 2 }, + }, + }, +}; + +/** + * Format negative percentage value + */ +export const PercentageNegative = { + args: { + value: -0.15, + dataFormat: { + type: 'percentage', + options: { decimals: 1 }, + }, + }, +}; + +/** + * Small font size + */ +export const SmallSize = { + args: { + value: 12345, + dataFormat: { type: 'number' }, + fontSize: 'sm', + }, +}; + +/** + * Large font size (default) + */ +export const LargeSize = { + args: { + value: 12345, + dataFormat: { type: 'number' }, + fontSize: 'lg', + }, +}; + +/** + * Extra large font size + */ +export const ExtraLargeSize = { + args: { + value: 12345, + dataFormat: { type: 'number' }, + fontSize: 'xl', + }, +}; + +/** + * Green color for positive values + */ +export const PositiveColor = { + args: { + value: 12345, + dataFormat: { type: 'number' }, + color: 'positive', + }, +}; + +/** + * Red color for negative values + */ +export const NegativeColor = { + args: { + value: 12345, + dataFormat: { type: 'number' }, + color: 'negative', + }, +}; + +/** + * Neutral color (default) + */ +export const NeutralColor = { + args: { + value: 12345, + dataFormat: { type: 'number' }, + color: 'neutral', + }, +}; 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/metric-with-comparison/stories/metric-with-comparison.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-with-comparison/stories/metric-with-comparison.stories.tsx new file mode 100644 index 000000000000..8a10773a28ab --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/metric-with-comparison/stories/metric-with-comparison.stories.tsx @@ -0,0 +1,230 @@ +import { MetricWithComparison } from '../metric-with-comparison'; + +const meta = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/MetricWithComparison', + component: MetricWithComparison, + tags: [ 'autodocs' ], + argTypes: { + value: { + control: 'number', + description: 'The current value to display', + }, + previousValue: { + control: 'number', + description: 'The previous value for comparison', + }, + direction: { + control: 'select', + options: [ 'row', 'column' ], + description: 'Layout direction', + }, + fontSize: { + control: 'select', + options: [ 'xs', 'sm', 'md', 'lg', 'xl', '2xl' ], + description: 'Font size token from WPDS', + }, + invertDeltaColors: { + control: 'boolean', + description: 'Invert colors (for metrics like bounce rate)', + }, + hideDeltaOnZero: { + control: 'boolean', + description: 'Hide delta when it is zero', + }, + showAbsoluteDelta: { + control: 'boolean', + description: 'Show absolute change instead of percentage', + }, + }, +}; + +export default meta; + +/** + * Default display with value only (no comparison) + */ +export const Default = { + args: { + value: 12345, + dataFormat: { type: 'number' }, + }, +}; + +/** + * With comparison showing percentage delta + */ +export const WithComparison = { + args: { + value: 15000, + previousValue: 12000, + dataFormat: { type: 'number' }, + }, +}; + +/** + * Horizontal layout (default) + */ +export const RowLayout = { + args: { + value: 45678, + previousValue: 40000, + dataFormat: { type: 'number' }, + direction: 'row', + }, +}; + +/** + * Vertical layout, commonly used in chart overlays + */ +export const ColumnLayout = { + args: { + value: 45678, + previousValue: 40000, + dataFormat: { type: 'number' }, + direction: 'column', + }, +}; + +/** + * Extra large font size (default) + */ +export const ExtraLargeSize = { + args: { + value: 12345, + previousValue: 10000, + dataFormat: { type: 'number' }, + fontSize: 'xl', + }, +}; + +/** + * Large font size + */ +export const LargeSize = { + args: { + value: 12345, + previousValue: 10000, + dataFormat: { type: 'number' }, + fontSize: 'lg', + }, +}; + +/** + * Small font size + */ +export const SmallSize = { + args: { + value: 12345, + previousValue: 10000, + dataFormat: { type: 'number' }, + fontSize: 'sm', + }, +}; + +/** + * Currency format with compact notation + */ +export const CurrencyFormat = { + args: { + value: 1234567, + previousValue: 1000000, + dataFormat: { + type: 'currency', + options: { useMultipliers: true, decimals: 1 }, + }, + }, +}; + +/** + * Percentage format + */ +export const PercentageFormat = { + args: { + value: 0.4523, + previousValue: 0.38, + dataFormat: { + type: 'percentage', + options: { decimals: 1 }, + }, + }, +}; + +/** + * Number format with decimals + */ +export const NumberFormat = { + args: { + value: 1234.56, + previousValue: 1100.25, + dataFormat: { + type: 'number', + options: { decimals: 2 }, + }, + }, +}; + +/** + * Inverted delta colors for metrics where decrease is good + */ +export const InvertedDeltaColors = { + args: { + value: 25, + previousValue: 35, + dataFormat: { + type: 'percentage', + options: { decimals: 1 }, + }, + invertDeltaColors: true, + }, +}; + +/** + * Negative change (value decreased) + */ +export const NegativeChange = { + args: { + value: 8000, + previousValue: 10000, + dataFormat: { type: 'number' }, + }, +}; + +/** + * Zero change - delta hidden + */ +export const ZeroChangeHidden = { + args: { + value: 10000, + previousValue: 10000, + dataFormat: { type: 'number' }, + hideDeltaOnZero: true, + }, +}; + +/** + * Show absolute delta instead of percentage + */ +export const AbsoluteDelta = { + args: { + value: 15000, + previousValue: 12000, + dataFormat: { type: 'currency' }, + showAbsoluteDelta: true, + }, +}; + +/** + * Column layout with currency - typical chart overlay + */ +export const ChartOverlayStyle = { + args: { + value: 1234567, + previousValue: 1000000, + dataFormat: { + type: 'currency', + options: { useMultipliers: true, decimals: 1 }, + }, + direction: 'column', + fontSize: 'xl', + }, +}; 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/report-metric/stories/report-metric-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-metric/stories/report-metric-widget.stories.tsx new file mode 100644 index 000000000000..93fb2eff797e --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/components/report-metric/stories/report-metric-widget.stories.tsx @@ -0,0 +1,298 @@ +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { ReportMetricWidget } from '../report-metric'; +import type { ReportMetricWidgetProps } from '../report-metric'; +import type { Meta, StoryObj } from '@storybook/react'; + +/** + * Helper to format date string with offset days + */ +const formatDateString = ( startDate: Date, offsetDays: number ): string => { + const date = new Date( startDate ); + date.setDate( date.getDate() + offsetDays ); + return date.toISOString().split( 'T' )[ 0 ]; +}; + +/** + * Multipliers for generating daily data points with visible variation + */ +const DATA_MULTIPLIERS = [ 0.85, 1.1, 0.95, 1.2, 0.9, 1.05, 0.95 ]; + +/** + * Mock report data matching the ReportData type + */ +const createMockReportData = ( + metricKey: string, + baseValue: number, + dateRange: { start: string; end: string } = { + start: '2024-01-01', + end: '2024-01-07', + } +) => { + const startDate = new Date( dateRange.start ); + + return { + summary: { + date_start: dateRange.start, + date_end: dateRange.end, + [ metricKey ]: baseValue, + }, + data: DATA_MULTIPLIERS.map( ( multiplier, index ) => ( { + date_start: formatDateString( startDate, index ), + [ metricKey ]: baseValue * multiplier, + } ) ), + }; +}; + +/** + * Date ranges for mock data - primary is current week, comparison is previous week + */ +const PRIMARY_DATE_RANGE = { start: '2024-01-08', end: '2024-01-14' }; +const COMPARISON_DATE_RANGE = { start: '2024-01-01', end: '2024-01-07' }; + +/** + * Create mock data for ReportHookResult with configurable states + */ +const createMockData = ( options: { + metricKey: string; + primaryValue: number; + comparisonValue?: number; + isLoading?: boolean; + isFetching?: boolean; + hasData?: boolean; + includeData?: boolean; +} ): ReportMetricWidgetProps[ 'data' ] => { + const { + metricKey, + primaryValue, + comparisonValue, + isLoading = false, + isFetching = false, + hasData = true, + includeData = true, + } = options; + + return { + primary: { + data: includeData + ? createMockReportData( metricKey, primaryValue, PRIMARY_DATE_RANGE ) + : undefined, + }, + comparison: { + data: + comparisonValue !== undefined + ? createMockReportData( metricKey, comparisonValue, COMPARISON_DATE_RANGE ) + : undefined, + }, + isLoading, + isFetching, + hasData, + isError: false, + error: null, + refetch: () => Promise.resolve(), + }; +}; + +const meta: Meta< typeof ReportMetricWidget > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Components/ReportMetricWidget', + component: ReportMetricWidget, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'Internal component that displays a metric with time series chart and optional comparison period.', + }, + }, + }, + decorators: [ withWidgetRoot() ], +}; + +export default meta; + +type Story = StoryObj< typeof ReportMetricWidget >; + +/** + * Loading state - shows initial loading skeleton overlay + * Triggered when `isLoading: true` and `hasData: false` + */ +export const Loading: Story = { + args: { + metricKey: 'total_sales', + data: createMockData( { + metricKey: 'total_sales', + primaryValue: 0, + isLoading: true, + hasData: false, + includeData: false, + } ), + dataFormat: { type: 'currency' }, + }, +}; + +/** + * Updating state - shows spinner overlay while refetching + * Triggered when `isLoading: true` (or `isFetching: true`) and `hasData: true` + */ +export const Updating: Story = { + args: { + metricKey: 'total_sales', + data: createMockData( { + metricKey: 'total_sales', + primaryValue: 45678.99, + isLoading: true, + hasData: true, + } ), + dataFormat: { type: 'currency' }, + }, +}; + +/** + * Empty state - component returns null when no data and not loading + * This story demonstrates the empty state behavior + */ +export const Empty: Story = { + args: { + metricKey: 'total_sales', + data: createMockData( { + metricKey: 'total_sales', + primaryValue: 0, + isLoading: false, + hasData: false, + includeData: false, + } ), + dataFormat: { type: 'currency' }, + }, + parameters: { + docs: { + description: { + story: + 'When there is no data and loading is complete, the component renders nothing (returns null).', + }, + }, + }, +}; + +/** + * Number format - displays plain numbers (e.g., order count) + */ +export const NumberFormat: Story = { + args: { + metricKey: 'orders_no', + data: createMockData( { + metricKey: 'orders_no', + primaryValue: 1234, + } ), + dataFormat: { type: 'number' }, + }, +}; + +/** + * Currency format with multipliers - uses abbreviations like "45.7K" + */ +export const CurrencyCompact: Story = { + args: { + metricKey: 'total_sales', + data: createMockData( { + metricKey: 'total_sales', + primaryValue: 1234567.89, + } ), + dataFormat: { + type: 'currency', + options: { useMultipliers: true, decimals: 1 }, + }, + }, +}; + +/** + * Percentage format - displays percentage values + */ +export const PercentageFormat: Story = { + args: { + metricKey: 'conversion_rate', + data: createMockData( { + metricKey: 'conversion_rate', + primaryValue: 0.342, + } ), + dataFormat: { + type: 'percentage', + options: { decimals: 1 }, + }, + }, +}; + +/** + * With comparison period - shows delta between current and previous period + */ +export const WithComparison: Story = { + args: { + metricKey: 'total_sales', + data: createMockData( { + metricKey: 'total_sales', + primaryValue: 45678.99, + comparisonValue: 38500.0, + } ), + dataFormat: { type: 'currency' }, + }, +}; + +/** + * Without comparison - shows only the current period value + */ +export const WithoutComparison: Story = { + args: { + metricKey: 'total_sales', + data: createMockData( { + metricKey: 'total_sales', + primaryValue: 45678.99, + } ), + dataFormat: { type: 'currency' }, + }, +}; + +/** + * Negative delta - comparison shows decrease from previous period + */ +export const NegativeDelta: Story = { + args: { + metricKey: 'total_sales', + data: createMockData( { + metricKey: 'total_sales', + primaryValue: 35000.0, + comparisonValue: 45678.99, + } ), + dataFormat: { type: 'currency' }, + }, +}; + +/** + * Positive delta - comparison shows increase from previous period + */ +export const PositiveDelta: Story = { + args: { + metricKey: 'total_sales', + data: createMockData( { + metricKey: 'total_sales', + primaryValue: 45678.99, + comparisonValue: 38500.0, + } ), + dataFormat: { type: 'currency' }, + }, +}; + +/** + * Large values - tests formatting with very large numbers + */ +export const LargeValues: Story = { + args: { + metricKey: 'total_sales', + data: createMockData( { + metricKey: 'total_sales', + primaryValue: 9876543.21, + comparisonValue: 7654321.0, + } ), + dataFormat: { + type: 'currency', + options: { useMultipliers: true, decimals: 1 }, + }, + }, +}; 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..9b561dd55f5b --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/index.ts @@ -0,0 +1,101 @@ +/** + * 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, +} 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/stories/mocks/data/bookings.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/bookings.ts new file mode 100644 index 000000000000..b0bff781d883 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/bookings.ts @@ -0,0 +1,307 @@ +/** + * Mock data generator for bookings endpoint + * + * API endpoint: /wc/v3/woocommerce-analytics/proxy/reports/bookings/by-date + * + * This module provides dynamic fixture generation based on request parameters. + */ +import { seededRandom } from './orders-by-product-type'; +import type { fetchReportBookings } from '../../../../../data/src/api/report-bookings-fetch/report-bookings-fetch'; + +/** + * Infer the response type from the fetch function. + */ +type BookingsReportResponse = Awaited< ReturnType< typeof fetchReportBookings > >; + +interface GenerateBookingsParams { + from: string; // ISO date string + to: string; // ISO date string + interval?: 'day' | 'week' | 'month'; + /** + * Optional seed for reproducible random data. + */ + seed?: number; + /** + * Generic density parameter (0-1). + * For bookings: probability that a day will have bookings + * Default: 0.8 (80% of days have bookings) + */ + density?: number; + /** + * Generic volume parameter. + * For bookings: average total bookings per active day + * Default: 5 + */ + volume?: number; +} + +/** + * Generate date intervals + */ +function generateDateIntervals( + from: string, + to: string, + interval: 'day' | 'week' | 'month' = 'day' +): Array< { start: Date; end: Date } > { + const startDate = new Date( from ); + const endDate = new Date( to ); + const intervals: Array< { start: Date; end: Date } > = []; + + const current = new Date( startDate ); + + while ( current <= endDate ) { + const intervalStart = new Date( current ); + let intervalEnd: Date; + + switch ( interval ) { + case 'day': + intervalEnd = new Date( current ); + intervalEnd.setHours( 23, 59, 59, 999 ); + current.setDate( current.getDate() + 1 ); + break; + case 'week': + intervalEnd = new Date( current ); + intervalEnd.setDate( intervalEnd.getDate() + 6 ); + intervalEnd.setHours( 23, 59, 59, 999 ); + current.setDate( current.getDate() + 7 ); + break; + case 'month': + intervalEnd = new Date( current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59, 999 ); + current.setMonth( current.getMonth() + 1 ); + break; + } + + if ( intervalEnd > endDate ) { + intervalEnd = new Date( endDate ); + } + + intervals.push( { + start: intervalStart, + end: intervalEnd, + } ); + + if ( intervalEnd >= endDate ) { + break; + } + } + + return intervals; +} + +/** + * Format a date in ISO format compatible with the API + */ +function formatISODate( date: Date ): string { + return date.toISOString().replace( /\.\d{3}Z$/, '+00:00' ); +} + +/** + * Format a date in YYYY-MM-DD format for time_interval + */ +function formatDateOnly( date: Date ): string { + return date.toISOString().split( 'T' )[ 0 ]; +} + +/** + * Generate mock booking data for a specific interval + */ +function generateIntervalData( + start: Date, + end: Date, + random: () => number, + density: number, + volume: number +): BookingsReportResponse[ 'data' ][ 0 ] { + const hasBookings = random() < density; + + if ( ! hasBookings ) { + return { + time_interval: formatDateOnly( start ), + date_start: formatISODate( start ), + date_end: formatISODate( end ), + status_unpaid: '0', + status_pending_confirmation: '0', + status_confirmed: '0', + status_paid: '0', + status_cancelled: '0', + status_complete: '0', + attendance_status_booked: '0', + attendance_status_no_show: '0', + attendance_status_checked_in: '0', + }; + } + + // Generate varied booking counts for each status independently + // This creates more natural "wavy" data patterns + + // Helper to generate a value with good variation + const generateCount = ( base: number, variance: number ): number => { + // Use sine-like wave pattern combined with randomness + const wave = Math.sin( random() * Math.PI * 2 ) * variance; + const noise = ( random() - 0.5 ) * variance; + return Math.max( 1, Math.round( base + wave + noise ) ); + }; + + // Scale base values by volume parameter + const scaleFactor = volume / 5; // normalize around default volume of 5 + + // Generate each status with independent variation + const statusUnpaid = generateCount( 2 * scaleFactor, 3 * scaleFactor ); + const statusPendingConfirmation = generateCount( 2 * scaleFactor, 2 * scaleFactor ); + const statusConfirmed = generateCount( 3 * scaleFactor, 4 * scaleFactor ); + const statusPaid = generateCount( 4 * scaleFactor, 5 * scaleFactor ); + const statusCancelled = generateCount( 2 * scaleFactor, 3 * scaleFactor ); + const statusComplete = generateCount( 5 * scaleFactor, 6 * scaleFactor ); + + // Generate attendance statuses with variation + const attendanceBooked = generateCount( 3 * scaleFactor, 4 * scaleFactor ); + const attendanceNoShow = generateCount( 1 * scaleFactor, 2 * scaleFactor ); + const attendanceCheckedIn = generateCount( 4 * scaleFactor, 5 * scaleFactor ); + + return { + time_interval: formatDateOnly( start ), + date_start: formatISODate( start ), + date_end: formatISODate( end ), + status_unpaid: statusUnpaid.toString(), + status_pending_confirmation: statusPendingConfirmation.toString(), + status_confirmed: statusConfirmed.toString(), + status_paid: statusPaid.toString(), + status_cancelled: statusCancelled.toString(), + status_complete: statusComplete.toString(), + attendance_status_booked: attendanceBooked.toString(), + attendance_status_no_show: attendanceNoShow.toString(), + attendance_status_checked_in: attendanceCheckedIn.toString(), + }; +} + +/** + * Calculate the summary from the array of data + */ +function calculateSummary( + data: BookingsReportResponse[ 'data' ], + from: string, + to: string +): BookingsReportResponse[ 'summary' ] { + const totals = data.reduce( + ( acc, item ) => ( { + status_unpaid: acc.status_unpaid + parseInt( item.status_unpaid || '0', 10 ), + status_pending_confirmation: + acc.status_pending_confirmation + parseInt( item.status_pending_confirmation || '0', 10 ), + status_confirmed: acc.status_confirmed + parseInt( item.status_confirmed || '0', 10 ), + status_paid: acc.status_paid + parseInt( item.status_paid || '0', 10 ), + status_cancelled: acc.status_cancelled + parseInt( item.status_cancelled || '0', 10 ), + status_complete: acc.status_complete + parseInt( item.status_complete || '0', 10 ), + attendance_status_booked: + acc.attendance_status_booked + parseInt( item.attendance_status_booked || '0', 10 ), + attendance_status_no_show: + acc.attendance_status_no_show + parseInt( item.attendance_status_no_show || '0', 10 ), + attendance_status_checked_in: + acc.attendance_status_checked_in + parseInt( item.attendance_status_checked_in || '0', 10 ), + } ), + { + status_unpaid: 0, + status_pending_confirmation: 0, + status_confirmed: 0, + status_paid: 0, + status_cancelled: 0, + status_complete: 0, + attendance_status_booked: 0, + attendance_status_no_show: 0, + attendance_status_checked_in: 0, + } + ); + + return { + status_unpaid: totals.status_unpaid.toString(), + status_pending_confirmation: totals.status_pending_confirmation.toString(), + status_confirmed: totals.status_confirmed.toString(), + status_paid: totals.status_paid.toString(), + status_cancelled: totals.status_cancelled.toString(), + status_complete: totals.status_complete.toString(), + attendance_status_booked: totals.attendance_status_booked.toString(), + attendance_status_no_show: totals.attendance_status_no_show.toString(), + attendance_status_checked_in: totals.attendance_status_checked_in.toString(), + date_start: formatISODate( new Date( from ) ), + date_end: formatISODate( new Date( to ) ), + }; +} + +/** + * Generate mock dynamic data for the bookings endpoint + * + * @param params - Generation parameters based on the request + * @return Mock data that matches the API format + * + * @example + * ```ts + * const mockData = generateBookings({ + * from: '2025-11-15T00:00:00.000+00:00', + * to: '2025-12-14T23:59:59.999+00:00', + * interval: 'day', + * seed: 12345, + * density: 0.8, + * volume: 5, + * }); + * ``` + */ +export function generateBookings( params: GenerateBookingsParams ): BookingsReportResponse { + const { from, to, interval = 'day', seed = Date.now(), density = 0.8, volume = 5 } = params; + + const random = seededRandom( seed ); + const intervals = generateDateIntervals( from, to, interval ); + + const data = intervals.map( ( { start, end } ) => + generateIntervalData( start, end, random, density, volume ) + ); + + const summary = calculateSummary( data, from, to ); + + return { + summary, + data, + }; +} + +/** + * Filters a full spectrum of data to a specific date range + */ +export function filterBookingsDataByDateRange( + fullData: BookingsReportResponse[ 'data' ], + requestFrom: string, + requestTo: string +): BookingsReportResponse[ 'data' ] { + const fromDate = new Date( requestFrom ); + const toDate = new Date( requestTo ); + + return fullData.filter( item => { + const itemDate = new Date( item.date_start ); + return itemDate >= fromDate && itemDate <= toDate; + } ); +} + +/** + * Recalculates summary based on filtered data + */ +export function recalculateBookingsSummary( + filteredData: BookingsReportResponse[ 'data' ], + from: string, + to: string +): BookingsReportResponse[ 'summary' ] { + if ( filteredData.length === 0 ) { + return { + status_unpaid: '0', + status_pending_confirmation: '0', + status_confirmed: '0', + status_paid: '0', + status_cancelled: '0', + status_complete: '0', + attendance_status_booked: '0', + attendance_status_no_show: '0', + attendance_status_checked_in: '0', + date_start: formatISODate( new Date( from ) ), + date_end: formatISODate( new Date( to ) ), + }; + } + + return calculateSummary( filteredData, from, to ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/coupons.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/coupons.ts new file mode 100644 index 000000000000..25424bcb07f9 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/coupons.ts @@ -0,0 +1,121 @@ +/** + * Mock data for Coupons endpoint + * + * Used by: SalesByCouponWidget + * + * Response structure matches: + * - summary: CouponsDataSummary + * - data: CouponsDataItem[] + */ + +export type MockCouponsItem = { + coupon_code: string; + discount_amount: string; + total_sales: string; + orders_count: string; +}; + +export type MockCouponsSummary = { + total_sales: string; + total_discount_amount: string; + total_orders: string; + date_start: string; + date_end: string; +}; + +export type MockCouponsResponse = { + summary: MockCouponsSummary; + data: MockCouponsItem[]; +}; + +/** + * Primary period mock data for coupons + */ +export const mockCouponsData: MockCouponsResponse = { + summary: { + total_sales: '45678.90', + total_discount_amount: '3456.78', + total_orders: '234', + date_start: '2024-01-01', + date_end: '2024-01-31', + }, + data: [ + { + coupon_code: 'SUMMER25', + discount_amount: '1234.56', + total_sales: '15678.90', + orders_count: '89', + }, + { + coupon_code: 'WELCOME10', + discount_amount: '987.65', + total_sales: '12345.67', + orders_count: '67', + }, + { + coupon_code: 'FLASH50', + discount_amount: '756.43', + total_sales: '9876.54', + orders_count: '45', + }, + { + coupon_code: 'LOYALTY15', + discount_amount: '478.14', + total_sales: '7777.79', + orders_count: '33', + }, + ], +}; + +/** + * Comparison period mock data for coupons (slightly lower values) + */ +export const mockCouponsComparisonData: MockCouponsResponse = { + summary: { + total_sales: '38765.40', + total_discount_amount: '2890.12', + total_orders: '198', + date_start: '2023-12-01', + date_end: '2023-12-31', + }, + data: [ + { + coupon_code: 'SUMMER25', + discount_amount: '1012.34', + total_sales: '13456.78', + orders_count: '72', + }, + { + coupon_code: 'WELCOME10', + discount_amount: '823.45', + total_sales: '10234.56', + orders_count: '54', + }, + { + coupon_code: 'FLASH50', + discount_amount: '623.21', + total_sales: '8234.56', + orders_count: '38', + }, + { + coupon_code: 'LOYALTY15', + discount_amount: '431.12', + total_sales: '6839.50', + orders_count: '34', + }, + ], +}; + +/** + * Empty state mock data + */ +export const mockCouponsEmptyData: MockCouponsResponse = { + summary: { + total_sales: '0', + total_discount_amount: '0', + total_orders: '0', + date_start: '2024-01-01', + date_end: '2024-01-31', + }, + data: [], +}; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/customers.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/customers.ts new file mode 100644 index 000000000000..dcc4392df343 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/customers.ts @@ -0,0 +1,225 @@ +/** + * Mock data for Customers New vs Returning endpoint + * + * Used by: RevenueByCustomerTypeWidget, BookingsRevenueByCustomerTypeWidget + * + * Response structure matches: + * - summary: CustomersNewReturningSummary + * - data: CustomersNewReturningItem[] + */ + +export type MockCustomersItem = { + customer_type: 'new' | 'returning'; + net_sales: string; + orders_count: string; +}; + +export type MockCustomersSummary = { + total_net_sales: string; + total_orders: string; + new_customer_sales: string; + returning_customer_sales: string; + date_start: string; + date_end: string; +}; + +export type MockCustomersResponse = { + summary: MockCustomersSummary; + data: MockCustomersItem[]; +}; + +/** + * Primary period mock data for customers + */ +export const mockCustomersData: MockCustomersResponse = { + summary: { + total_net_sales: '87654.32', + total_orders: '456', + new_customer_sales: '34567.89', + returning_customer_sales: '53086.43', + date_start: '2024-01-01', + date_end: '2024-01-31', + }, + data: [ + { + customer_type: 'returning', + net_sales: '53086.43', + orders_count: '267', + }, + { + customer_type: 'new', + net_sales: '34567.89', + orders_count: '189', + }, + ], +}; + +/** + * Comparison period mock data for customers (slightly lower values) + */ +export const mockCustomersComparisonData: MockCustomersResponse = { + summary: { + total_net_sales: '72345.67', + total_orders: '389', + new_customer_sales: '28901.23', + returning_customer_sales: '43444.44', + date_start: '2023-12-01', + date_end: '2023-12-31', + }, + data: [ + { + customer_type: 'returning', + net_sales: '43444.44', + orders_count: '221', + }, + { + customer_type: 'new', + net_sales: '28901.23', + orders_count: '168', + }, + ], +}; + +/** + * Empty state mock data + */ +export const mockCustomersEmptyData: MockCustomersResponse = { + summary: { + total_net_sales: '0', + total_orders: '0', + new_customer_sales: '0', + returning_customer_sales: '0', + date_start: '2024-01-01', + date_end: '2024-01-31', + }, + data: [], +}; + +/** + * Mock data for Customers By Date endpoint + * + * Used by: NewVsReturningCustomerWidget + * + * Response structure matches: + * - summary: CustomersByDateSummary (includes customer counts) + * - data: CustomersByDateItem[] + */ + +export type MockCustomersByDateSummary = { + 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; +}; + +export type MockCustomersByDateItem = { + 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; +}; + +export type MockCustomersByDateResponse = { + summary: MockCustomersByDateSummary; + data: MockCustomersByDateItem[]; +}; + +/** + * Primary period mock data for customers by date + */ +export const mockCustomersByDateData: MockCustomersByDateResponse = { + summary: { + total_net_sales: '87654.32', + total_gross_sales: '92000.00', + total_discounts: '4345.68', + total_refunds: '0', + total_orders: '456', + total_average_order_value: '192.22', + total_avg_items_per_order: '2.3', + total_customers: '2000', + new_customers: '400', + returning_customers: '1600', + new_customer_sales: '34567.89', + new_customer_gross_sales: '36000.00', + new_customer_discounts: '1432.11', + new_customer_refunds: '0', + new_customer_orders: '189', + new_customer_avg_order_value: '182.90', + new_customer_avg_items_per_order: '2.1', + returning_customer_sales: '53086.43', + returning_customer_gross_sales: '56000.00', + returning_customer_discounts: '2913.57', + returning_customer_refunds: '0', + returning_customer_orders: '267', + returning_customer_avg_order_value: '198.83', + returning_customer_avg_items_per_order: '2.5', + date_start: '2024-01-01', + date_end: '2024-01-31', + }, + data: [], +}; + +/** + * Comparison period mock data for customers by date (slightly lower values) + */ +export const mockCustomersByDateComparisonData: MockCustomersByDateResponse = { + summary: { + total_net_sales: '72345.67', + total_gross_sales: '76000.00', + total_discounts: '3654.33', + total_refunds: '0', + total_orders: '389', + total_average_order_value: '185.98', + total_avg_items_per_order: '2.2', + total_customers: '1940', + new_customers: '396', + returning_customers: '1544', + new_customer_sales: '28901.23', + new_customer_gross_sales: '30000.00', + new_customer_discounts: '1098.77', + new_customer_refunds: '0', + new_customer_orders: '168', + new_customer_avg_order_value: '172.03', + new_customer_avg_items_per_order: '2.0', + returning_customer_sales: '43444.44', + returning_customer_gross_sales: '46000.00', + returning_customer_discounts: '2555.56', + returning_customer_refunds: '0', + returning_customer_orders: '221', + returning_customer_avg_order_value: '196.58', + returning_customer_avg_items_per_order: '2.4', + date_start: '2023-12-01', + date_end: '2023-12-31', + }, + data: [], +}; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/index.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/index.ts new file mode 100644 index 000000000000..0562219e46dd --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/index.ts @@ -0,0 +1,55 @@ +/** + * Mock Data for Storybook + * + * This folder contains mock API responses for widget testing. + * Each file should export typed mock data matching the API response format. + * + * ## Adding New Mocks + * + * 1. Create a new file: `{endpoint-name}.ts` + * 2. Import the response type from `@jetpack-premium-analytics/data` (or use relative path) + * 3. Export typed mock data + * 4. Add export here and import in `../register-report-mocks.ts` + */ + +export { + mockOrderAttributionData, + mockOrderAttributionDeviceData, + mockOrderAttributionChannelData, + mockOrderAttributionSourceData, + mockOrderAttributionCampaignData, +} from './order-attribution'; + +export { + mockOrdersByProductTypeData, + mockOrdersByProductTypeComparisonData, + generateOrdersByProductType, + seededRandom, + filterDataByDateRange, + recalculateSummary, +} from './orders-by-product-type'; + +export { + generateBookings, + filterBookingsDataByDateRange, + recalculateBookingsSummary, +} from './bookings'; + +export { buildVisitorsByLocationResponse } from './visitors-by-location'; + +export { + mockSessionsByDeviceData, + mockSessionsByDeviceComparisonData, + mockSessionsByDeviceEmptyData, + mockSessionsByDeviceExtremeData, +} from './sessions-by-device'; + +export { mockCouponsData, mockCouponsComparisonData, mockCouponsEmptyData } from './coupons'; + +export { + mockCustomersData, + mockCustomersComparisonData, + mockCustomersEmptyData, + mockCustomersByDateData, + mockCustomersByDateComparisonData, +} from './customers'; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/order-attribution.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/order-attribution.ts new file mode 100644 index 000000000000..1800847e0b7d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/order-attribution.ts @@ -0,0 +1,218 @@ +/** + * Mock data for order attribution endpoint + * Used by: SalesByDeviceWidget + * + * API format: /wc/v3/woocommerce-analytics/proxy/reports/order-attribution/:view/summary + * Values are strings (the sanitizer converts them to numbers) + */ +import type { fetchReportOrderAttributionSummary } from '../../../../../data/src/api/report-order-attribution-summary-fetch/report-order-attribution-summary-fetch'; + +/** + * Infer the response type from the fetch function. + * This keeps types in sync without needing explicit exports. + */ +type OrderAttributionSummaryResponse = Awaited< + ReturnType< typeof fetchReportOrderAttributionSummary > +>; + +/** + * Order Attribution by Device mock data + * + * Uses semi-extreme values to test responsive layouts: + * - Labels with 2-3 words (realistic device names) + * - Large currency values (hundreds of thousands) + * - Mixed comparison deltas (positive, negative, small) + */ +export const mockOrderAttributionDeviceData: OrderAttributionSummaryResponse = { + view: 'device', + order_by: 'net_sales', + data: [ + { + item: 'Desktop Browser', + current_period: { + value: '847583.99', + intervals: [], + }, + previous_period: { + value: '692451.25', + intervals: [], + }, + }, + { + item: 'Mobile App', + current_period: { + value: '534721.50', + intervals: [], + }, + previous_period: { + value: '645892.75', + intervals: [], + }, + }, + { + item: 'Tablet Safari', + current_period: { + value: '158923.75', + intervals: [], + }, + previous_period: { + value: '152850.00', + intervals: [], + }, + }, + ], +}; + +/** + * Order Attribution by Channel mock data + */ +export const mockOrderAttributionChannelData: OrderAttributionSummaryResponse = { + view: 'channel', + order_by: 'net_sales', + data: [ + { + item: 'Organic Search', + current_period: { + value: '9832.20', + intervals: [], + }, + previous_period: { + value: '8500.00', + intervals: [], + }, + }, + { + item: 'Direct', + current_period: { + value: '7374.15', + intervals: [], + }, + previous_period: { + value: '6200.00', + intervals: [], + }, + }, + { + item: 'Paid Search', + current_period: { + value: '4916.10', + intervals: [], + }, + previous_period: { + value: '4100.00', + intervals: [], + }, + }, + { + item: 'Social', + current_period: { + value: '2458.05', + intervals: [], + }, + previous_period: { + value: '2000.00', + intervals: [], + }, + }, + ], +}; + +/** + * Order Attribution by Source mock data + */ +export const mockOrderAttributionSourceData: OrderAttributionSummaryResponse = { + view: 'source', + order_by: 'net_sales', + data: [ + { + item: 'google', + current_period: { + value: '12000.00', + intervals: [], + }, + previous_period: { + value: '10500.00', + intervals: [], + }, + }, + { + item: 'facebook', + current_period: { + value: '5500.00', + intervals: [], + }, + previous_period: { + value: '4800.00', + intervals: [], + }, + }, + { + item: 'instagram', + current_period: { + value: '3200.00', + intervals: [], + }, + previous_period: { + value: '2900.00', + intervals: [], + }, + }, + ], +}; + +/** + * Order Attribution by Campaign mock data + */ +export const mockOrderAttributionCampaignData: OrderAttributionSummaryResponse = { + view: 'campaign', + order_by: 'net_sales', + data: [ + { + item: 'Black Friday 2024', + current_period: { + value: '45200.00', + intervals: [], + }, + previous_period: { + value: '38500.00', + intervals: [], + }, + }, + { + item: 'Summer Sale', + current_period: { + value: '28750.00', + intervals: [], + }, + previous_period: { + value: '31200.00', + intervals: [], + }, + }, + { + item: 'New Year Promo', + current_period: { + value: '15800.00', + intervals: [], + }, + previous_period: { + value: '12400.00', + intervals: [], + }, + }, + { + item: 'Newsletter Exclusive', + current_period: { + value: '8900.00', + intervals: [], + }, + previous_period: { + value: '7650.00', + intervals: [], + }, + }, + ], +}; + +// Legacy export for backwards compatibility +export const mockOrderAttributionData = mockOrderAttributionDeviceData; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/orders-by-product-type.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/orders-by-product-type.ts new file mode 100644 index 000000000000..01ce55594ec8 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/orders-by-product-type.ts @@ -0,0 +1,405 @@ +/** + * Mock data generator for orders-by-product-type endpoint + * Used by: BookingOrderMetricWidget, future product-filtered widgets + * + * API endpoint: /wc/v3/woocommerce-analytics/proxy/reports/orders-by-product-type/by-date + * Called when: Product type filters are present (bookings, simple products, etc.) + * + * This module provides dynamic fixture generation based on request parameters. + */ +import type { fetchReportOrders } from '../../../../../data/src/api/report-orders-fetch/report-orders-fetch'; + +/** + * Infer the response type from the fetch function. + * This keeps types in sync without needing explicit exports. + */ +type OrdersReportResponse = Awaited< ReturnType< typeof fetchReportOrders > >; + +interface GenerateOrdersParams { + from: string; // ISO date string + to: string; // ISO date string + interval?: 'day' | 'week' | 'month'; + /** + * Optional seed for reproducible random data. + * If not provided, generates realistic but random data. + */ + seed?: number; + /** + * Generic density parameter (0-1). + * For orders: probability that a day will have orders + * Default: 0.9 (90% of days have orders) + */ + density?: number; + /** + * Generic volume parameter. + * For orders: average orders per active day + * Default: 7 + */ + volume?: number; +} + +/** + * Simple seeded random number generator + * + * Exported for reuse in other mock data generators to ensure + * consistent randomness across all endpoints when using the same seed. + * + * @param seed - Seed value for reproducible randomness + * @return Function that generates random numbers (0-1) + */ +export function seededRandom( seed: number ): () => number { + let state = seed; + return () => { + state = ( state * 9301 + 49297 ) % 233280; + return state / 233280; + }; +} + +/** + * Generate date intervals + * @param from - Start date + * @param to - End date + * @param interval - Interval type + * @return Array of date intervals + */ +function generateDateIntervals( + from: string, + to: string, + interval: 'day' | 'week' | 'month' = 'day' +): Array< { start: Date; end: Date } > { + const startDate = new Date( from ); + const endDate = new Date( to ); + const intervals: Array< { start: Date; end: Date } > = []; + + const current = new Date( startDate ); + + while ( current <= endDate ) { + const intervalStart = new Date( current ); + let intervalEnd: Date; + + switch ( interval ) { + case 'day': + intervalEnd = new Date( current ); + intervalEnd.setHours( 23, 59, 59, 999 ); + current.setDate( current.getDate() + 1 ); + break; + case 'week': + intervalEnd = new Date( current ); + intervalEnd.setDate( intervalEnd.getDate() + 6 ); + intervalEnd.setHours( 23, 59, 59, 999 ); + current.setDate( current.getDate() + 7 ); + break; + case 'month': + intervalEnd = new Date( current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59, 999 ); + current.setMonth( current.getMonth() + 1 ); + break; + } + + // Adjust the last interval to not exceed endDate + if ( intervalEnd > endDate ) { + intervalEnd = new Date( endDate ); + } + + intervals.push( { + start: intervalStart, + end: intervalEnd, + } ); + + if ( intervalEnd >= endDate ) { + break; + } + } + + return intervals; +} + +/** + * Format a date in ISO format compatible with the API + */ +function formatISODate( date: Date ): string { + return date.toISOString().replace( /\.\d{3}Z$/, '+00:00' ); +} + +/** + * Format a date in YYYY-MM-DD format for time_interval + */ +function formatDateOnly( date: Date ): string { + return date.toISOString().split( 'T' )[ 0 ]; +} + +/** + * Generate mock order data for a specific interval + */ +function generateIntervalData( + start: Date, + end: Date, + random: () => number, + sparsity: number, + avgOrders: number +): OrdersReportResponse[ 'data' ][ 0 ] { + // Determine if this interval has orders + const hasOrders = random() < sparsity; + + if ( ! hasOrders ) { + return { + time_interval: formatDateOnly( start ), + date_start: formatISODate( start ), + date_end: formatISODate( end ), + orders_no: '0', + orders_value_net: '0.0', + orders_value_gross: '0.0', + total_sales: '0.0', + avg_items: '0.0', + average_order_value: '0.0', + coupons: '0.0', + refunds: '0.0', + product_net_revenue: '0.0', + cogs_amount: '0.0', + profit_margin: '0.0', + paid_orders_count: '0', + paid_net_sales: '0.0', + unpaid_orders_count: '0', + unpaid_net_sales: '0.0', + }; + } + + // Generate number of orders (variation around the average) + const ordersNo = Math.max( 1, Math.floor( avgOrders + ( random() - 0.5 ) * avgOrders ) ); + + // Generate realistic values + const avgOrderValue = 50 + random() * 150; // Between $50 and $200 + const totalSales = ordersNo * avgOrderValue; + const avgItems = 1.5 + random() * 1.5; // Between 1.5 and 3 items per order + + // Calculate other values based on realistic relationships + const coupons = totalSales * ( 0.1 + random() * 0.3 ); // 10-40% in coupons + const ordersValueGross = totalSales * ( 1.1 + random() * 0.3 ); // +10-40% + const ordersValueNet = totalSales - coupons * 0.5; // Partial discount + const refunds = totalSales * ( random() * 0.05 ); // 0-5% in refunds + const cogsAmount = totalSales * ( 0.4 + random() * 0.2 ); // 40-60% in cost + const productNetRevenue = totalSales - refunds; + const profitMargin = productNetRevenue - cogsAmount; + + // Split orders into paid / unpaid buckets (most orders are paid). + const paidOrdersCount = Math.max( 0, Math.round( ordersNo * 0.85 ) ); + const unpaidOrdersCount = ordersNo - paidOrdersCount; + const paidNetSales = ordersValueNet * 0.85; + const unpaidNetSales = ordersValueNet - paidNetSales; + + return { + time_interval: formatDateOnly( start ), + date_start: formatISODate( start ), + date_end: formatISODate( end ), + orders_no: ordersNo.toString(), + orders_value_net: ordersValueNet.toFixed( 2 ), + orders_value_gross: ordersValueGross.toFixed( 2 ), + total_sales: totalSales.toFixed( 2 ), + avg_items: avgItems.toFixed( 4 ), + average_order_value: avgOrderValue.toFixed( 12 ), + coupons: coupons.toFixed( 2 ), + refunds: refunds.toFixed( 2 ), + product_net_revenue: productNetRevenue.toFixed( 2 ), + cogs_amount: cogsAmount.toFixed( 2 ), + profit_margin: profitMargin.toFixed( 2 ), + paid_orders_count: paidOrdersCount.toString(), + paid_net_sales: paidNetSales.toFixed( 2 ), + unpaid_orders_count: unpaidOrdersCount.toString(), + unpaid_net_sales: unpaidNetSales.toFixed( 2 ), + }; +} + +/** + * Calculate the summary from the array of data + */ +function calculateSummary( + data: OrdersReportResponse[ 'data' ], + from: string, + to: string +): OrdersReportResponse[ 'summary' ] { + const totals = data.reduce( + ( acc, item ) => ( { + orders_no: acc.orders_no + parseFloat( item.orders_no || '0' ), + orders_value_net: acc.orders_value_net + parseFloat( item.orders_value_net || '0' ), + orders_value_gross: acc.orders_value_gross + parseFloat( item.orders_value_gross || '0' ), + total_sales: acc.total_sales + parseFloat( item.total_sales || '0' ), + avg_items_sum: + acc.avg_items_sum + + parseFloat( item.avg_items || '0' ) * parseFloat( item.orders_no || '0' ), + coupons: acc.coupons + parseFloat( item.coupons || '0' ), + refunds: acc.refunds + parseFloat( item.refunds || '0' ), + product_net_revenue: acc.product_net_revenue + parseFloat( item.product_net_revenue || '0' ), + cogs_amount: acc.cogs_amount + parseFloat( item.cogs_amount || '0' ), + profit_margin: acc.profit_margin + parseFloat( item.profit_margin || '0' ), + paid_orders_count: acc.paid_orders_count + parseFloat( item.paid_orders_count || '0' ), + paid_net_sales: acc.paid_net_sales + parseFloat( item.paid_net_sales || '0' ), + unpaid_orders_count: acc.unpaid_orders_count + parseFloat( item.unpaid_orders_count || '0' ), + unpaid_net_sales: acc.unpaid_net_sales + parseFloat( item.unpaid_net_sales || '0' ), + } ), + { + orders_no: 0, + orders_value_net: 0, + orders_value_gross: 0, + total_sales: 0, + avg_items_sum: 0, + coupons: 0, + refunds: 0, + product_net_revenue: 0, + cogs_amount: 0, + profit_margin: 0, + paid_orders_count: 0, + paid_net_sales: 0, + unpaid_orders_count: 0, + unpaid_net_sales: 0, + } + ); + + const avgItems = totals.orders_no > 0 ? totals.avg_items_sum / totals.orders_no : 0; + const averageOrderValue = totals.orders_no > 0 ? totals.total_sales / totals.orders_no : 0; + + return { + orders_no: totals.orders_no.toString(), + orders_value_net: totals.orders_value_net.toFixed( 2 ), + orders_value_gross: totals.orders_value_gross.toFixed( 2 ), + total_sales: totals.total_sales.toFixed( 2 ), + avg_items: avgItems.toFixed( 4 ), + average_order_value: averageOrderValue.toFixed( 12 ), + coupons: totals.coupons.toFixed( 2 ), + refunds: totals.refunds.toFixed( 2 ), + product_net_revenue: totals.product_net_revenue.toFixed( 2 ), + cogs_amount: totals.cogs_amount.toFixed( 2 ), + profit_margin: totals.profit_margin.toFixed( 2 ), + paid_orders_count: totals.paid_orders_count.toString(), + paid_net_sales: totals.paid_net_sales.toFixed( 2 ), + unpaid_orders_count: totals.unpaid_orders_count.toString(), + unpaid_net_sales: totals.unpaid_net_sales.toFixed( 2 ), + date_start: formatISODate( new Date( from ) ), + date_end: formatISODate( new Date( to ) ), + }; +} + +/** + * Generate mock dynamic data for the orders-by-product-type endpoint + * + * @param params - Generation parameters based on the request + * @return Mock data that matches the API format + * + * @example + * ```ts + * const mockData = generateOrdersByProductType({ + * from: '2025-11-15T00:00:00.000+00:00', + * to: '2025-12-14T23:59:59.999+00:00', + * interval: 'day', + * seed: 12345, // For reproducible data + * density: 0.3, // 30% of days with orders + * volume: 3, // 3 orders per active day + * }); + * ``` + */ +export function generateOrdersByProductType( params: GenerateOrdersParams ): OrdersReportResponse { + const { from, to, interval = 'day', seed = Date.now(), density = 0.9, volume = 7 } = params; + + const random = seededRandom( seed ); + const intervals = generateDateIntervals( from, to, interval ); + + const data = intervals.map( ( { start, end } ) => + generateIntervalData( start, end, random, density, volume ) + ); + + const summary = calculateSummary( data, from, to ); + + return { + summary, + data, + }; +} + +/** + * Filters a full spectrum of data to a specific date range + * + * @param fullData - Complete dataset (the "spectrum") + * @param requestFrom - Start date of the request + * @param requestTo - End date of the request + * @return Filtered data array + */ +export function filterDataByDateRange( + fullData: OrdersReportResponse[ 'data' ], + requestFrom: string, + requestTo: string +): OrdersReportResponse[ 'data' ] { + const fromDate = new Date( requestFrom ); + const toDate = new Date( requestTo ); + + return fullData.filter( item => { + const itemDate = new Date( item.date_start ); + return itemDate >= fromDate && itemDate <= toDate; + } ); +} + +/** + * Recalculates summary based on filtered data + * + * @param filteredData - Array of filtered data items + * @param from - Request start date + * @param to - Request end date + * @return Summary object + */ +export function recalculateSummary( + filteredData: OrdersReportResponse[ 'data' ], + from: string, + to: string +): OrdersReportResponse[ 'summary' ] { + // If no data, return zeros + if ( filteredData.length === 0 ) { + return { + orders_no: '0', + orders_value_net: '0.0', + orders_value_gross: '0.0', + total_sales: '0.0', + avg_items: '0.0', + average_order_value: '0.0', + coupons: '0.0', + refunds: '0.0', + product_net_revenue: '0.0', + cogs_amount: '0.0', + profit_margin: '0.0', + paid_orders_count: '0', + paid_net_sales: '0.0', + unpaid_orders_count: '0', + unpaid_net_sales: '0.0', + date_start: formatISODate( new Date( from ) ), + date_end: formatISODate( new Date( to ) ), + }; + } + + // Calculate from filtered data + return calculateSummary( filteredData, from, to ); +} + +/** + * Mock data static - Primary Period (for compatibility) + * + * Uses the generator with default values + */ +export const mockOrdersByProductTypeData: OrdersReportResponse = generateOrdersByProductType( { + from: '2024-01-01T00:00:00.000+00:00', + to: '2024-01-07T23:59:59.999+00:00', + interval: 'day', + seed: 12345, + density: 0.9, // 90% of days have orders + volume: 7, // 7 orders per active day +} ); + +/** + * Mock data static - Comparison Period (for compatibility) + * + * Slightly lower values to show positive growth + */ +export const mockOrdersByProductTypeComparisonData: OrdersReportResponse = + generateOrdersByProductType( { + from: '2023-12-25T00:00:00.000+00:00', + to: '2023-12-31T23:59:59.999+00:00', + interval: 'day', + seed: 54321, + density: 0.85, // Slightly less dense + volume: 6, // Slightly lower volume + } ); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/sessions-by-device.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/sessions-by-device.ts new file mode 100644 index 000000000000..b9fa265d4477 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/sessions-by-device.ts @@ -0,0 +1,59 @@ +/** + * Mock data for sessions/by-device endpoint + * + * Used by: SessionsByDeviceWidget + * + * API Response format: + * - { summary: { active_sessions, ... }, items: [...] } + * - Values are strings (converted to numbers by sanitizer) + */ + +/** + * Response item from the sessions/by-device endpoint + */ +export type SessionsByDeviceItem = { + device_type: string; + active_sessions: string; +}; + +/** + * Mock data for primary period (current) + * + * Represents a typical distribution: + * - Mobile: ~56% (largest segment) + * - Desktop: ~31% + * - Tablet: ~13% (smallest segment) + */ +export const mockSessionsByDeviceData: SessionsByDeviceItem[] = [ + { device_type: 'mobile', active_sessions: '4523' }, + { device_type: 'desktop', active_sessions: '2487' }, + { device_type: 'tablet', active_sessions: '1012' }, +]; + +/** + * Mock data for comparison period (previous) + * + * Shows slightly lower numbers to create meaningful deltas: + * - Mobile: grew by ~8% + * - Desktop: grew by ~5% + * - Tablet: grew by ~3% + */ +export const mockSessionsByDeviceComparisonData: SessionsByDeviceItem[] = [ + { device_type: 'mobile', active_sessions: '4180' }, + { device_type: 'desktop', active_sessions: '2370' }, + { device_type: 'tablet', active_sessions: '982' }, +]; + +/** + * Empty mock data for empty state stories + */ +export const mockSessionsByDeviceEmptyData: SessionsByDeviceItem[] = []; + +/** + * Mock data with extreme values for stress testing + */ +export const mockSessionsByDeviceExtremeData: SessionsByDeviceItem[] = [ + { device_type: 'mobile', active_sessions: '1234567' }, + { device_type: 'desktop', active_sessions: '987654' }, + { device_type: 'tablet', active_sessions: '543210' }, +]; diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/visitors-by-location.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/visitors-by-location.ts new file mode 100644 index 000000000000..c7d0a5cd8bb8 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/data/visitors-by-location.ts @@ -0,0 +1,143 @@ +/** + * Visitors by location mock data (sessions/by-location) + * + * Mirrors the API response shape used by `fetchReportVisitorsByLocation`. + */ +type CountryItem = { + country_code: string; + label: string; + visitors: string; +}; + +type RegionItem = { + country_code: 'US'; + label: string; + region: string; + visitors: string; +}; + +export type VisitorsByLocationResponse = { + summary: { + visitors: string; + date_start: string; + date_end: string; + }; + data: Array< CountryItem | RegionItem >; +}; + +const US_STATE_PRIMARY = [ + { label: 'California', region: 'California', visitors: 1000 }, + { label: 'New York', region: 'New York', visitors: 500 }, + { label: 'Texas', region: 'Texas', visitors: 450 }, + { label: 'Florida', region: 'Florida', visitors: 400 }, + { label: 'Illinois', region: 'Illinois', visitors: 350 }, + { label: 'Ohio', region: 'Ohio', visitors: 300 }, + { label: 'Pennsylvania', region: 'Pennsylvania', visitors: 250 }, + { label: 'Michigan', region: 'Michigan', visitors: 200 }, + { label: 'Wisconsin', region: 'Wisconsin', visitors: 150 }, + { label: 'Minnesota', region: 'Minnesota', visitors: 120 }, + { label: 'Indiana', region: 'Indiana', visitors: 100 }, + { label: 'Iowa', region: 'Iowa', visitors: 80 }, + { label: 'Missouri', region: 'Missouri', visitors: 60 }, + { label: 'North Dakota', region: 'North Dakota', visitors: 40 }, + { label: 'South Dakota', region: 'South Dakota', visitors: 20 }, + { label: 'Nebraska', region: 'Nebraska', visitors: 10 }, +] as const; + +const US_STATE_COMPARISON = [ + { label: 'California', region: 'California', visitors: 900 }, + { label: 'New York', region: 'New York', visitors: 550 }, + { label: 'Texas', region: 'Texas', visitors: 400 }, + { label: 'Florida', region: 'Florida', visitors: 380 }, + { label: 'Illinois', region: 'Illinois', visitors: 360 }, + { label: 'Ohio', region: 'Ohio', visitors: 280 }, + { label: 'Pennsylvania', region: 'Pennsylvania', visitors: 240 }, + { label: 'Michigan', region: 'Michigan', visitors: 210 }, + { label: 'Wisconsin', region: 'Wisconsin', visitors: 140 }, + { label: 'Minnesota', region: 'Minnesota', visitors: 130 }, + { label: 'Indiana', region: 'Indiana', visitors: 90 }, + { label: 'Iowa', region: 'Iowa', visitors: 75 }, + { label: 'Missouri', region: 'Missouri', visitors: 55 }, + { label: 'North Dakota', region: 'North Dakota', visitors: 35 }, + { label: 'South Dakota', region: 'South Dakota', visitors: 22 }, + { label: 'Nebraska', region: 'Nebraska', visitors: 12 }, +] as const; + +const WORLD_COUNTRY_PRIMARY = [ + { country_code: 'US', label: 'United States', visitors: 8500 }, + { country_code: 'GB', label: 'United Kingdom', visitors: 4200 }, + { country_code: 'DE', label: 'Germany', visitors: 3800 }, + { country_code: 'JP', label: 'Japan', visitors: 3100 }, + { country_code: 'FR', label: 'France', visitors: 2900 }, + { country_code: 'BR', label: 'Brazil', visitors: 2400 }, + { country_code: 'IN', label: 'India', visitors: 2200 }, + { country_code: 'AU', label: 'Australia', visitors: 1800 }, + { country_code: 'CA', label: 'Canada', visitors: 1650 }, + { country_code: 'MX', label: 'Mexico', visitors: 1400 }, + { country_code: 'ES', label: 'Spain', visitors: 1100 }, + { country_code: 'IT', label: 'Italy', visitors: 950 }, + { country_code: 'NL', label: 'Netherlands', visitors: 720 }, + { country_code: 'SE', label: 'Sweden', visitors: 480 }, + { country_code: 'PL', label: 'Poland', visitors: 390 }, +] as const; + +const WORLD_COUNTRY_COMPARISON = [ + { country_code: 'US', label: 'United States', visitors: 7800 }, + { country_code: 'GB', label: 'United Kingdom', visitors: 4500 }, + { country_code: 'DE', label: 'Germany', visitors: 3500 }, + { country_code: 'JP', label: 'Japan', visitors: 2800 }, + { country_code: 'FR', label: 'France', visitors: 3100 }, + { country_code: 'BR', label: 'Brazil', visitors: 2100 }, + { country_code: 'IN', label: 'India', visitors: 1900 }, + { country_code: 'AU', label: 'Australia', visitors: 1750 }, + { country_code: 'CA', label: 'Canada', visitors: 1500 }, + { country_code: 'MX', label: 'Mexico', visitors: 1550 }, + { country_code: 'ES', label: 'Spain', visitors: 980 }, + { country_code: 'IT', label: 'Italy', visitors: 1020 }, + { country_code: 'NL', label: 'Netherlands', visitors: 680 }, + { country_code: 'SE', label: 'Sweden', visitors: 510 }, + { country_code: 'PL', label: 'Poland', visitors: 350 }, +] as const; + +function sumVisitors( items: ReadonlyArray< { visitors: number } > ) { + return items.reduce( ( acc, i ) => acc + i.visitors, 0 ); +} + +export function buildVisitorsByLocationResponse( opts: { + period: { from: string; to: string }; + groupBy: 'country' | 'region'; + isComparison: boolean; +} ): VisitorsByLocationResponse { + const { period, groupBy, isComparison } = opts; + + if ( groupBy === 'region' ) { + const source = isComparison ? US_STATE_COMPARISON : US_STATE_PRIMARY; + return { + summary: { + visitors: String( sumVisitors( source ) ), + date_start: period.from, + date_end: period.to, + }, + data: source.map( i => ( { + country_code: 'US', + label: i.label, + region: i.region, + visitors: String( i.visitors ), + } ) ), + }; + } + + const source = isComparison ? WORLD_COUNTRY_COMPARISON : WORLD_COUNTRY_PRIMARY; + return { + summary: { + visitors: String( sumVisitors( source ) ), + date_start: period.from, + date_end: period.to, + }, + data: source.map( i => ( { + country_code: i.country_code, + label: i.label, + visitors: String( i.visitors ), + } ) ), + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/presets.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/presets.ts new file mode 100644 index 000000000000..02c57c3dd956 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/presets.ts @@ -0,0 +1,81 @@ +/** + * Mock Data Presets + * + * Coordinated data scenarios that work across all endpoints. + * When you have multiple widgets using different endpoints, + * these presets ensure consistent data patterns. + * + * ## Available Presets + * + * - **default**: Standard store with consistent orders + * - **high-volume**: Busy store with lots of activity + * - **seasonal-spike**: Experiencing a seasonal increase + * - **slow-period**: Quieter period with fewer orders + * - **sparse**: Very few orders, lots of empty days + */ + +/** + * Generic mock data parameters that work across all endpoint types. + * + * Each generator interprets these parameters according to its domain: + * - Orders: density = days with orders, volume = orders per day + * - Visitors: density = days with traffic, volume = visitors * 100 + * - Attribution: density = distribution evenness, volume = total sales scale + */ +export interface MockDataPreset { + seed: number; + density: number; // 0-1: data sparsity/distribution (generic) + volume: number; // Relative amount/magnitude (generic, interpretable) + description: string; +} + +export const MOCK_DATA_PRESETS: Record< string, MockDataPreset > = { + default: { + seed: 12345, + density: 0.9, + volume: 7, + description: 'Standard store with consistent activity', + }, + 'high-volume': { + seed: 11111, + density: 1.0, + volume: 20, + description: 'Busy store with high volume across metrics', + }, + 'seasonal-spike': { + seed: 22222, + density: 0.7, + volume: 15, + description: 'Store experiencing seasonal spike', + }, + 'slow-period': { + seed: 33333, + density: 0.4, + volume: 3, + description: 'Store in slow period', + }, + sparse: { + seed: 44444, + density: 0.1, + volume: 2, + description: 'Sparse data with minimal activity', + }, +}; + +/** + * Get mock params from preset name + * + * Returns generic parameters that each generator interprets according to its domain + */ +export function getMockParamsFromPreset( preset: string ): { + seed: number; + density: number; + volume: number; +} { + const presetConfig = MOCK_DATA_PRESETS[ preset ] || MOCK_DATA_PRESETS.default; + return { + seed: presetConfig.seed, + density: presetConfig.density, + volume: presetConfig.volume, + }; +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts new file mode 100644 index 000000000000..0ae4e8ced7ab --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/mocks/register-report-mocks.ts @@ -0,0 +1,391 @@ +/** + * Storybook report-data mocking via a `@wordpress/api-fetch` middleware. + * + * The shared Jetpack Storybook config cannot be modified, and there is no + * analytics backend in Storybook. Upstream (woocommerce-analytics) solves this + * with MSW handlers that intercept `/wc/v3/woocommerce-analytics/proxy/reports/*`. + * + * Here we achieve the same effect by registering an `apiFetch` middleware that + * intercepts the same report paths and returns generated mock data. The data + * package fetches every report through `apiFetch( { path } )` using the same + * base path (`reportsPath`), so a single middleware covers all widget stories. + * + * The middleware is registered exactly once (guarded by a module-level flag) and + * is triggered automatically when `with-widget-root.tsx` is imported. + */ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +/** + * Internal dependencies + */ +import { + mockOrderAttributionDeviceData, + mockOrderAttributionChannelData, + mockOrderAttributionSourceData, + mockOrderAttributionCampaignData, + generateOrdersByProductType, + filterDataByDateRange, + recalculateSummary, + generateBookings, + filterBookingsDataByDateRange, + recalculateBookingsSummary, + buildVisitorsByLocationResponse, + mockSessionsByDeviceData, + mockSessionsByDeviceComparisonData, + mockCouponsData, + mockCouponsComparisonData, + mockCustomersData, + mockCustomersComparisonData, + mockCustomersByDateData, + mockCustomersByDateComparisonData, +} from './data'; +import { getMockParamsFromPreset } from './presets'; +import type { APIFetchMiddleware, APIFetchOptions } from '@wordpress/api-fetch'; + +/** + * Base path that all report requests share. Matches `reportsPath` in the data + * package (`@jetpack-premium-analytics/data`). + */ +const API_BASE = '/wc/v3/woocommerce-analytics/proxy/reports'; + +/** + * Days of mock data to generate (covering past requests). + */ +const SPECTRUM_DAYS = 90; + +/** + * Parameters for dynamic mock data generation. + */ +interface MockDataParams { + seed: number; + density: number; + volume: number; +} + +type VisitorsByLocationSignatureState = { + primaryFrom?: string; + comparisonFrom?: string; + lastSeenMs: number; +}; + +const visitorsByLocationRequestState = new Map< string, VisitorsByLocationSignatureState >(); + +/** + * Map view parameter to mock data for the order attribution endpoint. + */ +const orderAttributionMockMap: Record< string, object > = { + device: mockOrderAttributionDeviceData, + channel: mockOrderAttributionChannelData, + source: mockOrderAttributionSourceData, + campaign: mockOrderAttributionCampaignData, +}; + +/** + * Per-endpoint request counters used to alternate between primary and + * comparison data, mirroring the upstream MSW handlers (which kept a + * `requestCount` per handler closure). + */ +const requestCounters: Record< string, number > = {}; + +/** + * Returns true if the current request for the given endpoint key is the + * comparison request (every other request), then advances the counter. + * + * @param key - Endpoint identifier. + * @return Whether this request should serve comparison data. + */ +function nextIsComparison( key: string ): boolean { + const count = requestCounters[ key ] ?? 0; + requestCounters[ key ] = count + 1; + return count % 2 === 1; +} + +/** + * Resolves the mock data params. No Storybook toolbar global is wired up, so we + * fall back to the `default` preset, but still honour + * `window.__STORYBOOK_MOCK_PARAMS__` if a consumer sets it. + * + * @return Mock data params (seed/density/volume). + */ +function getMockParams(): MockDataParams { + const fromGlobal = + typeof window !== 'undefined' + ? ( window as unknown as { __STORYBOOK_MOCK_PARAMS__?: Partial< MockDataParams > } ) + .__STORYBOOK_MOCK_PARAMS__ + : undefined; + + return { ...getMockParamsFromPreset( 'default' ), ...fromGlobal }; +} + +/** + * Gets the end date for the mock data spectrum (end of today). + * + * @return Date set to the end of today (23:59:59.999). + */ +function getSpectrumToday(): Date { + const today = new Date(); + today.setHours( 23, 59, 59, 999 ); + return today; +} + +/** + * Computes the spectrum date range (90 days ending today). + * + * @return ISO `from`/`to` strings for the spectrum. + */ +function getSpectrumRange(): { from: string; to: string } { + const spectrumToday = getSpectrumToday(); + const spectrumFrom = new Date( spectrumToday ); + spectrumFrom.setDate( spectrumFrom.getDate() - SPECTRUM_DAYS ); + return { + from: spectrumFrom.toISOString(), + to: spectrumToday.toISOString(), + }; +} + +/** + * Splits an apiFetch path into its sub-path (relative to `API_BASE`) and parsed + * query string. + * + * @param path - Full request path. + * @return The sub-path and a `URLSearchParams` of the query. + */ +function parseReportPath( path: string ): { + subPath: string; + query: URLSearchParams; +} { + const withoutBase = path.slice( API_BASE.length ); + const queryIndex = withoutBase.indexOf( '?' ); + const subPath = queryIndex === -1 ? withoutBase : withoutBase.slice( 0, queryIndex ); + const query = new URLSearchParams( queryIndex === -1 ? '' : withoutBase.slice( queryIndex + 1 ) ); + return { subPath, query }; +} + +/** + * Builds the orders / orders-by-product-type response using the + * "spectrum + filter" strategy from upstream. + * + * @param key - Endpoint counter key. + * @param query - Parsed query params (uses `from`/`to`). + * @return Orders report response (summary + filtered data). + */ +function buildOrdersResponse( key: string, query: URLSearchParams ) { + const params = getMockParams(); + const isComparison = nextIsComparison( key ); + + const spectrum = getSpectrumRange(); + const requestFrom = query.get( 'from' ) || '2024-01-01T00:00:00.000+00:00'; + const requestTo = query.get( 'to' ) || '2024-01-07T23:59:59.999+00:00'; + + const seed = params.seed + ( isComparison ? 10000 : 0 ); + const density = isComparison ? Math.max( 0.1, params.density - 0.1 ) : params.density; + const volume = isComparison ? Math.max( 1, params.volume - 1 ) : params.volume; + + const fullSpectrum = generateOrdersByProductType( { + from: spectrum.from, + to: spectrum.to, + interval: 'day', + seed, + density, + volume, + } ); + + const filteredData = filterDataByDateRange( fullSpectrum.data, requestFrom, requestTo ); + const summary = recalculateSummary( filteredData, requestFrom, requestTo ); + + return { summary, data: filteredData }; +} + +/** + * Builds the bookings response using the "spectrum + filter" strategy. + * + * @param query - Parsed query params (uses `from`/`to`). + * @return Bookings report response (summary + filtered data). + */ +function buildBookingsResponse( query: URLSearchParams ) { + const params = getMockParams(); + const isComparison = nextIsComparison( 'bookings/by-date' ); + + const spectrum = getSpectrumRange(); + const requestFrom = query.get( 'from' ) || '2024-01-01T00:00:00.000+00:00'; + const requestTo = query.get( 'to' ) || '2024-01-07T23:59:59.999+00:00'; + + const seed = params.seed + ( isComparison ? 10000 : 0 ); + // Bookings default density is 0.8 upstream; honour the preset density. + const density = isComparison ? Math.max( 0.1, params.density - 0.1 ) : params.density; + const volume = isComparison ? Math.max( 1, params.volume - 2 ) : Math.max( 1, params.volume - 2 ); + + const fullSpectrum = generateBookings( { + from: spectrum.from, + to: spectrum.to, + interval: 'day', + seed, + density, + volume, + } ); + + const filteredData = filterBookingsDataByDateRange( fullSpectrum.data, requestFrom, requestTo ); + const summary = recalculateBookingsSummary( filteredData, requestFrom, requestTo ); + + return { summary, data: filteredData }; +} + +/** + * Builds the sessions/by-device response. + * + * Note: the data package's sanitizer reads `response.data` (not `items`), so we + * return the items under the `data` key here. + * + * @return Sessions-by-device report response. + */ +function buildSessionsByDeviceResponse() { + const isComparison = nextIsComparison( 'sessions/by-device' ); + const items = isComparison ? mockSessionsByDeviceComparisonData : mockSessionsByDeviceData; + + const totalSessions = items.reduce( + ( sum, item ) => sum + parseInt( item.active_sessions, 10 ), + 0 + ); + + return { + summary: { + active_sessions: String( totalSessions ), + total_orders: '0', + date_start: '', + date_end: '', + }, + data: items, + }; +} + +/** + * Builds the sessions/by-location (visitors by location) response, detecting + * comparison requests by tracking the distinct `from` values per request + * signature (mirrors upstream). + * + * @param query - Parsed query params. + * @return Visitors-by-location report response. + */ +function buildVisitorsByLocation( query: URLSearchParams ) { + const requestFrom = query.get( 'from' ) || '2024-01-01T00:00:00.000+00:00'; + const requestTo = query.get( 'to' ) || '2024-01-07T23:59:59.999+00:00'; + const groupBy = ( query.get( 'group_by' ) as 'country' | 'region' ) || 'country'; + const countryCode = query.get( 'country_code' ) || ''; + const limit = query.get( 'limit' ) || ''; + + const signature = [ groupBy, countryCode, limit ].join( '|' ); + const now = Date.now(); + + const state = visitorsByLocationRequestState.get( signature ) ?? { + lastSeenMs: 0, + }; + + // Reset if this signature hasn't been used recently (e.g. story changed). + if ( now - state.lastSeenMs > 2000 ) { + state.primaryFrom = undefined; + state.comparisonFrom = undefined; + } + + if ( requestFrom ) { + if ( ! state.primaryFrom ) { + state.primaryFrom = requestFrom; + } else if ( state.primaryFrom !== requestFrom && ! state.comparisonFrom ) { + // Assign primary/comparison by which range is more recent. + const primaryTime = Date.parse( state.primaryFrom ); + const otherTime = Date.parse( requestFrom ); + if ( ! isNaN( primaryTime ) && ! isNaN( otherTime ) && otherTime > primaryTime ) { + state.comparisonFrom = state.primaryFrom; + state.primaryFrom = requestFrom; + } else { + state.comparisonFrom = requestFrom; + } + } + } + + state.lastSeenMs = now; + visitorsByLocationRequestState.set( signature, state ); + + const isComparison = Boolean( state.comparisonFrom ) && requestFrom === state.comparisonFrom; + + return buildVisitorsByLocationResponse( { + period: { from: requestFrom, to: requestTo }, + groupBy, + isComparison, + } ); +} + +/** + * Routes a report sub-path to the matching mock generator. + * + * @param subPath - Path relative to `API_BASE` (e.g. `/orders/by-date`). + * @param query - Parsed query params. + * @return The mock response body, or `null` if no specific handler matched. + */ +function routeReport( subPath: string, query: URLSearchParams ): unknown { + // Order attribution: /order-attribution/{view}/summary + const attributionMatch = subPath.match( /^\/order-attribution\/([^/]+)\/summary$/ ); + if ( attributionMatch ) { + const view = attributionMatch[ 1 ]; + return orderAttributionMockMap[ view ] || mockOrderAttributionDeviceData; + } + + switch ( subPath ) { + case '/orders/by-date': + return buildOrdersResponse( 'orders/by-date', query ); + case '/orders-by-product-type/by-date': + return buildOrdersResponse( 'orders-by-product-type/by-date', query ); + case '/bookings/by-date': + return buildBookingsResponse( query ); + case '/sessions/by-device': + return buildSessionsByDeviceResponse(); + case '/sessions/by-location': + return buildVisitorsByLocation( query ); + case '/coupons/': + case '/coupons': + return nextIsComparison( 'coupons' ) ? mockCouponsComparisonData : mockCouponsData; + case '/customers/new-returning': + return nextIsComparison( 'customers/new-returning' ) + ? mockCustomersComparisonData + : mockCustomersData; + case '/customers/by-date': + return nextIsComparison( 'customers/by-date' ) + ? mockCustomersByDateComparisonData + : mockCustomersByDateData; + default: + return null; + } +} + +const reportMocksMiddleware: APIFetchMiddleware = async ( options: APIFetchOptions, next ) => { + const requestPath = options.path ?? options.url ?? ''; + + if ( ! requestPath.startsWith( API_BASE ) ) { + return next( options ); + } + + const { subPath, query } = parseReportPath( requestPath ); + const response = routeReport( subPath, query ); + + if ( response !== null ) { + return response; + } + + // Catch-all for any other report path: an empty-but-valid response. + return { data: [], summary: {} }; +}; + +let registered = false; + +/** + * Registers the report-mocking `apiFetch` middleware. Idempotent: repeated calls + * after the first are no-ops. + */ +export function registerReportMocks(): void { + if ( registered ) { + return; + } + registered = true; + apiFetch.use( reportMocksMiddleware ); +} diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/with-chart-theme.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/with-chart-theme.tsx new file mode 100644 index 000000000000..1ca586ea55e2 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/with-chart-theme.tsx @@ -0,0 +1,36 @@ +import { GlobalChartsProvider } from '@automattic/charts'; +import { useChartTheme } from '../hooks'; +import type { Decorator } from '@storybook/react'; +import type { ReactNode } from 'react'; + +/** + * Wraps children in a `GlobalChartsProvider` seeded with the Woo chart theme. + * Mirrors what `WidgetRoot` does in the app, where the provider lives at the + * top of the widget tree. + * + * @param props - Component props. + * @param props.children - The subtree to render inside the provider. + * @return The themed chart provider wrapping `children`. + */ +const ChartThemeProvider = ( { children }: { children: ReactNode } ) => { + const theme = useChartTheme(); + + return { children }; +}; + +/** + * Storybook decorator that supplies the charts context. + * + * Component-level stories that render a chart primitive from + * `@automattic/charts` (or call `useGlobalChartsContext` directly) render + * outside of `WidgetRoot`, so without this they throw + * "useGlobalChartsContext must be used within a GlobalChartsProvider". + * + * @param Story - The story being decorated. + * @return The story wrapped in a themed `GlobalChartsProvider`. + */ +export const withChartTheme: Decorator = Story => ( + + + +); diff --git a/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/with-widget-root.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/with-widget-root.tsx new file mode 100644 index 000000000000..aead35f0f0bc --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/stories/with-widget-root.tsx @@ -0,0 +1,34 @@ +import { getDefaultQueryParams, normalizeReportParams } from '@jetpack-premium-analytics/data'; +import { WidgetRoot } from '../components/widget-root/widget-root'; +import { registerReportMocks } from './mocks/register-report-mocks'; +import type { Decorator } from '@storybook/react'; + +/* + * Register the report-data `apiFetch` mock middleware as soon as this module is + * imported. Any story using `withWidgetRoot()` therefore gets mocked report data + * automatically, with no per-story wiring. `registerReportMocks` is idempotent. + */ +registerReportMocks(); + +/** + * Storybook decorator factory that wraps a story in the real `WidgetRoot`. + * + * `WidgetRoot` provides everything a widget needs to render: the report-params + * context, a `GlobalChartsProvider`, and the analytics react-query client. Use + * this for any story whose component reads `useWidgetRootContext` (directly or + * via `useWidgetError`) or fetches report data — wrapping in the bare context + * provider is not enough. + * + * Note: there is no analytics backend in Storybook, so data-fetching widgets + * render their loading/empty/error chrome rather than live data. + * + * @param reportParams - Report params to seed; pass `getDefaultQueryParams( true )` for comparison mode. + * @return A Storybook decorator wrapping the story in `WidgetRoot`. + */ +export const withWidgetRoot = + ( reportParams = getDefaultQueryParams() ): Decorator => + Story => ( + + + + ); 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/new-vs-returning-customer/stories/new-vs-returning-customer-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/new-vs-returning-customer/stories/new-vs-returning-customer-widget.stories.tsx new file mode 100644 index 000000000000..2ecf626aa5e8 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/new-vs-returning-customer/stories/new-vs-returning-customer-widget.stories.tsx @@ -0,0 +1,106 @@ +import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; +import { WidgetRootContext } from '../../../components/widget-root/context'; +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { NewVsReturningCustomerWidget } from '../new-vs-returning-customer-widget'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof NewVsReturningCustomerWidget > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/NewVsReturningCustomer', + component: NewVsReturningCustomerWidget, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'Displays unique customer counts broken down by new vs returning customers using a donut chart. Shows total customers with comparison support.', + }, + }, + }, + decorators: [ + withWidgetRoot(), + Story => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj< typeof NewVsReturningCustomerWidget >; + +/** + * Default state with mock data (no comparison) + */ +export const Default: Story = {}; + +/** + * With comparison period enabled + */ +export const WithComparison: Story = { + decorators: [ + Story => ( + + + + ), + ], +}; + +/* + * Container Size Stories + * + * These stories demonstrate how the widget adapts to different container sizes. + * Breakpoints aligned with Tailwind container query defaults (ARC-464). + * + * @see https://linear.app/a8c/issue/ARC-464/design-system-wide-responsiveness + * @see https://linear.app/a8c/issue/WOOA7S-869 + */ + +/** + * Creates a decorator that wraps the story in a fixed-size container + * with comparison enabled. + */ +const createSizeDecorator = ( width: string ): Decorator => { + return Story => ( + +
+ +
+
+ ); +}; + +/** + * Extra extra small widgets (256px / xxs breakpoint) + * Tests compact layout with comparison enabled. + */ +export const SizeXXSmall: Story = { + decorators: [ createSizeDecorator( '256px' ) ], +}; + +/** + * Medium container (448px / md breakpoint) + * Tests standard tile size with comparison. + */ +export const SizeMedium: Story = { + decorators: [ createSizeDecorator( '448px' ) ], +}; + +/** + * Large container (576px / xl breakpoint) + * Tests expanded layout with full data visibility. + */ +export const SizeLarge: Story = { + decorators: [ createSizeDecorator( '576px' ) ], +}; 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/stories/booking-order-metric-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/order-metric/stories/booking-order-metric-widget.stories.tsx new file mode 100644 index 000000000000..746eb3889db5 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/order-metric/stories/booking-order-metric-widget.stories.tsx @@ -0,0 +1,121 @@ +import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; +import { WidgetRootContext } from '../../../components/widget-root/context'; +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { BookingOrderMetricWidget } from '../booking-order-metric-widget'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof BookingOrderMetricWidget > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/BookingOrderMetricWidget', + component: BookingOrderMetricWidget, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'Displays booking order metrics over time with comparison support. Automatically filters data to show only booking product types (booking, bookable-event, bookable-service).', + }, + }, + }, + decorators: [ + withWidgetRoot(), + Story => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj< typeof BookingOrderMetricWidget >; + +/** + * Default state showing booking orders count (used by bookings-over-time widget) + */ +export const Default: Story = { + args: { + metricKey: 'orders_no', + }, +}; + +/** + * With comparison period enabled - shows delta between periods + */ +export const WithComparison: Story = { + args: { + metricKey: 'orders_no', + }, + decorators: [ + Story => ( + + + + ), + ], +}; + +/* + * Container Size Stories + * + * These stories demonstrate how the widget adapts to different container sizes. + * Breakpoints aligned with Tailwind container query defaults. + */ + +/** + * Creates a decorator that wraps the story in a fixed-size container + * with comparison enabled. + * + * Height is required because the chart uses height: 100% which needs + * a parent with explicit height to resolve properly. + */ +const createSizeDecorator = ( width: string, height = '290px' ): Decorator => { + return Story => ( + +
+ +
+
+ ); +}; + +/** + * Extra extra small container (256px / xxs breakpoint) + */ +export const SizeXXSmall: Story = { + args: { + metricKey: 'orders_no', + }, + decorators: [ createSizeDecorator( '256px' ) ], +}; + +/** + * Medium container (448px / md breakpoint) + */ +export const SizeMedium: Story = { + args: { + metricKey: 'orders_no', + }, + decorators: [ createSizeDecorator( '448px' ) ], +}; + +/** + * Large container (576px / xl breakpoint) + */ +export const SizeLarge: Story = { + args: { + metricKey: 'orders_no', + }, + decorators: [ createSizeDecorator( '576px' ) ], +}; 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/revenue-by-customer-type/stories/revenue-by-customer-type-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/stories/revenue-by-customer-type-widget.stories.tsx new file mode 100644 index 000000000000..3ade516c05e9 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/revenue-by-customer-type/stories/revenue-by-customer-type-widget.stories.tsx @@ -0,0 +1,107 @@ +import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; +import { WidgetRootContext } from '../../../components/widget-root/context'; +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { RevenueByCustomerTypeWidget } from '../revenue-by-customer-type-widget'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof RevenueByCustomerTypeWidget > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/RevenueByCustomerType', + component: RevenueByCustomerTypeWidget, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'Displays revenue comparison between new and returning customers using a bar chart. Shows data for all product types with comparison support.', + }, + }, + }, + decorators: [ + withWidgetRoot(), + Story => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj< typeof RevenueByCustomerTypeWidget >; + +/** + * Default state with mock data (no comparison) + */ +export const Default: Story = {}; + +/** + * With comparison period enabled + */ +export const WithComparison: Story = { + decorators: [ + Story => ( + + + + ), + ], +}; + +/* + * Container Size Stories + * + * These stories demonstrate how the widget adapts to different container sizes. + * Uses extreme data (long labels, large values) with comparison enabled. + * Breakpoints aligned with Tailwind container query defaults (ARC-464). + * + * @see https://linear.app/a8c/issue/ARC-464/design-system-wide-responsiveness + * @see https://linear.app/a8c/issue/WOOA7S-869 + */ + +/** + * Creates a decorator that wraps the story in a fixed-size container + * with comparison enabled and extreme data. + */ +const createSizeDecorator = ( width: string ): Decorator => { + return Story => ( + +
+ +
+
+ ); +}; + +/** + * Extra extra small widgets (256px / xxs breakpoint) + * Tests compact layout with comparison enabled. + */ +export const SizeXXSmall: Story = { + decorators: [ createSizeDecorator( '256px' ) ], +}; + +/** + * Medium container (448px / md breakpoint) + * Tests standard tile size with comparison. + */ +export const SizeMedium: Story = { + decorators: [ createSizeDecorator( '448px' ) ], +}; + +/** + * Large container (576px / xl breakpoint) + * Tests expanded layout with full data visibility. + */ +export const SizeLarge: Story = { + decorators: [ createSizeDecorator( '576px' ) ], +}; 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-coupon/stories/sales-by-coupon-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/stories/sales-by-coupon-widget.stories.tsx new file mode 100644 index 000000000000..d7b9bf619a55 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-coupon/stories/sales-by-coupon-widget.stories.tsx @@ -0,0 +1,107 @@ +import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; +import { WidgetRootContext } from '../../../components/widget-root/context'; +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { SalesByCouponWidget } from '../sales-by-coupon-widget'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof SalesByCouponWidget > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/SalesByCoupon', + component: SalesByCouponWidget, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'Displays revenue distribution by coupon using a bar chart. Shows top 3 coupons plus "Other" segment with comparison support.', + }, + }, + }, + decorators: [ + withWidgetRoot(), + Story => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj< typeof SalesByCouponWidget >; + +/** + * Default state with mock data (no comparison) + */ +export const Default: Story = {}; + +/** + * With comparison period enabled + */ +export const WithComparison: Story = { + decorators: [ + Story => ( + + + + ), + ], +}; + +/* + * Container Size Stories + * + * These stories demonstrate how the widget adapts to different container sizes. + * Uses extreme data (long labels, large values) with comparison enabled. + * Breakpoints aligned with Tailwind container query defaults (ARC-464). + * + * @see https://linear.app/a8c/issue/ARC-464/design-system-wide-responsiveness + * @see https://linear.app/a8c/issue/WOOA7S-869 + */ + +/** + * Creates a decorator that wraps the story in a fixed-size container + * with comparison enabled and extreme data. + */ +const createSizeDecorator = ( width: string ): Decorator => { + return Story => ( + +
+ +
+
+ ); +}; + +/** + * Extra extra small widgets (256px / xxs breakpoint) + * Tests compact layout with comparison enabled. + */ +export const SizeXXSmall: Story = { + decorators: [ createSizeDecorator( '256px' ) ], +}; + +/** + * Medium container (448px / md breakpoint) + * Tests standard tile size with comparison. + */ +export const SizeMedium: Story = { + decorators: [ createSizeDecorator( '448px' ) ], +}; + +/** + * Large container (576px / xl breakpoint) + * Tests expanded layout with full data visibility. + */ +export const SizeLarge: Story = { + decorators: [ createSizeDecorator( '576px' ) ], +}; 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-device/stories/sales-by-device-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/stories/sales-by-device-widget.stories.tsx new file mode 100644 index 000000000000..43c3cf13c876 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-device/stories/sales-by-device-widget.stories.tsx @@ -0,0 +1,107 @@ +import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; +import { WidgetRootContext } from '../../../components/widget-root/context'; +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { SalesByDeviceWidget } from '../sales-by-device-widget'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof SalesByDeviceWidget > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/SalesByDevice', + component: SalesByDeviceWidget, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'Displays sales attribution data by device type (Desktop, Mobile, Tablet) using a bar chart with comparison support.', + }, + }, + }, + decorators: [ + withWidgetRoot(), + Story => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj< typeof SalesByDeviceWidget >; + +/** + * Default state with mock data (no comparison) + */ +export const Default: Story = {}; + +/** + * With comparison period enabled + */ +export const WithComparison: Story = { + decorators: [ + Story => ( + + + + ), + ], +}; + +/* + * Container Size Stories + * + * These stories demonstrate how the widget adapts to different container sizes. + * Uses extreme data (long labels, large values) with comparison enabled. + * Breakpoints aligned with Tailwind container query defaults (ARC-464). + * + * @see https://linear.app/a8c/issue/ARC-464/design-system-wide-responsiveness + * @see https://linear.app/a8c/issue/WOOA7S-869 + */ + +/** + * Creates a decorator that wraps the story in a fixed-size container + * with comparison enabled and extreme data. + */ +const createSizeDecorator = ( width: string ): Decorator => { + return Story => ( + +
+ +
+
+ ); +}; + +/** + * Extra extra small widgets (256px / xxs breakpoint) + * Tests compact layout with comparison enabled. + */ +export const SizeXXSmall: Story = { + decorators: [ createSizeDecorator( '256px' ) ], +}; + +/** + * Medium container (448px / md breakpoint) + * Tests standard tile size with comparison. + */ +export const SizeMedium: Story = { + decorators: [ createSizeDecorator( '448px' ) ], +}; + +/** + * Large container (576px / xl breakpoint) + * Tests expanded layout with full data visibility. + */ +export const SizeLarge: Story = { + decorators: [ createSizeDecorator( '576px' ) ], +}; 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/sales-by-utm/stories/sales-by-utm-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/stories/sales-by-utm-widget.stories.tsx new file mode 100644 index 000000000000..da105a5d2895 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sales-by-utm/stories/sales-by-utm-widget.stories.tsx @@ -0,0 +1,121 @@ +import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { SalesByUtmWidget } from '../sales-by-utm-widget'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof SalesByUtmWidget > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/SalesByUtm', + component: SalesByUtmWidget, + tags: [ 'autodocs' ], + decorators: [ withWidgetRoot( getDefaultQueryParams( true ) ) ], + argTypes: { + view: { + control: 'select', + options: [ 'source', 'channel', 'campaign' ], + description: 'The order attribution view to display', + }, + }, + parameters: { + docs: { + description: { + component: + 'Displays a leaderboard chart showing sales breakdown by UTM parameters. Supports three views: source (referral source), channel (marketing channel), and campaign (campaign name).', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj< typeof SalesByUtmWidget >; + +/** + * Sales by Source - shows referral sources driving sales + */ +export const Source: Story = { + args: { + view: 'source', + }, +}; + +/** + * Sales by Channel - shows marketing channels driving sales + */ +export const Channel: Story = { + args: { + view: 'channel', + }, +}; + +/** + * Sales by Campaign - shows campaign performance + */ +export const Campaign: Story = { + args: { + view: 'campaign', + }, +}; + +/* + * Container Size Stories + * + * These stories demonstrate how the widget adapts to different container sizes. + * Uses source view with comparison enabled. + * Breakpoints aligned with Tailwind container query defaults (ARC-464). + * + * @see https://linear.app/a8c/issue/ARC-464/design-system-wide-responsiveness + * @see https://linear.app/a8c/issue/WOOA7S-869 + */ + +/** + * Creates a decorator that wraps the story in a fixed-size container. + * Context is provided by the meta-level decorator. + */ +const createSizeDecorator = ( width: string ): Decorator => { + return Story => ( +
+ +
+ ); +}; + +/** + * Small container (280px / xs breakpoint) + * Tests compact layout with comparison enabled. + */ +export const SizeSmall: Story = { + args: { + view: 'source', + }, + decorators: [ createSizeDecorator( '280px' ) ], +}; + +/** + * Medium container (448px / md breakpoint) + * Tests standard tile size with comparison. + */ +export const SizeMedium: Story = { + args: { + view: 'source', + }, + decorators: [ createSizeDecorator( '448px' ) ], +}; + +/** + * Large container (640px / xl breakpoint) + * Tests expanded layout with full data visibility. + */ +export const SizeLarge: Story = { + args: { + view: 'source', + }, + decorators: [ createSizeDecorator( '640px' ) ], +}; 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/sessions-by-device/stories/sessions-by-device-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sessions-by-device/stories/sessions-by-device-widget.stories.tsx new file mode 100644 index 000000000000..d1f28c00a463 --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/sessions-by-device/stories/sessions-by-device-widget.stories.tsx @@ -0,0 +1,84 @@ +import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; +import { WidgetRootContext } from '../../../components/widget-root/context'; +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { SessionsByDeviceWidget } from '../sessions-by-device-widget'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof SessionsByDeviceWidget > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/SessionsByDevice', + component: SessionsByDeviceWidget, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'Displays website sessions breakdown by device type (Mobile, Desktop, Tablet) using a semi-circle chart with comparison support.', + }, + }, + }, + decorators: [ withWidgetRoot() ], +}; + +export default meta; + +type Story = StoryObj< typeof SessionsByDeviceWidget >; + +/** + * Default state with mock data (no comparison) + */ +export const Default: Story = {}; + +/** + * With comparison period enabled + */ +export const WithComparison: Story = { + decorators: [ + Story => ( + + + + ), + ], +}; + +/** + * Creates a decorator that wraps the story in a fixed-size container + */ +const createSizeDecorator = ( width: string ): Decorator => { + return Story => ( + +
+ +
+
+ ); +}; + +/** + * Small container (256px) + */ +export const SizeSmall: Story = { + decorators: [ createSizeDecorator( '256px' ) ], +}; + +/** + * Medium container (350px) + */ +export const SizeMedium: Story = { + decorators: [ createSizeDecorator( '350px' ) ], +}; + +/** + * Large container (450px) + */ +export const SizeLarge: Story = { + decorators: [ createSizeDecorator( '450px' ) ], +}; 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/stories/total-returns-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/stories/total-returns-widget.stories.tsx new file mode 100644 index 000000000000..5b2bacb1368c --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/total-returns/stories/total-returns-widget.stories.tsx @@ -0,0 +1,103 @@ +import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; +import { WidgetRootContext } from '../../../components/widget-root/context'; +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { TotalReturnsWidget } from '../total-returns-widget'; +import type { Meta, StoryObj, Decorator } from '@storybook/react'; + +const meta: Meta< typeof TotalReturnsWidget > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/TotalReturns', + component: TotalReturnsWidget, + tags: [ 'autodocs' ], + parameters: { + docs: { + description: { + component: + 'Displays refunds vs total sales as a bar chart. Shows the proportion of returns compared to total revenue.', + }, + }, + }, + decorators: [ + withWidgetRoot(), + Story => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj< typeof TotalReturnsWidget >; + +/** + * Default state with mock data (no comparison) + */ +export const Default: Story = {}; + +/** + * With comparison period enabled + */ +export const WithComparison: Story = { + decorators: [ + Story => ( + + + + ), + ], +}; + +/* + * Container Size Stories + * + * These stories demonstrate how the widget adapts to different container sizes. + * Breakpoints aligned with Tailwind container query defaults (ARC-464). + */ + +/** + * Creates a decorator that wraps the story in a fixed-size container + * with comparison enabled. + */ +const createSizeDecorator = ( width: string ): Decorator => { + return Story => ( + +
+ +
+
+ ); +}; + +/** + * Extra extra small widgets (256px / xxs breakpoint) + * Tests compact layout with comparison enabled. + */ +export const SizeXXSmall: Story = { + decorators: [ createSizeDecorator( '256px' ) ], +}; + +/** + * Medium container (448px / md breakpoint) + * Tests standard tile size with comparison. + */ +export const SizeMedium: Story = { + decorators: [ createSizeDecorator( '448px' ) ], +}; + +/** + * Large container (576px / xl breakpoint) + * Tests expanded layout with full data visibility. + */ +export const SizeLarge: Story = { + decorators: [ createSizeDecorator( '576px' ) ], +}; 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/stories/visitors-by-location-widget.stories.tsx b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/stories/visitors-by-location-widget.stories.tsx new file mode 100644 index 000000000000..634b59bd394d --- /dev/null +++ b/projects/packages/premium-analytics/packages/widgets-toolkit/src/widgets/visitors-by-location/stories/visitors-by-location-widget.stories.tsx @@ -0,0 +1,64 @@ +import { getDefaultQueryParams } from '@jetpack-premium-analytics/data'; +import { withWidgetRoot } from '../../../stories/with-widget-root'; +import { VisitorsByLocationWidget } from '../visitors-by-location-widget'; +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta< typeof VisitorsByLocationWidget > = { + title: 'Packages/Premium Analytics/Widgets Toolkit/Widgets/VisitorsByLocation', + component: VisitorsByLocationWidget, + tags: [ 'autodocs' ], +}; + +export default meta; + +type Story = StoryObj< typeof VisitorsByLocationWidget >; + +/** + * Default state with mock data (no comparison) + */ +export const Default: Story = { + decorators: [ withWidgetRoot( getDefaultQueryParams() ) ], +}; + +/** + * With comparison period enabled + */ +export const WithComparison: Story = { + decorators: [ withWidgetRoot( getDefaultQueryParams( true ) ) ], +}; + +/** + * Simulate a single-column dashboard tile (map only) + */ +export const SingleColumnTile: Story = { + decorators: [ + withWidgetRoot( getDefaultQueryParams() ), + Story => ( +
+ +
+ ), + ], +}; + +/** + * Simulate being rendered inside 'Add widget' DataViews picker grid (map only) + */ +export const WidgetPickerGrid: Story = { + decorators: [ + withWidgetRoot( getDefaultQueryParams() ), + Story => ( +
+ +
+ ), + ], +}; 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 && } + + ); +}