(analytics) Improve analytics graph accuracy

This commit is contained in:
Baptiste Arnaud
2023-06-30 12:13:17 +02:00
parent 55ff944ebb
commit b0f25f301b
28 changed files with 512 additions and 157 deletions

View File

@@ -21,7 +21,7 @@ test('analytics are not available for non-pro workspaces', async ({ page }) => {
const firstDropoffBox = page.locator('text="%" >> nth=0')
await firstDropoffBox.hover()
await expect(
page.locator('text="Unlock Drop-off rate by upgrading to Pro plan"')
page.locator('text="Upgrade your plan to PRO to reveal drop-off rate."')
).toBeVisible()
await firstDropoffBox.click()
await expect(

View File

@@ -0,0 +1,60 @@
import prisma from '@/lib/prisma'
import { authenticatedProcedure } from '@/helpers/server/trpc'
import { TRPCError } from '@trpc/server'
import { PublicTypebot } from '@typebot.io/schemas'
import { z } from 'zod'
import { canReadTypebots } from '@/helpers/databaseRules'
import { totalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
export const getTotalAnswersInBlocks = authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/typebots/{typebotId}/analytics/totalAnswersInBlocks',
protect: true,
summary: 'List total answers in blocks',
tags: ['Analytics'],
},
})
.input(
z.object({
typebotId: z.string(),
})
)
.output(z.object({ totalAnswersInBlocks: z.array(totalAnswersInBlock) }))
.query(async ({ input: { typebotId }, ctx: { user } }) => {
const typebot = await prisma.typebot.findFirst({
where: canReadTypebots(typebotId, user),
select: { publishedTypebot: true },
})
if (!typebot?.publishedTypebot)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Published typebot not found',
})
const publishedTypebot = typebot.publishedTypebot as PublicTypebot
const totalAnswersPerBlock = await prisma.answer.groupBy({
by: ['itemId', 'blockId'],
where: {
result: {
typebotId: typebot.publishedTypebot.typebotId,
},
blockId: {
in: publishedTypebot.groups.flatMap((group) =>
group.blocks.map((block) => block.id)
),
},
},
_count: { _all: true },
})
return {
totalAnswersInBlocks: totalAnswersPerBlock.map((answer) => ({
blockId: answer.blockId,
itemId: answer.itemId ?? undefined,
total: answer._count._all,
})),
}
})

View File

@@ -0,0 +1,6 @@
import { router } from '@/helpers/server/trpc'
import { getTotalAnswersInBlocks } from './getTotalAnswersInBlocks'
export const analyticsRouter = router({
getTotalAnswersInBlocks,
})

View File

@@ -4,32 +4,37 @@ import {
useColorModeValue,
useDisclosure,
} from '@chakra-ui/react'
import { useToast } from '@/hooks/useToast'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { Stats } from '@typebot.io/schemas'
import React from 'react'
import { useAnswersCount } from '../hooks/useAnswersCount'
import { StatsCards } from './StatsCards'
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
import { Graph } from '@/features/graph/components/Graph'
import { GraphProvider } from '@/features/graph/providers/GraphProvider'
import { GroupsCoordinatesProvider } from '@/features/graph/providers/GroupsCoordinateProvider'
import { useI18n } from '@/locales'
import { trpc } from '@/lib/trpc'
import { isDefined } from '@typebot.io/lib'
export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
const t = useI18n()
const { isOpen, onOpen, onClose } = useDisclosure()
const { typebot, publishedTypebot } = useTypebot()
const { showToast } = useToast()
const { answersCounts } = useAnswersCount({
typebotId: publishedTypebot && typebot?.id,
onError: (err) => showToast({ title: err.name, description: err.message }),
})
const { data } = trpc.analytics.getTotalAnswersInBlocks.useQuery(
{
typebotId: typebot?.id as string,
},
{ enabled: isDefined(publishedTypebot) }
)
const startBlockId = publishedTypebot?.groups
.find((group) => group.blocks.at(0)?.type === 'start')
?.blocks.at(0)?.id
return (
<Flex
w="full"
pos="relative"
bgColor={useColorModeValue('white', 'gray.850')}
bgColor={useColorModeValue('#f4f5f8', 'gray.850')}
backgroundImage={useColorModeValue(
'radial-gradient(#c6d0e1 1px, transparent 0)',
'radial-gradient(#2f2f39 1px, transparent 0)'
@@ -39,18 +44,24 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
h="full"
justifyContent="center"
>
{publishedTypebot && answersCounts && stats ? (
{publishedTypebot &&
data?.totalAnswersInBlocks &&
stats &&
startBlockId ? (
<GraphProvider isReadOnly>
<GroupsCoordinatesProvider groups={publishedTypebot?.groups}>
<Graph
flex="1"
typebot={publishedTypebot}
onUnlockProPlanClick={onOpen}
answersCounts={
answersCounts[0]
totalAnswersInBlocks={
startBlockId
? [
{ ...answersCounts[0], totalAnswers: stats?.totalStarts },
...answersCounts.slice(1),
{
blockId: startBlockId,
total: stats.totalViews,
},
...data.totalAnswersInBlocks,
]
: []
}
@@ -72,7 +83,7 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
isOpen={isOpen}
type={t('billing.limitMessage.analytics')}
/>
<StatsCards stats={stats} pos="absolute" top={10} />
<StatsCards stats={stats} pos="absolute" />
</Flex>
)
}

View File

@@ -0,0 +1,85 @@
import { isInputBlock } from '@typebot.io/lib'
import { PublicTypebot } from '@typebot.io/schemas'
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
export const computePreviousTotalAnswers = (
publishedTypebot: PublicTypebot,
currentBlockId: string,
totalAnswersInBlocks: TotalAnswersInBlock[]
): number => {
let totalAnswers = 0
const allBlocks = publishedTypebot.groups.flatMap((group) => group.blocks)
const currentGroup = publishedTypebot.groups.find((group) =>
group.blocks.find((block) => block.id === currentBlockId)
)
if (!currentGroup) return 0
const currentBlockIndex = currentGroup.blocks.findIndex(
(block) => block.id === currentBlockId
)
const previousBlocks = currentGroup.blocks.slice(0, currentBlockIndex)
for (const block of previousBlocks.reverse()) {
if (isInputBlock(block) || block.type === 'start')
return (
totalAnswersInBlocks.find(
(totalAnswersInBlock) =>
totalAnswersInBlock.blockId === block.id &&
totalAnswersInBlock.itemId === undefined
)?.total ?? 0
)
const connectedEdges = publishedTypebot.edges.filter(
(edge) => edge.to.blockId === block.id
)
if (connectedEdges.length) {
for (const connectedEdge of connectedEdges) {
const connectedBlock = allBlocks.find(
(block) => block.id === connectedEdge.from.blockId
)
if (connectedBlock) {
if (isInputBlock(connectedBlock) || connectedBlock.type === 'start') {
totalAnswers +=
totalAnswersInBlocks.find(
(totalAnswersInBlock) =>
totalAnswersInBlock.blockId === connectedEdge.from.blockId &&
totalAnswersInBlock.itemId === connectedEdge.from.itemId
)?.total ?? 0
} else {
totalAnswers += computePreviousTotalAnswers(
publishedTypebot,
connectedBlock.id,
totalAnswersInBlocks
)
}
}
}
}
}
const edgesConnectedToGroup = publishedTypebot.edges.filter(
(edge) => edge.to.groupId === currentGroup.id
)
if (edgesConnectedToGroup.length) {
for (const connectedEdge of edgesConnectedToGroup) {
const connectedBlock = allBlocks.find(
(block) => block.id === connectedEdge.from.blockId
)
if (connectedBlock) {
if (isInputBlock(connectedBlock) || connectedBlock.type === 'start') {
totalAnswers +=
totalAnswersInBlocks.find(
(totalAnswersInBlock) =>
totalAnswersInBlock.blockId === connectedEdge.from.blockId &&
totalAnswersInBlock.itemId === connectedEdge.from.itemId
)?.total ?? 0
} else {
totalAnswers += computePreviousTotalAnswers(
publishedTypebot,
connectedBlock.id,
totalAnswersInBlocks
)
}
}
}
}
return totalAnswers
}

View File

@@ -1,25 +0,0 @@
import { fetcher } from '@/helpers/fetcher'
import useSWR from 'swr'
import { AnswersCount } from '../types'
export const useAnswersCount = ({
typebotId,
onError,
}: {
typebotId?: string
onError: (error: Error) => void
}) => {
const { data, error, mutate } = useSWR<
{ answersCounts: AnswersCount[] },
Error
>(
typebotId ? `/api/typebots/${typebotId}/analytics/answersCount` : null,
fetcher
)
if (error) onError(error)
return {
answersCounts: data?.answersCounts,
isLoading: !error && !data,
mutate,
}
}

View File

@@ -1 +0,0 @@
export type AnswersCount = { groupId: string; totalAnswers: number }