From 89cc175d735f59bc85d9ad7d02b6d67a7968c402 Mon Sep 17 00:00:00 2001 From: Yash <116657771+hackice20@users.noreply.github.com> Date: Tue, 28 Oct 2025 16:09:39 +0530 Subject: [PATCH] feat: add no-show hosts and csat graphs on Insights (#24556) * feat: Add No-Show Hosts and CSAT insights with beautiful card design - Add No-Show Hosts Over Time chart with line visualization - Add CSAT Over Time chart with percentage tracking - Add Recent No-Show Guests list with email copy functionality - Redesign all KPI cards as individual separated cards with curved edges - Add proper TRPC endpoints and database queries for new insights - Follow Cal.com design patterns with consistent styling - All charts now have individual cards with rounded-2xl borders and shadow-sm elevation - Fix all linting issues with unused imports and variables * coderabbit fixes * fix(insights): address review feedback on charts * fix(insights): correct dayjs timezone usage for type safety * Update packages/features/insights/services/InsightsBookingBaseService.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Fix InfoBadge import path * Wrap InfoBadge in span for styling * Add insights charts and i18n keys * Fix ChartCard usage and double card wrapping issues * Remove double card wrapping for chart components --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Eunjae Lee --- apps/web/modules/insights/insights-view.tsx | 7 + apps/web/public/static/locales/en/common.json | 4 + .../components/booking/CSATOverTimeChart.tsx | 103 +++++++++++++++ .../booking/NoShowHostsOverTimeChart.tsx | 103 +++++++++++++++ .../booking/RecentNoShowGuestsChart.tsx | 66 +++++----- .../insights/components/booking/index.ts | 2 + .../features/insights/server/trpc-router.ts | 86 +++++++++--- .../services/InsightsBookingBaseService.ts | 122 ++++++++++++++++++ 8 files changed, 444 insertions(+), 49 deletions(-) create mode 100644 packages/features/insights/components/booking/CSATOverTimeChart.tsx create mode 100644 packages/features/insights/components/booking/NoShowHostsOverTimeChart.tsx diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index 1ce5f285b9..bd02a21bdd 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -16,6 +16,7 @@ import { AverageEventDurationChart, BookingKPICards, BookingsByHourChart, + CSATOverTimeChart, EventTrendsChart, HighestNoShowHostTable, HighestRatedMembersTable, @@ -25,6 +26,7 @@ import { MostCancelledBookingsTables, MostCompletedTeamMembersTable, LeastCompletedTeamMembersTable, + NoShowHostsOverTimeChart, PopularEventsTable, RecentNoShowGuestsChart, RecentFeedbackTable, @@ -105,6 +107,11 @@ function InsightsPageContent() { +
+ + +
+
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 9f08bed0f1..9867807d2e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2406,6 +2406,10 @@ "insights": "Insights", "routing_forms": "Routing Forms", "insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.", + "no_show_hosts_over_time": "No-Show Hosts Over Time", + "csat_over_time": "CSAT Over Time", + "no_show_hosts": "No-Show Hosts", + "csat": "CSAT", "acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.", "days": "days", "card_details": "Card details", diff --git a/packages/features/insights/components/booking/CSATOverTimeChart.tsx b/packages/features/insights/components/booking/CSATOverTimeChart.tsx new file mode 100644 index 0000000000..15dc63851e --- /dev/null +++ b/packages/features/insights/components/booking/CSATOverTimeChart.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import type { RouterOutputs } from "@calcom/trpc/react"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; + +const COLOR = { + CSAT: "#22c55e", +}; + +type CSATOverTimeData = RouterOutputs["viewer"]["insights"]["csatOverTime"][number]; + +// Custom Tooltip component +const CustomTooltip = ({ + active, + payload, + label: _label, +}: { + active?: boolean; + payload?: Array<{ + value: number; + dataKey: string; + name: string; + color: string; + payload: CSATOverTimeData; + }>; + label?: string; +}) => { + const { t } = useLocale(); + if (!active || !payload?.length) { + return null; + } + + return ( +
+

{payload[0].payload.formattedDateFull}

+ {payload.map((entry, index: number) => ( +

+ {t("csat")}: {entry.value.toFixed(1)}% +

+ ))} +
+ ); +}; + +export const CSATOverTimeChart = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { + data: csatData, + isSuccess, + isPending, + } = trpc.viewer.insights.csatOverTime.useQuery(insightsBookingParams, { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, + }, + }); + + if (isPending) return ; + + if (!isSuccess) return null; + + return ( + +
+ + + + + `${value}%`} + domain={[0, 100]} + /> + } /> + + + +
+
+ ); +}; diff --git a/packages/features/insights/components/booking/NoShowHostsOverTimeChart.tsx b/packages/features/insights/components/booking/NoShowHostsOverTimeChart.tsx new file mode 100644 index 0000000000..a62ca30e09 --- /dev/null +++ b/packages/features/insights/components/booking/NoShowHostsOverTimeChart.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import type { RouterOutputs } from "@calcom/trpc/react"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { valueFormatter } from "../../lib/valueFormatter"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; + +const COLOR = { + NO_SHOW_HOST: "#64748b", +}; + +type NoShowHostsOverTimeData = RouterOutputs["viewer"]["insights"]["noShowHostsOverTime"][number]; + +// Custom Tooltip component +const CustomTooltip = ({ + active, + payload, + label: _label, +}: { + active?: boolean; + payload?: Array<{ + value: number; + dataKey: string; + name: string; + color: string; + payload: NoShowHostsOverTimeData; + }>; + label?: string; +}) => { + const { t } = useLocale(); + if (!active || !payload?.length) { + return null; + } + + return ( +
+

{payload[0].payload.formattedDateFull}

+ {payload.map((entry, index: number) => ( +

+ {t("no_show_hosts")}: {valueFormatter(entry.value)} +

+ ))} +
+ ); +}; + +export const NoShowHostsOverTimeChart = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { + data: noShowHostsData, + isSuccess, + isPending, + } = trpc.viewer.insights.noShowHostsOverTime.useQuery(insightsBookingParams, { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, + }, + }); + + if (isPending) return ; + + if (!isSuccess) return null; + + return ( + +
+ + + + + + } /> + + + +
+
+ ); +}; diff --git a/packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx b/packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx index f59c0cdfa7..4017520ae7 100644 --- a/packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx +++ b/packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx @@ -41,42 +41,44 @@ export const RecentNoShowGuestsChart = () => { title={t("recent_no_show_guests")} titleTooltip={t("recent_no_show_guests_tooltip")} className="h-full"> -
- {data.map((item) => ( - -
-
-
-
-

{item.guestName}

-
-

{item.eventTypeName}

-

- {Intl.DateTimeFormat(undefined, { - timeZone, - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(item.startTime))} -

+
+
+ {data.map((item) => ( + +
+
+
+
+

{item.guestName}

+
+

{item.eventTypeName}

+

+ {Intl.DateTimeFormat(undefined, { + timeZone, + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(item.startTime))} +

+
+
- -
-
- ))} -
- {data.length === 0 && ( -
-

{t("insights_no_data_found_for_filter")}

+ + ))}
- )} + {data.length === 0 && ( +
+

{t("insights_no_data_found_for_filter")}

+
+ )} +
); }; diff --git a/packages/features/insights/components/booking/index.ts b/packages/features/insights/components/booking/index.ts index 924cc5b7e1..e89484b454 100644 --- a/packages/features/insights/components/booking/index.ts +++ b/packages/features/insights/components/booking/index.ts @@ -1,5 +1,6 @@ export { AverageEventDurationChart } from "./AverageEventDurationChart"; export { BookingKPICards } from "./BookingKPICards"; +export { CSATOverTimeChart } from "./CSATOverTimeChart"; export { EventTrendsChart } from "./EventTrendsChart"; export { HighestNoShowHostTable } from "./HighestNoShowHostTable"; export { HighestRatedMembersTable } from "./HighestRatedMembersTable"; @@ -8,6 +9,7 @@ export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable"; export { LowestRatedMembersTable } from "./LowestRatedMembersTable"; export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable"; export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables"; +export { NoShowHostsOverTimeChart } from "./NoShowHostsOverTimeChart"; export { PopularEventsTable } from "./PopularEventsTable"; export { RecentNoShowGuestsChart } from "./RecentNoShowGuestsChart"; export { RecentFeedbackTable } from "./RecentFeedbackTable"; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 0367ecf671..80fb76a840 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -464,7 +464,7 @@ export const insightsRouter = router({ timeZone, dateRanges, }); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -475,7 +475,7 @@ export const insightsRouter = router({ try { return await insightsBookingService.getPopularEventsStats(); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -536,7 +536,7 @@ export const insightsRouter = router({ })); return result; - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -550,7 +550,7 @@ export const insightsRouter = router({ type: "cancelled", sortOrder: "DESC", }); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -565,7 +565,7 @@ export const insightsRouter = router({ sortOrder: "DESC", completed: true, }); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -580,7 +580,7 @@ export const insightsRouter = router({ sortOrder: "ASC", completed: true, }); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -591,7 +591,7 @@ export const insightsRouter = router({ try { return await insightsBookingService.getMembersStatsWithCount({ type: "all", sortOrder: "DESC" }); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -602,7 +602,7 @@ export const insightsRouter = router({ try { return await insightsBookingService.getMembersStatsWithCount({ type: "all", sortOrder: "ASC" }); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -798,7 +798,7 @@ export const insightsRouter = router({ try { return await insightsBookingService.getMembersStatsWithCount({ type: "noShow", sortOrder: "DESC" }); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -832,7 +832,7 @@ export const insightsRouter = router({ offset: offset ?? 0, timeZone, }); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -913,7 +913,7 @@ export const insightsRouter = router({ const insightsRoutingService = createInsightsRoutingService(ctx, input); try { return await insightsRoutingService.getFailedBookingsByFieldData(); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -949,7 +949,7 @@ export const insightsRouter = router({ limit, searchQuery, }); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -971,7 +971,7 @@ export const insightsRouter = router({ ).format("YYYY-MM-DD")}.csv`; return { data: csvString, filename: downloadAs }; - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -982,7 +982,7 @@ export const insightsRouter = router({ }); return routingForms; - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -1000,7 +1000,7 @@ export const insightsRouter = router({ const insightsRoutingService = createInsightsRoutingService(ctx, input); try { return await insightsRoutingService.getRoutingFunnelData(dateRanges); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -1014,7 +1014,7 @@ export const insightsRouter = router({ return await insightsBookingService.getBookingsByHourStats({ timeZone, }); - } catch (e) { + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), @@ -1025,7 +1025,59 @@ export const insightsRouter = router({ try { return await insightsBookingService.getRecentNoShowGuests(); - } catch (e) { + } catch { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + }), + noShowHostsOverTime: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ ctx, input }) => { + const { columnFilters, timeZone } = input; + const { startDate, endDate } = extractDateRangeFromColumnFilters(columnFilters); + + // Calculate timeView and dateRanges + const timeView = getTimeView(startDate, endDate); + const dateRanges = getDateRanges({ + startDate, + endDate, + timeView, + timeZone, + weekStart: ctx.user.weekStart, + }); + + const insightsBookingService = createInsightsBookingService(ctx, input); + try { + return await insightsBookingService.getNoShowHostsOverTimeStats({ + timeZone, + dateRanges, + }); + } catch { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + }), + csatOverTime: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ ctx, input }) => { + const { columnFilters, timeZone } = input; + const { startDate, endDate } = extractDateRangeFromColumnFilters(columnFilters); + + // Calculate timeView and dateRanges + const timeView = getTimeView(startDate, endDate); + const dateRanges = getDateRanges({ + startDate, + endDate, + timeView, + timeZone, + weekStart: ctx.user.weekStart, + }); + + const insightsBookingService = createInsightsBookingService(ctx, input); + try { + return await insightsBookingService.getCSATOverTimeStats({ + timeZone, + dateRanges, + }); + } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), diff --git a/packages/features/insights/services/InsightsBookingBaseService.ts b/packages/features/insights/services/InsightsBookingBaseService.ts index 4f35a6d50b..560686c2b8 100644 --- a/packages/features/insights/services/InsightsBookingBaseService.ts +++ b/packages/features/insights/services/InsightsBookingBaseService.ts @@ -1290,6 +1290,128 @@ export class InsightsBookingBaseService { }; } + async getNoShowHostsOverTimeStats({ timeZone, dateRanges }: { timeZone: string; dateRanges: DateRange[] }) { + if (!dateRanges.length) { + return []; + } + + const baseConditions = await this.getBaseConditions(); + + const query = Prisma.sql` + SELECT + DATE("createdAt" AT TIME ZONE ${timeZone}) as "date", + COUNT(*) as "count" + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} AND "noShowHost" = true + GROUP BY 1 + ORDER BY 1 + `; + + const data = await this.prisma.$queryRaw< + { + date: Date; + count: number; + }[] + >(query); + + // Initialize aggregate object with zero counts for all date ranges + const aggregate: { [date: string]: number } = {}; + + dateRanges.forEach(({ formattedDate }) => { + aggregate[formattedDate] = 0; + }); + + // Process the raw data and aggregate by date ranges + data.forEach(({ date, count }) => { + // 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; + aggregate[formattedDate] += Number(count); + }); + + // Transform aggregate data into the expected format + const result = dateRanges.map(({ formattedDate, formattedDateFull }) => ({ + formattedDateFull: formattedDateFull, + Month: formattedDate, + Count: aggregate[formattedDate] || 0, + })); + + return result; + } + + async getCSATOverTimeStats({ timeZone, dateRanges }: { timeZone: string; dateRanges: DateRange[] }) { + if (!dateRanges.length) { + return []; + } + + const baseConditions = await this.getBaseConditions(); + + const query = Prisma.sql` + SELECT + DATE("createdAt" AT TIME ZONE ${timeZone}) as "date", + COUNT(*) FILTER (WHERE "rating" >= 3) as "ratings_above_3", + COUNT(*) FILTER (WHERE "rating" IS NOT NULL) as "total_ratings" + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} AND "rating" IS NOT NULL + GROUP BY 1 + ORDER BY 1 + `; + + const data = await this.prisma.$queryRaw< + { + date: Date; + ratings_above_3: number; + total_ratings: number; + }[] + >(query); + + // Initialize aggregate object with zero counts for all date ranges + const aggregate: { [date: string]: { ratingsAbove3: number; totalRatings: number } } = {}; + + dateRanges.forEach(({ formattedDate }) => { + aggregate[formattedDate] = { ratingsAbove3: 0, totalRatings: 0 }; + }); + + // Process the raw data and aggregate by date ranges + data.forEach(({ date, ratings_above_3, total_ratings }) => { + // 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; + aggregate[formattedDate].ratingsAbove3 += Number(ratings_above_3); + aggregate[formattedDate].totalRatings += Number(total_ratings); + }); + + // Transform aggregate data into the expected format and calculate CSAT percentage + const result = dateRanges.map(({ formattedDate, formattedDateFull }) => { + const counts = aggregate[formattedDate]; + const csat = counts.totalRatings > 0 ? (counts.ratingsAbove3 / counts.totalRatings) * 100 : 0; + + return { + formattedDateFull: formattedDateFull, + Month: formattedDate, + CSAT: parseFloat(csat.toFixed(1)), + }; + }); + + return result; + } + private async isOwnerOrAdmin(userId: number, targetId: number): Promise { const permissionCheckService = new PermissionCheckService(); return await permissionCheckService.checkPermission({