From 98112527d5c2bf210d05729b814a11fa729b96ef Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 4 Sep 2026 14:56:04 +0200 Subject: [PATCH] feat: experiment conversion rate chart --- frontend/common/types/responses.ts | 18 + frontend/web/components/charts/BarChart.tsx | 110 +++++- .../web/components/charts/ChartTooltip.tsx | 12 +- .../ExperimentConversionRateCard.tsx | 146 ++++++++ .../ExperimentConversionRateCard/index.ts | 1 + .../__tests__/deriveConversionRate.test.ts | 320 ++++++++++++++++++ .../results/deriveConversionRate.ts | 221 ++++++++++++ .../components/pages/ExperimentDetailPage.tsx | 6 + 8 files changed, 828 insertions(+), 6 deletions(-) create mode 100644 frontend/web/components/experiments/results/ExperimentConversionRateCard/ExperimentConversionRateCard.tsx create mode 100644 frontend/web/components/experiments/results/ExperimentConversionRateCard/index.ts create mode 100644 frontend/web/components/experiments/results/__tests__/deriveConversionRate.test.ts create mode 100644 frontend/web/components/experiments/results/deriveConversionRate.ts diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 441f61ab872b..381285b2e982 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -727,6 +727,16 @@ export type ExposuresSummary = { timeseries: ExposuresTimeseries } +export type ConversionsTimeseriesPoint = { + bucket: string + converted_identities: Record +} + +export type ConversionsTimeseries = { + granularity: ExposureGranularity + points: ConversionsTimeseriesPoint[] +} + export type ExperimentExposures = { as_of: string | null last_error_at: string | null @@ -760,11 +770,19 @@ export type BayesianMetricResult = { metric_id: number variants: Record inference: Record + // Occurrence metrics only; null for value metrics. Absent from payloads + // stored before the backend shipped it (finalised experiments never gain it). + conversions_timeseries?: ConversionsTimeseries | null } export type BayesianResultsSummary = { srm_p_value: number | null metrics: BayesianMetricResult[] + // Denominator for the conversion-rate charts, same warehouse run as the + // metrics. Exposures bucket by first exposure and conversions by first + // conversion, so only running totals may be divided — a per-bucket division + // can exceed 100%. Absent from payloads stored before the backend shipped it. + exposures_timeseries?: ExposuresTimeseries } export enum TagStrategy { diff --git a/frontend/web/components/charts/BarChart.tsx b/frontend/web/components/charts/BarChart.tsx index 5e9dd782cd5f..ccf00e395b3f 100644 --- a/frontend/web/components/charts/BarChart.tsx +++ b/frontend/web/components/charts/BarChart.tsx @@ -35,20 +35,100 @@ type BarChartProps = { barSize?: number /** Render vertical grid lines (one per x tick). Default `true`. */ verticalGrid?: boolean + /** Chart height in pixels. Default 400. */ + height?: number + /** + * Render series side by side instead of stacked. Required for non-additive + * values (rates, percentages) where stacking would be meaningless. + */ + grouped?: boolean + /** + * dataKey → stack id, for part-of-whole bars: series sharing a stack id + * stack together, distinct ids sit side by side (e.g. converted/remainder + * segments stacked per variant, variants grouped). Overrides `grouped`. + */ + stackMap?: Record + /** + * dataKey → fill opacity (0–1). Colours are CSS `var()` strings, so + * transparency must come from SVG fill-opacity, not an alpha channel. + */ + opacityMap?: Record + /** Left axis overrides, e.g. a `%` tick formatter. */ + yAxis?: { + tickFormatter?: (value: number) => string + domain?: [number, number] + } + /** + * Per-entry tooltip value renderer, threaded to ChartTooltip. Skipped for + * missing or non-numeric values, which render blank. + */ + tooltipValueFormatter?: ( + value: number, + seriesKey: string, + label: string, + ) => string + /** + * Hide the tooltip's total row — required when `tooltipValueFormatter` + * renders a non-additive unit such as a percentage. + */ + tooltipHideTotal?: boolean } +type FadedSwatchLegendProps = { + opacityMap: Record + seriesLabels?: Record + // Injected by recharts' . + payload?: { value?: string | number; color?: string }[] +} + +const FadedSwatchLegend: FC = ({ + opacityMap, + payload, + seriesLabels, +}) => ( +
+ {payload?.map((entry) => { + const key = String(entry.value) + return ( + + + + {seriesLabels?.[key] ?? key} + + + ) + })} +
+) + const BarChart: FC = ({ barSize, colorMap, data, + grouped = false, + height = 400, + opacityMap, series, seriesLabels, showLegend = false, + stackMap, + tooltipHideTotal, + tooltipValueFormatter, verticalGrid = true, xAxisInterval = 0, + yAxis, }) => { + const defaultStackId = grouped ? undefined : 'series' return ( - + = ({ - value >= 1000 ? `${(value / 1000).toFixed(0)}k` : value + domain={yAxis?.domain} + tickFormatter={ + yAxis?.tickFormatter ?? + ((value) => + value >= 1000 ? `${(value / 1000).toFixed(0)}k` : value) } /> } + content={ + + } /> {showLegend && ( = ({ formatter={(value) => seriesLabels?.[String(value)] ?? String(value) } + content={ + // The default legend swatch ignores fillOpacity, so faded + // series need their own renderer to match the bars. + opacityMap ? ( + + ) : undefined + } /> )} {series.map((label, index) => ( & { /** * Hide the total row at the bottom. Useful for single-entry payloads * (e.g. a pie-slice hover) where the total just repeats the entry value. + * Also required when `valueFormatter` renders a non-additive unit such as a + * percentage, since the total row stays plain-number formatted. * Default: false. */ hideTotal?: boolean + /** + * Optional per-entry value renderer, e.g. to append units ("8.3%") or + * counts ("120 of 1,450"). Falls back to localised number formatting. + */ + valueFormatter?: (value: number, seriesKey: string, label: string) => string } const ChartTooltip: FC = ({ @@ -36,6 +43,7 @@ const ChartTooltip: FC = ({ label, payload, seriesLabels, + valueFormatter, }) => { if (!active || !payload || payload.length === 0) return null const total = payload.reduce( @@ -66,7 +74,9 @@ const ChartTooltip: FC = ({ {displayName}: - {formatNumber(entry.value)} + {typeof entry.value === 'number' && valueFormatter + ? valueFormatter(entry.value, key, String(label ?? '')) + : formatNumber(entry.value)} ) diff --git a/frontend/web/components/experiments/results/ExperimentConversionRateCard/ExperimentConversionRateCard.tsx b/frontend/web/components/experiments/results/ExperimentConversionRateCard/ExperimentConversionRateCard.tsx new file mode 100644 index 000000000000..671b8a983220 --- /dev/null +++ b/frontend/web/components/experiments/results/ExperimentConversionRateCard/ExperimentConversionRateCard.tsx @@ -0,0 +1,146 @@ +import { FC, useCallback, useMemo, useState } from 'react' +import moment from 'moment' +import { BarChart } from 'components/charts' +import ContentCard from 'components/base/grid/ContentCard' +import InlinePillToggle from 'components/base/forms/InlinePillToggle' +import { BayesianResultsSummary, Experiment } from 'common/types/responses' +import { getPrimaryMetric } from 'components/experiments/constants' +import { + getMetricResult, + getVariantIdentities, +} from 'components/experiments/results/derive' +import { + ConversionStackMode, + REST_SUFFIX, + buildConversionRateChartData, + buildConversionStackChartData, +} from 'components/experiments/results/deriveConversionRate' + +type ExperimentConversionRateCardProps = { + experiment: Experiment + results?: BayesianResultsSummary + asOf: string | null +} + +const ExperimentConversionRateCard: FC = ({ + asOf, + experiment, + results, +}) => { + const [mode, setMode] = useState('cumulative') + const metric = getPrimaryMetric(experiment) + const identities = useMemo( + () => getVariantIdentities(experiment.feature), + [experiment.feature], + ) + const chart = useMemo( + () => + metric && results + ? buildConversionStackChartData( + results, + metric.metric, + identities, + mode, + ) + : null, + [metric, results, identities, mode], + ) + // Running counts and rates, for the cumulative tooltip ("x of y (z%)"). + const rateChart = useMemo( + () => + metric && results + ? buildConversionRateChartData(results, metric.metric, identities) + : null, + [metric, results, identities], + ) + + const formatTooltipValue = useCallback( + (value: number, seriesKey: string, label: string) => { + if (mode === 'daily') return value.toLocaleString() + if (seriesKey.endsWith(REST_SUFFIX)) { + // The faded segment is labelled "exposures", so report the full bar + // total rather than the plotted remainder (exposures − conversions). + const variantKey = seriesKey.slice(0, -REST_SUFFIX.length) + const counts = rateChart?.countsByDay[label]?.[variantKey] + return (counts?.exposed ?? value).toLocaleString() + } + const counts = rateChart?.countsByDay[label]?.[seriesKey] + if (!counts) return value.toLocaleString() + const rate = rateChart?.points.find((p) => p.day === label)?.[seriesKey] + return `${counts.converted.toLocaleString()} of ${counts.exposed.toLocaleString()}${ + typeof rate === 'number' ? ` (${rate}%)` : '' + }` + }, + [mode, rateChart], + ) + + // Hidden entirely when no rate can be charted: value metrics, and + // payloads stored before the backend shipped the timeseries. + if (!metric || !results || !chart) return null + + const conversions = getMetricResult( + results, + metric.metric, + )?.conversions_timeseries + const hasConversions = !!conversions && conversions.points.length > 0 + + return ( + +