diff --git a/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--dark.png b/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--dark.png
index 2449e1ce566bd..d89a4dc74d0c7 100644
Binary files a/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--dark.png and b/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--light.png b/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--light.png
index e757987b40dd5..6568e076f39bb 100644
Binary files a/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--light.png and b/frontend/__snapshots__/scenes-app-experiments--complete-funnel-experiment--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--dark.png b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--dark.png
index a6d3889b23a43..24cbcf3db543d 100644
Binary files a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--dark.png and b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--light.png b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--light.png
index 9487f3b16e926..0ba496622b9f9 100644
Binary files a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--light.png and b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--dark.png b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--dark.png
index daf12837bf697..c7c70e450fbeb 100644
Binary files a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--dark.png and b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--dark.png differ
diff --git a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--light.png b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--light.png
index aa8ea4959a5cc..5f0732ee1d4f2 100644
Binary files a/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--light.png and b/frontend/__snapshots__/scenes-app-experiments--running-trend-experiment-many-variants--light.png differ
diff --git a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png
index 248d1f34b318a..9f3435ec919df 100644
Binary files a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png and b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png differ
diff --git a/frontend/src/scenes/experiments/Experiment.scss b/frontend/src/scenes/experiments/Experiment.scss
deleted file mode 100644
index df10e7141aafe..0000000000000
--- a/frontend/src/scenes/experiments/Experiment.scss
+++ /dev/null
@@ -1,161 +0,0 @@
-.experiment-form {
- .metrics-selection {
- width: 100%;
- padding-top: 1rem;
- border-top: 1px solid var(--border);
- }
-
- .person-selection {
- align-items: center;
- justify-content: space-between;
- width: 100%;
- padding-top: 1rem;
- border-top: 1px solid var(--border);
- }
-
- .experiment-preview {
- margin-bottom: 1rem;
- border-bottom: 1px solid var(--border);
- }
-
- .variants {
- padding-bottom: 1rem;
- margin-top: 0.5rem;
-
- .border-top {
- border-top-left-radius: 4px;
- border-top-right-radius: 4px;
- }
-
- .border-bottom {
- border-bottom-right-radius: 4px;
- border-bottom-left-radius: 4px;
- }
-
- .feature-flag-variant {
- display: flex;
- align-items: center;
- padding: 0.5rem;
- background: var(--bg-light);
- border-color: var(--border);
- border-width: 1px;
- border-top-style: solid;
- border-right-style: solid;
- border-left-style: solid;
-
- .extend-variant-fully {
- flex: 1;
- }
- }
-
- .variant-label {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
- min-width: 52px;
- padding: 2px 6px;
- margin-right: 8px;
- font-size: 12px;
- font-weight: 500;
- color: #fff;
- letter-spacing: 0.01em;
- border-radius: var(--radius);
- }
- }
-
- .secondary-metrics {
- width: 100%;
- padding-top: 1rem;
- margin-top: 1rem;
- margin-bottom: 1rem;
- border-top: 1px solid var(--border);
- }
-}
-
-.view-experiment {
- .draft-header {
- margin-bottom: 1rem;
- border-bottom: 1px solid var(--border);
- }
-
- .exp-description {
- font-style: italic;
- }
-
- .participants {
- background-color: white;
- }
-
- .variants-list {
- li {
- display: inline;
- }
-
- li::after {
- content: ', ';
- }
-
- li:last-child::after {
- content: '';
- }
- }
-
- .experiment-result {
- padding-top: 1rem;
- }
-
- .secondary-progress {
- margin-top: 0.5rem;
-
- li::before {
- display: inline-block;
- margin-right: 4px;
- font-weight: 900;
- content: '\2022';
- }
- }
-
- .no-experiment-results {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- min-height: 320px;
- margin-top: 1rem;
- font-size: 24px;
- background-color: var(--bg-3000);
- border: 1px solid var(--border);
- }
-
- .computation-time-and-sampling-notice {
- margin-top: 8px;
- }
-}
-
-.experiment-preview-row {
- padding-bottom: 1rem;
- margin-bottom: 1rem;
- border-bottom: 1px solid var(--border);
-
- &:last-child {
- padding-bottom: 0;
- margin-bottom: 0;
- border-bottom: none;
- }
-}
-
-.metric-name {
- flex: 1;
- padding: 8px 8px 8px 16px;
- margin-left: 0.5rem;
- border: 1px solid var(--border);
- border-radius: var(--radius);
-}
-
-.exp-flag-copy-label {
- font-size: 11px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
diff --git a/frontend/src/scenes/experiments/Experiment.tsx b/frontend/src/scenes/experiments/Experiment.tsx
index 6127fa87795fc..cca319e6f486e 100644
--- a/frontend/src/scenes/experiments/Experiment.tsx
+++ b/frontend/src/scenes/experiments/Experiment.tsx
@@ -1,5 +1,3 @@
-import './Experiment.scss'
-
import { useValues } from 'kea'
import { NotFound } from 'lib/components/NotFound'
import { SceneExport } from 'scenes/sceneTypes'
diff --git a/frontend/src/scenes/experiments/ExperimentForm.tsx b/frontend/src/scenes/experiments/ExperimentForm.tsx
index 125fb2320ddab..9715e32406c2a 100644
--- a/frontend/src/scenes/experiments/ExperimentForm.tsx
+++ b/frontend/src/scenes/experiments/ExperimentForm.tsx
@@ -1,5 +1,3 @@
-import './Experiment.scss'
-
import { IconMagicWand, IconPlusSmall, IconTrash } from '@posthog/icons'
import { LemonDivider, LemonInput, LemonTextArea, Tooltip } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
diff --git a/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx b/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx
index b6a69aeababa3..2463e1dd791a5 100644
--- a/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/DataCollection.tsx
@@ -1,5 +1,3 @@
-import '../Experiment.scss'
-
import { IconInfo } from '@posthog/icons'
import { LemonButton, LemonDivider, LemonModal, Link, Tooltip } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
diff --git a/frontend/src/scenes/experiments/ExperimentView/DeltaViz.tsx b/frontend/src/scenes/experiments/ExperimentView/DeltaViz.tsx
deleted file mode 100644
index 77a7b9d0359b3..0000000000000
--- a/frontend/src/scenes/experiments/ExperimentView/DeltaViz.tsx
+++ /dev/null
@@ -1,449 +0,0 @@
-import { useValues } from 'kea'
-import { useEffect, useRef, useState } from 'react'
-
-import { InsightType } from '~/types'
-
-import { experimentLogic } from '../experimentLogic'
-import { VariantTag } from './components'
-
-const BAR_HEIGHT = 8
-const BAR_PADDING = 10
-const TICK_PANEL_HEIGHT = 20
-const VIEW_BOX_WIDTH = 800
-const HORIZONTAL_PADDING = 20
-const CONVERSION_RATE_RECT_WIDTH = 2
-const TICK_FONT_SIZE = 7
-
-const COLORS = {
- BOUNDARY_LINES: '#d0d0d0',
- ZERO_LINE: '#666666',
- BAR_NEGATIVE: '#F44435',
- BAR_BEST: '#4DAF4F',
- BAR_DEFAULT: '#d9d9d9',
- BAR_CONTROL: 'rgba(217, 217, 217, 0.4)',
- BAR_MIDDLE_POINT: 'black',
- BAR_MIDDLE_POINT_CONTROL: 'rgba(0, 0, 0, 0.4)',
-}
-
-// Helper function to find nice round numbers for ticks
-export function getNiceTickValues(maxAbsValue: number): number[] {
- // Round up maxAbsValue to ensure we cover all values
- maxAbsValue = Math.ceil(maxAbsValue * 10) / 10
-
- const magnitude = Math.floor(Math.log10(maxAbsValue))
- const power = Math.pow(10, magnitude)
-
- let baseUnit
- const normalizedMax = maxAbsValue / power
- if (normalizedMax <= 1) {
- baseUnit = 0.2 * power
- } else if (normalizedMax <= 2) {
- baseUnit = 0.5 * power
- } else if (normalizedMax <= 5) {
- baseUnit = 1 * power
- } else {
- baseUnit = 2 * power
- }
-
- // Calculate how many baseUnits we need to exceed maxAbsValue
- const unitsNeeded = Math.ceil(maxAbsValue / baseUnit)
-
- // Determine appropriate number of decimal places based on magnitude
- const decimalPlaces = Math.max(0, -magnitude + 1)
-
- const ticks: number[] = []
- for (let i = -unitsNeeded; i <= unitsNeeded; i++) {
- // Round each tick value to avoid floating point precision issues
- const tickValue = Number((baseUnit * i).toFixed(decimalPlaces))
- ticks.push(tickValue)
- }
- return ticks
-}
-
-function formatTickValue(value: number): string {
- if (value === 0) {
- return '0%'
- }
-
- // Determine number of decimal places needed
- const absValue = Math.abs(value)
- let decimals = 0
-
- if (absValue < 0.01) {
- decimals = 3
- } else if (absValue < 0.1) {
- decimals = 2
- } else if (absValue < 1) {
- decimals = 1
- } else {
- decimals = 0
- }
-
- return `${(value * 100).toFixed(decimals)}%`
-}
-
-export function DeltaViz(): JSX.Element {
- const { experiment, experimentResults, getMetricType, metricResults } = useValues(experimentLogic)
-
- if (!experimentResults) {
- return <>>
- }
-
- const variants = experiment.parameters.feature_flag_variants
- const allResults = [...(metricResults || [])]
-
- return (
-
-
- {allResults.map((results, metricIndex) => {
- if (!results) {
- return null
- }
-
- const isFirstMetric = metricIndex === 0
-
- return (
-
-
-
- )
- })}
-
-
- )
-}
-
-function Chart({
- results,
- variants,
- metricType,
- isFirstMetric,
-}: {
- results: any
- variants: any[]
- metricType: InsightType
- isFirstMetric: boolean
-}): JSX.Element {
- const { credibleIntervalForVariant, conversionRateForVariant, experimentId } = useValues(experimentLogic)
- const [tooltipData, setTooltipData] = useState<{ x: number; y: number; variant: string } | null>(null)
-
- // Update chart height calculation to include only one BAR_PADDING for each space between bars
- const chartHeight = BAR_PADDING + (BAR_HEIGHT + BAR_PADDING) * variants.length
-
- // Find the maximum absolute value from all credible intervals
- const maxAbsValue = Math.max(
- ...variants.flatMap((variant) => {
- const interval = credibleIntervalForVariant(results, variant.key, metricType)
- return interval ? [Math.abs(interval[0] / 100), Math.abs(interval[1] / 100)] : []
- })
- )
-
- // Add padding to the range
- const padding = Math.max(maxAbsValue * 0.05, 0.02)
- const chartBound = maxAbsValue + padding
-
- const tickValues = getNiceTickValues(chartBound)
- const maxTick = Math.max(...tickValues)
-
- const valueToX = (value: number): number => {
- // Scale the value to fit within the padded area
- const percentage = (value / maxTick + 1) / 2
- return HORIZONTAL_PADDING + percentage * (VIEW_BOX_WIDTH - 2 * HORIZONTAL_PADDING)
- }
-
- const infoPanelWidth = '10%'
-
- const ticksSvgRef = useRef(null)
- const chartSvgRef = useRef(null)
- // :TRICKY: We need to track SVG heights dynamically because
- // we're fitting regular divs to match SVG viewports. SVGs scale
- // based on their viewBox and the viewport size, making it challenging
- // to match their effective rendered heights with regular div elements.
- const [ticksSvgHeight, setTicksSvgHeight] = useState(0)
- const [chartSvgHeight, setChartSvgHeight] = useState(0)
-
- useEffect(() => {
- const ticksSvg = ticksSvgRef.current
- const chartSvg = chartSvgRef.current
-
- // eslint-disable-next-line compat/compat
- const resizeObserver = new ResizeObserver((entries) => {
- for (const entry of entries) {
- if (entry.target === ticksSvg) {
- setTicksSvgHeight(entry.contentRect.height)
- } else if (entry.target === chartSvg) {
- setChartSvgHeight(entry.contentRect.height)
- }
- }
- })
-
- if (ticksSvg) {
- resizeObserver.observe(ticksSvg)
- }
- if (chartSvg) {
- resizeObserver.observe(chartSvg)
- }
-
- return () => {
- resizeObserver.disconnect()
- }
- }, [])
-
- return (
-
- {/* eslint-disable-next-line react/forbid-dom-props */}
-
- {isFirstMetric && (
-
- )}
- {isFirstMetric &&
}
- {/* eslint-disable-next-line react/forbid-dom-props */}
-
- {variants.map((variant) => (
-
-
-
- ))}
-
-
-
- {/* SVGs container */}
-
- {/* Ticks */}
- {isFirstMetric && (
-
- {tickValues.map((value, index) => {
- const x = valueToX(value)
- return (
-
-
- {formatTickValue(value)}
-
-
- )
- })}
-
- )}
- {isFirstMetric &&
}
- {/* Chart */}
-
- {/* Vertical grid lines */}
- {tickValues.map((value, index) => {
- const x = valueToX(value)
- return (
-
- )
- })}
-
- {variants.map((variant, index) => {
- const interval = credibleIntervalForVariant(results, variant.key, metricType)
- const [lower, upper] = interval ? [interval[0] / 100, interval[1] / 100] : [0, 0]
-
- const variantRate = conversionRateForVariant(results, variant.key)
- const controlRate = conversionRateForVariant(results, 'control')
- const delta = variantRate && controlRate ? (variantRate - controlRate) / controlRate : 0
-
- // Find the highest delta among all variants
- const maxDelta = Math.max(
- ...variants.map((v) => {
- const vRate = conversionRateForVariant(results, v.key)
- return vRate && controlRate ? (vRate - controlRate) / controlRate : 0
- })
- )
-
- let barColor
- if (variant.key === 'control') {
- barColor = COLORS.BAR_DEFAULT
- } else if (delta < 0) {
- barColor = COLORS.BAR_NEGATIVE
- } else if (delta === maxDelta) {
- barColor = COLORS.BAR_BEST
- } else {
- barColor = COLORS.BAR_DEFAULT
- }
-
- const y = BAR_PADDING + (BAR_HEIGHT + BAR_PADDING) * index
- const x1 = valueToX(lower)
- const x2 = valueToX(upper)
- const deltaX = valueToX(delta)
-
- return (
- {
- const rect = e.currentTarget.getBoundingClientRect()
- setTooltipData({
- x: rect.left + rect.width / 2,
- y: rect.top - 10,
- variant: variant.key,
- })
- }}
- onMouseLeave={() => setTooltipData(null)}
- >
- {/* Invisible full-width rect to ensure consistent hover */}
-
- {/* Visible elements */}
-
-
-
- )
- })}
-
-
- {/* Tooltip */}
- {tooltipData && (
-
-
-
-
- Conversion rate:
-
- {conversionRateForVariant(results, tooltipData.variant)?.toFixed(2)}%
-
-
-
- Delta:
-
- {tooltipData.variant === 'control' ? (
- Baseline
- ) : (
- (() => {
- const variantRate = conversionRateForVariant(results, tooltipData.variant)
- const controlRate = conversionRateForVariant(results, 'control')
- const delta =
- variantRate && controlRate
- ? (variantRate - controlRate) / controlRate
- : 0
- return delta ? (
- 0 ? 'text-success' : 'text-danger'}>
- {`${delta > 0 ? '+' : ''}${(delta * 100).toFixed(2)}%`}
-
- ) : (
- '—'
- )
- })()
- )}
-
-
-
- Credible interval:
-
- {(() => {
- const interval = credibleIntervalForVariant(
- results,
- tooltipData.variant,
- metricType
- )
- const [lower, upper] = interval
- ? [interval[0] / 100, interval[1] / 100]
- : [0, 0]
- return `[${lower > 0 ? '+' : ''}${(lower * 100).toFixed(2)}%, ${
- upper > 0 ? '+' : ''
- }${(upper * 100).toFixed(2)}%]`
- })()}
-
-
-
-
- )}
-
-
- )
-}
diff --git a/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx b/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx
index 5ebf192769a2d..b3c2962d95c55 100644
--- a/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/DistributionTable.tsx
@@ -1,5 +1,3 @@
-import '../Experiment.scss'
-
import { IconBalance, IconFlag } from '@posthog/icons'
import {
LemonBanner,
diff --git a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx
index 8225391583fc9..75aa23d2f6284 100644
--- a/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/ExperimentView.tsx
@@ -1,12 +1,12 @@
-import '../Experiment.scss'
-
import { LemonDivider, LemonTabs } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
+import { FEATURE_FLAGS } from 'lib/constants'
import { PostHogFeature } from 'posthog-js/react'
import { WebExperimentImplementationDetails } from 'scenes/experiments/WebExperimentImplementationDetails'
import { ExperimentImplementationDetails } from '../ExperimentImplementationDetails'
import { experimentLogic } from '../experimentLogic'
+import { MetricsView } from '../MetricsView/MetricsView'
import {
ExperimentLoadingAnimation,
LoadingState,
@@ -25,13 +25,15 @@ import { Results } from './Results'
import { SecondaryMetricsTable } from './SecondaryMetricsTable'
const ResultsTab = (): JSX.Element => {
- const { experiment, experimentResults } = useValues(experimentLogic)
+ const { experiment, experimentResults, featureFlags } = useValues(experimentLogic)
const hasResultsInsight = experimentResults && experimentResults.insight
return (
- {hasResultsInsight ? (
+ {featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] ? (
+
+ ) : hasResultsInsight ? (
) : (
<>
@@ -67,7 +69,7 @@ const VariantsTab = (): JSX.Element => {
}
export function ExperimentView(): JSX.Element {
- const { experimentLoading, experimentResultsLoading, experimentId, experimentResults, tabKey } =
+ const { experimentLoading, experimentResultsLoading, experimentId, experimentResults, tabKey, featureFlags } =
useValues(experimentLogic)
const { setTabKey } = useActions(experimentLogic)
@@ -87,20 +89,27 @@ export function ExperimentView(): JSX.Element {
) : (
<>
- {hasResultsInsight ? (
+ {hasResultsInsight && !featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] ? (
) : null}
-
-
-
-
-
-
-
+ {featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] ? (
+
+
+
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
Add goal
@@ -324,7 +323,7 @@ export function Goal(): JSX.Element {
) : (
)}
- setIsModalOpen(true)}>
+ openPrimaryMetricModal(0)}>
Change goal
@@ -342,14 +341,7 @@ export function Goal(): JSX.Element {
)}
)}
- {
- setIsModalOpen(false)
- loadExperiment()
- }}
- />
+
)
}
diff --git a/frontend/src/scenes/experiments/ExperimentView/Info.tsx b/frontend/src/scenes/experiments/ExperimentView/Info.tsx
index df08b130fe4ad..ef7940f5fa28e 100644
--- a/frontend/src/scenes/experiments/ExperimentView/Info.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/Info.tsx
@@ -1,10 +1,9 @@
-import '../Experiment.scss'
-
import { IconWarning } from '@posthog/icons'
import { Link, ProfilePicture, Tooltip } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
import { EditableField } from 'lib/components/EditableField/EditableField'
+import { FEATURE_FLAGS } from 'lib/constants'
import { IconOpenInNew } from 'lib/lemon-ui/icons'
import { urls } from 'scenes/urls'
@@ -16,7 +15,7 @@ import { ActionBanner, ResultsTag, StatusTag } from './components'
import { ExperimentDates } from './ExperimentDates'
export function Info(): JSX.Element {
- const { experiment } = useValues(experimentLogic)
+ const { experiment, featureFlags } = useValues(experimentLogic)
const { updateExperiment } = useActions(experimentLogic)
const { created_by } = experiment
@@ -33,10 +32,12 @@ export function Info(): JSX.Element {
Status
-
+ {!featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] && (
+
+ )}
{experiment.feature_flag && (
@@ -98,7 +99,7 @@ export function Info(): JSX.Element {
compactButtons
/>
-
+ {!featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] &&
}
)
}
diff --git a/frontend/src/scenes/experiments/ExperimentView/Overview.tsx b/frontend/src/scenes/experiments/ExperimentView/Overview.tsx
index 35f18c3c13b67..2095309364143 100644
--- a/frontend/src/scenes/experiments/ExperimentView/Overview.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/Overview.tsx
@@ -1,5 +1,3 @@
-import '../Experiment.scss'
-
import { useValues } from 'kea'
import { experimentLogic } from '../experimentLogic'
diff --git a/frontend/src/scenes/experiments/ExperimentView/ReleaseConditionsTable.tsx b/frontend/src/scenes/experiments/ExperimentView/ReleaseConditionsTable.tsx
index dfe6130db788e..fc90635d9a6af 100644
--- a/frontend/src/scenes/experiments/ExperimentView/ReleaseConditionsTable.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/ReleaseConditionsTable.tsx
@@ -1,5 +1,3 @@
-import '../Experiment.scss'
-
import { IconFlag } from '@posthog/icons'
import { LemonBanner, LemonButton, LemonModal, LemonTable, LemonTableColumns, LemonTag } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
diff --git a/frontend/src/scenes/experiments/ExperimentView/Results.tsx b/frontend/src/scenes/experiments/ExperimentView/Results.tsx
index 1f34f96fd7518..c4e7a4b05ed62 100644
--- a/frontend/src/scenes/experiments/ExperimentView/Results.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/Results.tsx
@@ -1,21 +1,16 @@
-import '../Experiment.scss'
-
import { useValues } from 'kea'
-import { FEATURE_FLAGS } from 'lib/constants'
import { experimentLogic } from '../experimentLogic'
import { ResultsHeader, ResultsQuery } from './components'
-import { DeltaViz } from './DeltaViz'
import { SummaryTable } from './SummaryTable'
export function Results(): JSX.Element {
- const { experimentResults, featureFlags } = useValues(experimentLogic)
+ const { experimentResults } = useValues(experimentLogic)
return (
- {featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] && }
)
diff --git a/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx b/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx
index 5474962ec738b..8369038f00cbb 100644
--- a/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/SecondaryMetricsTable.tsx
@@ -332,7 +332,7 @@ const AddSecondaryMetricButton = ({
}
type="secondary"
- size="small"
+ size="xsmall"
onClick={() => {
const newMetricsSecondary = [...experiment.metrics_secondary, getDefaultFunnelsMetric()]
setExperiment({
diff --git a/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx b/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx
index 4ba16ded0e86c..6150d4e7b7826 100644
--- a/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/SummaryTable.tsx
@@ -1,5 +1,3 @@
-import '../Experiment.scss'
-
import { IconInfo, IconRewindPlay } from '@posthog/icons'
import { LemonButton, LemonTable, LemonTableColumns, Tooltip } from '@posthog/lemon-ui'
import { useValues } from 'kea'
diff --git a/frontend/src/scenes/experiments/ExperimentView/components.tsx b/frontend/src/scenes/experiments/ExperimentView/components.tsx
index df8580fee68dd..9e5bfb7c2c4b0 100644
--- a/frontend/src/scenes/experiments/ExperimentView/components.tsx
+++ b/frontend/src/scenes/experiments/ExperimentView/components.tsx
@@ -1,5 +1,3 @@
-import '../Experiment.scss'
-
import { IconArchive, IconCheck, IconFlask, IconX } from '@posthog/icons'
import {
LemonBanner,
diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryGoalFunnels.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryGoalFunnels.tsx
index 50468541a0d9b..2c5fe6f2da780 100644
--- a/frontend/src/scenes/experiments/Metrics/PrimaryGoalFunnels.tsx
+++ b/frontend/src/scenes/experiments/Metrics/PrimaryGoalFunnels.tsx
@@ -25,11 +25,15 @@ import {
} from './Selectors'
export function PrimaryGoalFunnels(): JSX.Element {
const { currentTeam } = useValues(teamLogic)
- const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
+ const { experiment, isExperimentRunning, featureFlags, editingPrimaryMetricIndex } = useValues(experimentLogic)
const { setExperiment, setFunnelsMetric } = useActions(experimentLogic)
const hasFilters = (currentTeam?.test_account_filters || []).length > 0
- const metricIdx = 0
+ if (!editingPrimaryMetricIndex && editingPrimaryMetricIndex !== 0) {
+ return <>>
+ }
+
+ const metricIdx = editingPrimaryMetricIndex
const currentMetric = experiment.metrics[metricIdx] as ExperimentFunnelsQuery
const actionFilterProps = {
@@ -261,7 +265,7 @@ export function PrimaryGoalFunnels(): JSX.Element {
checked={(() => {
// :FLAG: CLEAN UP AFTER MIGRATION
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
- const val = (experiment.metrics[0] as ExperimentFunnelsQuery).funnels_query
+ const val = (experiment.metrics[metricIdx] as ExperimentFunnelsQuery).funnels_query
?.filterTestAccounts
return hasFilters ? !!val : false
}
diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx
index 0ce1cb72e33da..8979f63e71ee7 100644
--- a/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx
+++ b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx
@@ -17,12 +17,16 @@ import { experimentLogic } from '../experimentLogic'
import { commonActionFilterProps } from './Selectors'
export function PrimaryGoalTrends(): JSX.Element {
- const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
+ const { experiment, isExperimentRunning, featureFlags, editingPrimaryMetricIndex } = useValues(experimentLogic)
const { setExperiment, setTrendsMetric } = useActions(experimentLogic)
const { currentTeam } = useValues(teamLogic)
const hasFilters = (currentTeam?.test_account_filters || []).length > 0
- const metricIdx = 0
+ if (!editingPrimaryMetricIndex && editingPrimaryMetricIndex !== 0) {
+ return <>>
+ }
+
+ const metricIdx = editingPrimaryMetricIndex
const currentMetric = experiment.metrics[metricIdx] as ExperimentTrendsQuery
return (
diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrendsExposure.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrendsExposure.tsx
index 4ebe43c30e928..1dfa4aa8b08a4 100644
--- a/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrendsExposure.tsx
+++ b/frontend/src/scenes/experiments/Metrics/PrimaryGoalTrendsExposure.tsx
@@ -16,11 +16,17 @@ import { experimentLogic } from '../experimentLogic'
import { commonActionFilterProps } from './Selectors'
export function PrimaryGoalTrendsExposure(): JSX.Element {
- const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
+ const { experiment, isExperimentRunning, featureFlags, editingPrimaryMetricIndex } = useValues(experimentLogic)
const { setExperiment, setTrendsExposureMetric } = useActions(experimentLogic)
const { currentTeam } = useValues(teamLogic)
const hasFilters = (currentTeam?.test_account_filters || []).length > 0
- const currentMetric = experiment.metrics[0] as ExperimentTrendsQuery
+
+ if (!editingPrimaryMetricIndex && editingPrimaryMetricIndex !== 0) {
+ return <>>
+ }
+
+ const metricIdx = editingPrimaryMetricIndex
+ const currentMetric = experiment.metrics[metricIdx] as ExperimentTrendsQuery
return (
<>
@@ -43,7 +49,7 @@ export function PrimaryGoalTrendsExposure(): JSX.Element {
)
setTrendsExposureMetric({
- metricIdx: 0,
+ metricIdx,
series,
})
} else {
@@ -109,7 +115,7 @@ export function PrimaryGoalTrendsExposure(): JSX.Element {
// :FLAG: CLEAN UP AFTER MIGRATION
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
setTrendsExposureMetric({
- metricIdx: 0,
+ metricIdx,
filterTestAccounts: checked,
})
} else {
diff --git a/frontend/src/scenes/experiments/Metrics/PrimaryMetricModal.tsx b/frontend/src/scenes/experiments/Metrics/PrimaryMetricModal.tsx
index 14fd6c7d4e967..1afce61899d6b 100644
--- a/frontend/src/scenes/experiments/Metrics/PrimaryMetricModal.tsx
+++ b/frontend/src/scenes/experiments/Metrics/PrimaryMetricModal.tsx
@@ -9,19 +9,24 @@ import { experimentLogic, getDefaultFilters, getDefaultFunnelsMetric, getDefault
import { PrimaryGoalFunnels } from '../Metrics/PrimaryGoalFunnels'
import { PrimaryGoalTrends } from '../Metrics/PrimaryGoalTrends'
-export function PrimaryMetricModal({
- experimentId,
- isOpen,
- onClose,
-}: {
- experimentId: Experiment['id']
- isOpen: boolean
- onClose: () => void
-}): JSX.Element {
- const { experiment, experimentLoading, getMetricType, featureFlags } = useValues(experimentLogic({ experimentId }))
- const { updateExperimentGoal, setExperiment } = useActions(experimentLogic({ experimentId }))
+export function PrimaryMetricModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
+ const {
+ experiment,
+ experimentLoading,
+ getMetricType,
+ featureFlags,
+ isPrimaryMetricModalOpen,
+ editingPrimaryMetricIndex,
+ } = useValues(experimentLogic({ experimentId }))
+ const { updateExperimentGoal, setExperiment, closePrimaryMetricModal } = useActions(
+ experimentLogic({ experimentId })
+ )
+
+ if (!editingPrimaryMetricIndex && editingPrimaryMetricIndex !== 0) {
+ return <>>
+ }
- const metricIdx = 0
+ const metricIdx = editingPrimaryMetricIndex
const metricType = getMetricType(metricIdx)
let funnelStepsLength = 0
@@ -34,31 +39,50 @@ export function PrimaryMetricModal({
return (
-
- Cancel
-
+
{
+ const newMetrics = experiment.metrics.filter((_, idx) => idx !== metricIdx)
+ setExperiment({
+ metrics: newMetrics,
+ })
updateExperimentGoal(experiment.filters)
}}
- type="primary"
- loading={experimentLoading}
- data-attr="create-annotation-submit"
>
- Save
+ Delete
+
+
+ Cancel
+
+ {
+ updateExperimentGoal(experiment.filters)
+ }}
+ type="primary"
+ loading={experimentLoading}
+ data-attr="create-annotation-submit"
+ >
+ Save
+
+
}
>
diff --git a/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx b/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx
new file mode 100644
index 0000000000000..da42b4ac5ca94
--- /dev/null
+++ b/frontend/src/scenes/experiments/MetricsView/DeltaChart.tsx
@@ -0,0 +1,667 @@
+import { IconActivity, IconPencil } from '@posthog/icons'
+import { LemonButton, LemonTag } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { humanFriendlyNumber } from 'lib/utils'
+import { useEffect, useRef, useState } from 'react'
+
+import { themeLogic } from '~/layout/navigation-3000/themeLogic'
+import { InsightType, TrendExperimentVariant } from '~/types'
+
+import { experimentLogic } from '../experimentLogic'
+import { VariantTag } from '../ExperimentView/components'
+import { NoResultEmptyState } from './NoResultEmptyState'
+
+function formatTickValue(value: number): string {
+ if (value === 0) {
+ return '0%'
+ }
+
+ // Determine number of decimal places needed
+ const absValue = Math.abs(value)
+ let decimals = 0
+
+ if (absValue < 0.01) {
+ decimals = 3
+ } else if (absValue < 0.1) {
+ decimals = 2
+ } else if (absValue < 1) {
+ decimals = 1
+ } else {
+ decimals = 0
+ }
+
+ return `${(value * 100).toFixed(decimals)}%`
+}
+
+export function DeltaChart({
+ result,
+ error,
+ variants,
+ metricType,
+ metricIndex,
+ isFirstMetric,
+ metric,
+ tickValues,
+ chartBound,
+}: {
+ result: any
+ error: any
+ variants: any[]
+ metricType: InsightType
+ metricIndex: number
+ isFirstMetric: boolean
+ metric: any
+ tickValues: number[]
+ chartBound: number
+}): JSX.Element {
+ const {
+ credibleIntervalForVariant,
+ conversionRateForVariant,
+ experimentId,
+ countDataForVariant,
+ exposureCountDataForVariant,
+ metricResultsLoading,
+ } = useValues(experimentLogic)
+
+ const { experiment } = useValues(experimentLogic)
+ const { openPrimaryMetricModal } = useActions(experimentLogic)
+ const [tooltipData, setTooltipData] = useState<{ x: number; y: number; variant: string } | null>(null)
+ const [emptyStateTooltipVisible, setEmptyStateTooltipVisible] = useState(true)
+ const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 })
+
+ const BAR_HEIGHT = 8
+ const BAR_PADDING = 10
+ const TICK_PANEL_HEIGHT = 20
+ const VIEW_BOX_WIDTH = 800
+ const HORIZONTAL_PADDING = 20
+ const CONVERSION_RATE_RECT_WIDTH = 2
+ const TICK_FONT_SIZE = 9
+
+ const { isDarkModeOn } = useValues(themeLogic)
+ const COLORS = {
+ TICK_TEXT_COLOR: 'var(--text-secondary-3000)',
+ BOUNDARY_LINES: 'var(--border-3000)',
+ ZERO_LINE: 'var(--border-bold)',
+ BAR_NEGATIVE: isDarkModeOn ? 'rgb(206 66 54)' : '#F44435',
+ BAR_BEST: isDarkModeOn ? 'rgb(49 145 51)' : '#4DAF4F',
+ BAR_DEFAULT: isDarkModeOn ? 'rgb(121 121 121)' : 'rgb(217 217 217)',
+ BAR_CONTROL: isDarkModeOn ? 'rgba(217, 217, 217, 0.2)' : 'rgba(217, 217, 217, 0.4)',
+ BAR_MIDDLE_POINT: 'black',
+ BAR_MIDDLE_POINT_CONTROL: 'rgba(0, 0, 0, 0.4)',
+ }
+
+ // Update chart height calculation to include only one BAR_PADDING for each space between bars
+ const chartHeight = BAR_PADDING + (BAR_HEIGHT + BAR_PADDING) * variants.length
+
+ const valueToX = (value: number): number => {
+ // Scale the value to fit within the padded area
+ const percentage = (value / chartBound + 1) / 2
+ return HORIZONTAL_PADDING + percentage * (VIEW_BOX_WIDTH - 2 * HORIZONTAL_PADDING)
+ }
+
+ const metricTitlePanelWidth = '20%'
+ const variantsPanelWidth = '10%'
+
+ const ticksSvgRef = useRef(null)
+ const chartSvgRef = useRef(null)
+ // :TRICKY: We need to track SVG heights dynamically because
+ // we're fitting regular divs to match SVG viewports. SVGs scale
+ // based on their viewBox and the viewport size, making it challenging
+ // to match their effective rendered heights with regular div elements.
+ const [ticksSvgHeight, setTicksSvgHeight] = useState(0)
+ const [chartSvgHeight, setChartSvgHeight] = useState(0)
+
+ useEffect(() => {
+ const ticksSvg = ticksSvgRef.current
+ const chartSvg = chartSvgRef.current
+
+ // eslint-disable-next-line compat/compat
+ const resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ if (entry.target === ticksSvg) {
+ setTicksSvgHeight(entry.contentRect.height)
+ } else if (entry.target === chartSvg) {
+ setChartSvgHeight(entry.contentRect.height)
+ }
+ }
+ })
+
+ if (ticksSvg) {
+ resizeObserver.observe(ticksSvg)
+ }
+ if (chartSvg) {
+ resizeObserver.observe(chartSvg)
+ }
+
+ return () => {
+ resizeObserver.disconnect()
+ }
+ }, [])
+
+ return (
+
+ {/* Metric title panel */}
+ {/* eslint-disable-next-line react/forbid-dom-props */}
+
+ {isFirstMetric && (
+
+ )}
+ {isFirstMetric &&
}
+
+
+
+
+
+ {metricIndex + 1}.{' '}
+ {metric.name || Untitled metric }
+
+
}
+ onClick={() => openPrimaryMetricModal(metricIndex)}
+ />
+
+
+ {metric.kind === 'ExperimentFunnelsQuery' ? 'Funnel' : 'Trend'}
+
+
+
+
+
+
+ {/* Variants panel */}
+ {/* eslint-disable-next-line react/forbid-dom-props */}
+
+ {isFirstMetric && (
+
+ )}
+ {isFirstMetric &&
}
+ {/* eslint-disable-next-line react/forbid-dom-props */}
+
+ {variants.map((variant) => (
+
+
+
+ ))}
+
+
+
+ {/* SVGs container */}
+
+ {/* Ticks */}
+ {isFirstMetric && (
+
+ {tickValues.map((value, index) => {
+ const x = valueToX(value)
+ return (
+
+
+ {formatTickValue(value)}
+
+
+ )
+ })}
+
+ )}
+ {isFirstMetric &&
}
+ {/* Chart */}
+ {result ? (
+
+ {/* Vertical grid lines */}
+ {tickValues.map((value, index) => {
+ const x = valueToX(value)
+ return (
+
+ )
+ })}
+
+ {variants.map((variant, index) => {
+ const interval = credibleIntervalForVariant(result, variant.key, metricType)
+ const [lower, upper] = interval ? [interval[0] / 100, interval[1] / 100] : [0, 0]
+
+ let delta: number
+ if (metricType === InsightType.TRENDS) {
+ const controlVariant = result.variants.find(
+ (v: TrendExperimentVariant) => v.key === 'control'
+ ) as TrendExperimentVariant
+
+ const variantData = result.variants.find(
+ (v: TrendExperimentVariant) => v.key === variant.key
+ ) as TrendExperimentVariant
+
+ if (
+ !variantData?.count ||
+ !variantData?.absolute_exposure ||
+ !controlVariant?.count ||
+ !controlVariant?.absolute_exposure
+ ) {
+ delta = 0
+ } else {
+ const controlMean = controlVariant.count / controlVariant.absolute_exposure
+ const variantMean = variantData.count / variantData.absolute_exposure
+ delta = (variantMean - controlMean) / controlMean
+ }
+ } else {
+ const variantRate = conversionRateForVariant(result, variant.key)
+ const controlRate = conversionRateForVariant(result, 'control')
+ delta = variantRate && controlRate ? (variantRate - controlRate) / controlRate : 0
+ }
+
+ const y = BAR_PADDING + (BAR_HEIGHT + BAR_PADDING) * index
+ const x1 = valueToX(lower)
+ const x2 = valueToX(upper)
+ const deltaX = valueToX(delta)
+
+ return (
+ {
+ const rect = e.currentTarget.getBoundingClientRect()
+ setTooltipData({
+ x: rect.left + rect.width / 2,
+ y: rect.top - 10,
+ variant: variant.key,
+ })
+ }}
+ onMouseLeave={() => setTooltipData(null)}
+ >
+ {variant.key === 'control' ? (
+ // Control variant - single gray bar
+ <>
+
+
+ >
+ ) : (
+ // Test variants - split into positive and negative sections if needed
+ <>
+
+ {lower < 0 && upper > 0 ? (
+ // Bar spans across zero - need to split
+ <>
+
+
+ >
+ ) : (
+ // Bar is entirely positive or negative
+
+ )}
+ >
+ )}
+ {/* Delta marker */}
+
+
+ )
+ })}
+
+ ) : metricResultsLoading ? (
+
+ {
+ const rect = e.currentTarget.getBoundingClientRect()
+ setTooltipPosition({
+ x: rect.left + rect.width / 2,
+ y: rect.top,
+ })
+ setEmptyStateTooltipVisible(true)
+ }}
+ onMouseLeave={() => setEmptyStateTooltipVisible(false)}
+ >
+
+ Results loading…
+
+
+
+ ) : (
+
+ {!experiment.start_date ? (
+
+
+ Waiting for experiment to start…
+
+
+ ) : (
+ {
+ const rect = e.currentTarget.getBoundingClientRect()
+ setTooltipPosition({
+ x: rect.left + rect.width / 2,
+ y: rect.top,
+ })
+ setEmptyStateTooltipVisible(true)
+ }}
+ onMouseLeave={() => setEmptyStateTooltipVisible(false)}
+ >
+
+ {error?.hasDiagnostics ? (
+
+
+
+ {(() => {
+ try {
+ const detail = JSON.parse(error.detail)
+ return Object.values(detail).filter((v) => v === false).length
+ } catch {
+ return '0'
+ }
+ })()}
+
+ /4
+
+ ) : (
+
+ Error
+
+ )}
+ Results not yet available
+
+
+ )}
+
+ )}
+
+ {/* Variant result tooltip */}
+ {tooltipData && (
+
+
+
+ {metricType === InsightType.TRENDS ? (
+ <>
+
+ Count:
+
+ {(() => {
+ const count = countDataForVariant(result, tooltipData.variant)
+ return count !== null ? humanFriendlyNumber(count) : '—'
+ })()}
+
+
+
+ Exposure:
+
+ {(() => {
+ const exposure = exposureCountDataForVariant(
+ result,
+ tooltipData.variant
+ )
+ return exposure !== null ? humanFriendlyNumber(exposure) : '—'
+ })()}
+
+
+
+ Mean:
+
+ {(() => {
+ const variant = result.variants.find(
+ (v: TrendExperimentVariant) => v.key === tooltipData.variant
+ )
+ return variant?.count && variant?.absolute_exposure
+ ? (variant.count / variant.absolute_exposure).toFixed(2)
+ : '—'
+ })()}
+
+
+ >
+ ) : (
+
+ Conversion rate:
+
+ {conversionRateForVariant(result, tooltipData.variant)?.toFixed(2)}%
+
+
+ )}
+
+ Delta:
+
+ {tooltipData.variant === 'control' ? (
+ Baseline
+ ) : (
+ (() => {
+ if (metricType === InsightType.TRENDS) {
+ const controlVariant = result.variants.find(
+ (v: TrendExperimentVariant) => v.key === 'control'
+ )
+ const variant = result.variants.find(
+ (v: TrendExperimentVariant) => v.key === tooltipData.variant
+ )
+
+ if (
+ !variant?.count ||
+ !variant?.absolute_exposure ||
+ !controlVariant?.count ||
+ !controlVariant?.absolute_exposure
+ ) {
+ return '—'
+ }
+
+ const controlMean =
+ controlVariant.count / controlVariant.absolute_exposure
+ const variantMean = variant.count / variant.absolute_exposure
+ const delta = (variantMean - controlMean) / controlMean
+ return delta ? (
+ 0 ? 'text-success' : 'text-danger'}>
+ {`${delta > 0 ? '+' : ''}${(delta * 100).toFixed(2)}%`}
+
+ ) : (
+ '—'
+ )
+ }
+
+ const variantRate = conversionRateForVariant(result, tooltipData.variant)
+ const controlRate = conversionRateForVariant(result, 'control')
+ const delta =
+ variantRate && controlRate
+ ? (variantRate - controlRate) / controlRate
+ : 0
+ return delta ? (
+ 0 ? 'text-success' : 'text-danger'}>
+ {`${delta > 0 ? '+' : ''}${(delta * 100).toFixed(2)}%`}
+
+ ) : (
+ '—'
+ )
+ })()
+ )}
+
+
+
+ Credible interval:
+
+ {(() => {
+ const interval = credibleIntervalForVariant(
+ result,
+ tooltipData.variant,
+ metricType
+ )
+ const [lower, upper] = interval
+ ? [interval[0] / 100, interval[1] / 100]
+ : [0, 0]
+ return `[${lower > 0 ? '+' : ''}${(lower * 100).toFixed(2)}%, ${
+ upper > 0 ? '+' : ''
+ }${(upper * 100).toFixed(2)}%]`
+ })()}
+
+
+
+
+ )}
+
+ {/* Empty state tooltip */}
+ {emptyStateTooltipVisible && (
+
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx b/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx
new file mode 100644
index 0000000000000..3d56a7681b3ec
--- /dev/null
+++ b/frontend/src/scenes/experiments/MetricsView/MetricsView.tsx
@@ -0,0 +1,180 @@
+import { IconPlus } from '@posthog/icons'
+import { LemonButton } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { IconAreaChart } from 'lib/lemon-ui/icons'
+
+import { experimentLogic, getDefaultFunnelsMetric } from '../experimentLogic'
+import { MAX_PRIMARY_METRICS } from './const'
+import { DeltaChart } from './DeltaChart'
+
+// Helper function to find nice round numbers for ticks
+export function getNiceTickValues(maxAbsValue: number): number[] {
+ // Round up maxAbsValue to ensure we cover all values
+ maxAbsValue = Math.ceil(maxAbsValue * 10) / 10
+
+ const magnitude = Math.floor(Math.log10(maxAbsValue))
+ const power = Math.pow(10, magnitude)
+
+ let baseUnit
+ const normalizedMax = maxAbsValue / power
+ if (normalizedMax <= 1) {
+ baseUnit = 0.2 * power
+ } else if (normalizedMax <= 2) {
+ baseUnit = 0.5 * power
+ } else if (normalizedMax <= 5) {
+ baseUnit = 1 * power
+ } else {
+ baseUnit = 2 * power
+ }
+
+ // Calculate how many baseUnits we need to exceed maxAbsValue
+ const unitsNeeded = Math.ceil(maxAbsValue / baseUnit)
+
+ // Determine appropriate number of decimal places based on magnitude
+ const decimalPlaces = Math.max(0, -magnitude + 1)
+
+ const ticks: number[] = []
+ for (let i = -unitsNeeded; i <= unitsNeeded; i++) {
+ // Round each tick value to avoid floating point precision issues
+ const tickValue = Number((baseUnit * i).toFixed(decimalPlaces))
+ ticks.push(tickValue)
+ }
+ return ticks
+}
+
+function AddMetric({
+ metrics,
+ setExperiment,
+ openPrimaryMetricModal,
+}: {
+ metrics: any[]
+ setExperiment: (payload: { metrics: any[] }) => void
+ openPrimaryMetricModal: (index: number) => void
+}): JSX.Element {
+ return (
+ }
+ type="secondary"
+ size="xsmall"
+ onClick={() => {
+ const newMetrics = [...metrics, getDefaultFunnelsMetric()]
+ setExperiment({
+ metrics: newMetrics,
+ })
+ openPrimaryMetricModal(newMetrics.length - 1)
+ }}
+ disabledReason={
+ metrics.length >= MAX_PRIMARY_METRICS
+ ? `You can only add up to ${MAX_PRIMARY_METRICS} primary metrics.`
+ : undefined
+ }
+ >
+ Add metric
+
+ )
+}
+
+export function MetricsView(): JSX.Element {
+ const { experiment, getMetricType, metricResults, primaryMetricsResultErrors, credibleIntervalForVariant } =
+ useValues(experimentLogic)
+ const { setExperiment, openPrimaryMetricModal } = useActions(experimentLogic)
+
+ const variants = experiment.parameters.feature_flag_variants
+ const metrics = experiment.metrics || []
+
+ // Calculate the maximum absolute value across ALL metrics
+ const maxAbsValue = Math.max(
+ ...metrics.flatMap((_, metricIndex) => {
+ const result = metricResults?.[metricIndex]
+ if (!result) {
+ return []
+ }
+ return variants.flatMap((variant) => {
+ const interval = credibleIntervalForVariant(result, variant.key, getMetricType(metricIndex))
+ return interval ? [Math.abs(interval[0] / 100), Math.abs(interval[1] / 100)] : []
+ })
+ })
+ )
+
+ const padding = Math.max(maxAbsValue * 0.05, 0.02)
+ const chartBound = maxAbsValue + padding
+
+ const commonTickValues = getNiceTickValues(chartBound)
+
+ return (
+
+
+
+
+
+
+ {metrics.length > 0 && (
+
+ )}
+
+
+
+ {metrics.length > 0 ? (
+
+
+ {metrics.map((metric, metricIndex) => {
+ const result = metricResults?.[metricIndex]
+ const isFirstMetric = metricIndex === 0
+
+ return (
+
+
+
+ )
+ })}
+
+
+ ) : (
+
+
+
+
+ Add up to {MAX_PRIMARY_METRICS} primary metrics.
+
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/scenes/experiments/MetricsView/NoResultEmptyState.tsx b/frontend/src/scenes/experiments/MetricsView/NoResultEmptyState.tsx
new file mode 100644
index 0000000000000..e773e7d8d4494
--- /dev/null
+++ b/frontend/src/scenes/experiments/MetricsView/NoResultEmptyState.tsx
@@ -0,0 +1,68 @@
+import { IconCheck, IconX } from '@posthog/icons'
+
+export function NoResultEmptyState({ error }: { error: any }): JSX.Element {
+ if (!error) {
+ return <>>
+ }
+
+ type ErrorCode = 'no-events' | 'no-flag-info' | 'no-control-variant' | 'no-test-variant'
+
+ const { statusCode, hasDiagnostics } = error
+
+ function ChecklistItem({ errorCode, value }: { errorCode: ErrorCode; value: boolean }): JSX.Element {
+ const failureText = {
+ 'no-events': 'Metric events not received',
+ 'no-flag-info': 'Feature flag information not present on the events',
+ 'no-control-variant': 'Events with the control variant not received',
+ 'no-test-variant': 'Events with at least one test variant not received',
+ }
+
+ const successText = {
+ 'no-events': 'Experiment events have been received',
+ 'no-flag-info': 'Feature flag information is present on the events',
+ 'no-control-variant': 'Events with the control variant received',
+ 'no-test-variant': 'Events with at least one test variant received',
+ }
+
+ return (
+
+ {value === false ? (
+
+
+ {successText[errorCode]}
+
+ ) : (
+
+
+ {failureText[errorCode]}
+
+ )}
+
+ )
+ }
+
+ if (hasDiagnostics) {
+ const checklistItems = []
+ for (const [errorCode, value] of Object.entries(error.detail as Record)) {
+ checklistItems.push( )
+ }
+
+ return {checklistItems}
+ }
+
+ if (statusCode === 504) {
+ return (
+ <>
+ Experiment results timed out
+
+ This may occur when the experiment has a large amount of data or is particularly complex. We are
+ actively working on fixing this. In the meantime, please try refreshing the experiment to retrieve
+ the results.
+
+ >
+ )
+ }
+
+ // Other unexpected errors
+ return {error.detail}
+}
diff --git a/frontend/src/scenes/experiments/MetricsView/const.tsx b/frontend/src/scenes/experiments/MetricsView/const.tsx
new file mode 100644
index 0000000000000..d1f720a7b256a
--- /dev/null
+++ b/frontend/src/scenes/experiments/MetricsView/const.tsx
@@ -0,0 +1 @@
+export const MAX_PRIMARY_METRICS = 10
diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx
index 88d57b134e0d3..698c4182dc84d 100644
--- a/frontend/src/scenes/experiments/experimentLogic.tsx
+++ b/frontend/src/scenes/experiments/experimentLogic.tsx
@@ -263,6 +263,9 @@ export const experimentLogic = kea([
isSecondary,
}),
setTabKey: (tabKey: string) => ({ tabKey }),
+ openPrimaryMetricModal: (index: number) => ({ index }),
+ closePrimaryMetricModal: true,
+ setPrimaryMetricsResultErrors: (errors: any[]) => ({ errors }),
}),
reducers({
experiment: [
@@ -471,6 +474,31 @@ export const experimentLogic = kea([
setTabKey: (_, { tabKey }) => tabKey,
},
],
+ isPrimaryMetricModalOpen: [
+ false,
+ {
+ openPrimaryMetricModal: () => true,
+ closePrimaryMetricModal: () => false,
+ },
+ ],
+ editingPrimaryMetricIndex: [
+ null as number | null,
+ {
+ openPrimaryMetricModal: (_, { index }) => index,
+ closePrimaryMetricModal: () => null,
+ updateExperimentGoal: () => null,
+ },
+ ],
+ primaryMetricsResultErrors: [
+ [] as any[],
+ {
+ setPrimaryMetricsResultErrors: (_, { errors }) => errors,
+ // Reset errors when loading new results
+ loadMetricResults: () => [],
+ // Reset errors when loading a new experiment
+ loadExperiment: () => [],
+ },
+ ],
}),
listeners(({ values, actions }) => ({
createExperiment: async ({ draft }) => {
@@ -620,6 +648,7 @@ export const experimentLogic = kea([
minimum_detectable_effect: minimumDetectableEffect,
},
})
+ actions.closePrimaryMetricModal()
},
updateExperimentCollectionGoal: async () => {
const { recommendedRunningTime, recommendedSampleSize, minimumDetectableEffect } = values
@@ -648,6 +677,9 @@ export const experimentLogic = kea([
actions.loadExperiment()
}
},
+ closePrimaryMetricModal: () => {
+ actions.loadExperiment()
+ },
resetRunningExperiment: async () => {
actions.updateExperiment({ start_date: null, end_date: null, archived: false })
values.experiment && actions.reportExperimentReset(values.experiment)
@@ -858,7 +890,7 @@ export const experimentLogic = kea([
// :HANDLE FLAG: CLEAN UP AFTER MIGRATION
if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
const errorDetailMatch = error.detail.match(/\{.*\}/)
- errorDetail = errorDetailMatch[0]
+ errorDetail = errorDetailMatch ? errorDetailMatch[0] : error.detail
}
actions.setExperimentResultCalculationError({ detail: errorDetail, statusCode: error.status })
if (error.status === 504) {
@@ -870,15 +902,14 @@ export const experimentLogic = kea([
},
],
metricResults: [
- null as (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse)[] | null,
+ null as (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[] | null,
{
loadMetricResults: async (
refresh?: boolean
- ): Promise<(CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse)[] | null> => {
+ ): Promise<(CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[]> => {
return (await Promise.all(
- values.experiment?.metrics.map(async (metric) => {
+ values.experiment?.metrics.map(async (metric, index) => {
try {
- // Queries are shareable, so we need to set the experiment_id for the backend to correctly associate the query with the experiment
const queryWithExperimentId = {
...metric,
experiment_id: values.experimentId,
@@ -889,11 +920,22 @@ export const experimentLogic = kea([
...response,
fakeInsightId: Math.random().toString(36).substring(2, 15),
}
- } catch (error) {
- return {}
+ } catch (error: any) {
+ const errorDetailMatch = error.detail.match(/\{.*\}/)
+ const errorDetail = errorDetailMatch ? JSON.parse(errorDetailMatch[0]) : error.detail
+
+ // Store the error in primaryMetricsResultErrors
+ const currentErrors = [...(values.primaryMetricsResultErrors || [])]
+ currentErrors[index] = {
+ detail: errorDetail,
+ statusCode: error.status,
+ hasDiagnostics: !!errorDetailMatch,
+ }
+ actions.setPrimaryMetricsResultErrors(currentErrors)
+ return null
}
})
- )) as (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse)[]
+ )) as (CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse | null)[]
},
},
],
diff --git a/frontend/src/scenes/experiments/utils.test.ts b/frontend/src/scenes/experiments/utils.test.ts
index 22d03cad8829a..906841aaec363 100644
--- a/frontend/src/scenes/experiments/utils.test.ts
+++ b/frontend/src/scenes/experiments/utils.test.ts
@@ -1,6 +1,6 @@
import { EntityType, FeatureFlagFilters, InsightType } from '~/types'
-import { getNiceTickValues } from './ExperimentView/DeltaViz'
+import { getNiceTickValues } from './MetricsView/MetricsView'
import { getMinimumDetectableEffect, transformFiltersForWinningVariant } from './utils'
describe('utils', () => {