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>
This commit is contained in:
Eunjae Lee
2025-07-25 15:58:41 +00:00
committed by GitHub
co-authored by Anik Dhabal Babu
parent e98024b08e
commit e6a0a0fc69
6 changed files with 203 additions and 233 deletions
+2 -2
View File
@@ -10,7 +10,7 @@ import {
import {
AverageEventDurationChart,
BookingKPICards,
BookingStatusLineChart,
EventTrendsChart,
HighestNoShowHostTable,
HighestRatedMembersTable,
BookingsByHourChart,
@@ -70,7 +70,7 @@ function InsightsPageContent() {
<div className="my-4 space-y-4">
<BookingKPICards />
<BookingStatusLineChart />
<EventTrendsChart />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<div className="sm:col-span-2">
@@ -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 = () => {
<ChartCard title={t("event_trends")}>
<LineChart
className="linechart ml-4 mt-4 h-80 sm:ml-0"
data={eventsTimeLine ?? []}
data={eventTrends ?? []}
categories={["Created", "Completed", "Rescheduled", "Cancelled", "No-Show (Host)", "No-Show (Guest)"]}
index="Month"
colors={["purple", "green", "blue", "red", "slate", "orange"]}
@@ -2,7 +2,7 @@ export { AverageEventDurationChart } from "./AverageEventDurationChart";
export { BookingKPICards } from "./BookingKPICards";
export { BookingsByHourChart } from "./BookingsByHourChart";
export { BookingStatusLineChart } from "./BookingStatusLineChart";
export { EventTrendsChart } from "./EventTrendsChart";
export { FailedBookingsByField } from "./FailedBookingsByField";
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
export { HighestRatedMembersTable } from "./HighestRatedMembersTable";
+1 -95
View File
@@ -1,7 +1,6 @@
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { readonlyPrisma as prisma } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import type { Prisma } from "@calcom/prisma/client";
type TimeViewType = "week" | "month" | "year" | "day";
@@ -75,99 +74,6 @@ export interface GetDateRangesParams {
}
class EventsInsights {
static countGroupedByStatusForRanges = async (
whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput,
startDate: Dayjs,
endDate: Dayjs,
dateRanges: DateRange[],
timeZone: string
): Promise<AggregateResult> => {
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,
+57 -127
View File
@@ -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<typeof bookingRepositoryBaseInputSchema>,
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({
@@ -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<boolean> {
// Check if the user is an owner or admin of the organization
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });