diff --git a/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.ts b/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.ts index 6acf6bd829c0b..32114f00ac62a 100644 --- a/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.ts +++ b/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.ts @@ -436,6 +436,7 @@ export const retentionFilterToQuery = (filters: Partial): R targetEntity: sanitizeRetentionEntity(filters.target_entity), period: filters.period, showMean: filters.show_mean, + cumulative: filters.cumulative, }) // TODO: query.aggregation_group_type_index } diff --git a/frontend/src/queries/nodes/InsightQuery/utils/queryNodeToFilter.ts b/frontend/src/queries/nodes/InsightQuery/utils/queryNodeToFilter.ts index 378c3550f576f..b493bc46e4e1c 100644 --- a/frontend/src/queries/nodes/InsightQuery/utils/queryNodeToFilter.ts +++ b/frontend/src/queries/nodes/InsightQuery/utils/queryNodeToFilter.ts @@ -272,12 +272,14 @@ export const queryNodeToFilter = (query: InsightQueryNode): Partial camelCasedRetentionProps.target_entity = queryCopy.retentionFilter?.targetEntity camelCasedRetentionProps.total_intervals = queryCopy.retentionFilter?.totalIntervals camelCasedRetentionProps.show_mean = queryCopy.retentionFilter?.showMean + camelCasedRetentionProps.cumulative = queryCopy.retentionFilter?.cumulative delete queryCopy.retentionFilter?.retentionReference delete queryCopy.retentionFilter?.retentionType delete queryCopy.retentionFilter?.returningEntity delete queryCopy.retentionFilter?.targetEntity delete queryCopy.retentionFilter?.totalIntervals delete queryCopy.retentionFilter?.showMean + delete queryCopy.retentionFilter?.cumulative } else if (isPathsQuery(queryCopy)) { camelCasedPathsProps.edge_limit = queryCopy.pathsFilter?.edgeLimit camelCasedPathsProps.paths_hogql_expression = queryCopy.pathsFilter?.pathsHogQLExpression diff --git a/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx b/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx index 86d42ae2ea769..8208faecb56ce 100644 --- a/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx +++ b/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx @@ -17,6 +17,7 @@ import { ScalePicker } from 'scenes/insights/EditorFilters/ScalePicker' import { ShowLegendFilter } from 'scenes/insights/EditorFilters/ShowLegendFilter' import { ValueOnSeriesFilter } from 'scenes/insights/EditorFilters/ValueOnSeriesFilter' import { InsightDateFilter } from 'scenes/insights/filters/InsightDateFilter' +import { CumulativeRetentionCheckbox } from 'scenes/insights/filters/RetentionCumulativeCheckbox' import { RetentionMeanCheckbox } from 'scenes/insights/filters/RetentionMeanCheckbox' import { RetentionReferencePicker } from 'scenes/insights/filters/RetentionReferencePicker' import { insightLogic } from 'scenes/insights/insightLogic' @@ -146,6 +147,7 @@ export function InsightDisplayConfig(): JSX.Element { + )} diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 863fe9cd4f3af..14c8ccf5dad61 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -8090,6 +8090,9 @@ "RetentionFilter": { "additionalProperties": false, "properties": { + "cumulative": { + "type": "boolean" + }, "period": { "$ref": "#/definitions/RetentionPeriod", "default": "Day" diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index e97757584ef8e..d7f4ece53f11d 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -868,6 +868,7 @@ export type RetentionFilter = { /** @default Day */ period?: RetentionFilterLegacy['period'] showMean?: RetentionFilterLegacy['show_mean'] + cumulative?: RetentionFilterLegacy['cumulative'] } export interface RetentionValue { diff --git a/frontend/src/scenes/insights/utils/compareInsightQuery.ts b/frontend/src/scenes/insights/utils/compareInsightQuery.ts index f753d609c2be3..ec68b414ab743 100644 --- a/frontend/src/scenes/insights/utils/compareInsightQuery.ts +++ b/frontend/src/scenes/insights/utils/compareInsightQuery.ts @@ -61,6 +61,7 @@ const cleanInsightQuery = (query: InsightQueryNode, ignoreVisualizationOnlyChang toggledLifecycles: undefined, showLabelsOnSeries: undefined, showMean: undefined, + cumulative: undefined, yAxisScaleType: undefined, hiddenLegendIndexes: undefined, hiddenLegendBreakdowns: undefined, diff --git a/frontend/src/scenes/retention/RetentionModal.tsx b/frontend/src/scenes/retention/RetentionModal.tsx index 412e36c202426..9fc42d98c9f68 100644 --- a/frontend/src/scenes/retention/RetentionModal.tsx +++ b/frontend/src/scenes/retention/RetentionModal.tsx @@ -27,7 +27,7 @@ export function RetentionModal(): JSX.Element | null { const { results } = useValues(retentionLogic(insightProps)) const { people, peopleLoading, peopleLoadingMore } = useValues(retentionPeopleLogic(insightProps)) const { loadMorePeople } = useActions(retentionPeopleLogic(insightProps)) - const { aggregationTargetLabel, selectedInterval, exploreUrl, actorsQuery } = useValues( + const { aggregationTargetLabel, selectedInterval, exploreUrl, actorsQuery, retentionFilter } = useValues( retentionModalLogic(insightProps) ) const { closeModal } = useActions(retentionModalLogic(insightProps)) @@ -111,20 +111,34 @@ export function RetentionModal(): JSX.Element | null { {capitalizeFirstLetter(aggregationTargetLabel.singular)} - {row.values?.map((data: any, index: number) => ( - -
{results[index].label}
-
- {data.count} -   - {data.count > 0 && ( - - ({percentage(data.count / row?.values[0]['count'])}) - - )} -
- - ))} + {row.values?.map((data: any, index: number) => { + // Calculate the cumulative count + let cumulativeCount = data.count + if (retentionFilter?.cumulative) { + for (let i = index + 1; i < row.values.length; i++) { + cumulativeCount += row.values[i].count + } + cumulativeCount = Math.min(cumulativeCount, row.values[0].count) // Ensure cumulative count doesn't exceed total count + } + // Calculate the percentage based on the cumulative count + const percentageValue = + row.values[0].count > 0 ? cumulativeCount / row.values[0].count : 0 + + return ( + +
{results[index].label}
+
+ {cumulativeCount} +   + {cumulativeCount > 0 && ( + + ({percentage(percentageValue)}) + + )} +
+ + ) + })} {people.result && people.result.map((personAppearances: RetentionTableAppearanceType) => ( diff --git a/frontend/src/scenes/retention/retentionLineGraphLogic.ts b/frontend/src/scenes/retention/retentionLineGraphLogic.ts index 2bd1ff9663e2c..7815a68525b83 100644 --- a/frontend/src/scenes/retention/retentionLineGraphLogic.ts +++ b/frontend/src/scenes/retention/retentionLineGraphLogic.ts @@ -29,7 +29,7 @@ export const retentionLineGraphLogic = kea([ trendSeries: [ (s) => [s.results, s.retentionFilter], (results, retentionFilter): RetentionTrendPayload[] => { - const { period, retentionReference } = retentionFilter || {} + const { period, retentionReference, cumulative } = retentionFilter || {} // If the retention reference option is specified as previous, // then translate retention rates to relative to previous, // otherwise, just use what the result was originally. @@ -45,12 +45,22 @@ export const retentionLineGraphLogic = kea([ // further and translate these numbers into percentage of the // previous value so we get some idea for the rate of // convergence. + return results.map((cohortRetention, datasetIndex) => { - const retentionPercentages = cohortRetention.values + let retentionPercentages = cohortRetention.values .map((value) => value.count / cohortRetention.values[0].count) - // Make them display in the right scale .map((value) => (isNaN(value) ? 0 : 100 * value)) + if (cumulative) { + retentionPercentages = retentionPercentages.map((value, valueIndex, arr) => { + let cumulativeValue = value + for (let i = valueIndex + 1; i < arr.length; i++) { + cumulativeValue += arr[i] + } + return Math.min(cumulativeValue, 100) + }) + } + // To calculate relative percentages, we take for instance Cohort 1 as percentages // of the cohort size and create another series that has a 100 at prepended so we have // diff --git a/frontend/src/scenes/retention/retentionModalLogic.ts b/frontend/src/scenes/retention/retentionModalLogic.ts index 34275f019b1a2..e8e583298b178 100644 --- a/frontend/src/scenes/retention/retentionModalLogic.ts +++ b/frontend/src/scenes/retention/retentionModalLogic.ts @@ -22,7 +22,7 @@ export const retentionModalLogic = kea([ connect((props: InsightLogicProps) => ({ values: [ insightVizDataLogic(props), - ['querySource'], + ['querySource', 'retentionFilter'], groupsModel, ['aggregationLabel'], featureFlagLogic, diff --git a/frontend/src/scenes/retention/retentionTableLogic.ts b/frontend/src/scenes/retention/retentionTableLogic.ts index 0969d1ffa6f53..3b24682b8b66e 100644 --- a/frontend/src/scenes/retention/retentionTableLogic.ts +++ b/frontend/src/scenes/retention/retentionTableLogic.ts @@ -69,7 +69,7 @@ export const retentionTableLogic = kea([ tableRows: [ (s) => [s.results, s.maxIntervalsCount, s.retentionFilter, s.breakdownFilter, s.hideSizeColumn], (results, maxIntervalsCount, retentionFilter, breakdownFilter, hideSizeColumn) => { - const { period } = retentionFilter || {} + const { period, cumulative } = retentionFilter || {} const { breakdowns } = breakdownFilter || {} return range(maxIntervalsCount).map((index: number) => { @@ -99,12 +99,21 @@ export const retentionTableLogic = kea([ const secondColumn = hideSizeColumn ? [] : [currentResult.values[0].count] - const otherColumns = currentResult.values.map((value) => { + const otherColumns = currentResult.values.map((value, valueIndex) => { const totalCount = currentResult.values[0]['count'] - const percentage = totalCount > 0 ? (value['count'] / totalCount) * 100 : 0 + let count = value['count'] + + if (cumulative && valueIndex > 0) { + for (let i = valueIndex + 1; i < currentResult.values.length; i++) { + count += currentResult.values[i]['count'] + } + count = Math.min(count, totalCount) // Ensure cumulative count doesn't exceed total count + } + + const percentage = totalCount > 0 ? (count / totalCount) * 100 : 0 return { - count: value['count'], + count, percentage, } }) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c749bbcfc4ce8..f51fa711958fd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2268,6 +2268,7 @@ export interface RetentionFilterType extends FilterType { //frontend only show_mean?: boolean + cumulative?: boolean } export interface LifecycleFilterType extends FilterType { /** @deprecated */ diff --git a/posthog/schema.py b/posthog/schema.py index 4dfe859b4af86..a2fcdf90a3427 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -3083,6 +3083,7 @@ class RetentionFilter(BaseModel): model_config = ConfigDict( extra="forbid", ) + cumulative: Optional[bool] = None period: Optional[RetentionPeriod] = RetentionPeriod.DAY retentionReference: Optional[RetentionReference] = None retentionType: Optional[RetentionType] = None