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
-
-
Significance
- -
+ {!featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] && ( +
+
Significance
+ +
+ )} {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 ( +
+
+
+
+

Primary metrics

+
+
+ +
+
+ {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', () => {