diff --git a/products/experiments/frontend/components/RunningTimeCell.tsx b/products/experiments/frontend/components/RunningTimeCell.tsx new file mode 100644 index 000000000000..65c2a41d5b4a --- /dev/null +++ b/products/experiments/frontend/components/RunningTimeCell.tsx @@ -0,0 +1,67 @@ +import { Tooltip } from '@posthog/lemon-ui' + +import { dayjs } from 'lib/dayjs' +import { LemonProgress } from 'lib/lemon-ui/LemonProgress' +import { Spinner } from 'lib/lemon-ui/Spinner' + +import { Experiment } from '~/types' + +import type { RunningTimeEstimateApi } from 'products/experiments/frontend/generated/api.schemas' + +export const RunningTimeCell = ({ + experiment, + estimate, + loading, +}: { + experiment: Experiment + estimate: RunningTimeEstimateApi | undefined + loading: boolean +}): JSX.Element => { + if (loading && estimate === undefined) { + return + } + + const remainingDays = estimate?.remaining_days + const targetSampleSize = estimate?.target_sample_size + const currentExposures = estimate?.current_exposures + + // A negative estimate is not a real duration, so read it as "no estimate" rather than "~-N days". + if (remainingDays === undefined || remainingDays === null || remainingDays < 0) { + return ( + +
+ +
+
+ ) + } + + if (remainingDays === 0) { + return ( + +
+ +
+
+ ) + } + + // Automatic mode reports live exposures, so progress is exposures toward the target. Manual mode has + // no exposures; measure progress by days elapsed against the total (elapsed + remaining) instead. + let progress = 0 + if (currentExposures != null && targetSampleSize && targetSampleSize > 0) { + progress = Math.min((currentExposures / targetSampleSize) * 100, 100) + } else if (experiment.start_date) { + const daysElapsed = dayjs().diff(dayjs(experiment.start_date), 'day') + const totalDays = daysElapsed + remainingDays + progress = totalDays > 0 ? Math.min((daysElapsed / totalDays) * 100, 100) : 0 + } + + return ( + +
+ +
+
+ ) +} diff --git a/products/experiments/frontend/scenes/ExperimentsScene.tsx b/products/experiments/frontend/scenes/ExperimentsScene.tsx index b934ca01d444..60303db79b53 100644 --- a/products/experiments/frontend/scenes/ExperimentsScene.tsx +++ b/products/experiments/frontend/scenes/ExperimentsScene.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx' import { useActions, useValues } from 'kea' import { router } from 'kea-router' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { LemonInput, LemonSelect, LemonTag, Tooltip, lemonToast } from '@posthog/lemon-ui' @@ -17,7 +17,6 @@ import { LemonButton } from 'lib/lemon-ui/LemonButton' import { More } from 'lib/lemon-ui/LemonButton/More' import { LemonDivider } from 'lib/lemon-ui/LemonDivider' import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown' -import { LemonProgress } from 'lib/lemon-ui/LemonProgress' import { LemonTable, LemonTableColumn, LemonTableColumns } from 'lib/lemon-ui/LemonTable' import { atColumn, createdAtColumn, createdByColumn } from 'lib/lemon-ui/LemonTable/columnUtils' import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' @@ -50,6 +49,7 @@ import { import { CopyExperimentToProjectModal } from 'products/experiments/frontend/components/CopyExperimentToProjectModal' import { DuplicateExperimentModal } from 'products/experiments/frontend/components/DuplicateExperimentModal' import { ExperimentVelocityStats } from 'products/experiments/frontend/components/ExperimentVelocityStats' +import { RunningTimeCell } from 'products/experiments/frontend/components/RunningTimeCell' import { StatusTag } from 'products/experiments/frontend/components/StatusTag' import { CONCLUSION_DISPLAY_CONFIG } from 'products/experiments/frontend/constants' import { experimentsEmptyState } from 'products/experiments/frontend/emptyState/experimentsEmptyState' @@ -213,8 +213,16 @@ const ExperimentsTable = ({ openSurveyModal: (experiment: Experiment) => void openCopyToProjectModal: (experiment: Experiment) => void }): JSX.Element => { - const { currentProjectId, experiments, experimentsLoading, filters, count, pagination } = - useValues(experimentsLogic) + const { + currentProjectId, + experiments, + experimentsLoading, + filters, + count, + pagination, + runningTimeEstimates, + runningTimeEstimatesLoading, + } = useValues(experimentsLogic) const { loadExperiments, archiveExperiment, unarchiveExperiment, setExperimentsFilters } = useActions(experimentsLogic) const { currentOrganization } = useValues(organizationLogic) @@ -224,289 +232,269 @@ const ExperimentsTable = ({ const startCount = count === 0 ? 0 : (page - 1) * EXPERIMENTS_PER_PAGE + 1 const endCount = page * EXPERIMENTS_PER_PAGE < count ? page * EXPERIMENTS_PER_PAGE : count - const columns: LemonTableColumns = [ - { - title: 'Name', - dataIndex: 'name', - className: 'ph-no-capture', - sticky: true, - width: '40%', - render: function Render(_, experiment: Experiment) { - return ( - - {stringWithWBR(experiment.name, 17)} - {experiment.type === 'web' && ( - - No-code - - )} - {experiment.is_legacy && ( - - - Legacy - - - )} - {isSingleVariantShipped(experiment) && ( - - - 100% rollout + // A fresh columns array every render defeats LemonTable's TableRow React.memo, re-rendering all rows + // whenever the running-time estimates load. Memoize so only the changed cells re-render. + const columns: LemonTableColumns = useMemo( + () => [ + { + title: 'Name', + dataIndex: 'name', + className: 'ph-no-capture', + sticky: true, + width: '40%', + render: function Render(_, experiment: Experiment) { + return ( + + {stringWithWBR(experiment.name, 17)} + {experiment.type === 'web' && ( + + No-code - - )} - - } - description={ - experiment.description ? ( - // Hypotheses can run many paragraphs, so clamp to keep list rows compact - - {experiment.description} - - ) : undefined - } - /> - ) + )} + {experiment.is_legacy && ( + + + Legacy + + + )} + {isSingleVariantShipped(experiment) && ( + + + 100% rollout + + + )} + + } + description={ + experiment.description ? ( + // Hypotheses can run many paragraphs, so clamp to keep list rows compact + + {experiment.description} + + ) : undefined + } + /> + ) + }, }, - }, - createdByColumn() as LemonTableColumn, - createdAtColumn() as LemonTableColumn, - atColumn('start_date', 'Started') as LemonTableColumn, - { - title: 'Duration', - key: 'duration', - render: function Render(_, experiment: Experiment) { - const duration = getExperimentDuration(experiment) + createdByColumn() as LemonTableColumn, + createdAtColumn() as LemonTableColumn, + atColumn('start_date', 'Started') as LemonTableColumn, + { + title: 'Duration', + key: 'duration', + render: function Render(_, experiment: Experiment) { + const duration = getExperimentDuration(experiment) - return
{duration !== undefined ? `${duration} day${duration !== 1 ? 's' : ''}` : '—'}
- }, - sorter: (a, b) => { - const durationA = getExperimentDuration(a) ?? -1 - const durationB = getExperimentDuration(b) ?? -1 - return durationA > durationB ? 1 : -1 + return
{duration !== undefined ? `${duration} day${duration !== 1 ? 's' : ''}` : '—'}
+ }, + sorter: (a, b) => { + const durationA = getExperimentDuration(a) ?? -1 + const durationB = getExperimentDuration(b) ?? -1 + return durationA > durationB ? 1 : -1 + }, + align: 'right', }, - align: 'right', - }, - { - title: 'Remaining', - key: 'remaining_time', - width: 80, - render: function Render(_, experiment: Experiment) { - const remainingDays = experiment.running_time_calculation?.recommended_running_time - const daysElapsed = experiment.start_date - ? dayjs().diff(dayjs(experiment.start_date), 'day') - : undefined - - // A negative stored estimate isn't a real duration, so read it as "no estimate" - // rather than "~-N days remaining" (records saved before the persist guard can still hold one). - if (remainingDays === undefined || remainingDays === null || remainingDays < 0) { + { + title: 'Remaining', + key: 'remaining_time', + width: 80, + render: function Render(_, experiment: Experiment) { return ( - -
- -
-
+ ) - } + }, + }, + { + title: 'Status', + key: 'status', + render: function Render(_, experiment: Experiment) { + return + }, + align: 'center', + sorter: (a, b) => { + const statusA = getExperimentStatus(a) + const statusB = getExperimentStatus(b) - if (remainingDays === 0) { + const score: Record = { + [ExperimentStatus.Draft]: 1, + [ExperimentStatus.Running]: 2, + [ExperimentStatus.Paused]: 3, + [ExperimentStatus.ExposureFrozen]: 4, + [ExperimentStatus.Stopped]: 5, + } + return score[statusA] > score[statusB] ? 1 : -1 + }, + }, + { + title: 'Result', + key: 'conclusion', + render: function Render(_, experiment: Experiment) { + if (!experiment.conclusion) { + return — + } + const config = CONCLUSION_DISPLAY_CONFIG[experiment.conclusion] + const tooltip = experiment.conclusion_comment + ? `${config.description} — ${experiment.conclusion_comment}` + : config.description return ( - -
- + +
+
+ {config.title}
) - } - - const totalEstimatedDays = (daysElapsed ?? 0) + remainingDays - const progress = totalEstimatedDays > 0 ? ((daysElapsed ?? 0) / totalEstimatedDays) * 100 : 0 - - return ( - -
- -
-
- ) - }, - }, - { - title: 'Status', - key: 'status', - render: function Render(_, experiment: Experiment) { - return - }, - align: 'center', - sorter: (a, b) => { - const statusA = getExperimentStatus(a) - const statusB = getExperimentStatus(b) - - const score: Record = { - [ExperimentStatus.Draft]: 1, - [ExperimentStatus.Running]: 2, - [ExperimentStatus.Paused]: 3, - [ExperimentStatus.ExposureFrozen]: 4, - [ExperimentStatus.Stopped]: 5, - } - return score[statusA] > score[statusB] ? 1 : -1 - }, - }, - { - title: 'Result', - key: 'conclusion', - render: function Render(_, experiment: Experiment) { - if (!experiment.conclusion) { - return — - } - const config = CONCLUSION_DISPLAY_CONFIG[experiment.conclusion] - const tooltip = experiment.conclusion_comment - ? `${config.description} — ${experiment.conclusion_comment}` - : config.description - return ( - -
-
- {config.title} -
- - ) - }, - align: 'left', - sorter: (a, b) => { - const conclusionScore: Record = { - [ExperimentConclusion.Won]: 1, - [ExperimentConclusion.Lost]: 2, - [ExperimentConclusion.Inconclusive]: 3, - [ExperimentConclusion.StoppedEarly]: 4, - [ExperimentConclusion.Invalid]: 5, - } - const aScore = a.conclusion ? conclusionScore[a.conclusion] : 6 - const bScore = b.conclusion ? conclusionScore[b.conclusion] : 6 - return aScore - bScore + }, + align: 'left', + sorter: (a, b) => { + const conclusionScore: Record = { + [ExperimentConclusion.Won]: 1, + [ExperimentConclusion.Lost]: 2, + [ExperimentConclusion.Inconclusive]: 3, + [ExperimentConclusion.StoppedEarly]: 4, + [ExperimentConclusion.Invalid]: 5, + } + const aScore = a.conclusion ? conclusionScore[a.conclusion] : 6 + const bScore = b.conclusion ? conclusionScore[b.conclusion] : 6 + return aScore - bScore + }, }, - }, - { - width: 0, - render: function Render(_, experiment: Experiment) { - return ( - - - View - - openDuplicateModal(experiment)} - size="small" - fullWidth - disabledReason={ - experiment.is_legacy - ? 'Not supported for experiments using legacy metrics. Please recreate the experiment manually.' - : undefined - } - > - Duplicate - - {hasMultipleProjects && ( + { + width: 0, + render: function Render(_, experiment: Experiment) { + return ( + + + View + openCopyToProjectModal(experiment)} + onClick={() => openDuplicateModal(experiment)} size="small" fullWidth disabledReason={ experiment.is_legacy - ? 'Copying is not supported for experiments using legacy metrics.' + ? 'Not supported for experiments using legacy metrics. Please recreate the experiment manually.' : undefined } > - Copy to project + Duplicate - )} - { - openSurveyModal(experiment) - void addProductIntentForCrossSell({ - from: ProductKey.EXPERIMENTS, - to: ProductKey.SURVEYS, - intent_context: ProductIntentContext.QUICK_SURVEY_STARTED, - }) - }} - /> - {canArchiveExperiment(experiment) && ( - + {hasMultipleProjects && ( - confirmArchiveExperiment(experiment, (disableFlag) => - archiveExperiment({ - id: experiment.id as number, - disableFeatureFlag: disableFlag, - }) - ) - } - data-attr={`experiment-${experiment.id}-dropdown-archive`} + onClick={() => openCopyToProjectModal(experiment)} + size="small" fullWidth + disabledReason={ + experiment.is_legacy + ? 'Copying is not supported for experiments using legacy metrics.' + : undefined + } > - Archive experiment + Copy to project - - )} - {experiment.archived && ( + )} + { + openSurveyModal(experiment) + void addProductIntentForCrossSell({ + from: ProductKey.EXPERIMENTS, + to: ProductKey.SURVEYS, + intent_context: ProductIntentContext.QUICK_SURVEY_STARTED, + }) + }} + /> + {canArchiveExperiment(experiment) && ( + + + confirmArchiveExperiment(experiment, (disableFlag) => + archiveExperiment({ + id: experiment.id as number, + disableFeatureFlag: disableFlag, + }) + ) + } + data-attr={`experiment-${experiment.id}-dropdown-archive`} + fullWidth + > + Archive experiment + + + )} + {experiment.archived && ( + + unarchiveExperiment(experiment.id as number)} + data-attr={`experiment-${experiment.id}-dropdown-unarchive`} + fullWidth + > + Unarchive experiment + + + )} + unarchiveExperiment(experiment.id as number)} - data-attr={`experiment-${experiment.id}-dropdown-unarchive`} + status="danger" + onClick={() => + confirmDeleteExperiment({ + projectId: currentProjectId, + experiment, + onDelete: () => loadExperiments(), + }) + } + data-attr={`experiment-${experiment.id}-dropdown-remove`} fullWidth > - Unarchive experiment + Delete experiment - )} - - - - confirmDeleteExperiment({ - projectId: currentProjectId, - experiment, - onDelete: () => loadExperiments(), - }) - } - data-attr={`experiment-${experiment.id}-dropdown-remove`} - fullWidth - > - Delete experiment - - - - } - /> - ) + + } + /> + ) + }, }, - }, - ] + ], + [ + openDuplicateModal, + openSurveyModal, + openCopyToProjectModal, + hasMultipleProjects, + runningTimeEstimates, + runningTimeEstimatesLoading, + ] + ) return ( diff --git a/products/experiments/frontend/scenes/experimentsLogic.ts b/products/experiments/frontend/scenes/experimentsLogic.ts index e8b5a826900e..deaa0d7a7a74 100644 --- a/products/experiments/frontend/scenes/experimentsLogic.ts +++ b/products/experiments/frontend/scenes/experimentsLogic.ts @@ -27,6 +27,10 @@ import { import type { AvailableFeature, UserType } from '~/types' import type { TeamPublicType, TeamType } from '~/types' +import { getExperimentStatus } from 'products/experiments/frontend/experimentStatus' +import { experimentsRunningTimeEstimatesRetrieve } from 'products/experiments/frontend/generated/api' +import type { RunningTimeEstimatesResponseApiResults } from 'products/experiments/frontend/generated/api.schemas' + export const EXPERIMENTS_PER_PAGE = 100 export interface ExperimentsResult extends CountedPaginatedResponse { @@ -181,6 +185,8 @@ export interface experimentsLogicValues { search?: string | undefined status?: ExperimentStatus | 'all' | undefined } + runningTimeEstimates: RunningTimeEstimatesResponseApiResults + runningTimeEstimatesLoading: boolean sidePanelContext: SidePanelSceneContext tab: ExperimentsTabs unavailableFeatureFlagKeys: Set @@ -398,6 +404,21 @@ export interface experimentsLogicActions { } payload?: void } + loadRunningTimeEstimates: (experimentIds: number[]) => number[] + loadRunningTimeEstimatesFailure: ( + error: string, + errorObject?: any + ) => { + error: string + errorObject?: any + } + loadRunningTimeEstimatesSuccess: ( + runningTimeEstimates: RunningTimeEstimatesResponseApiResults, + payload?: number[] + ) => { + runningTimeEstimates: RunningTimeEstimatesResponseApiResults + payload?: number[] + } openFeatureFlagModal: () => { value: true } @@ -590,6 +611,15 @@ export const experimentsLogic = kea([ } actions.loadExperiments() }, + loadExperimentsSuccess: ({ experiments }) => { + // Remaining time is estimated on demand, not stored, so fetch it for the running rows on this page. + // Running experiments are persisted, so their id is always numeric (never 'new'/'web'). + const runningIds = experiments.results + .filter((experiment) => getExperimentStatus(experiment) === ExperimentStatus.Running) + .map((experiment) => experiment.id) + .filter((id): id is number => typeof id === 'number') + actions.loadRunningTimeEstimates(runningIds) + }, setFeatureFlagModalFilters: async (_, breakpoint) => { await breakpoint(300) actions.loadFeatureFlagModalFeatureFlags() @@ -722,6 +752,20 @@ export const experimentsLogic = kea([ }, }, ], + runningTimeEstimates: [ + {} as RunningTimeEstimatesResponseApiResults, + { + loadRunningTimeEstimates: async (experimentIds: number[]) => { + if (experimentIds.length === 0) { + return {} + } + const response = await experimentsRunningTimeEstimatesRetrieve(String(values.currentProjectId), { + ids: experimentIds.join(','), + }) + return response.results + }, + }, + ], featureFlagModalFeatureFlags: [ { results: [], count: 0 } as { results: FeatureFlagType[]; count: number }, {