⚡ (analytics) Improve analytics graph accuracy
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
})
|
||||
6
apps/builder/src/features/analytics/api/router.ts
Normal file
6
apps/builder/src/features/analytics/api/router.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { router } from '@/helpers/server/trpc'
|
||||
import { getTotalAnswersInBlocks } from './getTotalAnswersInBlocks'
|
||||
|
||||
export const analyticsRouter = router({
|
||||
getTotalAnswersInBlocks,
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export type AnswersCount = { groupId: string; totalAnswers: number }
|
||||
Reference in New Issue
Block a user