Skip to content

Commit 170e9fc

Browse files
committed
feat: experiment conversion rate chart
1 parent 2f1a915 commit 170e9fc

8 files changed

Lines changed: 768 additions & 6 deletions

File tree

frontend/common/types/responses.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,16 @@ export type ExposuresSummary = {
727727
timeseries: ExposuresTimeseries
728728
}
729729

730+
export type ConversionsTimeseriesPoint = {
731+
bucket: string
732+
converted_identities: Record<string, number>
733+
}
734+
735+
export type ConversionsTimeseries = {
736+
granularity: ExposureGranularity
737+
points: ConversionsTimeseriesPoint[]
738+
}
739+
730740
export type ExperimentExposures = {
731741
as_of: string | null
732742
last_error_at: string | null
@@ -760,11 +770,19 @@ export type BayesianMetricResult = {
760770
metric_id: number
761771
variants: Record<string, VariantStats>
762772
inference: Record<string, Inference | null>
773+
// Occurrence metrics only; null for value metrics. Absent from payloads
774+
// stored before the backend shipped it (finalised experiments never gain it).
775+
conversions_timeseries?: ConversionsTimeseries | null
763776
}
764777

765778
export type BayesianResultsSummary = {
766779
srm_p_value: number | null
767780
metrics: BayesianMetricResult[]
781+
// Denominator for the conversion-rate charts, same warehouse run as the
782+
// metrics. Exposures bucket by first exposure and conversions by first
783+
// conversion, so only running totals may be divided — a per-bucket division
784+
// can exceed 100%. Absent from payloads stored before the backend shipped it.
785+
exposures_timeseries?: ExposuresTimeseries
768786
}
769787

770788
export enum TagStrategy {

frontend/web/components/charts/BarChart.tsx

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,100 @@ type BarChartProps = {
3535
barSize?: number
3636
/** Render vertical grid lines (one per x tick). Default `true`. */
3737
verticalGrid?: boolean
38+
/** Chart height in pixels. Default 400. */
39+
height?: number
40+
/**
41+
* Render series side by side instead of stacked. Required for non-additive
42+
* values (rates, percentages) where stacking would be meaningless.
43+
*/
44+
grouped?: boolean
45+
/**
46+
* dataKey → stack id, for part-of-whole bars: series sharing a stack id
47+
* stack together, distinct ids sit side by side (e.g. converted/remainder
48+
* segments stacked per variant, variants grouped). Overrides `grouped`.
49+
*/
50+
stackMap?: Record<string, string>
51+
/**
52+
* dataKey → fill opacity (0–1). Colours are CSS `var()` strings, so
53+
* transparency must come from SVG fill-opacity, not an alpha channel.
54+
*/
55+
opacityMap?: Record<string, number>
56+
/** Left axis overrides, e.g. a `%` tick formatter. */
57+
yAxis?: {
58+
tickFormatter?: (value: number) => string
59+
domain?: [number, number]
60+
}
61+
/**
62+
* Per-entry tooltip value renderer, threaded to ChartTooltip. Skipped for
63+
* missing or non-numeric values, which render blank.
64+
*/
65+
tooltipValueFormatter?: (
66+
value: number,
67+
seriesKey: string,
68+
label: string,
69+
) => string
70+
/**
71+
* Hide the tooltip's total row — required when `tooltipValueFormatter`
72+
* renders a non-additive unit such as a percentage.
73+
*/
74+
tooltipHideTotal?: boolean
3875
}
3976

77+
type FadedSwatchLegendProps = {
78+
opacityMap: Record<string, number>
79+
seriesLabels?: Record<string, string>
80+
// Injected by recharts' <Legend content={...}>.
81+
payload?: { value?: string | number; color?: string }[]
82+
}
83+
84+
const FadedSwatchLegend: FC<FadedSwatchLegendProps> = ({
85+
opacityMap,
86+
payload,
87+
seriesLabels,
88+
}) => (
89+
<div className='d-flex justify-content-center flex-wrap gap-3'>
90+
{payload?.map((entry) => {
91+
const key = String(entry.value)
92+
return (
93+
<span className='d-flex align-items-center gap-1' key={key}>
94+
<span
95+
style={{
96+
backgroundColor: entry.color,
97+
display: 'inline-block',
98+
height: 10,
99+
opacity: opacityMap[key] ?? 1,
100+
width: 10,
101+
}}
102+
/>
103+
<span style={{ color: entry.color, fontSize: 12 }}>
104+
{seriesLabels?.[key] ?? key}
105+
</span>
106+
</span>
107+
)
108+
})}
109+
</div>
110+
)
111+
40112
const BarChart: FC<BarChartProps> = ({
41113
barSize,
42114
colorMap,
43115
data,
116+
grouped = false,
117+
height = 400,
118+
opacityMap,
44119
series,
45120
seriesLabels,
46121
showLegend = false,
122+
stackMap,
123+
tooltipHideTotal,
124+
tooltipValueFormatter,
47125
verticalGrid = true,
48126
xAxisInterval = 0,
127+
yAxis,
49128
}) => {
129+
const defaultStackId = grouped ? undefined : 'series'
50130
return (
51-
<ResponsiveContainer height={400} width='100%'>
131+
<ResponsiveContainer height={height} width='100%'>
52132
<RawBarChart data={data}>
53133
<CartesianGrid
54134
strokeDasharray='3 5'
@@ -69,28 +149,48 @@ const BarChart: FC<BarChartProps> = ({
69149
<YAxis
70150
tick={{ fill: colorTextSecondary, fontSize: 11 }}
71151
axisLine={{ stroke: colorTextSecondary }}
72-
tickFormatter={(value) =>
73-
value >= 1000 ? `${(value / 1000).toFixed(0)}k` : value
152+
domain={yAxis?.domain}
153+
tickFormatter={
154+
yAxis?.tickFormatter ??
155+
((value) =>
156+
value >= 1000 ? `${(value / 1000).toFixed(0)}k` : value)
74157
}
75158
/>
76159
<Tooltip
77160
cursor={{ fill: 'transparent' }}
78-
content={<ChartTooltip seriesLabels={seriesLabels} />}
161+
content={
162+
<ChartTooltip
163+
hideTotal={tooltipHideTotal}
164+
seriesLabels={seriesLabels}
165+
valueFormatter={tooltipValueFormatter}
166+
/>
167+
}
79168
/>
80169
{showLegend && (
81170
<Legend
82171
wrapperStyle={{ paddingTop: 16 }}
83172
formatter={(value) =>
84173
seriesLabels?.[String(value)] ?? String(value)
85174
}
175+
content={
176+
// The default legend swatch ignores fillOpacity, so faded
177+
// series need their own renderer to match the bars.
178+
opacityMap ? (
179+
<FadedSwatchLegend
180+
opacityMap={opacityMap}
181+
seriesLabels={seriesLabels}
182+
/>
183+
) : undefined
184+
}
86185
/>
87186
)}
88187
{series.map((label, index) => (
89188
<Bar
90189
key={label}
91190
dataKey={label}
92-
stackId='series'
191+
stackId={stackMap ? stackMap[label] : defaultStackId}
93192
fill={colorMap[label]}
193+
fillOpacity={opacityMap?.[label]}
94194
barSize={barSize}
95195
animationBegin={index * 80}
96196
animationDuration={600}

frontend/web/components/charts/ChartTooltip.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,16 @@ type ChartTooltipProps = TooltipProps<ValueType, NameType> & {
2525
/**
2626
* Hide the total row at the bottom. Useful for single-entry payloads
2727
* (e.g. a pie-slice hover) where the total just repeats the entry value.
28+
* Also required when `valueFormatter` renders a non-additive unit such as a
29+
* percentage, since the total row stays plain-number formatted.
2830
* Default: false.
2931
*/
3032
hideTotal?: boolean
33+
/**
34+
* Optional per-entry value renderer, e.g. to append units ("8.3%") or
35+
* counts ("120 of 1,450"). Falls back to localised number formatting.
36+
*/
37+
valueFormatter?: (value: number, seriesKey: string, label: string) => string
3138
}
3239

3340
const ChartTooltip: FC<ChartTooltipProps> = ({
@@ -36,6 +43,7 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
3643
label,
3744
payload,
3845
seriesLabels,
46+
valueFormatter,
3947
}) => {
4048
if (!active || !payload || payload.length === 0) return null
4149
const total = payload.reduce<number>(
@@ -66,7 +74,9 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
6674
<ColorSwatch color={entry.color ?? ''} size='sm' />
6775
<span className='text-default'>{displayName}:</span>
6876
<span className='fw-semibold text-default'>
69-
{formatNumber(entry.value)}
77+
{typeof entry.value === 'number' && valueFormatter
78+
? valueFormatter(entry.value, key, String(label ?? ''))
79+
: formatNumber(entry.value)}
7080
</span>
7181
</div>
7282
)
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { FC, useCallback, useMemo, useState } from 'react'
2+
import moment from 'moment'
3+
import { BarChart } from 'components/charts'
4+
import ContentCard from 'components/base/grid/ContentCard'
5+
import InlinePillToggle from 'components/base/forms/InlinePillToggle'
6+
import { BayesianResultsSummary, Experiment } from 'common/types/responses'
7+
import { getPrimaryMetric } from 'components/experiments/constants'
8+
import {
9+
getMetricResult,
10+
getVariantIdentities,
11+
} from 'components/experiments/results/derive'
12+
import {
13+
ConversionStackMode,
14+
REST_SUFFIX,
15+
buildConversionRateChartData,
16+
buildConversionStackChartData,
17+
} from 'components/experiments/results/deriveConversionRate'
18+
19+
type ExperimentConversionRateCardProps = {
20+
experiment: Experiment
21+
results?: BayesianResultsSummary
22+
asOf: string | null
23+
}
24+
25+
const ExperimentConversionRateCard: FC<ExperimentConversionRateCardProps> = ({
26+
asOf,
27+
experiment,
28+
results,
29+
}) => {
30+
const [mode, setMode] = useState<ConversionStackMode>('cumulative')
31+
const metric = getPrimaryMetric(experiment)
32+
const identities = useMemo(
33+
() => getVariantIdentities(experiment.feature),
34+
[experiment.feature],
35+
)
36+
const chart = useMemo(
37+
() =>
38+
metric && results
39+
? buildConversionStackChartData(
40+
results,
41+
metric.metric,
42+
identities,
43+
mode,
44+
)
45+
: null,
46+
[metric, results, identities, mode],
47+
)
48+
// Running counts and rates, for the cumulative tooltip ("x of y (z%)").
49+
const rateChart = useMemo(
50+
() =>
51+
metric && results
52+
? buildConversionRateChartData(results, metric.metric, identities)
53+
: null,
54+
[metric, results, identities],
55+
)
56+
57+
const formatTooltipValue = useCallback(
58+
(value: number, seriesKey: string, label: string) => {
59+
if (mode === 'daily' || seriesKey.endsWith(REST_SUFFIX)) {
60+
return value.toLocaleString()
61+
}
62+
const counts = rateChart?.countsByDay[label]?.[seriesKey]
63+
if (!counts) return value.toLocaleString()
64+
const rate = rateChart?.points.find((p) => p.day === label)?.[seriesKey]
65+
return `${counts.converted.toLocaleString()} of ${counts.exposed.toLocaleString()}${
66+
typeof rate === 'number' ? ` (${rate}%)` : ''
67+
}`
68+
},
69+
[mode, rateChart],
70+
)
71+
72+
// Hidden entirely when no rate can be charted: value metrics, and
73+
// payloads stored before the backend shipped the timeseries.
74+
if (!metric || !results || !chart) return null
75+
76+
const conversions = getMetricResult(
77+
results,
78+
metric.metric,
79+
)?.conversions_timeseries
80+
const hasConversions = !!conversions && conversions.points.length > 0
81+
82+
return (
83+
<ContentCard
84+
action={
85+
hasConversions ? (
86+
// Single metric today — disabled until multi-metric ships.
87+
<div style={{ minWidth: 180 }}>
88+
<Select
89+
isDisabled
90+
size='select-sm'
91+
value={{ label: metric.metric_name, value: metric.metric }}
92+
options={[{ label: metric.metric_name, value: metric.metric }]}
93+
/>
94+
</div>
95+
) : undefined
96+
}
97+
className='experiment-results__conversion-rate-card'
98+
title='Conversion rate over time'
99+
>
100+
{hasConversions ? (
101+
<>
102+
{/* mt-n2 halves the card's 16px child gap after the title. */}
103+
<div className='d-flex mt-n2'>
104+
<InlinePillToggle<ConversionStackMode>
105+
size='small'
106+
options={[
107+
{ label: 'Cumulative', value: 'cumulative' },
108+
{ label: 'Daily', value: 'daily' },
109+
]}
110+
value={mode}
111+
onChange={setMode}
112+
/>
113+
</div>
114+
<BarChart
115+
colorMap={chart.colorMap}
116+
data={chart.points}
117+
height={260}
118+
opacityMap={chart.opacityMap}
119+
series={chart.series}
120+
seriesLabels={chart.seriesLabels}
121+
showLegend
122+
stackMap={chart.stackMap}
123+
tooltipHideTotal
124+
tooltipValueFormatter={formatTooltipValue}
125+
/>
126+
<span className='text-muted fs-caption'>
127+
{asOf
128+
? `As of ${moment.utc(asOf).format('D MMM YYYY, HH:mm')} UTC`
129+
: ''}
130+
</span>
131+
</>
132+
) : (
133+
<div className='text-muted text-center py-5'>
134+
No conversions recorded yet.
135+
</div>
136+
)}
137+
</ContentCard>
138+
)
139+
}
140+
141+
export default ExperimentConversionRateCard
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './ExperimentConversionRateCard'

0 commit comments

Comments
 (0)