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 });