From e6a0a0fc6960c723a29bfcf0c419f9db58f0bd21 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 25 Jul 2025 17:58:41 +0200 Subject: [PATCH] refactor: event trends chart on /insights to use InsightsBookingService (#22741) * refactor: event trends chart on /insights to use InsightsBookingService * extract service instantiation * remove unused code * rename from eventsTimeline to eventTrends * clean up --------- Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> --- apps/web/modules/insights/insights-view.tsx | 4 +- ...atusLineChart.tsx => EventTrendsChart.tsx} | 20 +- .../features/insights/components/index.ts | 2 +- packages/features/insights/server/events.ts | 96 +-------- .../features/insights/server/trpc-router.ts | 184 ++++++------------ .../lib/server/service/insightsBooking.ts | 130 +++++++++++++ 6 files changed, 203 insertions(+), 233 deletions(-) rename packages/features/insights/components/{BookingStatusLineChart.tsx => EventTrendsChart.tsx} (67%) diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index 1b17fba8f1..98656bcd3a 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -10,7 +10,7 @@ import { import { AverageEventDurationChart, BookingKPICards, - BookingStatusLineChart, + EventTrendsChart, HighestNoShowHostTable, HighestRatedMembersTable, BookingsByHourChart, @@ -70,7 +70,7 @@ function InsightsPageContent() {
- +
diff --git a/packages/features/insights/components/BookingStatusLineChart.tsx b/packages/features/insights/components/EventTrendsChart.tsx similarity index 67% rename from packages/features/insights/components/BookingStatusLineChart.tsx rename to packages/features/insights/components/EventTrendsChart.tsx index c4cd9ec2b8..c5bdf1b521 100644 --- a/packages/features/insights/components/BookingStatusLineChart.tsx +++ b/packages/features/insights/components/EventTrendsChart.tsx @@ -1,4 +1,6 @@ +import { useDataTable } from "@calcom/features/data-table"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; import { trpc } from "@calcom/trpc"; import { useInsightsParameters } from "../hooks/useInsightsParameters"; @@ -7,22 +9,24 @@ import { ChartCard } from "./ChartCard"; import { LineChart } from "./LineChart"; import { LoadingInsight } from "./LoadingInsights"; -export const BookingStatusLineChart = () => { +export const EventTrendsChart = () => { const { t } = useLocale(); - const { isAll, teamId, userId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const { timeZone } = useDataTable(); const { - data: eventsTimeLine, + data: eventTrends, isSuccess, isPending, - } = trpc.viewer.insights.eventsTimeline.useQuery( + } = trpc.viewer.insights.eventTrends.useQuery( { + scope, + selectedTeamId, startDate, endDate, - teamId, + timeZone: timeZone || CURRENT_TIMEZONE, eventTypeId, - userId, - isAll, + memberUserId, }, { staleTime: 30000, @@ -40,7 +44,7 @@ export const BookingStatusLineChart = () => { => { - const formattedStartDate = dayjs(startDate).format("YYYY-MM-DD HH:mm:ss"); - const formattedEndDate = dayjs(endDate).format("YYYY-MM-DD HH:mm:ss"); - const whereClause = buildSqlCondition(whereConditional); - - const data = await prisma.$queryRaw< - { - date: Date; - bookingsCount: number; - timeStatus: string; - noShowHost: boolean; - noShowGuests: number; - }[] - >` - SELECT - "date", - CAST(COUNT(*) AS INTEGER) AS "bookingsCount", - CAST(COUNT(CASE WHEN "isNoShowGuest" = true THEN 1 END) AS INTEGER) AS "noShowGuests", - "timeStatus", - "noShowHost" - FROM ( - SELECT - DATE("createdAt" AT TIME ZONE ${timeZone}) as "date", - "a"."noShow" AS "isNoShowGuest", - "timeStatus", - "noShowHost" - FROM - "BookingTimeStatusDenormalized" - JOIN - "Attendee" "a" ON "a"."bookingId" = "BookingTimeStatusDenormalized"."id" - WHERE - "createdAt" BETWEEN ${formattedStartDate}::timestamp AND ${formattedEndDate}::timestamp - AND ${Prisma.raw(whereClause)} - ) AS bookings - GROUP BY - "date", - "timeStatus", - "noShowHost" - ORDER BY - "date"; - `; - - const aggregate: AggregateResult = {}; - - // Initialize all date ranges with zero counts - dateRanges.forEach(({ formattedDate }) => { - aggregate[formattedDate] = { - completed: 0, - rescheduled: 0, - cancelled: 0, - noShowHost: 0, - noShowGuests: 0, - _all: 0, - uncompleted: 0, - }; - }); - - // Process the raw data - data.forEach(({ date, bookingsCount, timeStatus, noShowHost, noShowGuests }) => { - // Find which date range this date belongs to - const dateRange = dateRanges.find((range) => - dayjs(date).isBetween(range.startDate, range.endDate, null, "[]") - ); - - if (!dateRange) return; - - const formattedDate = dateRange.formattedDate; - const statusKey = timeStatus as keyof StatusAggregate; - - // Add to the specific status count - aggregate[formattedDate][statusKey] += Number(bookingsCount); - - // Add to the total count (_all) - aggregate[formattedDate]["_all"] += Number(bookingsCount); - - // Track no-show host counts separately - if (noShowHost) { - aggregate[formattedDate]["noShowHost"] += Number(bookingsCount); - } - - // Track no-show guests explicitly - aggregate[formattedDate]["noShowGuests"] += noShowGuests; - }); - - return aggregate; - }; - static getTotalNoShowGuests = async (where: Prisma.BookingTimeStatusDenormalizedWhereInput) => { const bookings = await prisma.bookingTimeStatusDenormalized.findMany({ where, diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index e9c5443b79..c99950f0ff 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -313,6 +313,35 @@ export interface IResultTeamList { const BATCH_SIZE = 1000; // Adjust based on your needs +/** + * Helper function to create InsightsBookingService with standardized parameters + */ +function createInsightsBookingService( + ctx: { insightsDb: typeof readonlyPrisma; user: { id: number; organizationId: number | null } }, + input: z.infer, + dateTarget: "createdAt" | "startTime" = "createdAt" +) { + const { scope, selectedTeamId, eventTypeId, memberUserId, startDate, endDate } = input; + + return new InsightsBookingService({ + prisma: ctx.insightsDb, + options: { + scope, + userId: ctx.user.id, + orgId: ctx.user.organizationId ?? 0, + ...(selectedTeamId && { teamId: selectedTeamId }), + }, + filters: { + ...(eventTypeId && { eventTypeId }), + ...(memberUserId && { memberUserId }), + dateRange: { + target: dateTarget, + startDate, + endDate, + }, + }, + }); +} export const insightsRouter = router({ eventsByStatus: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { const { teamId, startDate, endDate, eventTypeId, memberUserId, userId, isAll } = input; @@ -451,78 +480,31 @@ export const insightsRouter = router({ return result; }), - eventsTimeline: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { - const { teamId, eventTypeId, memberUserId, isAll, startDate, endDate, userId: selfUserId } = input; - if (selfUserId && ctx.user?.id !== selfUserId) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } + eventTrends: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ ctx, input }) => { + const { startDate, endDate, timeZone } = input; - if (!teamId && !selfUserId) { - return []; - } + // Calculate timeView and dateRanges + const timeView = EventsInsights.getTimeView(startDate, endDate); + const dateRanges = EventsInsights.getDateRanges({ + startDate, + endDate, + timeView, + timeZone, + weekStart: ctx.user.weekStart, + }); - const timeView = EventsInsights.getTimeView(startDate, endDate); - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: selfUserId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - const { whereCondition: whereConditional } = r; - - const dateRanges = EventsInsights.getDateRanges({ - startDate, - endDate, - timeView, - timeZone: ctx.user.timeZone, - weekStart: ctx.user.weekStart as GetDateRangesParams["weekStart"], - }); - if (!dateRanges.length) { - return []; - } - - // Fetch counts grouped by status for the entire range - const countsByStatus = await EventsInsights.countGroupedByStatusForRanges( - whereConditional, - dayjs(startDate), - dayjs(endDate), - dateRanges, - ctx.user.timeZone - ); - - const result = dateRanges.map(({ formattedDate }) => { - const EventData = { - Month: formattedDate, - Created: 0, - Completed: 0, - Rescheduled: 0, - Cancelled: 0, - "No-Show (Host)": 0, - "No-Show (Guest)": 0, - }; - - const countsForDateRange = countsByStatus[formattedDate]; - - if (countsForDateRange) { - EventData["Created"] = countsForDateRange["_all"] || 0; - EventData["Completed"] = countsForDateRange["completed"] || 0; - EventData["Rescheduled"] = countsForDateRange["rescheduled"] || 0; - EventData["Cancelled"] = countsForDateRange["cancelled"] || 0; - EventData["No-Show (Host)"] = countsForDateRange["noShowHost"] || 0; - EventData["No-Show (Guest)"] = countsForDateRange["noShowGuests"] || 0; + const insightsBookingService = createInsightsBookingService(ctx, input); + try { + return await insightsBookingService.getEventTrendsStats({ + timeZone, + dateRanges, + }); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - return EventData; - }); - - return result; - }), + }), popularEventTypes: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { const { teamId, startDate, endDate, memberUserId, userId, isAll, eventTypeId } = input; @@ -655,26 +637,9 @@ export const insightsRouter = router({ averageEventDuration: userBelongsToTeamProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, timeZone } = input; + const { startDate, endDate, timeZone } = input; - const insightsBookingService = new InsightsBookingService({ - prisma: ctx.insightsDb, - options: { - scope, - userId: ctx.user.id, - orgId: ctx.user.organizationId ?? 0, - ...(selectedTeamId && { teamId: selectedTeamId }), - }, - filters: { - ...(eventTypeId && { eventTypeId }), - ...(memberUserId && { memberUserId }), - dateRange: { - target: "createdAt", - startDate, - endDate, - }, - }, - }); + const insightsBookingService = createInsightsBookingService(ctx, input); try { const timeView = EventsInsights.getTimeView(startDate, endDate); @@ -1504,26 +1469,9 @@ export const insightsRouter = router({ }) ) .query(async ({ ctx, input }) => { - const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, limit, offset } = input; + const { limit, offset } = input; - const insightsBookingService = new InsightsBookingService({ - prisma: ctx.insightsDb, - options: { - scope, - userId: ctx.user.id, - orgId: ctx.user.organizationId ?? 0, - ...(selectedTeamId && { teamId: selectedTeamId }), - }, - filters: { - ...(eventTypeId && { eventTypeId }), - ...(memberUserId && { memberUserId }), - dateRange: { - target: "createdAt", - startDate, - endDate, - }, - }, - }); + const insightsBookingService = createInsightsBookingService(ctx, input); try { return await insightsBookingService.getCsvData({ @@ -1763,26 +1711,8 @@ export const insightsRouter = router({ bookingsByHourStats: userBelongsToTeamProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, timeZone } = input; - - const insightsBookingService = new InsightsBookingService({ - prisma: ctx.insightsDb, - options: { - scope, - userId: ctx.user.id, - orgId: ctx.user.organizationId ?? 0, - ...(selectedTeamId && { teamId: selectedTeamId }), - }, - filters: { - ...(eventTypeId && { eventTypeId }), - ...(memberUserId && { memberUserId }), - dateRange: { - target: "startTime", - startDate, - endDate, - }, - }, - }); + const { timeZone } = input; + const insightsBookingService = createInsightsBookingService(ctx, input, "startTime"); try { return await insightsBookingService.getBookingsByHourStats({ diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index 7adb0199c9..08a3a7e746 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -1,6 +1,7 @@ import { Prisma } from "@prisma/client"; import { z } from "zod"; +import type { DateRange } from "@calcom/features/insights/server/events"; import type { readonlyPrisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -529,6 +530,135 @@ export class InsightsBookingService { return { data, total: totalCount }; } + + async getEventTrendsStats({ timeZone, dateRanges }: { timeZone: string; dateRanges: DateRange[] }) { + if (!dateRanges.length) { + return []; + } + + const baseConditions = await this.getBaseConditions(); + + const data = await this.prisma.$queryRaw< + { + date: Date; + bookingsCount: number; + timeStatus: string; + noShowHost: boolean; + noShowGuests: number; + }[] + >` + SELECT + "date", + CAST(COUNT(*) AS INTEGER) AS "bookingsCount", + CAST(COUNT(CASE WHEN "isNoShowGuest" = true THEN 1 END) AS INTEGER) AS "noShowGuests", + "timeStatus", + "noShowHost" + FROM ( + SELECT + DATE("createdAt" AT TIME ZONE ${timeZone}) as "date", + "a"."noShow" AS "isNoShowGuest", + "timeStatus", + "noShowHost" + FROM + "BookingTimeStatusDenormalized" + JOIN + "Attendee" "a" ON "a"."bookingId" = "BookingTimeStatusDenormalized"."id" + WHERE + ${baseConditions} + ) AS bookings + GROUP BY + "date", + "timeStatus", + "noShowHost" + ORDER BY + "date" + `; + + // Initialize aggregate object with zero counts for all date ranges + const aggregate: { + [date: string]: { + completed: number; + rescheduled: number; + cancelled: number; + noShowHost: number; + noShowGuests: number; + _all: number; + uncompleted: number; + }; + } = {}; + + dateRanges.forEach(({ formattedDate }) => { + aggregate[formattedDate] = { + completed: 0, + rescheduled: 0, + cancelled: 0, + noShowHost: 0, + noShowGuests: 0, + _all: 0, + uncompleted: 0, + }; + }); + + // Process the raw data and aggregate by date ranges + data.forEach(({ date, bookingsCount, timeStatus, noShowHost, noShowGuests }) => { + // Find which date range this date belongs to using native Date comparison + const dateRange = dateRanges.find((range) => { + const bookingDate = new Date(date); + const rangeStart = new Date(range.startDate); + const rangeEnd = new Date(range.endDate); + return bookingDate >= rangeStart && bookingDate <= rangeEnd; + }); + + if (!dateRange) return; + + const formattedDate = dateRange.formattedDate; + const statusKey = timeStatus as keyof (typeof aggregate)[string]; + + // Add to the specific status count + if (statusKey in aggregate[formattedDate]) { + aggregate[formattedDate][statusKey] += Number(bookingsCount); + } + + // Add to the total count (_all) + aggregate[formattedDate]["_all"] += Number(bookingsCount); + + // Track no-show host counts separately + if (noShowHost) { + aggregate[formattedDate]["noShowHost"] += Number(bookingsCount); + } + + // Track no-show guests explicitly + aggregate[formattedDate]["noShowGuests"] += noShowGuests; + }); + + // Transform aggregate data into the expected format + const result = dateRanges.map(({ formattedDate }) => { + const eventData = { + Month: formattedDate, + Created: 0, + Completed: 0, + Rescheduled: 0, + Cancelled: 0, + "No-Show (Host)": 0, + "No-Show (Guest)": 0, + }; + + const countsForDateRange = aggregate[formattedDate]; + + if (countsForDateRange) { + eventData["Created"] = countsForDateRange["_all"] || 0; + eventData["Completed"] = countsForDateRange["completed"] || 0; + eventData["Rescheduled"] = countsForDateRange["rescheduled"] || 0; + eventData["Cancelled"] = countsForDateRange["cancelled"] || 0; + eventData["No-Show (Host)"] = countsForDateRange["noShowHost"] || 0; + eventData["No-Show (Guest)"] = countsForDateRange["noShowGuests"] || 0; + } + return eventData; + }); + + return result; + } + private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise { // Check if the user is an owner or admin of the organization const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });