Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,16 @@ export type ExposuresSummary = {
timeseries: ExposuresTimeseries
}

export type ConversionsTimeseriesPoint = {
bucket: string
converted_identities: Record<string, number>
}

export type ConversionsTimeseries = {
granularity: ExposureGranularity
points: ConversionsTimeseriesPoint[]
}

export type ExperimentExposures = {
as_of: string | null
last_error_at: string | null
Expand Down Expand Up @@ -760,11 +770,19 @@ export type BayesianMetricResult = {
metric_id: number
variants: Record<string, VariantStats>
inference: Record<string, Inference | null>
// 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
Comment thread
Zaimwa9 marked this conversation as resolved.
}

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 {
Expand Down
110 changes: 105 additions & 5 deletions frontend/web/components/charts/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to have no caller for this one, and stackMap overrides it anyway. Same for yAxis on line 57. Do you mind checking it ?

/**
* 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<string, string>
/**
* dataKey → fill opacity (0–1). Colours are CSS `var()` strings, so
* transparency must come from SVG fill-opacity, not an alpha channel.
*/
opacityMap?: Record<string, number>
/** 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<string, number>
seriesLabels?: Record<string, string>
// Injected by recharts' <Legend content={...}>.
payload?: { value?: string | number; color?: string }[]
}

const FadedSwatchLegend: FC<FadedSwatchLegendProps> = ({
opacityMap,
payload,
seriesLabels,
}) => (
<div className='d-flex justify-content-center flex-wrap gap-3'>
{payload?.map((entry) => {
const key = String(entry.value)
return (
<span className='d-flex align-items-center gap-1' key={key}>
<span
style={{
backgroundColor: entry.color,
display: 'inline-block',
height: 10,
opacity: opacityMap[key] ?? 1,
width: 10,
}}
/>
<span style={{ color: entry.color, fontSize: 12 }}>
{seriesLabels?.[key] ?? key}
</span>
</span>
)
})}
</div>
)

const BarChart: FC<BarChartProps> = ({
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 (
<ResponsiveContainer height={400} width='100%'>
<ResponsiveContainer height={height} width='100%'>
<RawBarChart data={data}>
<CartesianGrid
strokeDasharray='3 5'
Expand All @@ -69,28 +149,48 @@ const BarChart: FC<BarChartProps> = ({
<YAxis
tick={{ fill: colorTextSecondary, fontSize: 11 }}
axisLine={{ stroke: colorTextSecondary }}
tickFormatter={(value) =>
value >= 1000 ? `${(value / 1000).toFixed(0)}k` : value
domain={yAxis?.domain}
tickFormatter={
yAxis?.tickFormatter ??
((value) =>
value >= 1000 ? `${(value / 1000).toFixed(0)}k` : value)
}
/>
<Tooltip
cursor={{ fill: 'transparent' }}
content={<ChartTooltip seriesLabels={seriesLabels} />}
content={
<ChartTooltip
hideTotal={tooltipHideTotal}
seriesLabels={seriesLabels}
valueFormatter={tooltipValueFormatter}
/>
}
/>
{showLegend && (
<Legend
wrapperStyle={{ paddingTop: 16 }}
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 ? (
<FadedSwatchLegend
opacityMap={opacityMap}
seriesLabels={seriesLabels}
/>
) : undefined
}
/>
)}
{series.map((label, index) => (
<Bar
key={label}
dataKey={label}
stackId='series'
stackId={stackMap ? stackMap[label] : defaultStackId}
fill={colorMap[label]}
fillOpacity={opacityMap?.[label]}
barSize={barSize}
animationBegin={index * 80}
animationDuration={600}
Expand Down
12 changes: 11 additions & 1 deletion frontend/web/components/charts/ChartTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,16 @@ type ChartTooltipProps = TooltipProps<ValueType, NameType> & {
/**
* 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<ChartTooltipProps> = ({
Expand All @@ -36,6 +43,7 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
label,
payload,
seriesLabels,
valueFormatter,
}) => {
if (!active || !payload || payload.length === 0) return null
const total = payload.reduce<number>(
Expand Down Expand Up @@ -66,7 +74,9 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
<ColorSwatch color={entry.color ?? ''} size='sm' />
<span className='text-default'>{displayName}:</span>
<span className='fw-semibold text-default'>
{formatNumber(entry.value)}
{typeof entry.value === 'number' && valueFormatter
? valueFormatter(entry.value, key, String(label ?? ''))
: formatNumber(entry.value)}
</span>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ExperimentConversionRateCardProps> = ({
asOf,
experiment,
results,
}) => {
const [mode, setMode] = useState<ConversionStackMode>('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 (
<ContentCard
action={
hasConversions ? (
// Single metric today — disabled until multi-metric ships.
<div style={{ minWidth: 180 }}>
<Select
isDisabled
size='select-sm'
value={{ label: metric.metric_name, value: metric.metric }}
options={[{ label: metric.metric_name, value: metric.metric }]}
/>
</div>
) : undefined
}
className='experiment-results__conversion-rate-card'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mind checking if this class is defined anywhere? I couldn't find a rule for it, and ContentCard has background='white' if that's the surface you were after.

title='Conversion rate over time'
>
{hasConversions ? (
<>
{/* mt-n2 halves the card's 16px child gap after the title. */}
<div className='d-flex mt-n2'>
<InlinePillToggle<ConversionStackMode>
size='small'
options={[
{ label: 'Cumulative', value: 'cumulative' },
{ label: 'Daily', value: 'daily' },
]}
value={mode}
onChange={setMode}
/>
</div>
<BarChart
colorMap={chart.colorMap}
data={chart.points}
height={260}
opacityMap={chart.opacityMap}
series={chart.series}
seriesLabels={chart.seriesLabels}
showLegend
stackMap={chart.stackMap}
tooltipHideTotal
tooltipValueFormatter={formatTooltipValue}
/>
<span className='text-muted fs-caption'>
{asOf
? `As of ${moment.utc(asOf).format('D MMM YYYY, HH:mm')} UTC`
: ''}
</span>
</>
) : (
<div className='text-muted text-center py-5'>
No conversions recorded yet.
</div>
)}
</ContentCard>
)
}

export default ExperimentConversionRateCard
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './ExperimentConversionRateCard'
Loading
Loading