⚡ (analytics) Improve analytics graph accuracy
This commit is contained in:
@@ -9,13 +9,13 @@ import { useUser } from '@/features/account/hooks/useUser'
|
||||
import { ZoomButtons } from './ZoomButtons'
|
||||
import { useGesture } from '@use-gesture/react'
|
||||
import { GraphNavigation } from '@typebot.io/prisma'
|
||||
import { AnswersCount } from '@/features/analytics/types'
|
||||
import { headerHeight } from '@/features/editor/constants'
|
||||
import { graphPositionDefaultValue, blockWidth } from '../constants'
|
||||
import { useBlockDnd } from '../providers/GraphDndProvider'
|
||||
import { useGraph } from '../providers/GraphProvider'
|
||||
import { useGroupsCoordinates } from '../providers/GroupsCoordinateProvider'
|
||||
import { Coordinates } from '../types'
|
||||
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
|
||||
|
||||
const maxScale = 2
|
||||
const minScale = 0.3
|
||||
@@ -23,12 +23,12 @@ const zoomButtonsScaleBlock = 0.2
|
||||
|
||||
export const Graph = ({
|
||||
typebot,
|
||||
answersCounts,
|
||||
totalAnswersInBlocks,
|
||||
onUnlockProPlanClick,
|
||||
...props
|
||||
}: {
|
||||
typebot: Typebot | PublicTypebot
|
||||
answersCounts?: AnswersCount[]
|
||||
totalAnswersInBlocks?: TotalAnswersInBlock[]
|
||||
onUnlockProPlanClick?: () => void
|
||||
} & FlexProps) => {
|
||||
const {
|
||||
@@ -244,7 +244,7 @@ export const Graph = ({
|
||||
<GraphElements
|
||||
edges={typebot.edges}
|
||||
groups={typebot.groups}
|
||||
answersCounts={answersCounts}
|
||||
totalAnswersInBlocks={totalAnswersInBlocks}
|
||||
onUnlockProPlanClick={onUnlockProPlanClick}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnswersCount } from '@/features/analytics/types'
|
||||
import { Edge, Group } from '@typebot.io/schemas'
|
||||
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
|
||||
import React, { memo } from 'react'
|
||||
import { EndpointsProvider } from '../providers/EndpointsProvider'
|
||||
import { Edges } from './edges/Edges'
|
||||
@@ -8,20 +8,20 @@ import { GroupNode } from './nodes/group'
|
||||
type Props = {
|
||||
edges: Edge[]
|
||||
groups: Group[]
|
||||
answersCounts?: AnswersCount[]
|
||||
totalAnswersInBlocks?: TotalAnswersInBlock[]
|
||||
onUnlockProPlanClick?: () => void
|
||||
}
|
||||
const GroupNodes = ({
|
||||
edges,
|
||||
groups,
|
||||
answersCounts,
|
||||
totalAnswersInBlocks,
|
||||
onUnlockProPlanClick,
|
||||
}: Props) => {
|
||||
return (
|
||||
<EndpointsProvider>
|
||||
<Edges
|
||||
edges={edges}
|
||||
answersCounts={answersCounts}
|
||||
totalAnswersInBlocks={totalAnswersInBlocks}
|
||||
onUnlockProPlanClick={onUnlockProPlanClick}
|
||||
/>
|
||||
{groups.map((group, idx) => (
|
||||
|
||||
@@ -9,23 +9,35 @@ import {
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
import { useWorkspace } from '@/features/workspace/WorkspaceProvider'
|
||||
import React, { useMemo } from 'react'
|
||||
import { byId, isDefined } from '@typebot.io/lib'
|
||||
import { useEndpoints } from '../../providers/EndpointsProvider'
|
||||
import { useGroupsCoordinates } from '../../providers/GroupsCoordinateProvider'
|
||||
import { AnswersCount } from '@/features/analytics/types'
|
||||
import { isProPlan } from '@/features/billing/helpers/isProPlan'
|
||||
import { computeDropOffPath } from '../../helpers/computeDropOffPath'
|
||||
import { computeSourceCoordinates } from '../../helpers/computeSourceCoordinates'
|
||||
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
|
||||
import { computePreviousTotalAnswers } from '@/features/analytics/helpers/computePreviousTotalAnswers'
|
||||
import { blockHasItems } from '@typebot.io/lib'
|
||||
|
||||
export const dropOffBoxDimensions = {
|
||||
width: 100,
|
||||
height: 55,
|
||||
}
|
||||
|
||||
export const dropOffSegmentLength = 80
|
||||
const dropOffSegmentMinWidth = 2
|
||||
const dropOffSegmentMaxWidth = 20
|
||||
|
||||
export const dropOffStubLength = 30
|
||||
|
||||
type Props = {
|
||||
groupId: string
|
||||
answersCounts: AnswersCount[]
|
||||
totalAnswersInBlocks: TotalAnswersInBlock[]
|
||||
blockId: string
|
||||
onUnlockProPlanClick?: () => void
|
||||
}
|
||||
|
||||
export const DropOffEdge = ({
|
||||
answersCounts,
|
||||
groupId,
|
||||
totalAnswersInBlocks,
|
||||
blockId,
|
||||
onUnlockProPlanClick,
|
||||
}: Props) => {
|
||||
const dropOffColor = useColorModeValue(
|
||||
@@ -36,25 +48,33 @@ export const DropOffEdge = ({
|
||||
const { groupsCoordinates } = useGroupsCoordinates()
|
||||
const { sourceEndpointYOffsets: sourceEndpoints } = useEndpoints()
|
||||
const { publishedTypebot } = useTypebot()
|
||||
const currentBlock = useMemo(
|
||||
() =>
|
||||
totalAnswersInBlocks.reduce<TotalAnswersInBlock | undefined>(
|
||||
(block, totalAnswersInBlock) => {
|
||||
if (totalAnswersInBlock.blockId === blockId) {
|
||||
return block
|
||||
? { ...block, total: block.total + totalAnswersInBlock.total }
|
||||
: totalAnswersInBlock
|
||||
}
|
||||
return block
|
||||
},
|
||||
undefined
|
||||
),
|
||||
[blockId, totalAnswersInBlocks]
|
||||
)
|
||||
|
||||
const isWorkspaceProPlan = isProPlan(workspace)
|
||||
|
||||
const totalAnswers = useMemo(
|
||||
() => answersCounts.find((a) => a.groupId === groupId)?.totalAnswers,
|
||||
[answersCounts, groupId]
|
||||
)
|
||||
|
||||
const { totalDroppedUser, dropOffRate } = useMemo(() => {
|
||||
if (!publishedTypebot || totalAnswers === undefined)
|
||||
if (!publishedTypebot || currentBlock?.total === undefined)
|
||||
return { previousTotal: undefined, dropOffRate: undefined }
|
||||
const previousGroupIds = publishedTypebot.edges
|
||||
.map((edge) =>
|
||||
edge.to.groupId === groupId ? edge.from.groupId : undefined
|
||||
)
|
||||
.filter(isDefined)
|
||||
const previousTotal = answersCounts
|
||||
.filter((a) => previousGroupIds.includes(a.groupId))
|
||||
.reduce((prev, acc) => acc.totalAnswers + prev, 0)
|
||||
const totalAnswers = currentBlock.total
|
||||
const previousTotal = computePreviousTotalAnswers(
|
||||
publishedTypebot,
|
||||
currentBlock.blockId,
|
||||
totalAnswersInBlocks
|
||||
)
|
||||
if (previousTotal === 0)
|
||||
return { previousTotal: undefined, dropOffRate: undefined }
|
||||
const totalDroppedUser = previousTotal - totalAnswers
|
||||
@@ -63,42 +83,92 @@ export const DropOffEdge = ({
|
||||
totalDroppedUser,
|
||||
dropOffRate: Math.round((totalDroppedUser / previousTotal) * 100),
|
||||
}
|
||||
}, [answersCounts, groupId, totalAnswers, publishedTypebot])
|
||||
|
||||
const group = publishedTypebot?.groups.find(byId(groupId))
|
||||
}, [
|
||||
currentBlock?.blockId,
|
||||
currentBlock?.total,
|
||||
publishedTypebot,
|
||||
totalAnswersInBlocks,
|
||||
])
|
||||
|
||||
const sourceTop = useMemo(() => {
|
||||
const endpointId = group?.blocks[group.blocks.length - 1].id
|
||||
return endpointId ? sourceEndpoints.get(endpointId)?.y : undefined
|
||||
}, [group?.blocks, sourceEndpoints])
|
||||
const blockTop = currentBlock?.blockId
|
||||
? sourceEndpoints.get(currentBlock.blockId)?.y
|
||||
: undefined
|
||||
if (blockTop) return blockTop
|
||||
const block = publishedTypebot?.groups
|
||||
.flatMap((group) => group.blocks)
|
||||
.find((block) => block.id === currentBlock?.blockId)
|
||||
if (!block || !blockHasItems(block)) return 0
|
||||
const itemId = block.items.at(-1)?.id
|
||||
if (!itemId) return 0
|
||||
return sourceEndpoints.get(itemId)?.y
|
||||
}, [currentBlock?.blockId, publishedTypebot?.groups, sourceEndpoints])
|
||||
|
||||
const labelCoordinates = useMemo(() => {
|
||||
if (!groupsCoordinates[groupId]) return
|
||||
return computeSourceCoordinates(groupsCoordinates[groupId], sourceTop ?? 0)
|
||||
}, [groupsCoordinates, groupId, sourceTop])
|
||||
const endpointCoordinates = useMemo(() => {
|
||||
const groupId = publishedTypebot?.groups.find((group) =>
|
||||
group.blocks.some((block) => block.id === currentBlock?.blockId)
|
||||
)?.id
|
||||
if (!groupId) return undefined
|
||||
const coordinates = groupsCoordinates[groupId]
|
||||
if (!coordinates) return undefined
|
||||
return computeSourceCoordinates(coordinates, sourceTop ?? 0)
|
||||
}, [
|
||||
publishedTypebot?.groups,
|
||||
groupsCoordinates,
|
||||
sourceTop,
|
||||
currentBlock?.blockId,
|
||||
])
|
||||
|
||||
const isLastBlock = useMemo(() => {
|
||||
if (!publishedTypebot) return false
|
||||
const lastBlock = publishedTypebot.groups
|
||||
.find((group) =>
|
||||
group.blocks.some((block) => block.id === currentBlock?.blockId)
|
||||
)
|
||||
?.blocks.at(-1)
|
||||
return lastBlock?.id === currentBlock?.blockId
|
||||
}, [publishedTypebot, currentBlock?.blockId])
|
||||
|
||||
if (!endpointCoordinates) return null
|
||||
|
||||
if (!labelCoordinates) return <></>
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d={computeDropOffPath(
|
||||
{ x: labelCoordinates.x - 300, y: labelCoordinates.y },
|
||||
sourceTop ?? 0
|
||||
{
|
||||
x: endpointCoordinates.x,
|
||||
y: endpointCoordinates.y,
|
||||
},
|
||||
isLastBlock
|
||||
)}
|
||||
stroke={dropOffColor}
|
||||
strokeWidth="2px"
|
||||
markerEnd="url(#red-arrow)"
|
||||
strokeWidth={
|
||||
dropOffSegmentMinWidth * (1 - (dropOffRate ?? 0) / 100) +
|
||||
dropOffSegmentMaxWidth * ((dropOffRate ?? 0) / 100)
|
||||
}
|
||||
fill="none"
|
||||
/>
|
||||
<foreignObject
|
||||
width="100"
|
||||
height="80"
|
||||
x={labelCoordinates.x - 30}
|
||||
y={labelCoordinates.y + 80}
|
||||
width={dropOffBoxDimensions.width}
|
||||
height={dropOffBoxDimensions.height}
|
||||
x={endpointCoordinates.x + dropOffStubLength}
|
||||
y={
|
||||
endpointCoordinates.y +
|
||||
(isLastBlock
|
||||
? dropOffSegmentLength
|
||||
: -(dropOffBoxDimensions.height / 2))
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
label="Unlock Drop-off rate by upgrading to Pro plan"
|
||||
label={
|
||||
isWorkspaceProPlan
|
||||
? `At this input, ${totalDroppedUser} user${
|
||||
(totalDroppedUser ?? 2) > 1 ? 's' : ''
|
||||
} left. This represents ${dropOffRate}% of the users who saw this input.`
|
||||
: 'Upgrade your plan to PRO to reveal drop-off rate.'
|
||||
}
|
||||
isDisabled={isWorkspaceProPlan}
|
||||
placement="top"
|
||||
>
|
||||
<VStack
|
||||
bgColor={dropOffColor}
|
||||
@@ -110,8 +180,9 @@ export const DropOffEdge = ({
|
||||
h="full"
|
||||
onClick={isWorkspaceProPlan ? undefined : onUnlockProPlanClick}
|
||||
cursor={isWorkspaceProPlan ? 'auto' : 'pointer'}
|
||||
spacing={0.5}
|
||||
>
|
||||
<Text filter={isWorkspaceProPlan ? '' : 'blur(2px)'}>
|
||||
<Text filter={isWorkspaceProPlan ? '' : 'blur(2px)'} fontSize="sm">
|
||||
{isWorkspaceProPlan ? (
|
||||
dropOffRate
|
||||
) : (
|
||||
@@ -121,7 +192,7 @@ export const DropOffEdge = ({
|
||||
)}
|
||||
%
|
||||
</Text>
|
||||
<Tag colorScheme="red">
|
||||
<Tag colorScheme="red" size="sm">
|
||||
{isWorkspaceProPlan ? (
|
||||
totalDroppedUser
|
||||
) : (
|
||||
@@ -129,7 +200,7 @@ export const DropOffEdge = ({
|
||||
NN
|
||||
</Text>
|
||||
)}{' '}
|
||||
users
|
||||
user{(totalDroppedUser ?? 2) > 1 ? 's' : ''}
|
||||
</Tag>
|
||||
</VStack>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { chakra, useColorMode } from '@chakra-ui/react'
|
||||
import { colors } from '@/lib/theme'
|
||||
import { Edge as EdgeProps } from '@typebot.io/schemas'
|
||||
import React from 'react'
|
||||
import { AnswersCount } from '@/features/analytics/types'
|
||||
import React, { useMemo } from 'react'
|
||||
import { DrawingEdge } from './DrawingEdge'
|
||||
import { DropOffEdge } from './DropOffEdge'
|
||||
import { Edge } from './Edge'
|
||||
import { TotalAnswersInBlock } from '@typebot.io/schemas/features/analytics'
|
||||
|
||||
type Props = {
|
||||
edges: EdgeProps[]
|
||||
answersCounts?: AnswersCount[]
|
||||
totalAnswersInBlocks?: TotalAnswersInBlock[]
|
||||
onUnlockProPlanClick?: () => void
|
||||
}
|
||||
|
||||
export const Edges = ({
|
||||
edges,
|
||||
answersCounts,
|
||||
totalAnswersInBlocks,
|
||||
onUnlockProPlanClick,
|
||||
}: Props) => {
|
||||
const isDark = useColorMode().colorMode === 'dark'
|
||||
const uniqueBlockIds = useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
totalAnswersInBlocks?.map(
|
||||
(totalAnswersInBlock) => totalAnswersInBlock.blockId
|
||||
)
|
||||
),
|
||||
],
|
||||
[totalAnswersInBlocks]
|
||||
)
|
||||
return (
|
||||
<chakra.svg
|
||||
width="full"
|
||||
@@ -33,14 +43,17 @@ export const Edges = ({
|
||||
{edges.map((edge) => (
|
||||
<Edge key={edge.id} edge={edge} />
|
||||
))}
|
||||
{answersCounts?.map((answerCount) => (
|
||||
<DropOffEdge
|
||||
key={answerCount.groupId}
|
||||
answersCounts={answersCounts}
|
||||
groupId={answerCount.groupId}
|
||||
onUnlockProPlanClick={onUnlockProPlanClick}
|
||||
/>
|
||||
))}
|
||||
{totalAnswersInBlocks &&
|
||||
uniqueBlockIds
|
||||
?.slice(1)
|
||||
.map((blockId) => (
|
||||
<DropOffEdge
|
||||
key={blockId}
|
||||
blockId={blockId}
|
||||
totalAnswersInBlocks={totalAnswersInBlocks}
|
||||
onUnlockProPlanClick={onUnlockProPlanClick}
|
||||
/>
|
||||
))}
|
||||
<marker
|
||||
id={'arrow'}
|
||||
refX="8"
|
||||
@@ -71,21 +84,6 @@ export const Edges = ({
|
||||
fill={colors.blue[400]}
|
||||
/>
|
||||
</marker>
|
||||
<marker
|
||||
id={'red-arrow'}
|
||||
refX="8"
|
||||
refY="4"
|
||||
orient="auto"
|
||||
viewBox="0 0 20 20"
|
||||
markerUnits="userSpaceOnUse"
|
||||
markerWidth="20"
|
||||
markerHeight="20"
|
||||
>
|
||||
<path
|
||||
d="M7.07138888,5.50174526 L2.43017246,7.82235347 C1.60067988,8.23709976 0.592024983,7.90088146 0.177278692,7.07138888 C0.0606951226,6.83822174 0,6.58111307 0,6.32042429 L0,1.67920787 C0,0.751806973 0.751806973,0 1.67920787,0 C1.93989666,0 2.19700532,0.0606951226 2.43017246,0.177278692 L7,3 C7.82949258,3.41474629 8.23709976,3.92128809 7.82235347,4.75078067 C7.6598671,5.07575341 7.39636161,5.33925889 7.07138888,5.50174526 Z"
|
||||
fill="#e53e3e"
|
||||
/>
|
||||
</marker>
|
||||
</chakra.svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@ const endpointHeight = 32
|
||||
|
||||
export const SourceEndpoint = ({
|
||||
source,
|
||||
isHidden,
|
||||
...props
|
||||
}: BoxProps & {
|
||||
source: Source
|
||||
isHidden?: boolean
|
||||
}) => {
|
||||
const id = source.itemId ?? source.blockId
|
||||
const color = useColorModeValue('blue.200', 'blue.100')
|
||||
@@ -123,6 +125,7 @@ export const SourceEndpoint = ({
|
||||
justify="center"
|
||||
align="center"
|
||||
pointerEvents="all"
|
||||
visibility={isHidden ? 'hidden' : 'visible'}
|
||||
{...props}
|
||||
>
|
||||
<Flex
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
TextBubbleBlock,
|
||||
LogicBlockType,
|
||||
} from '@typebot.io/schemas'
|
||||
import { isBubbleBlock, isDefined, isTextBubbleBlock } from '@typebot.io/lib'
|
||||
import {
|
||||
isBubbleBlock,
|
||||
isDefined,
|
||||
isInputBlock,
|
||||
isTextBubbleBlock,
|
||||
} from '@typebot.io/lib'
|
||||
import { BlockNodeContent } from './BlockNodeContent'
|
||||
import { BlockSettings, SettingsPopoverContent } from './SettingsPopoverContent'
|
||||
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
|
||||
@@ -54,6 +59,7 @@ export const BlockNode = ({
|
||||
const bg = useColorModeValue('gray.50', 'gray.850')
|
||||
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.800')
|
||||
const { pathname } = useRouter()
|
||||
const { query } = useRouter()
|
||||
const {
|
||||
setConnectingIds,
|
||||
@@ -241,17 +247,20 @@ export const BlockNode = ({
|
||||
groupId={block.groupId}
|
||||
/>
|
||||
)}
|
||||
{isConnectable && hasDefaultConnector(block) && (
|
||||
<SourceEndpoint
|
||||
source={{
|
||||
groupId: block.groupId,
|
||||
blockId: block.id,
|
||||
}}
|
||||
pos="absolute"
|
||||
right="-34px"
|
||||
bottom="10px"
|
||||
/>
|
||||
)}
|
||||
{(isConnectable ||
|
||||
(pathname.endsWith('analytics') && isInputBlock(block))) &&
|
||||
hasDefaultConnector(block) && (
|
||||
<SourceEndpoint
|
||||
source={{
|
||||
groupId: block.groupId,
|
||||
blockId: block.id,
|
||||
}}
|
||||
pos="absolute"
|
||||
right="-34px"
|
||||
bottom="10px"
|
||||
isHidden={!isConnectable}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Flex>
|
||||
</PopoverTrigger>
|
||||
|
||||
Reference in New Issue
Block a user