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 <hey@eunjae.dev>
This commit is contained in:
Yash
2025-10-28 10:39:39 +00:00
committed by GitHub
co-authored by cubic-dev-ai[bot] Eunjae Lee
parent c7c8564b63
commit 89cc175d73
8 changed files with 444 additions and 49 deletions
@@ -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() {
<EventTrendsChart />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<NoShowHostsOverTimeChart />
<CSATOverTimeChart />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<div className="sm:col-span-2">
<BookingsByHourChart />
@@ -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",
@@ -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 (
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
<p className="text-default font-medium">{payload[0].payload.formattedDateFull}</p>
{payload.map((entry, index: number) => (
<p key={index} style={{ color: entry.color }}>
{t("csat")}: {entry.value.toFixed(1)}%
</p>
))}
</div>
);
};
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 <LoadingInsight />;
if (!isSuccess) return null;
return (
<ChartCard title={t("csat_over_time")} className="h-full">
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={csatData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={true}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={(value) => `${value}%`}
domain={[0, 100]}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="linear"
dataKey="CSAT"
name={t("csat")}
stroke={COLOR.CSAT}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
</ChartCard>
);
};
@@ -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 (
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
<p className="text-default font-medium">{payload[0].payload.formattedDateFull}</p>
{payload.map((entry, index: number) => (
<p key={index} style={{ color: entry.color }}>
{t("no_show_hosts")}: {valueFormatter(entry.value)}
</p>
))}
</div>
);
};
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 <LoadingInsight />;
if (!isSuccess) return null;
return (
<ChartCard title={t("no_show_hosts_over_time")} className="h-full">
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={noShowHostsData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={false}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={valueFormatter}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="linear"
dataKey="Count"
name={t("no_show_hosts")}
stroke={COLOR.NO_SHOW_HOST}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
</ChartCard>
);
};
@@ -41,42 +41,44 @@ export const RecentNoShowGuestsChart = () => {
title={t("recent_no_show_guests")}
titleTooltip={t("recent_no_show_guests_tooltip")}
className="h-full">
<div className="sm:max-h-[30.6rem] sm:overflow-y-auto">
{data.map((item) => (
<ChartCardItem key={item.bookingId}>
<div className="flex w-full items-center justify-between">
<div className="flex gap-2">
<div className="bg-subtle h-16 w-[2px] shrink-0 rounded-sm" />
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium">{item.guestName}</p>
<div className="text-subtle text-sm leading-tight">
<p>{item.eventTypeName}</p>
<p>
{Intl.DateTimeFormat(undefined, {
timeZone,
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(item.startTime))}
</p>
<div className="h-full">
<div className="sm:max-h-[30.6rem] sm:overflow-y-auto">
{data.map((item) => (
<ChartCardItem key={item.bookingId}>
<div className="flex w-full items-center justify-between">
<div className="flex gap-2">
<div className="bg-subtle h-16 w-[2px] shrink-0 rounded-sm" />
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium">{item.guestName}</p>
<div className="text-subtle text-sm leading-tight">
<p>{item.eventTypeName}</p>
<p>
{Intl.DateTimeFormat(undefined, {
timeZone,
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(item.startTime))}
</p>
</div>
</div>
</div>
<Button
color="minimal"
size="sm"
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
onClick={() => handleCopyEmail(item.guestEmail)}>
{!isCopied ? t("email") : t("copied")}
</Button>
</div>
<Button
color="minimal"
size="sm"
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
onClick={() => handleCopyEmail(item.guestEmail)}>
{!isCopied ? t("email") : t("copied")}
</Button>
</div>
</ChartCardItem>
))}
</div>
{data.length === 0 && (
<div className="flex h-60 text-center">
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
</ChartCardItem>
))}
</div>
)}
{data.length === 0 && (
<div className="flex h-60 text-center">
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
</div>
)}
</div>
</ChartCard>
);
};
@@ -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";
@@ -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" });
}
}),
@@ -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<boolean> {
const permissionCheckService = new PermissionCheckService();
return await permissionCheckService.checkPermission({