From f6d2b15a16c30c867299eb162050dcb080c4fa9c Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Fri, 15 Mar 2024 10:13:46 +0100 Subject: [PATCH] :children_crossing: (results) Improve time filter so that it takes into account user timezone --- .../src/features/analytics/api/getStats.ts | 127 ++++++++++-------- .../features/analytics/api/getTotalAnswers.ts | 88 ++++++------ .../analytics/api/getTotalVisitedEdges.ts | 74 +++++----- .../components/AnalyticsGraphContainer.tsx | 4 + .../src/features/analytics/constants.ts | 6 +- .../helpers/parseDateFromTimeFilter.ts | 78 +++++++++-- .../src/features/results/api/getResults.ts | 17 ++- .../results/components/ResultsPage.tsx | 3 + .../features/results/hooks/useResultsQuery.ts | 3 + apps/docs/openapi/builder.json | 44 +++++- 10 files changed, 291 insertions(+), 153 deletions(-) diff --git a/apps/builder/src/features/analytics/api/getStats.ts b/apps/builder/src/features/analytics/api/getStats.ts index e38efc026..e264307a0 100644 --- a/apps/builder/src/features/analytics/api/getStats.ts +++ b/apps/builder/src/features/analytics/api/getStats.ts @@ -5,7 +5,10 @@ import { z } from 'zod' import { canReadTypebots } from '@/helpers/databaseRules' import { Stats, statsSchema } from '@typebot.io/schemas' import { defaultTimeFilter, timeFilterValues } from '../constants' -import { parseDateFromTimeFilter } from '../helpers/parseDateFromTimeFilter' +import { + parseFromDateFromTimeFilter, + parseToDateFromTimeFilter, +} from '../helpers/parseDateFromTimeFilter' export const getStats = authenticatedProcedure .meta({ @@ -21,69 +24,75 @@ export const getStats = authenticatedProcedure z.object({ typebotId: z.string(), timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter), + timeZone: z.string().optional(), }) ) .output(z.object({ stats: statsSchema })) - .query(async ({ input: { typebotId, timeFilter }, ctx: { user } }) => { - const typebot = await prisma.typebot.findFirst({ - where: canReadTypebots(typebotId, user), - select: { publishedTypebot: true, id: true }, - }) - if (!typebot?.publishedTypebot) - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Published typebot not found', + .query( + async ({ input: { typebotId, timeFilter, timeZone }, ctx: { user } }) => { + const typebot = await prisma.typebot.findFirst({ + where: canReadTypebots(typebotId, user), + select: { publishedTypebot: true, id: true }, }) + if (!typebot?.publishedTypebot) + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Published typebot not found', + }) - const date = parseDateFromTimeFilter(timeFilter) + const fromDate = parseFromDateFromTimeFilter(timeFilter, timeZone) + const toDate = parseToDateFromTimeFilter(timeFilter, timeZone) - const [totalViews, totalStarts, totalCompleted] = await prisma.$transaction( - [ - prisma.result.count({ - where: { - typebotId: typebot.id, - isArchived: false, - createdAt: date - ? { - gte: date, - } - : undefined, - }, - }), - prisma.result.count({ - where: { - typebotId: typebot.id, - isArchived: false, - hasStarted: true, - createdAt: date - ? { - gte: date, - } - : undefined, - }, - }), - prisma.result.count({ - where: { - typebotId: typebot.id, - isArchived: false, - isCompleted: true, - createdAt: date - ? { - gte: date, - } - : undefined, - }, - }), - ] - ) + const [totalViews, totalStarts, totalCompleted] = + await prisma.$transaction([ + prisma.result.count({ + where: { + typebotId: typebot.id, + isArchived: false, + createdAt: fromDate + ? { + gte: fromDate, + lte: toDate ?? undefined, + } + : undefined, + }, + }), + prisma.result.count({ + where: { + typebotId: typebot.id, + isArchived: false, + hasStarted: true, + createdAt: fromDate + ? { + gte: fromDate, + lte: toDate ?? undefined, + } + : undefined, + }, + }), + prisma.result.count({ + where: { + typebotId: typebot.id, + isArchived: false, + isCompleted: true, + createdAt: fromDate + ? { + gte: fromDate, + lte: toDate ?? undefined, + } + : undefined, + }, + }), + ]) - const stats: Stats = { - totalViews, - totalStarts, - totalCompleted, + const stats: Stats = { + totalViews, + totalStarts, + totalCompleted, + } + + return { + stats, + } } - - return { - stats, - } - }) + ) diff --git a/apps/builder/src/features/analytics/api/getTotalAnswers.ts b/apps/builder/src/features/analytics/api/getTotalAnswers.ts index 8297f6198..50dc0fe3d 100644 --- a/apps/builder/src/features/analytics/api/getTotalAnswers.ts +++ b/apps/builder/src/features/analytics/api/getTotalAnswers.ts @@ -7,7 +7,10 @@ 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' +import { + parseFromDateFromTimeFilter, + parseToDateFromTimeFilter, +} from '../helpers/parseDateFromTimeFilter' export const getTotalAnswers = authenticatedProcedure .meta({ @@ -23,48 +26,53 @@ export const getTotalAnswers = authenticatedProcedure z.object({ typebotId: z.string(), timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter), + timeZone: z.string().optional(), }) ) .output(z.object({ totalAnswers: z.array(totalAnswersSchema) })) - .query(async ({ input: { typebotId, timeFilter }, ctx: { user } }) => { - const typebot = await prisma.typebot.findFirst({ - where: canReadTypebots(typebotId, user), - select: { publishedTypebot: true }, - }) - if (!typebot?.publishedTypebot) - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Published typebot not found', + .query( + async ({ input: { typebotId, timeFilter, timeZone }, ctx: { user } }) => { + const typebot = await prisma.typebot.findFirst({ + where: canReadTypebots(typebotId, user), + select: { publishedTypebot: true }, + }) + if (!typebot?.publishedTypebot) + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Published typebot not found', + }) + + const fromDate = parseFromDateFromTimeFilter(timeFilter, timeZone) + const toDate = parseToDateFromTimeFilter(timeFilter, timeZone) + + const totalAnswersPerBlock = await prisma.answer.groupBy({ + by: ['blockId'], + where: { + result: { + typebotId: typebot.publishedTypebot.typebotId, + createdAt: fromDate + ? { + gte: fromDate, + lte: toDate ?? undefined, + } + : undefined, + }, + blockId: { + in: parseGroups(typebot.publishedTypebot.groups, { + typebotVersion: typebot.publishedTypebot.version, + }).flatMap((group) => + group.blocks.filter(isInputBlock).map((block) => block.id) + ), + }, + }, + _count: { _all: true }, }) - 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, { - typebotVersion: typebot.publishedTypebot.version, - }).flatMap((group) => - group.blocks.filter(isInputBlock).map((block) => block.id) - ), - }, - }, - _count: { _all: true }, - }) - - return { - totalAnswers: totalAnswersPerBlock.map((a) => ({ - blockId: a.blockId, - total: a._count._all, - })), + return { + totalAnswers: totalAnswersPerBlock.map((a) => ({ + blockId: a.blockId, + total: a._count._all, + })), + } } - }) + ) diff --git a/apps/builder/src/features/analytics/api/getTotalVisitedEdges.ts b/apps/builder/src/features/analytics/api/getTotalVisitedEdges.ts index ad350586b..b083aa21e 100644 --- a/apps/builder/src/features/analytics/api/getTotalVisitedEdges.ts +++ b/apps/builder/src/features/analytics/api/getTotalVisitedEdges.ts @@ -5,7 +5,10 @@ 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' +import { + parseFromDateFromTimeFilter, + parseToDateFromTimeFilter, +} from '../helpers/parseDateFromTimeFilter' export const getTotalVisitedEdges = authenticatedProcedure .meta({ @@ -21,6 +24,7 @@ export const getTotalVisitedEdges = authenticatedProcedure z.object({ typebotId: z.string(), timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter), + timeZone: z.string().optional(), }) ) .output( @@ -28,38 +32,42 @@ export const getTotalVisitedEdges = authenticatedProcedure totalVisitedEdges: z.array(totalVisitedEdgesSchema), }) ) - .query(async ({ input: { typebotId, timeFilter }, ctx: { user } }) => { - const typebot = await prisma.typebot.findFirst({ - where: canReadTypebots(typebotId, user), - select: { id: true }, - }) - if (!typebot?.id) - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Published typebot not found', + .query( + async ({ input: { typebotId, timeFilter, timeZone }, ctx: { user } }) => { + const typebot = await prisma.typebot.findFirst({ + where: canReadTypebots(typebotId, user), + select: { id: true }, + }) + if (!typebot?.id) + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Published typebot not found', + }) + + const fromDate = parseFromDateFromTimeFilter(timeFilter, timeZone) + const toDate = parseToDateFromTimeFilter(timeFilter, timeZone) + + const edges = await prisma.visitedEdge.groupBy({ + by: ['edgeId'], + where: { + result: { + typebotId: typebot.id, + createdAt: fromDate + ? { + gte: fromDate, + lte: toDate ?? undefined, + } + : undefined, + }, + }, + _count: { resultId: true }, }) - 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 }, - }) - - return { - totalVisitedEdges: edges.map((e) => ({ - edgeId: e.edgeId, - total: e._count.resultId, - })), + return { + totalVisitedEdges: edges.map((e) => ({ + edgeId: e.edgeId, + total: e._count.resultId, + })), + } } - }) + ) diff --git a/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx b/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx index cf7439e42..bd21f1ad8 100644 --- a/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx +++ b/apps/builder/src/features/analytics/components/AnalyticsGraphContainer.tsx @@ -17,6 +17,8 @@ import { isDefined } from '@typebot.io/lib' import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider' import { timeFilterValues } from '../constants' +const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + type Props = { timeFilter: (typeof timeFilterValues)[number] onTimeFilterChange: (timeFilter: (typeof timeFilterValues)[number]) => void @@ -35,6 +37,7 @@ export const AnalyticsGraphContainer = ({ { typebotId: typebot?.id as string, timeFilter, + timeZone, }, { enabled: isDefined(publishedTypebot) } ) @@ -43,6 +46,7 @@ export const AnalyticsGraphContainer = ({ { typebotId: typebot?.id as string, timeFilter, + timeZone, }, { enabled: isDefined(publishedTypebot) } ) diff --git a/apps/builder/src/features/analytics/constants.ts b/apps/builder/src/features/analytics/constants.ts index eff30f7b3..ebba096af 100644 --- a/apps/builder/src/features/analytics/constants.ts +++ b/apps/builder/src/features/analytics/constants.ts @@ -2,6 +2,8 @@ export const timeFilterValues = [ 'today', 'last7Days', 'last30Days', + 'monthToDate', + 'lastMonth', 'yearToDate', 'allTime', ] as const @@ -13,8 +15,10 @@ export const timeFilterLabels: Record< today: 'Today', last7Days: 'Last 7 days', last30Days: 'Last 30 days', + monthToDate: 'Month to date', + lastMonth: 'Last month', yearToDate: 'Year to date', allTime: 'All time', } -export const defaultTimeFilter = 'today' as const +export const defaultTimeFilter = 'last7Days' as const diff --git a/apps/builder/src/features/analytics/helpers/parseDateFromTimeFilter.ts b/apps/builder/src/features/analytics/helpers/parseDateFromTimeFilter.ts index b1534c0d3..783012c42 100644 --- a/apps/builder/src/features/analytics/helpers/parseDateFromTimeFilter.ts +++ b/apps/builder/src/features/analytics/helpers/parseDateFromTimeFilter.ts @@ -1,18 +1,72 @@ import { timeFilterValues } from '../constants' +import { + startOfDay, + subDays, + startOfYear, + startOfMonth, + endOfMonth, + subMonths, +} from 'date-fns' +import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz' + +export const parseFromDateFromTimeFilter = ( + timeFilter: (typeof timeFilterValues)[number], + userTimezone: string = 'UTC' +): Date | null => { + const nowInUserTimezone = utcToZonedTime(new Date(), userTimezone) -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 'today': { + return zonedTimeToUtc(startOfDay(nowInUserTimezone), userTimezone) + } + case 'last7Days': { + return zonedTimeToUtc( + subDays(startOfDay(nowInUserTimezone), 6), + userTimezone + ) + } + case 'last30Days': { + return zonedTimeToUtc( + subDays(startOfDay(nowInUserTimezone), 29), + userTimezone + ) + } + case 'lastMonth': { + return zonedTimeToUtc( + subMonths(startOfMonth(nowInUserTimezone), 1), + userTimezone + ) + } + case 'monthToDate': { + return zonedTimeToUtc(startOfMonth(nowInUserTimezone), userTimezone) + } + case 'yearToDate': { + return zonedTimeToUtc(startOfYear(nowInUserTimezone), userTimezone) + } case 'allTime': - return + return null + } +} + +export const parseToDateFromTimeFilter = ( + timeFilter: (typeof timeFilterValues)[number], + userTimezone: string = 'UTC' +): Date | null => { + const nowInUserTimezone = utcToZonedTime(new Date(), userTimezone) + + switch (timeFilter) { + case 'lastMonth': { + return zonedTimeToUtc( + subMonths(endOfMonth(nowInUserTimezone), 1), + userTimezone + ) + } + case 'last30Days': + case 'last7Days': + case 'today': + case 'monthToDate': + case 'yearToDate': + case 'allTime': + return null } } diff --git a/apps/builder/src/features/results/api/getResults.ts b/apps/builder/src/features/results/api/getResults.ts index ddbe2e2d9..301dbb9ca 100644 --- a/apps/builder/src/features/results/api/getResults.ts +++ b/apps/builder/src/features/results/api/getResults.ts @@ -8,7 +8,10 @@ import { timeFilterValues, defaultTimeFilter, } from '@/features/analytics/constants' -import { parseDateFromTimeFilter } from '@/features/analytics/helpers/parseDateFromTimeFilter' +import { + parseFromDateFromTimeFilter, + parseToDateFromTimeFilter, +} from '@/features/analytics/helpers/parseDateFromTimeFilter' const maxLimit = 100 @@ -32,6 +35,7 @@ export const getResults = authenticatedProcedure limit: z.coerce.number().min(1).max(maxLimit).default(50), cursor: z.string().optional(), timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter), + timeZone: z.string().optional(), }) ) .output( @@ -77,7 +81,11 @@ export const getResults = authenticatedProcedure if (!typebot || (await isReadTypebotForbidden(typebot, user))) throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' }) - const date = parseDateFromTimeFilter(input.timeFilter) + const fromDate = parseFromDateFromTimeFilter( + input.timeFilter, + input.timeZone + ) + const toDate = parseToDateFromTimeFilter(input.timeFilter, input.timeZone) const results = await prisma.result.findMany({ take: limit + 1, @@ -86,9 +94,10 @@ export const getResults = authenticatedProcedure typebotId: typebot.id, hasStarted: true, isArchived: false, - createdAt: date + createdAt: fromDate ? { - gte: date, + gte: fromDate, + lte: toDate ?? undefined, } : undefined, }, diff --git a/apps/builder/src/features/results/components/ResultsPage.tsx b/apps/builder/src/features/results/components/ResultsPage.tsx index 9568d2151..26a3c4a02 100644 --- a/apps/builder/src/features/results/components/ResultsPage.tsx +++ b/apps/builder/src/features/results/components/ResultsPage.tsx @@ -24,6 +24,8 @@ import { timeFilterValues, } from '@/features/analytics/constants' +const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + export const ResultsPage = () => { const router = useRouter() const { workspace } = useWorkspace() @@ -45,6 +47,7 @@ export const ResultsPage = () => { { typebotId: publishedTypebot?.typebotId as string, timeFilter, + timeZone, }, { enabled: !!publishedTypebot, diff --git a/apps/builder/src/features/results/hooks/useResultsQuery.ts b/apps/builder/src/features/results/hooks/useResultsQuery.ts index 1feb67e77..c5ead0932 100644 --- a/apps/builder/src/features/results/hooks/useResultsQuery.ts +++ b/apps/builder/src/features/results/hooks/useResultsQuery.ts @@ -7,10 +7,13 @@ type Params = { onError?: (error: string) => void } +const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + export const useResultsQuery = ({ timeFilter, typebotId, onError }: Params) => { const { data, error, fetchNextPage, hasNextPage, refetch } = trpc.results.getResults.useInfiniteQuery( { + timeZone, timeFilter, typebotId, }, diff --git a/apps/docs/openapi/builder.json b/apps/docs/openapi/builder.json index 87d458861..cdc4f3cac 100644 --- a/apps/docs/openapi/builder.json +++ b/apps/docs/openapi/builder.json @@ -2703,10 +2703,19 @@ "today", "last7Days", "last30Days", + "monthToDate", + "lastMonth", "yearToDate", "allTime" ], - "default": "today" + "default": "last7Days" + } + }, + { + "in": "query", + "name": "timeZone", + "schema": { + "type": "string" } } ], @@ -2830,10 +2839,19 @@ "today", "last7Days", "last30Days", + "monthToDate", + "lastMonth", "yearToDate", "allTime" ], - "default": "today" + "default": "last7Days" + } + }, + { + "in": "query", + "name": "timeZone", + "schema": { + "type": "string" } } ], @@ -2954,10 +2972,19 @@ "today", "last7Days", "last30Days", + "monthToDate", + "lastMonth", "yearToDate", "allTime" ], - "default": "today" + "default": "last7Days" + } + }, + { + "in": "query", + "name": "timeZone", + "schema": { + "type": "string" } } ], @@ -11070,10 +11097,19 @@ "today", "last7Days", "last30Days", + "monthToDate", + "lastMonth", "yearToDate", "allTime" ], - "default": "today" + "default": "last7Days" + } + }, + { + "in": "query", + "name": "timeZone", + "schema": { + "type": "string" } } ],