diff --git a/cypress/e2e/insights-navigation-open-sql-insight-first.cy.ts b/cypress/e2e/insights-navigation-open-sql-insight-first.cy.ts index a902e861bacd6..81111c98e83ed 100644 --- a/cypress/e2e/insights-navigation-open-sql-insight-first.cy.ts +++ b/cypress/e2e/insights-navigation-open-sql-insight-first.cy.ts @@ -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', () => { diff --git a/cypress/e2e/insights-navigation.cy.ts b/cypress/e2e/insights-navigation.cy.ts index 39a7f6f48b699..b6998785fd5b5 100644 --- a/cypress/e2e/insights-navigation.cy.ts +++ b/cypress/e2e/insights-navigation.cy.ts @@ -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', () => { diff --git a/frontend/__snapshots__/scenes-app-insights--retention--dark.png b/frontend/__snapshots__/scenes-app-insights--retention--dark.png index a49b3c1f0707e..6b653111ed6ea 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention--dark.png and b/frontend/__snapshots__/scenes-app-insights--retention--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--retention--light.png b/frontend/__snapshots__/scenes-app-insights--retention--light.png index 64cbbdd51b73d..b0b10817cc2e6 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention--light.png and b/frontend/__snapshots__/scenes-app-insights--retention--light.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--retention-breakdown--dark.png b/frontend/__snapshots__/scenes-app-insights--retention-breakdown--dark.png index c9496e09b52dd..4a5ea9df34cb3 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention-breakdown--dark.png and b/frontend/__snapshots__/scenes-app-insights--retention-breakdown--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--retention-breakdown--light.png b/frontend/__snapshots__/scenes-app-insights--retention-breakdown--light.png index c74ec68a8bb36..5f9e1c795f4f0 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention-breakdown--light.png and b/frontend/__snapshots__/scenes-app-insights--retention-breakdown--light.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--dark--webkit.png b/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--dark--webkit.png index ee97ec81e3347..91e1f770555d7 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--dark--webkit.png and b/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--dark--webkit.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--dark.png b/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--dark.png index fe17c5d4d33b2..a706aea32a08a 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--dark.png and b/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--light--webkit.png b/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--light--webkit.png index f9e9a6da4abad..a371825a29c66 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--light--webkit.png and b/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--light--webkit.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--light.png b/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--light.png index e3afc7c99f762..177abad5889ea 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--light.png and b/frontend/__snapshots__/scenes-app-insights--retention-breakdown-edit--light.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--retention-edit--dark--webkit.png b/frontend/__snapshots__/scenes-app-insights--retention-edit--dark--webkit.png index 19015926db0d4..1074de5a35fe4 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention-edit--dark--webkit.png and b/frontend/__snapshots__/scenes-app-insights--retention-edit--dark--webkit.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--retention-edit--dark.png b/frontend/__snapshots__/scenes-app-insights--retention-edit--dark.png index 1fb6bf8b4d851..797c5a21d2a1c 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention-edit--dark.png and b/frontend/__snapshots__/scenes-app-insights--retention-edit--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--retention-edit--light--webkit.png b/frontend/__snapshots__/scenes-app-insights--retention-edit--light--webkit.png index 19518f6f2aa3d..de8915f211997 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention-edit--light--webkit.png and b/frontend/__snapshots__/scenes-app-insights--retention-edit--light--webkit.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--retention-edit--light.png b/frontend/__snapshots__/scenes-app-insights--retention-edit--light.png index 4b3b5b1af4754..a8c8f42b12ab8 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--retention-edit--light.png and b/frontend/__snapshots__/scenes-app-insights--retention-edit--light.png differ diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 1b26d32c15361..8ada4ecdcd834 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -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', diff --git a/frontend/src/queries/nodes/InsightQuery/defaults.ts b/frontend/src/queries/nodes/InsightQuery/defaults.ts index 6334c57b2c98c..3db2d3c9c1c68 100644 --- a/frontend/src/queries/nodes/InsightQuery/defaults.ts +++ b/frontend/src/queries/nodes/InsightQuery/defaults.ts @@ -54,6 +54,7 @@ const retentionQueryDefault: RetentionQuery = { type: 'events', }, retentionType: 'retention_first_time', + showMean: 'simple', }, } diff --git a/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.test.ts b/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.test.ts index 0b10cb3821bc2..690d804154ef2 100644 --- a/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.test.ts +++ b/frontend/src/queries/nodes/InsightQuery/utils/filtersToQueryNode.test.ts @@ -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) @@ -683,7 +683,7 @@ describe('filtersToQueryNode', () => { returningEntity: { id: '1' }, targetEntity: { id: '1' }, period: RetentionPeriod.Day, - showMean: true, + showMean: 'simple', }, } expect(result).toEqual(query) diff --git a/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx b/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx index 5715d8ef88218..51caf45829daa 100644 --- a/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx +++ b/frontend/src/queries/nodes/InsightViz/InsightDisplayConfig.tsx @@ -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' @@ -178,7 +178,7 @@ export function InsightDisplayConfig(): JSX.Element { - + )} diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 341d45984e930..ec815a16cae59 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -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", @@ -12479,7 +12480,8 @@ "$ref": "#/definitions/RetentionEntity" }, "showMean": { - "type": "boolean" + "enum": ["simple", "weighted", "none"], + "type": "string" }, "targetEntity": { "$ref": "#/definitions/RetentionEntity" @@ -12513,7 +12515,8 @@ "$ref": "#/definitions/RetentionEntity" }, "show_mean": { - "type": "boolean" + "enum": ["simple", "weighted", "none"], + "type": "string" }, "target_entity": { "$ref": "#/definitions/RetentionEntity" diff --git a/frontend/src/scenes/insights/filters/RetentionMeanCheckbox.tsx b/frontend/src/scenes/insights/filters/RetentionMeanCheckbox.tsx deleted file mode 100644 index dbedb799f55f6..0000000000000 --- a/frontend/src/scenes/insights/filters/RetentionMeanCheckbox.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { LemonSwitch } from '@posthog/lemon-ui' -import { useActions, useValues } from 'kea' -import { insightLogic } from 'scenes/insights/insightLogic' -import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic' - -export function RetentionMeanCheckbox(): JSX.Element | null { - const { insightProps, canEditInsight } = useValues(insightLogic) - - const { retentionFilter } = useValues(insightVizDataLogic(insightProps)) - const { updateInsightFilter } = useActions(insightVizDataLogic(insightProps)) - - const showMean = retentionFilter?.showMean || false - - if (!canEditInsight) { - return null - } - - return ( - { - updateInsightFilter({ showMean }) - }} - checked={showMean} - label={Show mean across cohorts} - bordered - size="small" - /> - ) -} diff --git a/frontend/src/scenes/insights/filters/RetentionMeanDropdown.tsx b/frontend/src/scenes/insights/filters/RetentionMeanDropdown.tsx new file mode 100644 index 0000000000000..caa422d527d60 --- /dev/null +++ b/frontend/src/scenes/insights/filters/RetentionMeanDropdown.tsx @@ -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 ( + { + updateInsightFilter({ showMean }) + }} + 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', + 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.', + }, + ]} + /> + ) +} diff --git a/frontend/src/scenes/insights/utils.tsx b/frontend/src/scenes/insights/utils.tsx index 077107b8f6a35..b5fff6d7ed66f 100644 --- a/frontend/src/scenes/insights/utils.tsx +++ b/frontend/src/scenes/insights/utils.tsx @@ -570,28 +570,35 @@ export function isQueryTooLarge(query: Node>): boolean { return queryLength > 1024 * 1024 } -export function parseDraftQueryFromLocalStorage( - query: string -): { query: Node>; timestamp: number } | null { +function parseAndMigrateQuery(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>; timestamp: number } | null { + return parseAndMigrateQuery(query) +} + export function crushDraftQueryForLocalStorage(query: Node>, timestamp: number): string { return JSON.stringify({ query, timestamp }) } export function parseDraftQueryFromURL(query: string): Node> | null { - try { - return JSON.parse(query) - } catch (e) { - console.error('Error parsing query', e) - return null - } + return parseAndMigrateQuery(query) } export function crushDraftQueryForURL(query: Node>): string { diff --git a/frontend/src/scenes/insights/utils/cleanFilters.ts b/frontend/src/scenes/insights/utils/cleanFilters.ts index b0ba0308bec3e..95567d2c78ba0 100644 --- a/frontend/src/scenes/insights/utils/cleanFilters.ts +++ b/frontend/src/scenes/insights/utils/cleanFilters.ts @@ -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' @@ -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 diff --git a/frontend/src/scenes/retention/RetentionTable.tsx b/frontend/src/scenes/retention/RetentionTable.tsx index 3e1bb9a4ed4eb..4690c1168ad63 100644 --- a/frontend/src/scenes/retention/RetentionTable.tsx +++ b/frontend/src/scenes/retention/RetentionTable.tsx @@ -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' @@ -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 ( - {showMean && tableRows.length > 0 ? ( + {showMean === 'weighted' && tableRows.length > 0 ? ( + + {range(0, tableRows[0].length).map((columnIndex) => ( + + ))} + + ) : undefined} + + {showMean === 'simple' && tableRows.length > 0 ? ( {range(0, tableRows[0].length).map((columnIndex) => (
+ {columnIndex <= (hideSizeColumn ? 0 : 1) ? ( + columnIndex == 0 ? ( + Weighted Mean + ) : null + ) : ( + { + 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} + /> + )} +
@@ -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 } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f548fa1121e57..f44c706850f48 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -16,6 +16,7 @@ import { PluginsAccessLevel, PROPERTY_MATCH_TYPE, RETENTION_FIRST_TIME, + RETENTION_MEAN_NONE, RETENTION_RECURRING, ShownAsValue, TeamMembershipLevel, @@ -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 */ diff --git a/posthog/hogql_queries/legacy_compatibility/test/test_filter_to_query.py b/posthog/hogql_queries/legacy_compatibility/test/test_filter_to_query.py index 9ba7c48f80c2a..697e48d4b0052 100644 --- a/posthog/hogql_queries/legacy_compatibility/test/test_filter_to_query.py +++ b/posthog/hogql_queries/legacy_compatibility/test/test_filter_to_query.py @@ -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, } @@ -1531,7 +1531,7 @@ def test_retention_filter(self): "custom_name": None, "order": None, }, - showMean=True, + showMean="simple", cumulative=True, ), ) diff --git a/posthog/migrations/0660_migrate_retention_show_mean.py b/posthog/migrations/0660_migrate_retention_show_mean.py new file mode 100644 index 0000000000000..fd286a8916664 --- /dev/null +++ b/posthog/migrations/0660_migrate_retention_show_mean.py @@ -0,0 +1,49 @@ +from django.db import migrations + + +def migrate_show_mean_from_boolean_to_string(apps, schema_editor): + Insight = apps.get_model("posthog", "Insight") + + # Get all retention insights + retention_insights = Insight.objects.filter( + filters__insight="RETENTION", deleted=False, filters__has_key="show_mean" + ).exclude( + filters__show_mean__isnull=True, + ) + + for insight in retention_insights.iterator(chunk_size=100): + if isinstance(insight.filters.get("show_mean"), bool): + # Convert boolean to string - if True, use 'simple' + insight.filters["show_mean"] = "simple" if insight.filters["show_mean"] else None + insight.save() + + +def reverse_migrate_show_mean_from_string_to_boolean(apps, schema_editor): + Insight = apps.get_model("posthog", "Insight") + + # Get all retention insights + retention_insights = Insight.objects.filter( + filters__insight="RETENTION", deleted=False, filters__has_key="show_mean" + ).exclude( + filters__show_mean__isnull=True, + ) + + for insight in retention_insights.iterator(chunk_size=100): + if isinstance(insight.filters.get("show_mean"), str): + # Convert string back to boolean - 'simple' and 'weighted' becomes True + insight.filters["show_mean"] = ( + insight.filters["show_mean"] == "simple" or insight.filters["show_mean"] == "weighted" + ) + insight.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("posthog", "0559_team_api_query_rate_limit"), + ] + + operations = [ + migrations.RunPython( + migrate_show_mean_from_boolean_to_string, reverse_migrate_show_mean_from_string_to_boolean + ), + ] diff --git a/posthog/migrations/max_migration.txt b/posthog/migrations/max_migration.txt index 0a43743bbee45..9b16823a2cfdc 100644 --- a/posthog/migrations/max_migration.txt +++ b/posthog/migrations/max_migration.txt @@ -1 +1 @@ -0559_team_api_query_rate_limit +0660_migrate_retention_show_mean diff --git a/posthog/schema.py b/posthog/schema.py index c03d9b04112d4..e953e5d7f52b2 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -149,6 +149,12 @@ class RetentionReference(StrEnum): PREVIOUS = "previous" +class ShowMean(StrEnum): + SIMPLE = "simple" + WEIGHTED = "weighted" + NONE = "none" + + class AssistantSetPropertyFilterOperator(StrEnum): IS_SET = "is_set" IS_NOT_SET = "is_not_set" @@ -5498,7 +5504,7 @@ class RetentionFilter(BaseModel): ) retentionType: Optional[RetentionType] = None returningEntity: Optional[RetentionEntity] = None - showMean: Optional[bool] = None + showMean: Optional[ShowMean] = None targetEntity: Optional[RetentionEntity] = None totalIntervals: Optional[int] = 11 @@ -5515,7 +5521,7 @@ class RetentionFilterLegacy(BaseModel): ) retention_type: Optional[RetentionType] = None returning_entity: Optional[RetentionEntity] = None - show_mean: Optional[bool] = None + show_mean: Optional[ShowMean] = None target_entity: Optional[RetentionEntity] = None total_intervals: Optional[int] = None @@ -5892,7 +5898,7 @@ class AssistantRetentionFilter(BaseModel): returningEntity: Optional[RetentionEntity] = Field( default=None, description="Retention event (event marking the user coming back)." ) - showMean: Optional[bool] = Field( + showMean: Optional[ShowMean] = Field( default=None, description=( "Whether an additional series should be shown, showing the mean conversion for each period across cohorts."