2
0

(analytics) Add time dropdown to filter analytics with a time range

This commit is contained in:
Baptiste Arnaud
2024-01-29 19:41:27 +01:00
parent 07928c743c
commit 515fcafcd8
7 changed files with 139 additions and 15 deletions

View File

@ -17,11 +17,22 @@ import { ChevronLeftIcon } from '@/components/icons'
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react'
import { MoreInfoTooltip } from './MoreInfoTooltip' import { MoreInfoTooltip } from './MoreInfoTooltip'
type Item =
| string
| number
| {
label: string
value: string
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type Props<T extends readonly any[]> = { type Props<T extends Item> = {
currentItem: T[number] | undefined currentItem: string | number | undefined
onItemSelect: (item: T[number]) => void onItemSelect: (
items: T value: T extends string ? T : T extends number ? T : string,
item?: T
) => void
items: readonly T[]
placeholder?: string placeholder?: string
label?: string label?: string
isRequired?: boolean isRequired?: boolean
@ -31,7 +42,7 @@ type Props<T extends readonly any[]> = {
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const DropdownList = <T extends readonly any[]>({ export const DropdownList = <T extends Item>({
currentItem, currentItem,
onItemSelect, onItemSelect,
items, items,
@ -43,8 +54,14 @@ export const DropdownList = <T extends readonly any[]>({
moreInfoTooltip, moreInfoTooltip,
...props ...props
}: Props<T> & ButtonProps) => { }: Props<T> & ButtonProps) => {
const handleMenuItemClick = (operator: T[number]) => () => { const handleMenuItemClick = (item: T) => () => {
onItemSelect(operator) if (typeof item === 'string' || typeof item === 'number')
onItemSelect(item as T extends string ? T : T extends number ? T : string)
else
onItemSelect(
item.value as T extends string ? T : T extends number ? T : string,
item
)
} }
return ( return (
<FormControl <FormControl
@ -73,7 +90,15 @@ export const DropdownList = <T extends readonly any[]>({
{...props} {...props}
> >
<chakra.span noOfLines={1} display="block"> <chakra.span noOfLines={1} display="block">
{currentItem ?? placeholder ?? 'Select an item'} {currentItem
? getItemLabel(
items?.find((item) =>
typeof item === 'string' || typeof item === 'number'
? currentItem === item
: currentItem === item.value
)
)
: placeholder ?? 'Select an item'}
</chakra.span> </chakra.span>
</MenuButton> </MenuButton>
<Portal> <Portal>
@ -88,7 +113,7 @@ export const DropdownList = <T extends readonly any[]>({
textOverflow="ellipsis" textOverflow="ellipsis"
onClick={handleMenuItemClick(item)} onClick={handleMenuItemClick(item)}
> >
{item} {typeof item === 'object' ? item.label : item}
</MenuItem> </MenuItem>
))} ))}
</Stack> </Stack>
@ -99,3 +124,9 @@ export const DropdownList = <T extends readonly any[]>({
</FormControl> </FormControl>
) )
} }
const getItemLabel = (item?: Item) => {
if (!item) return ''
if (typeof item === 'object') return item.label
return item
}

View File

@ -6,6 +6,8 @@ import { canReadTypebots } from '@/helpers/databaseRules'
import { totalAnswersSchema } from '@typebot.io/schemas/features/analytics' import { totalAnswersSchema } from '@typebot.io/schemas/features/analytics'
import { parseGroups } from '@typebot.io/schemas' import { parseGroups } from '@typebot.io/schemas'
import { isInputBlock } from '@typebot.io/lib' import { isInputBlock } from '@typebot.io/lib'
import { defaultTimeFilter, timeFilterValues } from '../constants'
import { parseDateFromTimeFilter } from '../helpers/parseDateFromTimeFilter'
export const getTotalAnswers = authenticatedProcedure export const getTotalAnswers = authenticatedProcedure
.meta({ .meta({
@ -20,10 +22,11 @@ export const getTotalAnswers = authenticatedProcedure
.input( .input(
z.object({ z.object({
typebotId: z.string(), typebotId: z.string(),
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
}) })
) )
.output(z.object({ totalAnswers: z.array(totalAnswersSchema) })) .output(z.object({ totalAnswers: z.array(totalAnswersSchema) }))
.query(async ({ input: { typebotId }, ctx: { user } }) => { .query(async ({ input: { typebotId, timeFilter }, ctx: { user } }) => {
const typebot = await prisma.typebot.findFirst({ const typebot = await prisma.typebot.findFirst({
where: canReadTypebots(typebotId, user), where: canReadTypebots(typebotId, user),
select: { publishedTypebot: true }, select: { publishedTypebot: true },
@ -34,11 +37,18 @@ export const getTotalAnswers = authenticatedProcedure
message: 'Published typebot not found', message: 'Published typebot not found',
}) })
const date = parseDateFromTimeFilter(timeFilter)
const totalAnswersPerBlock = await prisma.answer.groupBy({ const totalAnswersPerBlock = await prisma.answer.groupBy({
by: ['blockId'], by: ['blockId'],
where: { where: {
result: { result: {
typebotId: typebot.publishedTypebot.typebotId, typebotId: typebot.publishedTypebot.typebotId,
createdAt: date
? {
gte: date,
}
: undefined,
}, },
blockId: { blockId: {
in: parseGroups(typebot.publishedTypebot.groups, { in: parseGroups(typebot.publishedTypebot.groups, {

View File

@ -4,6 +4,8 @@ import { TRPCError } from '@trpc/server'
import { z } from 'zod' import { z } from 'zod'
import { canReadTypebots } from '@/helpers/databaseRules' import { canReadTypebots } from '@/helpers/databaseRules'
import { totalVisitedEdgesSchema } from '@typebot.io/schemas' import { totalVisitedEdgesSchema } from '@typebot.io/schemas'
import { defaultTimeFilter, timeFilterValues } from '../constants'
import { parseDateFromTimeFilter } from '../helpers/parseDateFromTimeFilter'
export const getTotalVisitedEdges = authenticatedProcedure export const getTotalVisitedEdges = authenticatedProcedure
.meta({ .meta({
@ -18,6 +20,7 @@ export const getTotalVisitedEdges = authenticatedProcedure
.input( .input(
z.object({ z.object({
typebotId: z.string(), typebotId: z.string(),
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
}) })
) )
.output( .output(
@ -25,7 +28,7 @@ export const getTotalVisitedEdges = authenticatedProcedure
totalVisitedEdges: z.array(totalVisitedEdgesSchema), totalVisitedEdges: z.array(totalVisitedEdgesSchema),
}) })
) )
.query(async ({ input: { typebotId }, ctx: { user } }) => { .query(async ({ input: { typebotId, timeFilter }, ctx: { user } }) => {
const typebot = await prisma.typebot.findFirst({ const typebot = await prisma.typebot.findFirst({
where: canReadTypebots(typebotId, user), where: canReadTypebots(typebotId, user),
select: { id: true }, select: { id: true },
@ -36,11 +39,18 @@ export const getTotalVisitedEdges = authenticatedProcedure
message: 'Published typebot not found', message: 'Published typebot not found',
}) })
const date = parseDateFromTimeFilter(timeFilter)
const edges = await prisma.visitedEdge.groupBy({ const edges = await prisma.visitedEdge.groupBy({
by: ['edgeId'], by: ['edgeId'],
where: { where: {
result: { result: {
typebotId: typebot.id, typebotId: typebot.id,
createdAt: date
? {
gte: date,
}
: undefined,
}, },
}, },
_count: { resultId: true }, _count: { resultId: true },

View File

@ -6,7 +6,7 @@ import {
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider' import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { Stats } from '@typebot.io/schemas' import { Stats } from '@typebot.io/schemas'
import React from 'react' import React, { useState } from 'react'
import { StatsCards } from './StatsCards' import { StatsCards } from './StatsCards'
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal' import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
import { Graph } from '@/features/graph/components/Graph' import { Graph } from '@/features/graph/components/Graph'
@ -15,14 +15,18 @@ import { useTranslate } from '@tolgee/react'
import { trpc } from '@/lib/trpc' import { trpc } from '@/lib/trpc'
import { isDefined } from '@typebot.io/lib' import { isDefined } from '@typebot.io/lib'
import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider' import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider'
import { defaultTimeFilter, timeFilterValues } from '../constants'
export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => { export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
const { t } = useTranslate() const { t } = useTranslate()
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const { typebot, publishedTypebot } = useTypebot() const { typebot, publishedTypebot } = useTypebot()
const [timeFilter, setTimeFilter] =
useState<(typeof timeFilterValues)[number]>(defaultTimeFilter)
const { data } = trpc.analytics.getTotalAnswers.useQuery( const { data } = trpc.analytics.getTotalAnswers.useQuery(
{ {
typebotId: typebot?.id as string, typebotId: typebot?.id as string,
timeFilter,
}, },
{ enabled: isDefined(publishedTypebot) } { enabled: isDefined(publishedTypebot) }
) )
@ -30,6 +34,7 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
const { data: edgesData } = trpc.analytics.getTotalVisitedEdges.useQuery( const { data: edgesData } = trpc.analytics.getTotalVisitedEdges.useQuery(
{ {
typebotId: typebot?.id as string, typebotId: typebot?.id as string,
timeFilter,
}, },
{ enabled: isDefined(publishedTypebot) } { enabled: isDefined(publishedTypebot) }
) )
@ -76,7 +81,12 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
type={t('billing.limitMessage.analytics')} type={t('billing.limitMessage.analytics')}
excludedPlans={['STARTER']} excludedPlans={['STARTER']}
/> />
<StatsCards stats={stats} pos="absolute" /> <StatsCards
stats={stats}
pos="absolute"
timeFilter={timeFilter}
setTimeFilter={setTimeFilter}
/>
</Flex> </Flex>
) )
} }

View File

@ -10,6 +10,8 @@ import {
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { Stats } from '@typebot.io/schemas' import { Stats } from '@typebot.io/schemas'
import React from 'react' import React from 'react'
import { DropdownList } from '@/components/DropdownList'
import { timeFilterLabels, timeFilterValues } from '../constants'
const computeCompletionRate = const computeCompletionRate =
(notAvailableLabel: string) => (notAvailableLabel: string) =>
@ -20,13 +22,24 @@ const computeCompletionRate =
export const StatsCards = ({ export const StatsCards = ({
stats, stats,
timeFilter,
setTimeFilter,
...props ...props
}: { stats?: Stats } & GridProps) => { }: {
stats?: Stats
timeFilter: (typeof timeFilterValues)[number]
setTimeFilter: (timeFilter: (typeof timeFilterValues)[number]) => void
} & GridProps) => {
const { t } = useTranslate() const { t } = useTranslate()
const bg = useColorModeValue('white', 'gray.900') const bg = useColorModeValue('white', 'gray.900')
return ( return (
<SimpleGrid columns={{ base: 1, md: 3 }} spacing="6" {...props}> <SimpleGrid
columns={{ base: 1, md: 4 }}
spacing="6"
alignItems="center"
{...props}
>
<Stat bgColor={bg} p="4" rounded="md" boxShadow="md"> <Stat bgColor={bg} p="4" rounded="md" boxShadow="md">
<StatLabel>{t('analytics.viewsLabel')}</StatLabel> <StatLabel>{t('analytics.viewsLabel')}</StatLabel>
{stats ? ( {stats ? (
@ -56,6 +69,18 @@ export const StatsCards = ({
<Skeleton w="50%" h="10px" mt="2" /> <Skeleton w="50%" h="10px" mt="2" />
)} )}
</Stat> </Stat>
<DropdownList
items={Object.entries(timeFilterLabels).map(([value, label]) => ({
label,
value,
}))}
currentItem={timeFilter}
onItemSelect={(val) =>
setTimeFilter(val as (typeof timeFilterValues)[number])
}
backgroundColor="white"
boxShadow="md"
/>
</SimpleGrid> </SimpleGrid>
) )
} }

View File

@ -0,0 +1,20 @@
export const timeFilterValues = [
'today',
'last7Days',
'last30Days',
'yearToDate',
'allTime',
] as const
export const timeFilterLabels: Record<
(typeof timeFilterValues)[number],
string
> = {
today: 'Today',
last7Days: 'Last 7 days',
last30Days: 'Last 30 days',
yearToDate: 'Year to date',
allTime: 'All time',
}
export const defaultTimeFilter = 'today' as const

View File

@ -0,0 +1,18 @@
import { timeFilterValues } from '../constants'
export const parseDateFromTimeFilter = (
timeFilter: (typeof timeFilterValues)[number]
): Date | undefined => {
switch (timeFilter) {
case 'today':
return new Date(new Date().setHours(0, 0, 0, 0))
case 'last7Days':
return new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
case 'last30Days':
return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
case 'yearToDate':
return new Date(new Date().getFullYear(), 0, 1)
case 'allTime':
return
}
}