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 more details to NPS score [part 2] #29112

Merged
merged 11 commits into from
Feb 25, 2025
7 changes: 4 additions & 3 deletions frontend/src/scenes/surveys/QuestionBranchingInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { LemonDialog, LemonSelect } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { truncate } from 'lib/utils'
import { NPS_DETRACTOR_LABEL, NPS_PASSIVE_LABEL, NPS_PROMOTER_LABEL } from 'scenes/surveys/constants'

import { MultipleSurveyQuestion, RatingSurveyQuestion, SurveyQuestionBranchingType, SurveyQuestionType } from '~/types'

Expand Down Expand Up @@ -146,9 +147,9 @@ function QuestionResponseBasedBranchingInput({
} else if (question.type === SurveyQuestionType.Rating && question.scale === 10) {
config = [
// NPS categories
{ value: 'detractors', label: '0 to 6 (Detractors)' },
{ value: 'passives', label: '7 to 8 (Passives)' },
{ value: 'promoters', label: '9 to 10 (Promoters)' },
{ value: 'detractors', label: `0 to 6 (${NPS_DETRACTOR_LABEL})` },
{ value: 'passives', label: `7 to 8 (${NPS_PASSIVE_LABEL})` },
{ value: 'promoters', label: `9 to 10 (${NPS_PROMOTER_LABEL})` },
]
} else if (question.type === SurveyQuestionType.SingleChoice) {
config = question.choices.map((choice, choiceIndex) => ({
Expand Down
34 changes: 25 additions & 9 deletions frontend/src/scenes/surveys/SurveyView.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import './SurveyView.scss'

import { IconGraph } from '@posthog/icons'
import { LemonButton, LemonDialog, LemonDivider, Link, Spinner } from '@posthog/lemon-ui'
import { IconGraph, IconInfo } from '@posthog/icons'
import { LemonButton, LemonDialog, LemonDivider, Link, Spinner, Tooltip } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog'
import { CompareFilter } from 'lib/components/CompareFilter/CompareFilter'
Expand Down Expand Up @@ -34,14 +34,15 @@ import {
SurveyType,
} from '~/types'

import { SURVEY_EVENT_NAME, SurveyQuestionLabel } from './constants'
import { NPS_DETRACTOR_LABEL, NPS_PASSIVE_LABEL, SURVEY_EVENT_NAME, SurveyQuestionLabel } from './constants'
import { SurveyDisplaySummary } from './Survey'
import { SurveyAPIEditor } from './SurveyAPIEditor'
import { SurveyFormAppearance } from './SurveyFormAppearance'
import { surveyLogic } from './surveyLogic'
import { surveysLogic } from './surveysLogic'
import {
MultipleChoiceQuestionBarChart,
NPSStackedBar,
NPSSurveyResultsBarChart,
OpenTextViz,
RatingQuestionBarChart,
Expand Down Expand Up @@ -623,15 +624,30 @@ function SurveyNPSResults({
surveyNPSScore?: string | null
questionIndex: number
}): JSX.Element {
const { dateRange, interval, compareFilter, defaultInterval } = useValues(surveyLogic)
const { dateRange, interval, compareFilter, defaultInterval, npsBreakdown } = useValues(surveyLogic)
const { setDateRange, setInterval, setCompareFilter } = useActions(surveyLogic)

return (
<div>
{surveyNPSScore && (
<>
<div className="text-4xl font-bold">{surveyNPSScore}</div>
<div className="mb-2 font-semibold text-secondary">Latest NPS Score</div>
<div className="flex items-center gap-2">
<div className="text-4xl font-bold">{surveyNPSScore}</div>
</div>
<div className="mb-2 font-semibold text-secondary">
<Tooltip
placement="bottom"
title="NPS Score is calculated by subtracting the percentage of detractors (0-6) from the percentage of promoters (9-10). Passives (7-8) are not included in the calculation. It can range from -100 to 100."
>
<IconInfo className="text-muted" />
</Tooltip>{' '}
Latest NPS Score
</div>
{npsBreakdown && (
<div className="space-y-2 mt-2 mb-4">
<NPSStackedBar npsBreakdown={npsBreakdown} />
</div>
)}
</>
)}
<div className="space-y-2 bg-surface-primary p-2 rounded">
Expand Down Expand Up @@ -682,7 +698,7 @@ function SurveyNPSResults({
{
event: SURVEY_EVENT_NAME,
kind: NodeKind.EventsNode,
custom_name: 'Promoters',
custom_name: NPS_PASSIVE_LABEL,
properties: [
{
type: PropertyFilterType.Event,
Expand All @@ -695,7 +711,7 @@ function SurveyNPSResults({
{
event: SURVEY_EVENT_NAME,
kind: NodeKind.EventsNode,
custom_name: 'Passives',
custom_name: NPS_PASSIVE_LABEL,
properties: [
{
type: PropertyFilterType.Event,
Expand All @@ -708,7 +724,7 @@ function SurveyNPSResults({
{
event: SURVEY_EVENT_NAME,
kind: NodeKind.EventsNode,
custom_name: 'Detractors',
custom_name: NPS_DETRACTOR_LABEL,
properties: [
{
type: PropertyFilterType.Event,
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/scenes/surveys/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,7 @@ export const WEB_SAFE_FONTS = [
'Georgia',
'Courier New',
] as const

export const NPS_DETRACTOR_LABEL = 'Detractors'
export const NPS_PASSIVE_LABEL = 'Passives'
export const NPS_PROMOTER_LABEL = 'Promoters'
173 changes: 93 additions & 80 deletions frontend/src/scenes/surveys/surveyViewViz.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
IconThumbsUpFilled,
} from '@posthog/icons'
import { LemonButton, LemonTable } from '@posthog/lemon-ui'
import clsx from 'clsx'
import { BindLogic, useActions, useValues } from 'kea'
import { FlaggedFeature } from 'lib/components/FlaggedFeature'
import { FEATURE_FLAGS } from 'lib/constants'
Expand All @@ -23,7 +24,8 @@ import { PieChart } from 'scenes/insights/views/LineGraph/PieChart'
import { maxGlobalLogic } from 'scenes/max/maxGlobalLogic'
import { PersonDisplay } from 'scenes/persons/PersonDisplay'
import { AIConsentPopoverWrapper } from 'scenes/settings/organization/AIConsentPopoverWrapper'
import { getSurveyResponseKey } from 'scenes/surveys/utils'
import { NPS_DETRACTOR_LABEL, NPS_PASSIVE_LABEL, NPS_PROMOTER_LABEL } from 'scenes/surveys/constants'
import { getSurveyResponseKey, NPSBreakdown } from 'scenes/surveys/utils'

import { GraphType, InsightLogicProps, SurveyQuestionType } from '~/types'

Expand Down Expand Up @@ -53,99 +55,68 @@ const formatCount = (count: number, total: number): string => {
return `${humanFriendlyNumber(count)}`
}

export function UsersCount({ surveyUserStats }: { surveyUserStats: SurveyUserStats }): JSX.Element {
const { seen, dismissed, sent } = surveyUserStats
const total = seen + dismissed + sent
const labelTotal = total === 1 ? 'Unique user shown' : 'Unique users shown'
const labelSent = sent === 1 ? 'Response sent' : 'Responses sent'
// Define a type for the color classes to ensure type safety
type ColorClass = 'bg-brand-blue' | 'bg-warning' | 'bg-success' | 'bg-danger'

return (
<div className="inline-flex mb-4">
<div>
<div className="text-4xl font-bold">{humanFriendlyNumber(total)}</div>
<div className="font-semibold text-secondary">{labelTotal}</div>
</div>
{sent > 0 && (
<div className="ml-10">
<div className="text-4xl font-bold">{humanFriendlyNumber(sent)}</div>
<div className="font-semibold text-secondary">{labelSent}</div>
</div>
)}
</div>
)
type StackedBarSegment = {
count: number
label: string
colorClass: ColorClass
}

export function UsersStackedBar({ surveyUserStats }: { surveyUserStats: SurveyUserStats }): JSX.Element {
const { seen, dismissed, sent } = surveyUserStats

const total = seen + dismissed + sent
const seenPercentage = (seen / total) * 100
const dismissedPercentage = (dismissed / total) * 100
const sentPercentage = (sent / total) * 100
function StackedBar({ segments }: { segments: StackedBarSegment[] }): JSX.Element {
const total = segments.reduce((sum, segment) => sum + segment.count, 0)
let accumulatedPercentage = 0

return (
<>
{total > 0 && (
<div>
<div className="relative w-full mx-auto h-10 mb-4">
{[
{
count: seen,
label: 'Unanswered',
classes: `rounded-l ${dismissed === 0 && sent === 0 ? 'rounded-r' : ''}`,
style: { backgroundColor: '#1D4AFF', width: `${seenPercentage}%` },
},
{
count: dismissed,
label: 'Dismissed',
classes: `${seen === 0 ? 'rounded-l' : ''} ${sent === 0 ? 'rounded-r' : ''}`,
style: {
backgroundColor: '#E3A506',
width: `${dismissedPercentage}%`,
left: `${seenPercentage}%`,
},
},
{
count: sent,
label: 'Sent',
classes: `rounded-r ${seen === 0 && dismissed === 0 ? 'rounded-l' : ''}`,
style: {
backgroundColor: '#529B08',
width: `${sentPercentage}%`,
left: `${seenPercentage + dismissedPercentage}%`,
},
},
].map(({ count, label, classes, style }) => (
<Tooltip
key={`survey-summary-chart-${label}`}
title={`${label} surveys: ${count}`}
delayMs={0}
placement="top"
>
<div
className={`h-10 text-white text-center absolute cursor-pointer ${classes}`}
// eslint-disable-next-line react/forbid-dom-props
style={style}
{segments.map(({ count, label, colorClass }, index) => {
const percentage = (count / total) * 100
const left = accumulatedPercentage
accumulatedPercentage += percentage

const isFirst = index === 0
const isLast = index === segments.length - 1
const isOnly = segments.length === 1

return (
<Tooltip
key={`stacked-bar-${label}`}
title={`${label}: ${count} (${percentage.toFixed(1)}%)`}
delayMs={0}
placement="top"
>
<span className="inline-flex font-semibold max-w-full px-1 truncate leading-10">
{formatCount(count, total)}
</span>
</div>
</Tooltip>
))}
<div
className={clsx(
'h-10 text-white text-center absolute cursor-pointer',
colorClass,
isFirst || isOnly ? 'rounded-l' : '',
isLast || isOnly ? 'rounded-r' : ''
)}
// eslint-disable-next-line react/forbid-dom-props
style={{
width: `${percentage}%`,
left: `${left}%`,
}}
>
<span className="inline-flex font-semibold max-w-full px-1 truncate leading-10">
{formatCount(count, total)}
</span>
</div>
</Tooltip>
)
})}
</div>
<div className="w-full flex justify-center">
<div className="flex items-center">
{[
{ count: seen, label: 'Unanswered', style: { backgroundColor: '#1D4AFF' } },
{ count: dismissed, label: 'Dismissed', style: { backgroundColor: '#E3A506' } },
{ count: sent, label: 'Submitted', style: { backgroundColor: '#529B08' } },
].map(
({ count, label, style }) =>
{segments.map(
({ count, label, colorClass }) =>
count > 0 && (
<div key={`survey-summary-legend-${label}`} className="flex items-center mr-6">
{/* eslint-disable-next-line react/forbid-dom-props */}
<div className="w-3 h-3 rounded-full mr-2" style={style} />
<div key={`stacked-bar-legend-${label}`} className="flex items-center mr-6">
<div className={clsx('w-3 h-3 rounded-full mr-2', colorClass)} />
<span className="font-semibold text-secondary">{`${label} (${(
(count / total) *
100
Expand All @@ -161,6 +132,48 @@ export function UsersStackedBar({ surveyUserStats }: { surveyUserStats: SurveyUs
)
}

export function UsersCount({ surveyUserStats }: { surveyUserStats: SurveyUserStats }): JSX.Element {
const { seen, dismissed, sent } = surveyUserStats
const total = seen + dismissed + sent

return (
<div className="inline-flex mb-4">
<div>
<div className="text-4xl font-bold">{humanFriendlyNumber(total)}</div>
<div className="font-semibold text-secondary">Unique user(s) shown</div>
</div>
{sent > 0 && (
<div className="ml-10">
<div className="text-4xl font-bold">{humanFriendlyNumber(sent)}</div>
<div className="font-semibold text-secondary">Response(s) sent</div>
</div>
)}
</div>
)
}

export function UsersStackedBar({ surveyUserStats }: { surveyUserStats: SurveyUserStats }): JSX.Element {
const { seen, dismissed, sent } = surveyUserStats

const segments: StackedBarSegment[] = [
{ count: seen, label: 'Unanswered', colorClass: 'bg-brand-blue' },
{ count: dismissed, label: 'Dismissed', colorClass: 'bg-warning' },
{ count: sent, label: 'Submitted', colorClass: 'bg-success' },
]

return <StackedBar segments={segments} />
}

export function NPSStackedBar({ npsBreakdown }: { npsBreakdown: NPSBreakdown }): JSX.Element {
const segments: StackedBarSegment[] = [
{ count: npsBreakdown.detractors, label: NPS_DETRACTOR_LABEL, colorClass: 'bg-danger' },
{ count: npsBreakdown.passives, label: NPS_PASSIVE_LABEL, colorClass: 'bg-warning' },
{ count: npsBreakdown.promoters, label: NPS_PROMOTER_LABEL, colorClass: 'bg-success' },
]

return <StackedBar segments={segments} />
}

export function Summary({
surveyUserStats,
surveyUserStatsLoading,
Expand Down
Loading