From d3a360c3a05e44909f918bd455f19bf1ed5485db Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 18 Jun 2026 10:34:17 +0200 Subject: [PATCH 01/11] =?UTF-8?q?fix(experiments):=20address=20PR=20review?= =?UTF-8?q?=20=E2=80=94=20drop=20dead=20code,=20fix=20HomePage=20revert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore HomePage optional chaining (accidental revert of #7788) - Remove unused VariantShareLegend component - Strip results.scss to PR1-only classes - Use getPrimaryMetric helper instead of experiment.metrics[0] - Remove unused _status param from deriveExposuresViewState - Remove dead getRefreshPollInterval export and its tests --- frontend/web/components/experiments/results/results.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/web/components/experiments/results/results.scss b/frontend/web/components/experiments/results/results.scss index 39dee4e3ee9b..387e46923462 100644 --- a/frontend/web/components/experiments/results/results.scss +++ b/frontend/web/components/experiments/results/results.scss @@ -2,5 +2,4 @@ &__exposures-card { background: var(--color-surface-default); } - } From bf07a5fb96ffec72bf8dbd2978a9cd04dd962295 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 18 Jun 2026 10:50:25 +0200 Subject: [PATCH 02/11] fix(experiments): revert unnecessary skip on experiment query --- frontend/web/components/pages/ExperimentDetailPage.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/web/components/pages/ExperimentDetailPage.tsx b/frontend/web/components/pages/ExperimentDetailPage.tsx index 2c153002b156..a9d92da5c710 100644 --- a/frontend/web/components/pages/ExperimentDetailPage.tsx +++ b/frontend/web/components/pages/ExperimentDetailPage.tsx @@ -17,7 +17,6 @@ const ExperimentDetailPage: FC = () => { useParams() const history = useHistory() const numericId = Number(experimentId) - const hasFeature = Utils.getFlagsmithHasFeature('experimental_flags') const { data: experiment, @@ -25,10 +24,10 @@ const ExperimentDetailPage: FC = () => { isLoading, } = useGetExperimentQuery( { environmentId, experimentId: numericId }, - { refetchOnMountOrArgChange: true, skip: !hasFeature }, + { refetchOnMountOrArgChange: true }, ) - if (!hasFeature) { + if (!Utils.getFlagsmithHasFeature('experimental_flags')) { history.replace( `/project/${projectId}/environment/${environmentId}/features`, ) From 56800187cfb177b07ab085ca26bc4fe6258b409d Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 18 Jun 2026 10:51:45 +0200 Subject: [PATCH 03/11] fix(experiments): skip experiment query when feature flag is off --- frontend/web/components/pages/ExperimentDetailPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/web/components/pages/ExperimentDetailPage.tsx b/frontend/web/components/pages/ExperimentDetailPage.tsx index a9d92da5c710..2c153002b156 100644 --- a/frontend/web/components/pages/ExperimentDetailPage.tsx +++ b/frontend/web/components/pages/ExperimentDetailPage.tsx @@ -17,6 +17,7 @@ const ExperimentDetailPage: FC = () => { useParams() const history = useHistory() const numericId = Number(experimentId) + const hasFeature = Utils.getFlagsmithHasFeature('experimental_flags') const { data: experiment, @@ -24,10 +25,10 @@ const ExperimentDetailPage: FC = () => { isLoading, } = useGetExperimentQuery( { environmentId, experimentId: numericId }, - { refetchOnMountOrArgChange: true }, + { refetchOnMountOrArgChange: true, skip: !hasFeature }, ) - if (!Utils.getFlagsmithHasFeature('experimental_flags')) { + if (!hasFeature) { history.replace( `/project/${projectId}/environment/${environmentId}/features`, ) From 436fdbd28eb770d9a492a71ea7b89ce0fe508933 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 18 Jun 2026 11:21:55 +0200 Subject: [PATCH 04/11] feat(experiments): add Bayesian analysis scorecards and results UI Summary scorecard with recommendation banner, metric scorecard with per-variant lift, credible intervals, win probability, and SRM warning. Shared axis chart for visual comparison across variants. --- .../results/CredibleIntervalBar.tsx | 41 ++ .../results/ExperimentMetricScorecard.tsx | 399 ++++++++++++++++++ .../results/ExperimentSummaryScorecard.tsx | 162 +++++++ .../experiments/results/StatCard.tsx | 19 + .../experiments/results/results.scss | 270 ++++++++++++ .../components/pages/ExperimentDetailPage.tsx | 26 +- 6 files changed, 916 insertions(+), 1 deletion(-) create mode 100644 frontend/web/components/experiments/results/CredibleIntervalBar.tsx create mode 100644 frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx create mode 100644 frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx create mode 100644 frontend/web/components/experiments/results/StatCard.tsx diff --git a/frontend/web/components/experiments/results/CredibleIntervalBar.tsx b/frontend/web/components/experiments/results/CredibleIntervalBar.tsx new file mode 100644 index 000000000000..8ad39b0ec22e --- /dev/null +++ b/frontend/web/components/experiments/results/CredibleIntervalBar.tsx @@ -0,0 +1,41 @@ +import { FC } from 'react' +import { Inference } from 'common/types/responses' +import './results.scss' + +type CredibleIntervalBarProps = { + inference: Inference + colour: string + domain: number +} + +const pct = (v: number, domain: number): number => + Math.max(0, Math.min(100, ((v + domain) / (2 * domain)) * 100)) + +const CredibleIntervalBar: FC = ({ + colour, + domain, + inference, +}) => { + const left = pct(inference.ci_low, domain) + const right = pct(inference.ci_high, domain) + const point = pct(inference.lift, domain) + return ( +
+
+
+
+
+ ) +} + +export default CredibleIntervalBar diff --git a/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx b/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx new file mode 100644 index 000000000000..fc3e37cc0818 --- /dev/null +++ b/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx @@ -0,0 +1,399 @@ +import { FC, ReactNode, useMemo } from 'react' +import ColorSwatch from 'components/ColorSwatch' +import Icon from 'components/icons/Icon' +import { + BayesianMetricResult, + BayesianResultsSummary, + ExpectedDirection, + Experiment, + Inference, + MetricAggregation, +} from 'common/types/responses' +import { getPrimaryMetric } from 'components/experiments/constants' +import { VariantIdentity, getVariantIdentities } from './derive' +import './results.scss' + +type ExperimentMetricScorecardProps = { + experiment: Experiment + results?: BayesianResultsSummary +} + +const renderMean = ( + mean: number | null, + aggregation: MetricAggregation, +): string => { + if (mean === null) return '—' + if (aggregation === 'occurrence') return `${(mean * 100).toFixed(1)}%` + return mean.toFixed(2) +} + +const isLiftFavourable = ( + lift: number, + direction: ExpectedDirection, +): boolean => { + if (direction === 'increase' || direction === 'not_decrease') return lift > 0 + return lift < 0 +} + +const liftColour = (lift: number, direction: ExpectedDirection): string => + isLiftFavourable(lift, direction) + ? 'var(--color-text-success)' + : 'var(--color-text-danger)' + +type AxisRange = { min: number; max: number } + +const computeAxisRange = ( + identities: VariantIdentity[], + metricResult?: BayesianMetricResult, +): AxisRange => { + let min = -0.1 + let max = 0.1 + identities.forEach((v) => { + if (v.isControl) return + const inf = metricResult?.inference[v.key] + if (!inf) return + if (inf.ci_low < min) min = inf.ci_low + if (inf.ci_high > max) max = inf.ci_high + }) + const pad = (max - min) * 0.15 + return { max: max + pad, min: min - pad } +} + +const valueToPercent = (value: number, range: AxisRange): number => + ((value - range.min) / (range.max - range.min)) * 100 + +const buildTicks = (range: AxisRange): number[] => { + const span = range.max - range.min + let step = 0.05 + if (span > 0.6) step = 0.2 + else if (span > 0.3) step = 0.1 + + const ticks: number[] = [] + const start = Math.ceil(range.min / step) * step + for (let v = start; v <= range.max; v += step) { + ticks.push(Math.round(v * 1000) / 1000) + } + return ticks +} + +const TickLines: FC<{ ticks: number[]; range: AxisRange }> = ({ + range, + ticks, +}) => ( + <> + {ticks.map((t) => ( +
+ ))} + +) + +const SharedAxisChart: FC<{ + identities: VariantIdentity[] + metricName: string + metricResult?: BayesianMetricResult + direction: ExpectedDirection + range: AxisRange +}> = ({ direction, identities, metricName, metricResult, range }) => { + const ticks = useMemo(() => buildTicks(range), [range]) + + return ( +
+
+ + Primary + + {metricName} +
+
+
+ {ticks.map((t) => ( + + {t === 0 ? '0%' : `${(t * 100).toFixed(0)}%`} + + ))} +
+
+
+ +
+ {identities.map((v) => { + const inf = metricResult?.inference[v.key] ?? null + if (v.isControl) { + return ( +
+
+ + + {v.name} + +
+
+ ) + } + if (!inf) return null + const colour = liftColour(inf.lift, direction) + const ciLeft = valueToPercent(inf.ci_low, range) + const ciRight = valueToPercent(inf.ci_high, range) + const dotPos = valueToPercent(inf.lift, range) + return ( +
+
+ + + {v.name} + +
+
+
+
+ ) + })} +
+
+
+ ) +} + +const LIFT_RANGE = 0.3 +const liftToPercent = (value: number): number => + Math.max(0, Math.min(100, ((value / LIFT_RANGE + 1) / 2) * 100)) + +const renderLift = ( + identity: VariantIdentity, + inference: Inference | null, + direction: ExpectedDirection, +): ReactNode => { + if (identity.isControl) { + return Baseline + } + if (!inference) { + return Collecting data… + } + const liftPct = inference.lift * 100 + const colour = liftColour(inference.lift, direction) + const left = liftToPercent(inference.ci_low) + const right = liftToPercent(inference.ci_high) + const dotPos = liftToPercent(inference.lift) + + return ( +
+
+
+
+
+
+ + {liftPct >= 0 ? '+' : ''} + {liftPct.toFixed(1)}% + +
+ ) +} + +const renderCI = ( + identity: VariantIdentity, + inference: Inference | null, +): ReactNode => { + if (identity.isControl) { + return Baseline + } + if (!inference) return '—' + return ( + + [{(inference.ci_low * 100).toFixed(1)}%,{' '} + {(inference.ci_high * 100).toFixed(1)}%] + + ) +} + +const renderWinProbability = ( + identity: VariantIdentity, + inference: Inference | null, + isHighest: boolean, +): ReactNode => { + if (identity.isControl || !inference) return '—' + const pct = Math.round(inference.chance_to_win * 100) + const colour = isHighest + ? 'var(--color-text-success)' + : 'var(--color-text-secondary)' + return ( +
+
+
+
+ {pct}% +
+ ) +} + +const ExperimentMetricScorecard: FC = ({ + experiment, + results, +}) => { + const metric = getPrimaryMetric(experiment) + const identities = getVariantIdentities(experiment.feature) + const metricResult = metric + ? results?.metrics.find((m) => m.metric_id === metric.metric) + : undefined + const srmBroken = + !!results && results.srm_p_value !== null && results.srm_p_value < 0.001 + + const highestCtw = identities.reduce<{ + key: string | null + value: number + }>( + (best, v) => { + if (v.isControl) return best + const ctw = metricResult?.inference[v.key]?.chance_to_win ?? 0 + return ctw > best.value ? { key: v.key, value: ctw } : best + }, + { key: null, value: 0 }, + ) + + const axisRange = useMemo( + () => computeAxisRange(identities, metricResult), + [identities, metricResult], + ) + + if (!metric) return null + + return ( + <> + {metricResult && ( + + )} + +
+ {srmBroken && ( +
+ Sample ratio mismatch detected — the variation split looks broken; + interpret results with caution. +
+ )} + + + + + + + + + + + + + + {identities.map((v) => { + const stats = metricResult?.variants[v.key] + const inference = metricResult?.inference[v.key] ?? null + const mean = stats && stats.n > 0 ? stats.sum / stats.n : null + return ( + + + + + + + + + ) + })} + +
VariantExposures + {metric.aggregation === 'occurrence' + ? 'Occurrence Rate' + : 'Mean'} + + + Delta + + + } + > + How much better or worse a variant performed compared to + control, as a percentage of the control's value. + + + + Credible Interval (95%) + + + } + > + The range we are 95% confident the true lift falls within. If + it doesn't cross zero, the result is statistically + significant. + + Win Probability
+ + + {v.name} + + {stats ? stats.n.toLocaleString() : '—'}{renderMean(mean, metric.aggregation)}{renderLift(v, inference, metric.expected_direction)}{renderCI(v, inference)} + {renderWinProbability( + v, + inference, + v.key === highestCtw.key, + )} +
+
+ + ) +} + +export default ExperimentMetricScorecard diff --git a/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx b/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx new file mode 100644 index 000000000000..f709a5b97b3a --- /dev/null +++ b/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx @@ -0,0 +1,162 @@ +import { FC, useMemo } from 'react' +import Icon from 'components/icons/Icon' +import InfoMessage from 'components/InfoMessage' +import { + BayesianResultsSummary, + Experiment, + Inference, +} from 'common/types/responses' +import { getPrimaryMetric } from 'components/experiments/constants' +import { getVariantIdentities } from './derive' +import StatCard from './StatCard' + +type ExperimentSummaryScorecardProps = { + usersEnrolled: number | null + experiment?: Experiment + results?: BayesianResultsSummary +} + +type SummaryStats = { + winnerName: string + chanceToBest: string + liftVsControl: string + liftFavourable: boolean +} | null + +const deriveSummary = ( + experiment: Experiment, + results: BayesianResultsSummary, +): SummaryStats => { + const metric = getPrimaryMetric(experiment) + if (!metric) return null + const metricResult = results.metrics.find( + (m) => m.metric_id === metric.metric, + ) + if (!metricResult) return null + + const identities = getVariantIdentities(experiment.feature) + let best: { name: string; ctw: number; inference: Inference } | null = null + + identities.forEach((v) => { + if (v.isControl) return + const inf = metricResult.inference[v.key] + if (!inf) return + if (!best || inf.chance_to_win > best.ctw) { + best = { ctw: inf.chance_to_win, inference: inf, name: v.name } + } + }) + + if (!best) return null + const winner = best as { name: string; ctw: number; inference: Inference } + const dir = metric.expected_direction + const favourable = + dir === 'increase' || dir === 'not_decrease' + ? winner.inference.lift > 0 + : winner.inference.lift < 0 + return { + chanceToBest: `${Math.round(winner.ctw * 100)}%`, + liftFavourable: favourable, + liftVsControl: `${winner.inference.lift >= 0 ? '+' : ''}${( + winner.inference.lift * 100 + ).toFixed(1)}%`, + winnerName: winner.name, + } +} + +const ExperimentSummaryScorecard: FC = ({ + experiment, + results, + usersEnrolled, +}) => { + const summary = useMemo( + () => (experiment && results ? deriveSummary(experiment, results) : null), + [experiment, results], + ) + const hasResults = !!results + + return ( + <> + {summary ? ( +
+
+ + + Recommendation + +
+
+ {summary.winnerName} is outperforming Control with{' '} + {summary.chanceToBest} probability of being the best variant. +
+
+ ) : ( + hasResults && + !summary && ( + + The experiment is still gathering data. Results will appear once + there is enough traffic for statistically meaningful analysis. + + ) + )} +
+
+ +
+
+ + {summary.winnerName} + + ) : undefined + } + /> +
+
+ +
+
+ + {summary.liftVsControl} + + ) : undefined + } + /> +
+
+ + ) +} + +export default ExperimentSummaryScorecard diff --git a/frontend/web/components/experiments/results/StatCard.tsx b/frontend/web/components/experiments/results/StatCard.tsx new file mode 100644 index 000000000000..42d3a6baf32a --- /dev/null +++ b/frontend/web/components/experiments/results/StatCard.tsx @@ -0,0 +1,19 @@ +import { FC, ReactNode } from 'react' +import ContentCard from 'components/base/grid/ContentCard' + +type StatCardProps = { + label: string + value?: ReactNode + loading?: boolean +} + +const StatCard: FC = ({ label, loading, value }) => ( + +
{label}
+
+ {loading ? : value ?? '—'} +
+
+) + +export default StatCard diff --git a/frontend/web/components/experiments/results/results.scss b/frontend/web/components/experiments/results/results.scss index 387e46923462..0698e9477fec 100644 --- a/frontend/web/components/experiments/results/results.scss +++ b/frontend/web/components/experiments/results/results.scss @@ -1,5 +1,275 @@ .experiment-results { + &__legend-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: var(--font-caption-size); + margin-bottom: 6px; + } + + &__legend-name { + display: flex; + align-items: center; + gap: 8px; + color: var(--color-text-primary); + } + + &__bar { + height: 8px; + border-radius: var(--radius-sm); + background: var(--color-surface-emphasis); + overflow: hidden; + margin-bottom: 12px; + } + + &__bar-fill { + height: 100%; + border-radius: var(--radius-sm); + } + + &__lift-bar { + display: flex; + align-items: center; + gap: 8px; + } + + &__lift-track { + position: relative; + flex: 1; + height: 12px; + background: var(--color-surface-muted); + border-radius: var(--radius-full); + overflow: hidden; + } + + &__lift-zero { + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 1px; + background: var(--color-border-default); + } + + &__lift-fill { + position: absolute; + top: 2px; + bottom: 2px; + border-radius: var(--radius-full); + } + + &__lift-dot { + position: absolute; + top: 50%; + width: 10px; + height: 10px; + border-radius: 50%; + transform: translate(-50%, -50%); + border: 2px solid var(--color-surface-default); + } + + &__lift-value { + flex-shrink: 0; + font-size: var(--font-caption-size); + font-weight: var(--font-weight-regular); + white-space: nowrap; + } + + &__axis-card { + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + background: var(--color-surface-default); + overflow: hidden; + margin-bottom: 16px; + } + + &__axis-metric-header { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 20px 0; + font-size: var(--font-body-sm-size); + } + + &__axis-chart { + padding: 16px 20px 12px; + } + + &__axis-header { + position: relative; + height: 20px; + margin-bottom: 4px; + } + + &__axis-tick-label { + position: absolute; + transform: translateX(-50%); + font-size: 10px; + font-weight: var(--font-weight-bold); + color: var(--color-text-secondary); + white-space: nowrap; + bottom: 0; + } + + &__axis-grid { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + pointer-events: none; + } + + &__axis-tick-line { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--color-border-default); + opacity: 0.4; + + &--zero { + width: 3px; + opacity: 0.8; + } + } + + &__axis-row { + position: relative; + padding: 4px 0; + } + + &__axis-row-label { + position: absolute; + top: 50%; + transform: translate(-100%, -50%); + z-index: 1; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--color-text-default); + white-space: nowrap; + padding-right: 6px; + } + + &__axis-tracks { + position: relative; + padding: 4px 0; + background: var(--color-surface-muted); + border-radius: var(--radius-sm); + } + + &__axis-track { + position: relative; + height: 20px; + } + + &__axis-bar { + position: absolute; + top: 4px; + bottom: 4px; + border-radius: var(--radius-full); + opacity: 0.35; + } + + &__axis-dot { + position: absolute; + top: 50%; + width: 10px; + height: 10px; + border-radius: 50%; + transform: translate(-50%, -50%); + border: 2px solid var(--color-surface-default); + } + + &__win-prob { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--font-body-sm-size); + white-space: nowrap; + } + + &__win-prob-track { + width: 100px; + height: 6px; + background: var(--color-surface-muted); + border-radius: var(--radius-full); + overflow: hidden; + flex-shrink: 0; + } + + &__win-prob-fill { + height: 100%; + border-radius: var(--radius-full); + } + + &__scorecard { + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); + background: var(--color-surface-default); + overflow: hidden; + } + + &__scorecard-table { + width: 100%; + border-collapse: collapse; + + th { + padding: 16px 20px; + font-size: 11px; + font-weight: var(--font-weight-medium); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); + background: var(--color-surface-muted); + text-align: left; + border-top: 1px solid var(--color-border-default); + border-bottom: 1px solid var(--color-border-default); + + .react-tooltip.react-tooltip { + font-size: var(--font-body-sm-size); + font-weight: var(--font-weight-regular); + text-transform: none; + letter-spacing: normal; + } + + svg { + cursor: pointer; + } + } + + td { + padding: 22px 20px; + font-size: var(--font-body-sm-size); + color: var(--color-text-default); + border-bottom: 1px solid var(--color-border-default); + vertical-align: middle; + } + + tbody tr { + transition: background var(--duration-fast) var(--easing-standard); + + &:hover { + background: var(--color-surface-subtle); + } + } + + tr:last-child td { + border-bottom: none; + } + } + &__exposures-card { background: var(--color-surface-default); } + + &__refresh { + display: flex; + align-items: center; + gap: 10px; + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } } diff --git a/frontend/web/components/pages/ExperimentDetailPage.tsx b/frontend/web/components/pages/ExperimentDetailPage.tsx index 2c153002b156..eae941daa1ae 100644 --- a/frontend/web/components/pages/ExperimentDetailPage.tsx +++ b/frontend/web/components/pages/ExperimentDetailPage.tsx @@ -1,9 +1,15 @@ import { FC } from 'react' import { useHistory, useParams } from 'react-router-dom' import Utils from 'common/utils/utils' -import { useGetExperimentQuery } from 'common/services/useExperiment' +import { + useGetExperimentExposuresQuery, + useGetExperimentQuery, +} from 'common/services/useExperiment' +import { getHeadlineTotal } from 'components/experiments/results/derive' import ExperimentDetailHeader from 'components/experiments/results/ExperimentDetailHeader' import ExperimentConfiguration from 'components/experiments/results/ExperimentConfiguration' +import ExperimentSummaryScorecard from 'components/experiments/results/ExperimentSummaryScorecard' +import ExperimentMetricScorecard from 'components/experiments/results/ExperimentMetricScorecard' import ExperimentExposuresPanel from 'components/experiments/results/ExperimentExposuresPanel' type ExperimentDetailParams = { @@ -28,6 +34,11 @@ const ExperimentDetailPage: FC = () => { { refetchOnMountOrArgChange: true, skip: !hasFeature }, ) + const { data: exposures } = useGetExperimentExposuresQuery( + { environmentId, experimentId: numericId }, + { skip: !hasFeature }, + ) + if (!hasFeature) { history.replace( `/project/${projectId}/environment/${environmentId}/features`, @@ -55,6 +66,10 @@ const ExperimentDetailPage: FC = () => { ) } + const usersEnrolled = exposures?.payload + ? getHeadlineTotal(exposures.payload) + : null + return (
{ {experiment.status !== 'created' && ( <> +
Results
+ + +
Analysis
+ +
Exposures
Date: Thu, 18 Jun 2026 13:45:13 +0200 Subject: [PATCH 05/11] =?UTF-8?q?fix(experiments):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20extract=20shared=20helpers,=20drop=20dead=20code,?= =?UTF-8?q?=20use=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../results/CredibleIntervalBar.tsx | 41 ---------- .../results/ExperimentMetricScorecard.tsx | 67 ++++++++-------- .../results/ExperimentSummaryScorecard.tsx | 76 ++++++------------- .../components/experiments/results/derive.ts | 51 +++++++++++++ .../experiments/results/results.scss | 6 +- 5 files changed, 113 insertions(+), 128 deletions(-) delete mode 100644 frontend/web/components/experiments/results/CredibleIntervalBar.tsx diff --git a/frontend/web/components/experiments/results/CredibleIntervalBar.tsx b/frontend/web/components/experiments/results/CredibleIntervalBar.tsx deleted file mode 100644 index 8ad39b0ec22e..000000000000 --- a/frontend/web/components/experiments/results/CredibleIntervalBar.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { FC } from 'react' -import { Inference } from 'common/types/responses' -import './results.scss' - -type CredibleIntervalBarProps = { - inference: Inference - colour: string - domain: number -} - -const pct = (v: number, domain: number): number => - Math.max(0, Math.min(100, ((v + domain) / (2 * domain)) * 100)) - -const CredibleIntervalBar: FC = ({ - colour, - domain, - inference, -}) => { - const left = pct(inference.ci_low, domain) - const right = pct(inference.ci_high, domain) - const point = pct(inference.lift, domain) - return ( -
-
-
-
-
- ) -} - -export default CredibleIntervalBar diff --git a/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx b/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx index fc3e37cc0818..a24d5318a8ee 100644 --- a/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx +++ b/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx @@ -9,8 +9,16 @@ import { Inference, MetricAggregation, } from 'common/types/responses' +import { colorIconSecondary } from 'common/theme/tokens' import { getPrimaryMetric } from 'components/experiments/constants' -import { VariantIdentity, getVariantIdentities } from './derive' +import { + VariantIdentity, + formatLiftPct, + getMetricResult, + getVariantIdentities, + getWinningVariant, + isLiftFavourable, +} from './derive' import './results.scss' type ExperimentMetricScorecardProps = { @@ -27,14 +35,6 @@ const renderMean = ( return mean.toFixed(2) } -const isLiftFavourable = ( - lift: number, - direction: ExpectedDirection, -): boolean => { - if (direction === 'increase' || direction === 'not_decrease') return lift > 0 - return lift < 0 -} - const liftColour = (lift: number, direction: ExpectedDirection): string => isLiftFavourable(lift, direction) ? 'var(--color-text-success)' @@ -183,6 +183,7 @@ const SharedAxisChart: FC<{ ) } +// Fixed ±30% scale for the inline table bar; SharedAxisChart uses a dynamic range. const LIFT_RANGE = 0.3 const liftToPercent = (value: number): number => Math.max(0, Math.min(100, ((value / LIFT_RANGE + 1) / 2) * 100)) @@ -198,7 +199,6 @@ const renderLift = ( if (!inference) { return Collecting data… } - const liftPct = inference.lift * 100 const colour = liftColour(inference.lift, direction) const left = liftToPercent(inference.ci_low) const right = liftToPercent(inference.ci_high) @@ -225,8 +225,7 @@ const renderLift = ( className='experiment-results__lift-value' style={{ color: colour }} > - {liftPct >= 0 ? '+' : ''} - {liftPct.toFixed(1)}% + {formatLiftPct(inference.lift)}
) @@ -276,23 +275,21 @@ const ExperimentMetricScorecard: FC = ({ results, }) => { const metric = getPrimaryMetric(experiment) - const identities = getVariantIdentities(experiment.feature) - const metricResult = metric - ? results?.metrics.find((m) => m.metric_id === metric.metric) - : undefined + const identities = useMemo( + () => getVariantIdentities(experiment.feature), + [experiment.feature], + ) + const metricResult = useMemo( + () => + metric && results ? getMetricResult(results, metric.metric) : undefined, + [metric, results], + ) const srmBroken = !!results && results.srm_p_value !== null && results.srm_p_value < 0.001 - const highestCtw = identities.reduce<{ - key: string | null - value: number - }>( - (best, v) => { - if (v.isControl) return best - const ctw = metricResult?.inference[v.key]?.chance_to_win ?? 0 - return ctw > best.value ? { key: v.key, value: ctw } : best - }, - { key: null, value: 0 }, + const winner = useMemo( + () => (metricResult ? getWinningVariant(metricResult, identities) : null), + [metricResult, identities], ) const axisRange = useMemo( @@ -337,7 +334,11 @@ const ExperimentMetricScorecard: FC = ({ title={ Delta - + } > @@ -350,7 +351,11 @@ const ExperimentMetricScorecard: FC = ({ title={ Credible Interval (95%) - + } > @@ -380,11 +385,7 @@ const ExperimentMetricScorecard: FC = ({ {renderLift(v, inference, metric.expected_direction)} {renderCI(v, inference)} - {renderWinProbability( - v, - inference, - v.key === highestCtw.key, - )} + {renderWinProbability(v, inference, v.key === winner?.key)} ) diff --git a/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx b/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx index f709a5b97b3a..942fa78dc011 100644 --- a/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx +++ b/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx @@ -1,13 +1,15 @@ import { FC, useMemo } from 'react' import Icon from 'components/icons/Icon' import InfoMessage from 'components/InfoMessage' -import { - BayesianResultsSummary, - Experiment, - Inference, -} from 'common/types/responses' +import { BayesianResultsSummary, Experiment } from 'common/types/responses' import { getPrimaryMetric } from 'components/experiments/constants' -import { getVariantIdentities } from './derive' +import { + formatLiftPct, + getMetricResult, + getVariantIdentities, + getWinningVariant, + isLiftFavourable, +} from './derive' import StatCard from './StatCard' type ExperimentSummaryScorecardProps = { @@ -21,44 +23,28 @@ type SummaryStats = { chanceToBest: string liftVsControl: string liftFavourable: boolean -} | null +} const deriveSummary = ( experiment: Experiment, results: BayesianResultsSummary, -): SummaryStats => { +): SummaryStats | null => { const metric = getPrimaryMetric(experiment) if (!metric) return null - const metricResult = results.metrics.find( - (m) => m.metric_id === metric.metric, - ) + const metricResult = getMetricResult(results, metric.metric) if (!metricResult) return null const identities = getVariantIdentities(experiment.feature) - let best: { name: string; ctw: number; inference: Inference } | null = null - - identities.forEach((v) => { - if (v.isControl) return - const inf = metricResult.inference[v.key] - if (!inf) return - if (!best || inf.chance_to_win > best.ctw) { - best = { ctw: inf.chance_to_win, inference: inf, name: v.name } - } - }) + const winner = getWinningVariant(metricResult, identities) + if (!winner) return null - if (!best) return null - const winner = best as { name: string; ctw: number; inference: Inference } - const dir = metric.expected_direction - const favourable = - dir === 'increase' || dir === 'not_decrease' - ? winner.inference.lift > 0 - : winner.inference.lift < 0 return { - chanceToBest: `${Math.round(winner.ctw * 100)}%`, - liftFavourable: favourable, - liftVsControl: `${winner.inference.lift >= 0 ? '+' : ''}${( - winner.inference.lift * 100 - ).toFixed(1)}%`, + chanceToBest: `${Math.round(winner.chancToWin * 100)}%`, + liftFavourable: isLiftFavourable( + winner.inference.lift, + metric.expected_direction, + ), + liftVsControl: formatLiftPct(winner.inference.lift), winnerName: winner.name, } } @@ -84,14 +70,7 @@ const ExperimentSummaryScorecard: FC = ({ width={20} fill='var(--color-text-success)' /> - - Recommendation - + Recommendation
{summary.winnerName} is outperforming Control with{' '} @@ -99,8 +78,7 @@ const ExperimentSummaryScorecard: FC = ({
) : ( - hasResults && - !summary && ( + hasResults && ( The experiment is still gathering data. Results will appear once there is enough traffic for statistically meaningful analysis. @@ -121,9 +99,7 @@ const ExperimentSummaryScorecard: FC = ({ loading={!hasResults} value={ summary?.winnerName ? ( - - {summary.winnerName} - + {summary.winnerName} ) : undefined } /> @@ -142,11 +118,9 @@ const ExperimentSummaryScorecard: FC = ({ value={ summary?.liftVsControl ? ( {summary.liftVsControl} diff --git a/frontend/web/components/experiments/results/derive.ts b/frontend/web/components/experiments/results/derive.ts index 3e489042577b..15cd42e578d2 100644 --- a/frontend/web/components/experiments/results/derive.ts +++ b/frontend/web/components/experiments/results/derive.ts @@ -1,9 +1,13 @@ import moment from 'moment' import { ChartDataPoint, buildChartColorMap } from 'components/charts' import { + BayesianMetricResult, + BayesianResultsSummary, + ExpectedDirection, ExperimentFeature, ExposureGranularity, ExposuresSummary, + Inference, MultivariateOption, } from 'common/types/responses' @@ -106,6 +110,53 @@ export const getHeadlineTotal = (summary: ExposuresSummary): number => 0, ) +export const isLiftFavourable = ( + lift: number, + direction: ExpectedDirection, +): boolean => { + if (direction === 'increase' || direction === 'not_decrease') return lift > 0 + return lift < 0 +} + +export const formatLiftPct = (lift: number): string => { + const pct = lift * 100 + return `${pct >= 0 ? '+' : ''}${pct.toFixed(1)}%` +} + +export const getMetricResult = ( + results: BayesianResultsSummary, + metricId: number, +): BayesianMetricResult | undefined => + results.metrics.find((m) => m.metric_id === metricId) + +export type WinningVariant = { + key: string + name: string + chancToWin: number + inference: Inference +} + +export const getWinningVariant = ( + metricResult: BayesianMetricResult, + identities: VariantIdentity[], +): WinningVariant | null => { + let best: WinningVariant | null = null + identities.forEach((v) => { + if (v.isControl) return + const inf = metricResult.inference[v.key] + if (!inf) return + if (!best || inf.chance_to_win > best.chancToWin) { + best = { + chancToWin: inf.chance_to_win, + inference: inf, + key: v.key, + name: v.name, + } + } + }) + return best +} + export type VariantTotal = { key: string name: string diff --git a/frontend/web/components/experiments/results/results.scss b/frontend/web/components/experiments/results/results.scss index 0698e9477fec..734b1a654dd4 100644 --- a/frontend/web/components/experiments/results/results.scss +++ b/frontend/web/components/experiments/results/results.scss @@ -104,7 +104,7 @@ &__axis-tick-label { position: absolute; transform: translateX(-50%); - font-size: 10px; + font-size: var(--font-caption-size); font-weight: var(--font-weight-bold); color: var(--color-text-secondary); white-space: nowrap; @@ -147,7 +147,7 @@ display: inline-flex; align-items: center; gap: 4px; - font-size: 11px; + font-size: var(--font-caption-size); color: var(--color-text-default); white-space: nowrap; padding-right: 6px; @@ -218,7 +218,7 @@ th { padding: 16px 20px; - font-size: 11px; + font-size: var(--font-caption-size); font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.05em; From 913c98640d6bbb716cb12e22f4743bbf8b0eee98 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 19 Jun 2026 10:17:11 +0200 Subject: [PATCH 06/11] feat(experiments): wire Bayesian results endpoint and improve axis chart spacing Add RTK Query endpoints for GET/POST experiment results (Bayesian analysis), matching the backend envelope from #7796. Wire the detail page to pass results data to both scorecards. Add exposuresOverride prop to ExperimentExposuresPanel for dev/QA use. Bump axis chart row and track padding for better visual breathing room. --- frontend/common/services/useExperiment.ts | 32 ++++++++++++++++- frontend/common/types/requests.ts | 5 +++ frontend/common/types/responses.ts | 10 +++++- .../results/ExperimentExposuresPanel.tsx | 8 +++-- .../experiments/results/results.scss | 4 +-- .../components/pages/ExperimentDetailPage.tsx | 36 ++++++++++++++++--- 6 files changed, 84 insertions(+), 11 deletions(-) diff --git a/frontend/common/services/useExperiment.ts b/frontend/common/services/useExperiment.ts index 934c58699810..5a6bbda8ac6d 100644 --- a/frontend/common/services/useExperiment.ts +++ b/frontend/common/services/useExperiment.ts @@ -5,7 +5,9 @@ import Utils from 'common/utils/utils' import transformCorePaging from 'common/transformCorePaging' export const experimentService = service - .enhanceEndpoints({ addTagTypes: ['Experiment', 'ExperimentExposures'] }) + .enhanceEndpoints({ + addTagTypes: ['Experiment', 'ExperimentExposures', 'ExperimentResults'], + }) .injectEndpoints({ endpoints: (builder) => ({ completeExperiment: builder.mutation< @@ -47,6 +49,20 @@ export const experimentService = service url: `environments/${environmentId}/experiments/${experimentId}/`, }), }), + getExperimentBayesianResults: builder.query< + Res['experimentBayesianResults'] | null, + Req['getExperimentBayesianResults'] + >({ + providesTags: (_res, _err, { experimentId }) => [ + { id: experimentId, type: 'ExperimentResults' }, + ], + query: ({ environmentId, experimentId }) => ({ + url: `environments/${environmentId}/experiments/${experimentId}/results/`, + }), + transformResponse: (res: { + results: Res['experimentBayesianResults'] | null + }) => res.results, + }), getExperimentExposures: builder.query< Res['experimentExposures'] | null, Req['getExperimentExposures'] @@ -83,6 +99,18 @@ export const experimentService = service url: `environments/${environmentId}/experiments/${experimentId}/pause/`, }), }), + refreshExperimentBayesianResults: builder.mutation< + void, + Req['refreshExperimentBayesianResults'] + >({ + invalidatesTags: (_res, _err, { experimentId }) => [ + { id: experimentId, type: 'ExperimentResults' }, + ], + query: ({ environmentId, experimentId }) => ({ + method: 'POST', + url: `environments/${environmentId}/experiments/${experimentId}/results/refresh/`, + }), + }), refreshExperimentExposures: builder.mutation< Res['experimentExposures'], Req['refreshExperimentExposures'] @@ -147,10 +175,12 @@ export const { useCompleteExperimentMutation, useCreateExperimentMutation, useDeleteExperimentMutation, + useGetExperimentBayesianResultsQuery, useGetExperimentExposuresQuery, useGetExperimentQuery, useGetExperimentsQuery, usePauseExperimentMutation, + useRefreshExperimentBayesianResultsMutation, useRefreshExperimentExposuresMutation, useStartExperimentMutation, useUpdateExperimentMutation, diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 7a8802c56a47..8f2d3ccd8e35 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -1052,6 +1052,11 @@ export type Req = { getExperiment: { environmentId: string; experimentId: number } getExperimentExposures: { environmentId: string; experimentId: number } refreshExperimentExposures: { environmentId: string; experimentId: number } + getExperimentBayesianResults: { environmentId: string; experimentId: number } + refreshExperimentBayesianResults: { + environmentId: string + experimentId: number + } getMetrics: PagedRequest<{ environmentId: string q?: string diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 3331b7fc3b10..498c79129914 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -674,6 +674,14 @@ export type ExperimentExposures = { payload: ExposuresSummary | null } +export type ExperimentBayesianResults = { + as_of: string | null + last_error_at: string | null + refresh_requested_at: string | null + payload: BayesianResultsSummary | null + is_final: boolean +} + // --- Bayesian results (defined now, consumed when the endpoint ships) --- export type VariantStats = { n: number @@ -1480,7 +1488,7 @@ export type Res = { } experiment: Experiment experimentExposures: ExperimentExposures - experimentBayesianResults: BayesianResultsSummary + experimentBayesianResults: ExperimentBayesianResults metric: Metric metrics: PagedResponse multivariateOption: MultivariateOption diff --git a/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx b/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx index 88f10981178e..ee5d9b93ce9d 100644 --- a/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx +++ b/frontend/web/components/experiments/results/ExperimentExposuresPanel.tsx @@ -7,7 +7,7 @@ import { useGetExperimentExposuresQuery, useRefreshExperimentExposuresMutation, } from 'common/services/useExperiment' -import { Experiment } from 'common/types/responses' +import { Experiment, ExperimentExposures } from 'common/types/responses' import { buildExposuresChartData, getHeadlineTotal, @@ -53,6 +53,7 @@ const formatCountdown = (seconds: number): string => { type ExperimentExposuresPanelProps = { experiment: Experiment environmentId: string + exposuresOverride?: ExperimentExposures } const REFRESH_DISABLED_COPY: Record = { @@ -63,18 +64,21 @@ const REFRESH_DISABLED_COPY: Record = { const ExperimentExposuresPanel: FC = ({ environmentId, experiment, + exposuresOverride, }) => { const [pollInterval, setPollInterval] = useState(0) const [refreshRequested, setRefreshRequested] = useState(false) const [pollStartedAt, setPollStartedAt] = useState(null) const [retryAfter, setRetryAfter] = useState(null) - const { data: exposures } = useGetExperimentExposuresQuery( + const { data: fetched } = useGetExperimentExposuresQuery( { environmentId, experimentId: experiment.id }, { pollingInterval: pollInterval, refetchOnMountOrArgChange: true, + skip: !!exposuresOverride, }, ) + const exposures = exposuresOverride ?? fetched const [refresh, { isLoading: isSubmitting }] = useRefreshExperimentExposuresMutation() diff --git a/frontend/web/components/experiments/results/results.scss b/frontend/web/components/experiments/results/results.scss index 734b1a654dd4..6d8ba0773dca 100644 --- a/frontend/web/components/experiments/results/results.scss +++ b/frontend/web/components/experiments/results/results.scss @@ -136,7 +136,7 @@ &__axis-row { position: relative; - padding: 4px 0; + padding: 8px 0; } &__axis-row-label { @@ -155,7 +155,7 @@ &__axis-tracks { position: relative; - padding: 4px 0; + padding: 12px 0; background: var(--color-surface-muted); border-radius: var(--radius-sm); } diff --git a/frontend/web/components/pages/ExperimentDetailPage.tsx b/frontend/web/components/pages/ExperimentDetailPage.tsx index eae941daa1ae..3fb582cccac5 100644 --- a/frontend/web/components/pages/ExperimentDetailPage.tsx +++ b/frontend/web/components/pages/ExperimentDetailPage.tsx @@ -1,7 +1,8 @@ -import { FC } from 'react' +import { FC, useMemo } from 'react' import { useHistory, useParams } from 'react-router-dom' import Utils from 'common/utils/utils' import { + useGetExperimentBayesianResultsQuery, useGetExperimentExposuresQuery, useGetExperimentQuery, } from 'common/services/useExperiment' @@ -11,6 +12,11 @@ import ExperimentConfiguration from 'components/experiments/results/ExperimentCo import ExperimentSummaryScorecard from 'components/experiments/results/ExperimentSummaryScorecard' import ExperimentMetricScorecard from 'components/experiments/results/ExperimentMetricScorecard' import ExperimentExposuresPanel from 'components/experiments/results/ExperimentExposuresPanel' +import { + buildFakeExposures, + buildFakeResults, + isFakeDataEnabled, +} from 'components/experiments/results/fakeData' type ExperimentDetailParams = { projectId: string @@ -24,6 +30,7 @@ const ExperimentDetailPage: FC = () => { const history = useHistory() const numericId = Number(experimentId) const hasFeature = Utils.getFlagsmithHasFeature('experimental_flags') + const useFake = isFakeDataEnabled() const { data: experiment, @@ -36,9 +43,26 @@ const ExperimentDetailPage: FC = () => { const { data: exposures } = useGetExperimentExposuresQuery( { environmentId, experimentId: numericId }, - { skip: !hasFeature }, + { skip: !hasFeature || useFake }, + ) + + const { data: bayesianResults } = useGetExperimentBayesianResultsQuery( + { environmentId, experimentId: numericId }, + { skip: !hasFeature || useFake }, ) + const fakeExposures = useMemo( + () => (useFake && experiment ? buildFakeExposures(experiment) : undefined), + [useFake, experiment], + ) + const fakeResults = useMemo( + () => (useFake && experiment ? buildFakeResults(experiment) : undefined), + [useFake, experiment], + ) + + const effectiveExposures = fakeExposures ?? exposures + const results = fakeResults ?? bayesianResults?.payload ?? undefined + if (!hasFeature) { history.replace( `/project/${projectId}/environment/${environmentId}/features`, @@ -66,8 +90,8 @@ const ExperimentDetailPage: FC = () => { ) } - const usersEnrolled = exposures?.payload - ? getHeadlineTotal(exposures.payload) + const usersEnrolled = effectiveExposures?.payload + ? getHeadlineTotal(effectiveExposures.payload) : null return ( @@ -83,16 +107,18 @@ const ExperimentDetailPage: FC = () => {
Results
Analysis
- +
Exposures
)} From c99569612c692a8b695be97fd498f6b8694b7b56 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 19 Jun 2026 10:34:17 +0200 Subject: [PATCH 07/11] fix(experiments): remove uncommitted fakeData import from detail page --- .../components/pages/ExperimentDetailPage.tsx | 29 ++++--------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/frontend/web/components/pages/ExperimentDetailPage.tsx b/frontend/web/components/pages/ExperimentDetailPage.tsx index 3fb582cccac5..511f030cc440 100644 --- a/frontend/web/components/pages/ExperimentDetailPage.tsx +++ b/frontend/web/components/pages/ExperimentDetailPage.tsx @@ -1,4 +1,4 @@ -import { FC, useMemo } from 'react' +import { FC } from 'react' import { useHistory, useParams } from 'react-router-dom' import Utils from 'common/utils/utils' import { @@ -12,11 +12,6 @@ import ExperimentConfiguration from 'components/experiments/results/ExperimentCo import ExperimentSummaryScorecard from 'components/experiments/results/ExperimentSummaryScorecard' import ExperimentMetricScorecard from 'components/experiments/results/ExperimentMetricScorecard' import ExperimentExposuresPanel from 'components/experiments/results/ExperimentExposuresPanel' -import { - buildFakeExposures, - buildFakeResults, - isFakeDataEnabled, -} from 'components/experiments/results/fakeData' type ExperimentDetailParams = { projectId: string @@ -30,7 +25,6 @@ const ExperimentDetailPage: FC = () => { const history = useHistory() const numericId = Number(experimentId) const hasFeature = Utils.getFlagsmithHasFeature('experimental_flags') - const useFake = isFakeDataEnabled() const { data: experiment, @@ -43,25 +37,15 @@ const ExperimentDetailPage: FC = () => { const { data: exposures } = useGetExperimentExposuresQuery( { environmentId, experimentId: numericId }, - { skip: !hasFeature || useFake }, + { skip: !hasFeature }, ) const { data: bayesianResults } = useGetExperimentBayesianResultsQuery( { environmentId, experimentId: numericId }, - { skip: !hasFeature || useFake }, - ) - - const fakeExposures = useMemo( - () => (useFake && experiment ? buildFakeExposures(experiment) : undefined), - [useFake, experiment], - ) - const fakeResults = useMemo( - () => (useFake && experiment ? buildFakeResults(experiment) : undefined), - [useFake, experiment], + { skip: !hasFeature }, ) - const effectiveExposures = fakeExposures ?? exposures - const results = fakeResults ?? bayesianResults?.payload ?? undefined + const results = bayesianResults?.payload ?? undefined if (!hasFeature) { history.replace( @@ -90,8 +74,8 @@ const ExperimentDetailPage: FC = () => { ) } - const usersEnrolled = effectiveExposures?.payload - ? getHeadlineTotal(effectiveExposures.payload) + const usersEnrolled = exposures?.payload + ? getHeadlineTotal(exposures.payload) : null return ( @@ -118,7 +102,6 @@ const ExperimentDetailPage: FC = () => { )} From 57338f5f08571d23b32688e25b684ed4171dd018 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 19 Jun 2026 10:34:51 +0200 Subject: [PATCH 08/11] fix(experiments): scorecard header fixes and metric value rendering - Add white-space: nowrap to th cells so headers don't squash icons - Reduce header font to 11px - Add flex-shrink-0 on tooltip info icons - Handle all four aggregation types (count, sum, mean, occurrence) with proper header labels and value formatting --- .../results/ExperimentMetricScorecard.tsx | 29 ++++++++++++------- .../experiments/results/results.scss | 3 +- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx b/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx index a24d5318a8ee..4e583d3e0d18 100644 --- a/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx +++ b/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx @@ -26,11 +26,21 @@ type ExperimentMetricScorecardProps = { results?: BayesianResultsSummary } -const renderMean = ( - mean: number | null, +const AGGREGATION_HEADER: Record = { + count: 'Count', + mean: 'Mean', + occurrence: 'Occ. Rate', + sum: 'Sum', +} + +const renderMetricValue = ( + stats: { n: number; sum: number } | undefined, aggregation: MetricAggregation, ): string => { - if (mean === null) return '—' + if (!stats || stats.n === 0) return '—' + if (aggregation === 'count' || aggregation === 'sum') + return stats.sum.toLocaleString(undefined, { maximumFractionDigits: 2 }) + const mean = stats.sum / stats.n if (aggregation === 'occurrence') return `${(mean * 100).toFixed(1)}%` return mean.toFixed(2) } @@ -325,16 +335,15 @@ const ExperimentMetricScorecard: FC = ({ Variant Exposures - {metric.aggregation === 'occurrence' - ? 'Occurrence Rate' - : 'Mean'} + {AGGREGATION_HEADER[metric.aggregation]} + Delta = ({ + Credible Interval (95%) = ({ {identities.map((v) => { const stats = metricResult?.variants[v.key] const inference = metricResult?.inference[v.key] ?? null - const mean = stats && stats.n > 0 ? stats.sum / stats.n : null return ( @@ -381,7 +390,7 @@ const ExperimentMetricScorecard: FC = ({ {stats ? stats.n.toLocaleString() : '—'} - {renderMean(mean, metric.aggregation)} + {renderMetricValue(stats, metric.aggregation)} {renderLift(v, inference, metric.expected_direction)} {renderCI(v, inference)} diff --git a/frontend/web/components/experiments/results/results.scss b/frontend/web/components/experiments/results/results.scss index 6d8ba0773dca..47f8bd3818de 100644 --- a/frontend/web/components/experiments/results/results.scss +++ b/frontend/web/components/experiments/results/results.scss @@ -218,13 +218,14 @@ th { padding: 16px 20px; - font-size: var(--font-caption-size); + font-size: 11px; font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-secondary); background: var(--color-surface-muted); text-align: left; + white-space: nowrap; border-top: 1px solid var(--color-border-default); border-bottom: 1px solid var(--color-border-default); From e7928cf41debb9e57bf2b2db1f39b26f91d2881f Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 19 Jun 2026 17:36:10 +0200 Subject: [PATCH 09/11] fix(experiments): mobile responsive fixes for results page Add vertical gutter to configuration and stat card rows when stacked on mobile. Make scorecard table horizontally scrollable on narrow viewports. --- .../experiments/results/ExperimentConfiguration.tsx | 2 +- .../experiments/results/ExperimentSummaryScorecard.tsx | 2 +- frontend/web/components/experiments/results/results.scss | 3 ++- frontend/web/components/pages/ExperimentDetailPage.tsx | 5 ++++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/web/components/experiments/results/ExperimentConfiguration.tsx b/frontend/web/components/experiments/results/ExperimentConfiguration.tsx index 8c015b0309a4..05dfc6ae4260 100644 --- a/frontend/web/components/experiments/results/ExperimentConfiguration.tsx +++ b/frontend/web/components/experiments/results/ExperimentConfiguration.tsx @@ -38,7 +38,7 @@ const ExperimentConfiguration: FC = ({ ?.default_percentage_allocation ?? 0 return ( -
+
diff --git a/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx b/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx index 942fa78dc011..4c9df8a9a2e9 100644 --- a/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx +++ b/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx @@ -85,7 +85,7 @@ const ExperimentSummaryScorecard: FC = ({ ) )} -
+
{ />
Analysis
- +
Exposures
Date: Fri, 19 Jun 2026 17:42:43 +0200 Subject: [PATCH 10/11] fix(experiments): fix chancToWin typo and add missing Tooltip import --- .../experiments/results/ExperimentMetricScorecard.tsx | 1 + .../experiments/results/ExperimentSummaryScorecard.tsx | 2 +- frontend/web/components/experiments/results/derive.ts | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx b/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx index 4e583d3e0d18..23317720dccc 100644 --- a/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx +++ b/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx @@ -1,6 +1,7 @@ import { FC, ReactNode, useMemo } from 'react' import ColorSwatch from 'components/ColorSwatch' import Icon from 'components/icons/Icon' +import Tooltip from 'components/Tooltip' import { BayesianMetricResult, BayesianResultsSummary, diff --git a/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx b/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx index 4c9df8a9a2e9..1f890bead821 100644 --- a/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx +++ b/frontend/web/components/experiments/results/ExperimentSummaryScorecard.tsx @@ -39,7 +39,7 @@ const deriveSummary = ( if (!winner) return null return { - chanceToBest: `${Math.round(winner.chancToWin * 100)}%`, + chanceToBest: `${Math.round(winner.chanceToWin * 100)}%`, liftFavourable: isLiftFavourable( winner.inference.lift, metric.expected_direction, diff --git a/frontend/web/components/experiments/results/derive.ts b/frontend/web/components/experiments/results/derive.ts index 15cd42e578d2..60de54d05ac0 100644 --- a/frontend/web/components/experiments/results/derive.ts +++ b/frontend/web/components/experiments/results/derive.ts @@ -132,7 +132,7 @@ export const getMetricResult = ( export type WinningVariant = { key: string name: string - chancToWin: number + chanceToWin: number inference: Inference } @@ -145,9 +145,9 @@ export const getWinningVariant = ( if (v.isControl) return const inf = metricResult.inference[v.key] if (!inf) return - if (!best || inf.chance_to_win > best.chancToWin) { + if (!best || inf.chance_to_win > best.chanceToWin) { best = { - chancToWin: inf.chance_to_win, + chanceToWin: inf.chance_to_win, inference: inf, key: v.key, name: v.name, From cdea1fdcedab222a190735976c5016e943f9d601 Mon Sep 17 00:00:00 2001 From: wadii Date: Mon, 22 Jun 2026 11:50:18 +0200 Subject: [PATCH 11/11] fix(experiments): scorecard header fixes and metric value rendering Dynamic tick stepping for wide CI ranges in axis chart. Dynamic lift range for inline delta bar. Add percentage label above dots on axis chart. Increase row spacing in axis chart. --- .../results/ExperimentMetricScorecard.tsx | 43 +++++++++++++++---- .../experiments/results/results.scss | 11 ++++- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx b/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx index 23317720dccc..b16a0631c07a 100644 --- a/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx +++ b/frontend/web/components/experiments/results/ExperimentMetricScorecard.tsx @@ -76,7 +76,9 @@ const valueToPercent = (value: number, range: AxisRange): number => const buildTicks = (range: AxisRange): number[] => { const span = range.max - range.min let step = 0.05 - if (span > 0.6) step = 0.2 + if (span > 5) step = 1 + else if (span > 2) step = 0.5 + else if (span > 0.6) step = 0.2 else if (span > 0.3) step = 0.1 const ticks: number[] = [] @@ -184,6 +186,12 @@ const SharedAxisChart: FC<{ className='experiment-results__axis-dot' style={{ background: colour, left: `${dotPos}%` }} /> + + {formatLiftPct(inf.lift)} +
) @@ -194,15 +202,14 @@ const SharedAxisChart: FC<{ ) } -// Fixed ±30% scale for the inline table bar; SharedAxisChart uses a dynamic range. -const LIFT_RANGE = 0.3 -const liftToPercent = (value: number): number => - Math.max(0, Math.min(100, ((value / LIFT_RANGE + 1) / 2) * 100)) +const liftToPercent = (value: number, liftRange: number): number => + Math.max(0, Math.min(100, ((value / liftRange + 1) / 2) * 100)) const renderLift = ( identity: VariantIdentity, inference: Inference | null, direction: ExpectedDirection, + liftRange: number, ): ReactNode => { if (identity.isControl) { return Baseline @@ -211,9 +218,9 @@ const renderLift = ( return Collecting data… } const colour = liftColour(inference.lift, direction) - const left = liftToPercent(inference.ci_low) - const right = liftToPercent(inference.ci_high) - const dotPos = liftToPercent(inference.lift) + const left = liftToPercent(inference.ci_low, liftRange) + const right = liftToPercent(inference.ci_high, liftRange) + const dotPos = liftToPercent(inference.lift, liftRange) return (
@@ -308,6 +315,17 @@ const ExperimentMetricScorecard: FC = ({ [identities, metricResult], ) + const liftRange = useMemo(() => { + let max = 0.3 + identities.forEach((v) => { + if (v.isControl) return + const inf = metricResult?.inference[v.key] + if (!inf) return + max = Math.max(max, Math.abs(inf.ci_low), Math.abs(inf.ci_high)) + }) + return max * 1.1 + }, [identities, metricResult]) + if (!metric) return null return ( @@ -392,7 +410,14 @@ const ExperimentMetricScorecard: FC = ({ {stats ? stats.n.toLocaleString() : '—'} {renderMetricValue(stats, metric.aggregation)} - {renderLift(v, inference, metric.expected_direction)} + + {renderLift( + v, + inference, + metric.expected_direction, + liftRange, + )} + {renderCI(v, inference)} {renderWinProbability(v, inference, v.key === winner?.key)} diff --git a/frontend/web/components/experiments/results/results.scss b/frontend/web/components/experiments/results/results.scss index 5aff23bf06ca..424963deff9a 100644 --- a/frontend/web/components/experiments/results/results.scss +++ b/frontend/web/components/experiments/results/results.scss @@ -136,7 +136,7 @@ &__axis-row { position: relative; - padding: 8px 0; + padding: 12px 0; } &__axis-row-label { @@ -267,6 +267,15 @@ background: var(--color-surface-default); } + &__axis-dot-label { + position: absolute; + top: -14px; + transform: translateX(-50%); + font-size: 10px; + font-weight: var(--font-weight-bold); + white-space: nowrap; + } + &__refresh { display: flex; align-items: center;