✨ (analytics) Add time dropdown to filter analytics with a time range
This commit is contained in:
@ -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
|
||||||
|
}
|
||||||
|
@ -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, {
|
||||||
|
@ -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 },
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
20
apps/builder/src/features/analytics/constants.ts
Normal file
20
apps/builder/src/features/analytics/constants.ts
Normal 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
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user