2
0

🚸 (results) Improve time filter so that it takes into account user timezone

This commit is contained in:
Baptiste Arnaud
2024-03-15 10:13:46 +01:00
parent 001e696bf6
commit f6d2b15a16
10 changed files with 291 additions and 153 deletions

View File

@ -5,7 +5,10 @@ import { z } from 'zod'
import { canReadTypebots } from '@/helpers/databaseRules' import { canReadTypebots } from '@/helpers/databaseRules'
import { Stats, statsSchema } from '@typebot.io/schemas' import { Stats, statsSchema } from '@typebot.io/schemas'
import { defaultTimeFilter, timeFilterValues } from '../constants' import { defaultTimeFilter, timeFilterValues } from '../constants'
import { parseDateFromTimeFilter } from '../helpers/parseDateFromTimeFilter' import {
parseFromDateFromTimeFilter,
parseToDateFromTimeFilter,
} from '../helpers/parseDateFromTimeFilter'
export const getStats = authenticatedProcedure export const getStats = authenticatedProcedure
.meta({ .meta({
@ -21,69 +24,75 @@ export const getStats = authenticatedProcedure
z.object({ z.object({
typebotId: z.string(), typebotId: z.string(),
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter), timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
timeZone: z.string().optional(),
}) })
) )
.output(z.object({ stats: statsSchema })) .output(z.object({ stats: statsSchema }))
.query(async ({ input: { typebotId, timeFilter }, ctx: { user } }) => { .query(
const typebot = await prisma.typebot.findFirst({ async ({ input: { typebotId, timeFilter, timeZone }, ctx: { user } }) => {
where: canReadTypebots(typebotId, user), const typebot = await prisma.typebot.findFirst({
select: { publishedTypebot: true, id: true }, where: canReadTypebots(typebotId, user),
}) select: { publishedTypebot: true, id: true },
if (!typebot?.publishedTypebot)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Published typebot not found',
}) })
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( const [totalViews, totalStarts, totalCompleted] =
[ await prisma.$transaction([
prisma.result.count({ prisma.result.count({
where: { where: {
typebotId: typebot.id, typebotId: typebot.id,
isArchived: false, isArchived: false,
createdAt: date createdAt: fromDate
? { ? {
gte: date, gte: fromDate,
} lte: toDate ?? undefined,
: undefined, }
}, : undefined,
}), },
prisma.result.count({ }),
where: { prisma.result.count({
typebotId: typebot.id, where: {
isArchived: false, typebotId: typebot.id,
hasStarted: true, isArchived: false,
createdAt: date hasStarted: true,
? { createdAt: fromDate
gte: date, ? {
} gte: fromDate,
: undefined, lte: toDate ?? undefined,
}, }
}), : undefined,
prisma.result.count({ },
where: { }),
typebotId: typebot.id, prisma.result.count({
isArchived: false, where: {
isCompleted: true, typebotId: typebot.id,
createdAt: date isArchived: false,
? { isCompleted: true,
gte: date, createdAt: fromDate
} ? {
: undefined, gte: fromDate,
}, lte: toDate ?? undefined,
}), }
] : undefined,
) },
}),
])
const stats: Stats = { const stats: Stats = {
totalViews, totalViews,
totalStarts, totalStarts,
totalCompleted, totalCompleted,
}
return {
stats,
}
} }
)
return {
stats,
}
})

View File

@ -7,7 +7,10 @@ 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 { defaultTimeFilter, timeFilterValues } from '../constants'
import { parseDateFromTimeFilter } from '../helpers/parseDateFromTimeFilter' import {
parseFromDateFromTimeFilter,
parseToDateFromTimeFilter,
} from '../helpers/parseDateFromTimeFilter'
export const getTotalAnswers = authenticatedProcedure export const getTotalAnswers = authenticatedProcedure
.meta({ .meta({
@ -23,48 +26,53 @@ export const getTotalAnswers = authenticatedProcedure
z.object({ z.object({
typebotId: z.string(), typebotId: z.string(),
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter), timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
timeZone: z.string().optional(),
}) })
) )
.output(z.object({ totalAnswers: z.array(totalAnswersSchema) })) .output(z.object({ totalAnswers: z.array(totalAnswersSchema) }))
.query(async ({ input: { typebotId, timeFilter }, ctx: { user } }) => { .query(
const typebot = await prisma.typebot.findFirst({ async ({ input: { typebotId, timeFilter, timeZone }, ctx: { user } }) => {
where: canReadTypebots(typebotId, user), const typebot = await prisma.typebot.findFirst({
select: { publishedTypebot: true }, where: canReadTypebots(typebotId, user),
}) select: { publishedTypebot: true },
if (!typebot?.publishedTypebot) })
throw new TRPCError({ if (!typebot?.publishedTypebot)
code: 'NOT_FOUND', throw new TRPCError({
message: 'Published typebot not found', 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) return {
totalAnswers: totalAnswersPerBlock.map((a) => ({
const totalAnswersPerBlock = await prisma.answer.groupBy({ blockId: a.blockId,
by: ['blockId'], total: a._count._all,
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,
})),
} }
}) )

View File

@ -5,7 +5,10 @@ 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 { defaultTimeFilter, timeFilterValues } from '../constants'
import { parseDateFromTimeFilter } from '../helpers/parseDateFromTimeFilter' import {
parseFromDateFromTimeFilter,
parseToDateFromTimeFilter,
} from '../helpers/parseDateFromTimeFilter'
export const getTotalVisitedEdges = authenticatedProcedure export const getTotalVisitedEdges = authenticatedProcedure
.meta({ .meta({
@ -21,6 +24,7 @@ export const getTotalVisitedEdges = authenticatedProcedure
z.object({ z.object({
typebotId: z.string(), typebotId: z.string(),
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter), timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
timeZone: z.string().optional(),
}) })
) )
.output( .output(
@ -28,38 +32,42 @@ export const getTotalVisitedEdges = authenticatedProcedure
totalVisitedEdges: z.array(totalVisitedEdgesSchema), totalVisitedEdges: z.array(totalVisitedEdgesSchema),
}) })
) )
.query(async ({ input: { typebotId, timeFilter }, ctx: { user } }) => { .query(
const typebot = await prisma.typebot.findFirst({ async ({ input: { typebotId, timeFilter, timeZone }, ctx: { user } }) => {
where: canReadTypebots(typebotId, user), const typebot = await prisma.typebot.findFirst({
select: { id: true }, where: canReadTypebots(typebotId, user),
}) select: { id: true },
if (!typebot?.id) })
throw new TRPCError({ if (!typebot?.id)
code: 'NOT_FOUND', throw new TRPCError({
message: 'Published typebot not found', 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) return {
totalVisitedEdges: edges.map((e) => ({
const edges = await prisma.visitedEdge.groupBy({ edgeId: e.edgeId,
by: ['edgeId'], total: e._count.resultId,
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,
})),
} }
}) )

View File

@ -17,6 +17,8 @@ import { isDefined } from '@typebot.io/lib'
import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider' import { EventsCoordinatesProvider } from '@/features/graph/providers/EventsCoordinateProvider'
import { timeFilterValues } from '../constants' import { timeFilterValues } from '../constants'
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
type Props = { type Props = {
timeFilter: (typeof timeFilterValues)[number] timeFilter: (typeof timeFilterValues)[number]
onTimeFilterChange: (timeFilter: (typeof timeFilterValues)[number]) => void onTimeFilterChange: (timeFilter: (typeof timeFilterValues)[number]) => void
@ -35,6 +37,7 @@ export const AnalyticsGraphContainer = ({
{ {
typebotId: typebot?.id as string, typebotId: typebot?.id as string,
timeFilter, timeFilter,
timeZone,
}, },
{ enabled: isDefined(publishedTypebot) } { enabled: isDefined(publishedTypebot) }
) )
@ -43,6 +46,7 @@ export const AnalyticsGraphContainer = ({
{ {
typebotId: typebot?.id as string, typebotId: typebot?.id as string,
timeFilter, timeFilter,
timeZone,
}, },
{ enabled: isDefined(publishedTypebot) } { enabled: isDefined(publishedTypebot) }
) )

View File

@ -2,6 +2,8 @@ export const timeFilterValues = [
'today', 'today',
'last7Days', 'last7Days',
'last30Days', 'last30Days',
'monthToDate',
'lastMonth',
'yearToDate', 'yearToDate',
'allTime', 'allTime',
] as const ] as const
@ -13,8 +15,10 @@ export const timeFilterLabels: Record<
today: 'Today', today: 'Today',
last7Days: 'Last 7 days', last7Days: 'Last 7 days',
last30Days: 'Last 30 days', last30Days: 'Last 30 days',
monthToDate: 'Month to date',
lastMonth: 'Last month',
yearToDate: 'Year to date', yearToDate: 'Year to date',
allTime: 'All time', allTime: 'All time',
} }
export const defaultTimeFilter = 'today' as const export const defaultTimeFilter = 'last7Days' as const

View File

@ -1,18 +1,72 @@
import { timeFilterValues } from '../constants' 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) { switch (timeFilter) {
case 'today': case 'today': {
return new Date(new Date().setHours(0, 0, 0, 0)) return zonedTimeToUtc(startOfDay(nowInUserTimezone), userTimezone)
case 'last7Days': }
return new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) case 'last7Days': {
case 'last30Days': return zonedTimeToUtc(
return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) subDays(startOfDay(nowInUserTimezone), 6),
case 'yearToDate': userTimezone
return new Date(new Date().getFullYear(), 0, 1) )
}
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': 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
} }
} }

View File

@ -8,7 +8,10 @@ import {
timeFilterValues, timeFilterValues,
defaultTimeFilter, defaultTimeFilter,
} from '@/features/analytics/constants' } from '@/features/analytics/constants'
import { parseDateFromTimeFilter } from '@/features/analytics/helpers/parseDateFromTimeFilter' import {
parseFromDateFromTimeFilter,
parseToDateFromTimeFilter,
} from '@/features/analytics/helpers/parseDateFromTimeFilter'
const maxLimit = 100 const maxLimit = 100
@ -32,6 +35,7 @@ export const getResults = authenticatedProcedure
limit: z.coerce.number().min(1).max(maxLimit).default(50), limit: z.coerce.number().min(1).max(maxLimit).default(50),
cursor: z.string().optional(), cursor: z.string().optional(),
timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter), timeFilter: z.enum(timeFilterValues).default(defaultTimeFilter),
timeZone: z.string().optional(),
}) })
) )
.output( .output(
@ -77,7 +81,11 @@ export const getResults = authenticatedProcedure
if (!typebot || (await isReadTypebotForbidden(typebot, user))) if (!typebot || (await isReadTypebotForbidden(typebot, user)))
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' }) 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({ const results = await prisma.result.findMany({
take: limit + 1, take: limit + 1,
@ -86,9 +94,10 @@ export const getResults = authenticatedProcedure
typebotId: typebot.id, typebotId: typebot.id,
hasStarted: true, hasStarted: true,
isArchived: false, isArchived: false,
createdAt: date createdAt: fromDate
? { ? {
gte: date, gte: fromDate,
lte: toDate ?? undefined,
} }
: undefined, : undefined,
}, },

View File

@ -24,6 +24,8 @@ import {
timeFilterValues, timeFilterValues,
} from '@/features/analytics/constants' } from '@/features/analytics/constants'
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
export const ResultsPage = () => { export const ResultsPage = () => {
const router = useRouter() const router = useRouter()
const { workspace } = useWorkspace() const { workspace } = useWorkspace()
@ -45,6 +47,7 @@ export const ResultsPage = () => {
{ {
typebotId: publishedTypebot?.typebotId as string, typebotId: publishedTypebot?.typebotId as string,
timeFilter, timeFilter,
timeZone,
}, },
{ {
enabled: !!publishedTypebot, enabled: !!publishedTypebot,

View File

@ -7,10 +7,13 @@ type Params = {
onError?: (error: string) => void onError?: (error: string) => void
} }
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
export const useResultsQuery = ({ timeFilter, typebotId, onError }: Params) => { export const useResultsQuery = ({ timeFilter, typebotId, onError }: Params) => {
const { data, error, fetchNextPage, hasNextPage, refetch } = const { data, error, fetchNextPage, hasNextPage, refetch } =
trpc.results.getResults.useInfiniteQuery( trpc.results.getResults.useInfiniteQuery(
{ {
timeZone,
timeFilter, timeFilter,
typebotId, typebotId,
}, },

View File

@ -2703,10 +2703,19 @@
"today", "today",
"last7Days", "last7Days",
"last30Days", "last30Days",
"monthToDate",
"lastMonth",
"yearToDate", "yearToDate",
"allTime" "allTime"
], ],
"default": "today" "default": "last7Days"
}
},
{
"in": "query",
"name": "timeZone",
"schema": {
"type": "string"
} }
} }
], ],
@ -2830,10 +2839,19 @@
"today", "today",
"last7Days", "last7Days",
"last30Days", "last30Days",
"monthToDate",
"lastMonth",
"yearToDate", "yearToDate",
"allTime" "allTime"
], ],
"default": "today" "default": "last7Days"
}
},
{
"in": "query",
"name": "timeZone",
"schema": {
"type": "string"
} }
} }
], ],
@ -2954,10 +2972,19 @@
"today", "today",
"last7Days", "last7Days",
"last30Days", "last30Days",
"monthToDate",
"lastMonth",
"yearToDate", "yearToDate",
"allTime" "allTime"
], ],
"default": "today" "default": "last7Days"
}
},
{
"in": "query",
"name": "timeZone",
"schema": {
"type": "string"
} }
} }
], ],
@ -11070,10 +11097,19 @@
"today", "today",
"last7Days", "last7Days",
"last30Days", "last30Days",
"monthToDate",
"lastMonth",
"yearToDate", "yearToDate",
"allTime" "allTime"
], ],
"default": "today" "default": "last7Days"
}
},
{
"in": "query",
"name": "timeZone",
"schema": {
"type": "string"
} }
} }
], ],