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
26 changes: 21 additions & 5 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 @@ -42,6 +42,7 @@ import { surveyLogic } from './surveyLogic'
import { surveysLogic } from './surveysLogic'
import {
MultipleChoiceQuestionBarChart,
NPSStackedBar,
NPSSurveyResultsBarChart,
OpenTextViz,
RatingQuestionBarChart,
Expand Down Expand Up @@ -623,13 +624,28 @@ function SurveyNPSResults({
surveyNPSScore?: string
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>
<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">
<div className="flex items-center justify-between gap-2">
<h4 className="text-lg font-semibold">NPS Trend</h4>
Expand Down
27 changes: 21 additions & 6 deletions frontend/src/scenes/surveys/surveyLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ import {
import { defaultSurveyAppearance, defaultSurveyFieldValues, NEW_SURVEY, NewSurvey } from './constants'
import type { surveyLogicType } from './surveyLogicType'
import { surveysLogic } from './surveysLogic'
import { getSurveyResponseKey, sanitizeHTML, sanitizeSurveyAppearance, validateColor } from './utils'
import {
calculateNpsBreakdown,
calculateNpsScore,
getSurveyResponseKey,
sanitizeHTML,
sanitizeSurveyAppearance,
validateColor,
} from './utils'

const DEFAULT_OPERATORS: Record<SurveyQuestionType, { label: string; value: PropertyOperator }> = {
[SurveyQuestionType.Open]: {
Expand Down Expand Up @@ -1258,15 +1265,23 @@ export const surveyLogic = kea<surveyLogicType>([

const data: number[] = questionResults.data
if (data.length === 11) {
const promoters = data.slice(9, 11).reduce((a, b) => a + b, 0)
const passives = data.slice(7, 9).reduce((a, b) => a + b, 0)
const detractors = data.slice(0, 7).reduce((a, b) => a + b, 0)
const npsScore = ((promoters - detractors) / (promoters + passives + detractors)) * 100
return npsScore.toFixed(1)
return calculateNpsScore(calculateNpsBreakdown(questionResults)).toFixed(1)
}
}
},
],
npsBreakdown: [
(s) => [s.surveyRatingResults],
(surveyRatingResults) => {
if (!surveyRatingResults) {
return null
}
const questionIdx = Object.keys(surveyRatingResults)[0]
const questionResults = surveyRatingResults[questionIdx]

return calculateNpsBreakdown(questionResults)
},
],
getBranchingDropdownValue: [
(s) => [s.survey],
(survey) => (questionIndex: number, question: RatingSurveyQuestion | MultipleSurveyQuestion) => {
Expand Down
178 changes: 97 additions & 81 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,7 @@ 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 { getSurveyResponseKey, NPSBreakdown } from 'scenes/surveys/utils'

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

Expand Down Expand Up @@ -53,99 +54,70 @@ 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'

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
color: string
}

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, color }, 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',
isFirst || isOnly ? 'rounded-l' : '',
isLast || isOnly ? 'rounded-r' : ''
)}
// eslint-disable-next-line react/forbid-dom-props
style={{
backgroundColor: color,
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, color }) =>
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">
{}
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Empty JSX fragment {} can be removed

Suggested change
{}

<div
className="w-3 h-3 rounded-full mr-2"
// eslint-disable-next-line react/forbid-dom-props
style={{ backgroundColor: color }}
/>
<span className="font-semibold text-secondary">{`${label} (${(
(count / total) *
100
Expand All @@ -161,6 +133,50 @@ export function UsersStackedBar({ surveyUserStats }: { surveyUserStats: SurveyUs
)
}

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'

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>
)
}

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

const segments: StackedBarSegment[] = [
{ count: seen, label: 'Unanswered', color: '#1D4AFF' },
{ count: dismissed, label: 'Dismissed', color: '#E3A506' },
{ count: sent, label: 'Submitted', color: '#529B08' },
]

return <StackedBar segments={segments} />
}

export function NPSStackedBar({ npsBreakdown }: { npsBreakdown: NPSBreakdown }): JSX.Element {
const segments: StackedBarSegment[] = [
{ count: npsBreakdown.detractors, label: 'Detractors', color: 'var(--danger)' },
{ count: npsBreakdown.passives, label: 'Passives', color: 'var(--warning)' },
{ count: npsBreakdown.promoters, label: 'Promoters', color: 'var(--success)' },
]

return <StackedBar segments={segments} />
}

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