Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add weighted mean for retention insights #28302

Merged
merged 16 commits into from
Feb 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('Insights', () => {
it('can open a new retention insight', () => {
insight.clickTab('RETENTION')
cy.get('.RetentionContainer canvas').should('exist')
cy.get('.RetentionTable__Tab').should('have.length', 66)
cy.get('.RetentionTable__Tab').should('have.length', 77)
})

it('can open a new paths insight', () => {
Expand Down
2 changes: 1 addition & 1 deletion cypress/e2e/insights-navigation.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('Insights', () => {
cy.get('[data-attr="insight-save-button"]').click()

cy.get('.RetentionContainer canvas').should('exist')
cy.get('.RetentionTable__Tab').should('have.length', 66)
cy.get('.RetentionTable__Tab').should('have.length', 77)
})

it('can open a new SQL insight and navigate to a different one, then back to SQL, and back again', () => {
Expand Down
Binary file modified frontend/__snapshots__/scenes-app-insights--retention--dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified frontend/__snapshots__/scenes-app-insights--retention--light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ export enum FunnelLayout {

export const BIN_COUNT_AUTO = 'auto' as const

export const RETENTION_MEAN_NONE = 'none' as const

// Cohort types
export enum CohortTypeEnum {
Static = 'static',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/queries/nodes/InsightQuery/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const retentionQueryDefault: RetentionQuery = {
type: 'events',
},
retentionType: 'retention_first_time',
showMean: 'simple',
},
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,7 @@ describe('filtersToQueryNode', () => {
returning_entity: { id: '1' },
target_entity: { id: '1' },
period: RetentionPeriod.Day,
show_mean: true,
show_mean: 'simple',
}

const result = filtersToQueryNode(filters)
Expand All @@ -683,7 +683,7 @@ describe('filtersToQueryNode', () => {
returningEntity: { id: '1' },
targetEntity: { id: '1' },
period: RetentionPeriod.Day,
showMean: true,
showMean: 'simple',
},
}
expect(result).toEqual(query)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { ShowMultipleYAxesFilter } from 'scenes/insights/EditorFilters/ShowMulti
import { ValueOnSeriesFilter } from 'scenes/insights/EditorFilters/ValueOnSeriesFilter'
import { InsightDateFilter } from 'scenes/insights/filters/InsightDateFilter'
import { RetentionCumulativeCheckbox } from 'scenes/insights/filters/RetentionCumulativeCheckbox'
import { RetentionMeanCheckbox } from 'scenes/insights/filters/RetentionMeanCheckbox'
import { RetentionMeanDropdown } from 'scenes/insights/filters/RetentionMeanDropdown'
import { RetentionReferencePicker } from 'scenes/insights/filters/RetentionReferencePicker'
import { insightLogic } from 'scenes/insights/insightLogic'
import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'
Expand Down Expand Up @@ -178,7 +178,7 @@ export function InsightDisplayConfig(): JSX.Element {
<ConfigFilter>
<RetentionDatePicker />
<RetentionReferencePicker />
<RetentionMeanCheckbox />
<RetentionMeanDropdown />
<RetentionCumulativeCheckbox />
</ConfigFilter>
)}
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/queries/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1258,7 +1258,8 @@
},
"showMean": {
"description": "Whether an additional series should be shown, showing the mean conversion for each period across cohorts.",
"type": "boolean"
"enum": ["simple", "weighted", "none"],
"type": "string"
},
"targetEntity": {
"$ref": "#/definitions/RetentionEntity",
Expand Down Expand Up @@ -12479,7 +12480,8 @@
"$ref": "#/definitions/RetentionEntity"
},
"showMean": {
"type": "boolean"
"enum": ["simple", "weighted", "none"],
"type": "string"
},
"targetEntity": {
"$ref": "#/definitions/RetentionEntity"
Expand Down Expand Up @@ -12513,7 +12515,8 @@
"$ref": "#/definitions/RetentionEntity"
},
"show_mean": {
"type": "boolean"
"enum": ["simple", "weighted", "none"],
"type": "string"
},
"target_entity": {
"$ref": "#/definitions/RetentionEntity"
Expand Down
29 changes: 0 additions & 29 deletions frontend/src/scenes/insights/filters/RetentionMeanCheckbox.tsx

This file was deleted.

52 changes: 52 additions & 0 deletions frontend/src/scenes/insights/filters/RetentionMeanDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { LemonSelect } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { RETENTION_MEAN_NONE } from 'lib/constants'
import { insightLogic } from 'scenes/insights/insightLogic'
import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'

export type RetentionMeanType = 'simple' | 'weighted' | typeof RETENTION_MEAN_NONE

export function RetentionMeanDropdown(): JSX.Element | null {
const { insightProps, canEditInsight } = useValues(insightLogic)

const { retentionFilter } = useValues(insightVizDataLogic(insightProps))
const { updateInsightFilter } = useActions(insightVizDataLogic(insightProps))

const showMean = retentionFilter?.showMean || RETENTION_MEAN_NONE

if (!canEditInsight) {
return null
}

return (
<LemonSelect
className="w-44"
size="small"
value={showMean}
onChange={(showMean) => {
updateInsightFilter({ showMean })
}}
Comment on lines +26 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: consider adding type safety for showMean parameter to ensure it matches RetentionMeanType

options={[
{
value: RETENTION_MEAN_NONE,
labelInMenu: 'No mean calculation',
label: 'No mean calculation',
},
{
value: 'simple',
labelInMenu: 'Simple mean',
label: 'Simple mean',
tooltip:
'Calculates the average retention rate across all cohorts by giving equal weight to each cohort, regardless of its size.',
},
{
value: 'weighted',
Copy link
Contributor

@aspicer aspicer Feb 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we could add a tooltip here that just describes what this is in slightly more words?

Not sure - do you think it's more clear if these modes are called
"Average by week"
"Average by person"

Or do you think the current "Mean" and "weight mean" are more clear?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think simple mean and weighted mean are more clear. Even if someone doesn't know what a weighted mean is, it's something they can look up. I do think it might be helpful to have a concise tooltip though. Not sure what the copy should be, I'll sleep on it and add this in the AM.

labelInMenu: 'Weighted mean',
label: 'Weighted mean',
tooltip:
'Calculates the average retention rate by giving more weight to larger cohorts, accounting for different cohort sizes in the final mean.',
},
]}
/>
)
}
27 changes: 17 additions & 10 deletions frontend/src/scenes/insights/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -570,28 +570,35 @@ export function isQueryTooLarge(query: Node<Record<string, any>>): boolean {
return queryLength > 1024 * 1024
}

export function parseDraftQueryFromLocalStorage(
query: string
): { query: Node<Record<string, any>>; timestamp: number } | null {
function parseAndMigrateQuery<T>(query: string): T | null {
try {
return JSON.parse(query)
const parsedQuery = JSON.parse(query)
// We made a database migration to convert showMean from a boolean to a string,
// to allow for weighted and simple mean in retention tables. This ensures older URLs
// are parsed correctly.
const retentionFilter = parsedQuery?.source?.retentionFilter
if (retentionFilter && 'showMean' in retentionFilter && typeof retentionFilter.showMean === 'boolean') {
retentionFilter.showMean = retentionFilter.showMean ? 'simple' : null
}
return parsedQuery
} catch (e) {
console.error('Error parsing query', e)
return null
}
}

export function parseDraftQueryFromLocalStorage(
query: string
): { query: Node<Record<string, any>>; timestamp: number } | null {
return parseAndMigrateQuery(query)
}

export function crushDraftQueryForLocalStorage(query: Node<Record<string, any>>, timestamp: number): string {
return JSON.stringify({ query, timestamp })
}

export function parseDraftQueryFromURL(query: string): Node<Record<string, any>> | null {
try {
return JSON.parse(query)
} catch (e) {
console.error('Error parsing query', e)
return null
}
return parseAndMigrateQuery(query)
}

export function crushDraftQueryForURL(query: Node<Record<string, any>>): string {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/scenes/insights/utils/cleanFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
NON_VALUES_ON_SERIES_DISPLAY_TYPES,
PERCENT_STACK_VIEW_DISPLAY_TYPE,
RETENTION_FIRST_TIME,
RETENTION_MEAN_NONE,
ShownAsValue,
} from 'lib/constants'
import { clamp } from 'lib/utils'
Expand Down Expand Up @@ -307,7 +308,7 @@ export function cleanFilters(
breakdowns: filters.breakdowns,
breakdown_type: filters.breakdown_type,
retention_reference: filters.retention_reference,
show_mean: filters.show_mean,
...(filters.show_mean && filters.show_mean !== RETENTION_MEAN_NONE ? { show_mean: filters.show_mean } : {}),
cumulative: filters.cumulative,
total_intervals: Math.min(Math.max(filters.total_intervals ?? 11, 0), 100),
...(filters.aggregation_group_type_index != undefined
Expand Down
56 changes: 51 additions & 5 deletions frontend/src/scenes/retention/RetentionTable.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import './RetentionTable.scss'

import clsx from 'clsx'
import { mean } from 'd3'
import { mean, sum } from 'd3'
import { useActions, useValues } from 'kea'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { gradateColor, range } from 'lib/utils'
Expand All @@ -17,7 +17,7 @@ export function RetentionTable({ inSharedMode = false }: { inSharedMode?: boolea
const { openModal } = useActions(retentionModalLogic(insightProps))
const backgroundColor = theme?.['preset-1'] || '#000000' // Default to black if no color found
const backgroundColorMean = theme?.['preset-2'] || '#000000' // Default to black if no color found
const showMean = retentionFilter?.showMean || false
const showMean = retentionFilter?.showMean || null

return (
<table
Expand All @@ -37,7 +37,51 @@ export function RetentionTable({ inSharedMode = false }: { inSharedMode?: boolea
))}
</tr>

{showMean && tableRows.length > 0 ? (
{showMean === 'weighted' && tableRows.length > 0 ? (
<tr className="border-b" key={-2}>
{range(0, tableRows[0].length).map((columnIndex) => (
<td key={columnIndex} className="pb-2">
{columnIndex <= (hideSizeColumn ? 0 : 1) ? (
columnIndex == 0 ? (
<span className="RetentionTable__TextTab">Weighted Mean</span>
) : null
) : (
<CohortDay
percentage={
(() => {
const validRows = tableRows.filter((row) => {
return !(
(columnIndex >= row.length - 1 && isLatestPeriod) ||
!row[columnIndex] ||
row[columnIndex].count <= 0
)
})
if (validRows.length === 0) {
return 0
}
const weights = validRows.map((row) =>
parseInt(row[1]?.toString() || '0')
)
const weightedSum = sum(
validRows.map(
(row, i) => (row[columnIndex]?.percentage || 0) * weights[i]
)
)
const totalWeight = sum(weights)
return totalWeight > 0 ? weightedSum / totalWeight : 0
})() || 0
}
latest={isLatestPeriod && columnIndex == tableRows[0].length - 1}
clickable={false}
backgroundColor={backgroundColorMean}
/>
)}
</td>
))}
</tr>
) : undefined}

{showMean === 'simple' && tableRows.length > 0 ? (
<tr className="border-b" key={-1}>
{range(0, tableRows[0].length).map((columnIndex) => (
<td key={columnIndex} className="pb-2">
Expand All @@ -50,10 +94,12 @@ export function RetentionTable({ inSharedMode = false }: { inSharedMode?: boolea
percentage={
mean(
tableRows.map((row) => {
// Stop before the last item in a row, which is an incomplete time period
// Don't include the last item in a row, which is an incomplete time period
// Also don't include the percentage if the cohort size (count) is 0 or less
if (
(columnIndex >= row.length - 1 && isLatestPeriod) ||
!row[columnIndex]
!row[columnIndex] ||
row[columnIndex].count <= 0
) {
return null
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
PluginsAccessLevel,
PROPERTY_MATCH_TYPE,
RETENTION_FIRST_TIME,
RETENTION_MEAN_NONE,
RETENTION_RECURRING,
ShownAsValue,
TeamMembershipLevel,
Expand Down Expand Up @@ -2435,7 +2436,7 @@ export interface RetentionFilterType extends FilterType {
cumulative?: boolean

//frontend only
show_mean?: boolean
show_mean?: 'simple' | 'weighted' | typeof RETENTION_MEAN_NONE
}
export interface LifecycleFilterType extends FilterType {
/** @deprecated */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1504,7 +1504,7 @@ def test_retention_filter(self):
},
"target_entity": {"id": "$pageview", "name": "$pageview", "type": "events"},
"period": "Week",
"show_mean": True,
"show_mean": "simple",
"cumulative": True,
}

Expand All @@ -1531,7 +1531,7 @@ def test_retention_filter(self):
"custom_name": None,
"order": None,
},
showMean=True,
showMean="simple",
cumulative=True,
),
)
Expand Down
Loading
Loading