🚸 (results) Improve time filter so that it takes into account user timezone
This commit is contained in:
@ -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,10 +24,12 @@ 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 } }) => {
|
||||
.query(
|
||||
async ({ input: { typebotId, timeFilter, timeZone }, ctx: { user } }) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canReadTypebots(typebotId, user),
|
||||
select: { publishedTypebot: true, id: true },
|
||||
@ -35,17 +40,19 @@ export const getStats = authenticatedProcedure
|
||||
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({
|
||||
where: {
|
||||
typebotId: typebot.id,
|
||||
isArchived: false,
|
||||
createdAt: date
|
||||
createdAt: fromDate
|
||||
? {
|
||||
gte: date,
|
||||
gte: fromDate,
|
||||
lte: toDate ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
@ -55,9 +62,10 @@ export const getStats = authenticatedProcedure
|
||||
typebotId: typebot.id,
|
||||
isArchived: false,
|
||||
hasStarted: true,
|
||||
createdAt: date
|
||||
createdAt: fromDate
|
||||
? {
|
||||
gte: date,
|
||||
gte: fromDate,
|
||||
lte: toDate ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
@ -67,15 +75,15 @@ export const getStats = authenticatedProcedure
|
||||
typebotId: typebot.id,
|
||||
isArchived: false,
|
||||
isCompleted: true,
|
||||
createdAt: date
|
||||
createdAt: fromDate
|
||||
? {
|
||||
gte: date,
|
||||
gte: fromDate,
|
||||
lte: toDate ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
]
|
||||
)
|
||||
])
|
||||
|
||||
const stats: Stats = {
|
||||
totalViews,
|
||||
@ -86,4 +94,5 @@ export const getStats = authenticatedProcedure
|
||||
return {
|
||||
stats,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
@ -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,10 +26,12 @@ 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 } }) => {
|
||||
.query(
|
||||
async ({ input: { typebotId, timeFilter, timeZone }, ctx: { user } }) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canReadTypebots(typebotId, user),
|
||||
select: { publishedTypebot: true },
|
||||
@ -37,16 +42,18 @@ export const getTotalAnswers = authenticatedProcedure
|
||||
message: 'Published typebot not found',
|
||||
})
|
||||
|
||||
const date = parseDateFromTimeFilter(timeFilter)
|
||||
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: date
|
||||
createdAt: fromDate
|
||||
? {
|
||||
gte: date,
|
||||
gte: fromDate,
|
||||
lte: toDate ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
@ -67,4 +74,5 @@ export const getTotalAnswers = authenticatedProcedure
|
||||
total: a._count._all,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
@ -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,7 +32,8 @@ export const getTotalVisitedEdges = authenticatedProcedure
|
||||
totalVisitedEdges: z.array(totalVisitedEdgesSchema),
|
||||
})
|
||||
)
|
||||
.query(async ({ input: { typebotId, timeFilter }, ctx: { user } }) => {
|
||||
.query(
|
||||
async ({ input: { typebotId, timeFilter, timeZone }, ctx: { user } }) => {
|
||||
const typebot = await prisma.typebot.findFirst({
|
||||
where: canReadTypebots(typebotId, user),
|
||||
select: { id: true },
|
||||
@ -39,16 +44,18 @@ export const getTotalVisitedEdges = authenticatedProcedure
|
||||
message: 'Published typebot not found',
|
||||
})
|
||||
|
||||
const date = parseDateFromTimeFilter(timeFilter)
|
||||
const fromDate = parseFromDateFromTimeFilter(timeFilter, timeZone)
|
||||
const toDate = parseToDateFromTimeFilter(timeFilter, timeZone)
|
||||
|
||||
const edges = await prisma.visitedEdge.groupBy({
|
||||
by: ['edgeId'],
|
||||
where: {
|
||||
result: {
|
||||
typebotId: typebot.id,
|
||||
createdAt: date
|
||||
createdAt: fromDate
|
||||
? {
|
||||
gte: date,
|
||||
gte: fromDate,
|
||||
lte: toDate ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
@ -62,4 +69,5 @@ export const getTotalVisitedEdges = authenticatedProcedure
|
||||
total: e._count.resultId,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
@ -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) }
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
Reference in New Issue
Block a user