diff --git a/apps/builder/src/components/DropdownList.tsx b/apps/builder/src/components/DropdownList.tsx index 849600d82..b17fd2b56 100644 --- a/apps/builder/src/components/DropdownList.tsx +++ b/apps/builder/src/components/DropdownList.tsx @@ -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 = { - currentItem: T[number] | undefined - onItemSelect: (item: T[number]) => void - items: T +type Props = { + 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 = { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const DropdownList = ({ +export const DropdownList = ({ currentItem, onItemSelect, items, @@ -43,8 +54,14 @@ export const DropdownList = ({ moreInfoTooltip, ...props }: Props & 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 ( ({ {...props} > - {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'} @@ -88,7 +113,7 @@ export const DropdownList = ({ textOverflow="ellipsis" onClick={handleMenuItemClick(item)} > - {item} + {typeof item === 'object' ? item.label : item} ))} @@ -99,3 +124,9 @@ export const DropdownList = ({ ) } + +const getItemLabel = (item?: Item) => { + if (!item) return '' + if (typeof item === 'object') return item.label + return item +} diff --git a/apps/builder/src/features/analytics/api/getTotalAnswers.ts b/apps/builder/src/features/analytics/api/getTotalAnswers.ts index 878071eae..8297f6198 100644 --- a/apps/builder/src/features/analytics/api/getTotalAnswers.ts +++ b/apps/builder/src/features/analytics/api/getTotalAnswers.ts @@ -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, { diff --git a/apps/builder/src/features/analytics/api/getTotalVisitedEdges.ts b/apps/builder/src/features/analytics/api/getTotalVisitedEdges.ts index e42137661..ad350586b 100644 --- a/apps/builder/src/features/analytics/api/getTotalVisitedEdges.ts +++ b/apps/builder/src/features/analytics/api/getTotalVisitedEdges.ts @@ -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 }, diff --git a/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx b/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx index 2fdac6b6a..3598bf53d 100644 --- a/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx +++ b/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx @@ -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']} /> - + ) } diff --git a/apps/builder/src/features/analytics/components/StatsCards.tsx b/apps/builder/src/features/analytics/components/StatsCards.tsx index f63e64ef3..f077fa697 100644 --- a/apps/builder/src/features/analytics/components/StatsCards.tsx +++ b/apps/builder/src/features/analytics/components/StatsCards.tsx @@ -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 ( - + {t('analytics.viewsLabel')} {stats ? ( @@ -56,6 +69,18 @@ export const StatsCards = ({ )} + ({ + label, + value, + }))} + currentItem={timeFilter} + onItemSelect={(val) => + setTimeFilter(val as (typeof timeFilterValues)[number]) + } + backgroundColor="white" + boxShadow="md" + /> ) } diff --git a/apps/builder/src/features/analytics/constants.ts b/apps/builder/src/features/analytics/constants.ts new file mode 100644 index 000000000..eff30f7b3 --- /dev/null +++ b/apps/builder/src/features/analytics/constants.ts @@ -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 diff --git a/apps/builder/src/features/analytics/helpers/parseDateFromTimeFilter.ts b/apps/builder/src/features/analytics/helpers/parseDateFromTimeFilter.ts new file mode 100644 index 000000000..b1534c0d3 --- /dev/null +++ b/apps/builder/src/features/analytics/helpers/parseDateFromTimeFilter.ts @@ -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 + } +}