Skip to content

Commit

Permalink
feat: add more details to NPS score [part 2] (#29112)
Browse files Browse the repository at this point in the history
  • Loading branch information
lucasheriques authored Feb 25, 2025
1 parent 203acba commit 5f918f5
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 93 deletions.
2 changes: 1 addition & 1 deletion cypress/e2e/surveys.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ describe('Surveys', () => {
// refresh, see survey show up on page
cy.reload()

cy.contains('Unique users shown').should('exist')
cy.contains('Unique user(s) shown').should('exist')

// Update the stop survey part
cy.contains('Stop').click()
Expand Down
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 @@ -324,3 +324,7 @@ export const WEB_SAFE_FONTS = [
{ value: 'Georgia', label: 'Georgia' },
{ value: 'Courier New', label: '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

0 comments on commit 5f918f5

Please sign in to comment.