✨ (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 { MoreInfoTooltip } from './MoreInfoTooltip'
|
||||
|
||||
type Item =
|
||||
| string
|
||||
| number
|
||||
| {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Props<T extends readonly any[]> = {
|
||||
currentItem: T[number] | undefined
|
||||
onItemSelect: (item: T[number]) => void
|
||||
items: T
|
||||
type Props<T extends Item> = {
|
||||
currentItem: string | number | undefined
|
||||
onItemSelect: (
|
||||
value: T extends string ? T : T extends number ? T : string,
|
||||
item?: T
|
||||
) => void
|
||||
items: readonly T[]
|
||||
placeholder?: string
|
||||
label?: string
|
||||
isRequired?: boolean
|
||||
@ -31,7 +42,7 @@ type Props<T extends readonly any[]> = {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const DropdownList = <T extends readonly any[]>({
|
||||
export const DropdownList = <T extends Item>({
|
||||
currentItem,
|
||||
onItemSelect,
|
||||
items,
|
||||
@ -43,8 +54,14 @@ export const DropdownList = <T extends readonly any[]>({
|
||||
moreInfoTooltip,
|
||||
...props
|
||||
}: Props<T> & ButtonProps) => {
|
||||
const handleMenuItemClick = (operator: T[number]) => () => {
|
||||
onItemSelect(operator)
|
||||
const handleMenuItemClick = (item: T) => () => {
|
||||
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 (
|
||||
<FormControl
|
||||
@ -73,7 +90,15 @@ export const DropdownList = <T extends readonly any[]>({
|
||||
{...props}
|
||||
>
|
||||
<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>
|
||||
</MenuButton>
|
||||
<Portal>
|
||||
@ -88,7 +113,7 @@ export const DropdownList = <T extends readonly any[]>({
|
||||
textOverflow="ellipsis"
|
||||
onClick={handleMenuItemClick(item)}
|
||||
>
|
||||
{item}
|
||||
{typeof item === 'object' ? item.label : item}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Stack>
|
||||
@ -99,3 +124,9 @@ export const DropdownList = <T extends readonly any[]>({
|
||||
</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 { parseGroups } from '@typebot.io/schemas'
|
||||
import { isInputBlock } from '@typebot.io/lib'
|
||||
import { defaultTimeFilter, timeFilterValues } from '../constants'
|
||||
import { parseDateFromTimeFilter } from '../helpers/parseDateFromTimeFilter'
|
||||
|
||||
export const getTotalAnswers = authenticatedProcedure
|
||||
.meta({
|
||||
@ -20,10 +22,11 @@ export const getTotalAnswers = authenticatedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
typebotId: z.string(),
|
||||
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
|
||||
})
|
||||
)
|
||||
.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({
|
||||
where: canReadTypebots(typebotId, user),
|
||||
select: { publishedTypebot: true },
|
||||
@ -34,11 +37,18 @@ export const getTotalAnswers = authenticatedProcedure
|
||||
message: 'Published typebot not found',
|
||||
})
|
||||
|
||||
const date = parseDateFromTimeFilter(timeFilter)
|
||||
|
||||
const totalAnswersPerBlock = await prisma.answer.groupBy({
|
||||
by: ['blockId'],
|
||||
where: {
|
||||
result: {
|
||||
typebotId: typebot.publishedTypebot.typebotId,
|
||||
createdAt: date
|
||||
? {
|
||||
gte: date,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
blockId: {
|
||||
in: parseGroups(typebot.publishedTypebot.groups, {
|
||||
|
@ -4,6 +4,8 @@ import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { canReadTypebots } from '@/helpers/databaseRules'
|
||||
import { totalVisitedEdgesSchema } from '@typebot.io/schemas'
|
||||
import { defaultTimeFilter, timeFilterValues } from '../constants'
|
||||
import { parseDateFromTimeFilter } from '../helpers/parseDateFromTimeFilter'
|
||||
|
||||
export const getTotalVisitedEdges = authenticatedProcedure
|
||||
.meta({
|
||||
@ -18,6 +20,7 @@ export const getTotalVisitedEdges = authenticatedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
typebotId: z.string(),
|
||||
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
@ -25,7 +28,7 @@ export const getTotalVisitedEdges = authenticatedProcedure
|
||||
totalVisitedEdges: z.array(totalVisitedEdgesSchema),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { typebotId }, ctx: { user } }) => {
|
||||
.query(async ({ input: { typebotId, timeFilter }, ctx: { user } }) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canReadTypebots(typebotId, user),
|
||||
select: { id: true },
|
||||
@ -36,11 +39,18 @@ export const getTotalVisitedEdges = authenticatedProcedure
|
||||
message: 'Published typebot not found',
|
||||
})
|
||||
|
||||
const date = parseDateFromTimeFilter(timeFilter)
|
||||
|
||||
const edges = await prisma.visitedEdge.groupBy({
|
||||
by: ['edgeId'],
|
||||
where: {
|
||||
result: {
|
||||
typebotId: typebot.id,
|
||||
createdAt: date
|
||||
? {
|
||||
gte: date,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
_count: { resultId: true },
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
} from '@chakra-ui/react'
|
||||
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
|
||||
import { Stats } from '@typebot.io/schemas'
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { StatsCards } from './StatsCards'
|
||||
import { ChangePlanModal } from '@/features/billing/components/ChangePlanModal'
|
||||
import { Graph } from '@/features/graph/components/Graph'
|
||||
@ -15,14 +15,18 @@ import { useTranslate } from '@tolgee/react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider'
|
||||
import { defaultTimeFilter, timeFilterValues } from '../constants'
|
||||
|
||||
export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
||||
const { t } = useTranslate()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { typebot, publishedTypebot } = useTypebot()
|
||||
const [timeFilter, setTimeFilter] =
|
||||
useState<(typeof timeFilterValues)[number]>(defaultTimeFilter)
|
||||
const { data } = trpc.analytics.getTotalAnswers.useQuery(
|
||||
{
|
||||
typebotId: typebot?.id as string,
|
||||
timeFilter,
|
||||
},
|
||||
{ enabled: isDefined(publishedTypebot) }
|
||||
)
|
||||
@ -30,6 +34,7 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
||||
const { data: edgesData } = trpc.analytics.getTotalVisitedEdges.useQuery(
|
||||
{
|
||||
typebotId: typebot?.id as string,
|
||||
timeFilter,
|
||||
},
|
||||
{ enabled: isDefined(publishedTypebot) }
|
||||
)
|
||||
@ -76,7 +81,12 @@ export const AnalyticsGraphContainer = ({ stats }: { stats?: Stats }) => {
|
||||
type={t('billing.limitMessage.analytics')}
|
||||
excludedPlans={['STARTER']}
|
||||
/>
|
||||
<StatsCards stats={stats} pos="absolute" />
|
||||
<StatsCards
|
||||
stats={stats}
|
||||
pos="absolute"
|
||||
timeFilter={timeFilter}
|
||||
setTimeFilter={setTimeFilter}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
@ -10,6 +10,8 @@ import {
|
||||
} from '@chakra-ui/react'
|
||||
import { Stats } from '@typebot.io/schemas'
|
||||
import React from 'react'
|
||||
import { DropdownList } from '@/components/DropdownList'
|
||||
import { timeFilterLabels, timeFilterValues } from '../constants'
|
||||
|
||||
const computeCompletionRate =
|
||||
(notAvailableLabel: string) =>
|
||||
@ -20,13 +22,24 @@ const computeCompletionRate =
|
||||
|
||||
export const StatsCards = ({
|
||||
stats,
|
||||
timeFilter,
|
||||
setTimeFilter,
|
||||
...props
|
||||
}: { stats?: Stats } & GridProps) => {
|
||||
}: {
|
||||
stats?: Stats
|
||||
timeFilter: (typeof timeFilterValues)[number]
|
||||
setTimeFilter: (timeFilter: (typeof timeFilterValues)[number]) => void
|
||||
} & GridProps) => {
|
||||
const { t } = useTranslate()
|
||||
const bg = useColorModeValue('white', 'gray.900')
|
||||
|
||||
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">
|
||||
<StatLabel>{t('analytics.viewsLabel')}</StatLabel>
|
||||
{stats ? (
|
||||
@ -56,6 +69,18 @@ export const StatsCards = ({
|
||||
<Skeleton w="50%" h="10px" mt="2" />
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
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