Skip to content

Commit 59c7614

Browse files
feat: add Recent No-Show Guests chart to insights page (calcom#23381)
* feat: add Recent No-Show Guests chart to insights page - Add RecentNoShowGuestsChart component with ChartCard wrapper - Add tRPC handler for recentNoShowGuests query - Add getRecentNoShowGuests method to InsightsBookingBaseService - Display guest name, booking time, event type, and copy email button - Filter for bookings where ALL attendees are no-shows - Add translation strings for new UI elements - Integrate chart into insights view grid layout Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * re-order charts * clean up * feat: add optional tooltip functionality to PanelCard - Add titleTooltip prop to PanelCard component with InfoBadge - Pass through titleTooltip prop in ChartCard - Add tooltip to RecentNoShowGuestsChart explaining complete no-show filtering - Add translation string for tooltip explanation Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * style adjustment * update style * address feedback --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent a494428 commit 59c7614

8 files changed

Lines changed: 183 additions & 5 deletions

File tree

apps/web/modules/insights/insights-view.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
MostCompletedTeamMembersTable,
2323
LeastCompletedTeamMembersTable,
2424
PopularEventsTable,
25+
RecentNoShowGuestsChart,
2526
RecentFeedbackTable,
2627
TimezoneBadge,
2728
} from "@calcom/features/insights/components/booking";
@@ -94,7 +95,7 @@ function InsightsPageContent() {
9495
<MostCancelledBookingsTables />
9596
<HighestNoShowHostTable />
9697
<div className="sm:col-span-2">
97-
<PopularEventsTable />
98+
<RecentNoShowGuestsChart />
9899
</div>
99100
</div>
100101

@@ -106,6 +107,12 @@ function InsightsPageContent() {
106107
</div>
107108
</div>
108109

110+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
111+
<div className="sm:col-span-2">
112+
<PopularEventsTable />
113+
</div>
114+
</div>
115+
109116
<small className="text-default block text-center">
110117
{t("looking_for_more_insights")}{" "}
111118
<a

apps/web/public/static/locales/en/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3273,6 +3273,8 @@
32733273
"routing_form_select_members_to_email": "Send email responses to",
32743274
"routing_incomplete_booking_tab": "Incomplete Bookings",
32753275
"include_no_show_in_rr_calculation": "Include no show bookings in round robin calculations",
3276+
"recent_no_show_guests": "Recent No-Show Guests",
3277+
"recent_no_show_guests_tooltip": "Shows bookings where all attendees were no-shows, not partial no-shows",
32763278
"matching": "Matching",
32773279
"event_redirect": "Event Redirect",
32783280
"reset_form": "Reset Form",

packages/features/insights/components/ChartCard.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,28 @@ export function ChartCard({
2020
legend,
2121
legendSize,
2222
children,
23+
className,
24+
titleTooltip,
2325
}: {
2426
title: string | ReactNode;
2527
subtitle?: string;
2628
cta?: { label: string; onClick: () => void };
2729
legend?: Array<LegendItem>;
2830
legendSize?: LegendSize;
31+
className?: string;
32+
titleTooltip?: string;
2933
children: ReactNode;
3034
}) {
3135
const legendComponent = legend && legend.length > 0 ? <Legend items={legend} size={legendSize} /> : null;
3236

3337
return (
34-
<PanelCard title={title} subtitle={subtitle} cta={cta} headerContent={legendComponent}>
38+
<PanelCard
39+
title={title}
40+
subtitle={subtitle}
41+
cta={cta}
42+
headerContent={legendComponent}
43+
className={className}
44+
titleTooltip={titleTooltip}>
3545
{children}
3646
</PanelCard>
3747
);
@@ -52,7 +62,7 @@ export function ChartCardItem({
5262
"text-default border-muted flex items-center justify-between border-b px-3 py-3.5 last:border-b-0",
5363
className
5464
)}>
55-
<div className="text-sm font-medium">{children}</div>
65+
<div className="grow text-sm font-medium">{children}</div>
5666
{count !== undefined && <div className="text-sm font-medium">{count}</div>}
5767
</div>
5868
);
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"use client";
2+
3+
import { useCopy } from "@calcom/lib/hooks/useCopy";
4+
import { useLocale } from "@calcom/lib/hooks/useLocale";
5+
import { trpc } from "@calcom/trpc";
6+
import { Button } from "@calcom/ui/components/button";
7+
import { showToast } from "@calcom/ui/components/toast";
8+
9+
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
10+
import { ChartCard, ChartCardItem } from "../ChartCard";
11+
import { LoadingInsight } from "../LoadingInsights";
12+
13+
export const RecentNoShowGuestsChart = () => {
14+
const { t } = useLocale();
15+
const { copyToClipboard, isCopied } = useCopy();
16+
const insightsBookingParams = useInsightsBookingParameters();
17+
const timeZone = insightsBookingParams.timeZone;
18+
19+
const { data, isSuccess, isPending } = trpc.viewer.insights.recentNoShowGuests.useQuery(
20+
insightsBookingParams,
21+
{
22+
staleTime: 180000,
23+
refetchOnWindowFocus: false,
24+
trpc: {
25+
context: { skipBatch: true },
26+
},
27+
}
28+
);
29+
30+
if (isPending) return <LoadingInsight />;
31+
32+
if (!isSuccess || !data) return null;
33+
34+
const handleCopyEmail = (email: string) => {
35+
copyToClipboard(email);
36+
showToast(t("email_copied"), "success");
37+
};
38+
39+
return (
40+
<ChartCard
41+
title={t("recent_no_show_guests")}
42+
titleTooltip={t("recent_no_show_guests_tooltip")}
43+
className="h-full">
44+
<div className="sm:max-h-[30.6rem] sm:overflow-y-auto">
45+
{data.map((item) => (
46+
<ChartCardItem key={item.bookingId}>
47+
<div className="flex w-full items-center justify-between">
48+
<div className="flex gap-2">
49+
<div className="bg-subtle h-16 w-[2px] shrink-0 rounded-sm" />
50+
<div className="flex flex-col space-y-1">
51+
<p className="text-sm font-medium">{item.guestName}</p>
52+
<div className="text-subtle text-sm leading-tight">
53+
<p>{item.eventTypeName}</p>
54+
<p>
55+
{Intl.DateTimeFormat(undefined, {
56+
timeZone,
57+
dateStyle: "medium",
58+
timeStyle: "short",
59+
}).format(new Date(item.startTime))}
60+
</p>
61+
</div>
62+
</div>
63+
</div>
64+
<Button
65+
color="minimal"
66+
size="sm"
67+
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
68+
onClick={() => handleCopyEmail(item.guestEmail)}>
69+
{!isCopied ? t("email") : t("copied")}
70+
</Button>
71+
</div>
72+
</ChartCardItem>
73+
))}
74+
</div>
75+
{data.length === 0 && (
76+
<div className="flex h-60 text-center">
77+
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
78+
</div>
79+
)}
80+
</ChartCard>
81+
);
82+
};

packages/features/insights/components/booking/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { LowestRatedMembersTable } from "./LowestRatedMembersTable";
99
export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable";
1010
export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables";
1111
export { PopularEventsTable } from "./PopularEventsTable";
12+
export { RecentNoShowGuestsChart } from "./RecentNoShowGuestsChart";
1213
export { RecentFeedbackTable } from "./RecentFeedbackTable";
1314
export { TimezoneBadge } from "./TimezoneBadge";
1415
export { MostCompletedTeamMembersTable } from "./MostCompletedBookings";

packages/features/insights/server/trpc-router.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,17 @@ export const insightsRouter = router({
10171017
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
10181018
}
10191019
}),
1020+
recentNoShowGuests: userBelongsToTeamProcedure
1021+
.input(bookingRepositoryBaseInputSchema)
1022+
.query(async ({ ctx, input }) => {
1023+
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");
1024+
1025+
try {
1026+
return await insightsBookingService.getRecentNoShowGuests();
1027+
} catch (e) {
1028+
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
1029+
}
1030+
}),
10201031
});
10211032

10221033
export async function getEventTypeList({

packages/lib/server/service/InsightsBookingBaseService.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,58 @@ export class InsightsBookingBaseService {
11221122
};
11231123
}
11241124

1125+
async getRecentNoShowGuests() {
1126+
const baseConditions = await this.getBaseConditions();
1127+
1128+
const recentNoShowBookings = await this.prisma.$queryRaw<
1129+
Array<{
1130+
bookingId: number;
1131+
startTime: Date;
1132+
eventTypeName: string;
1133+
guestName: string;
1134+
guestEmail: string;
1135+
}>
1136+
>`
1137+
WITH booking_attendee_stats AS (
1138+
SELECT
1139+
b.id as booking_id,
1140+
b."startTime",
1141+
b.title as event_type_name,
1142+
COUNT(a.id) as total_attendees,
1143+
COUNT(CASE WHEN a."noShow" = true THEN 1 END) as no_show_attendees
1144+
FROM "BookingTimeStatusDenormalized" b
1145+
INNER JOIN "Attendee" a ON a."bookingId" = b.id
1146+
WHERE ${baseConditions} and b.status = 'accepted'
1147+
GROUP BY b.id, b."startTime", b.title
1148+
HAVING COUNT(a.id) > 0 AND COUNT(a.id) = COUNT(CASE WHEN a."noShow" = true THEN 1 END)
1149+
),
1150+
recent_no_shows AS (
1151+
SELECT
1152+
bas.booking_id,
1153+
bas."startTime",
1154+
bas.event_type_name,
1155+
a.name as guest_name,
1156+
a.email as guest_email,
1157+
ROW_NUMBER() OVER (PARTITION BY bas.booking_id ORDER BY a.id) as rn
1158+
FROM booking_attendee_stats bas
1159+
INNER JOIN "Attendee" a ON a."bookingId" = bas.booking_id
1160+
WHERE a."noShow" = true
1161+
)
1162+
SELECT
1163+
booking_id as "bookingId",
1164+
"startTime",
1165+
event_type_name as "eventTypeName",
1166+
guest_name as "guestName",
1167+
guest_email as "guestEmail"
1168+
FROM recent_no_shows
1169+
WHERE rn = 1
1170+
ORDER BY "startTime" DESC
1171+
LIMIT 10
1172+
`;
1173+
1174+
return recentNoShowBookings;
1175+
}
1176+
11251177
calculatePreviousPeriodDates() {
11261178
if (!this.filters?.dateRange) {
11271179
throw new Error("Date range is required for calculating previous period");

packages/ui/components/card/PanelCard.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,38 @@
11
import type { ReactNode } from "react";
22

3+
import classNames from "@calcom/ui/classNames";
4+
import { InfoBadge } from "@calcom/ui/components/badge";
35
import { Button } from "@calcom/ui/components/button";
46

57
export function PanelCard({
68
title,
79
subtitle,
810
cta,
911
headerContent,
12+
className,
13+
titleTooltip,
1014
children,
1115
}: {
1216
title: string | ReactNode;
1317
subtitle?: string;
1418
cta?: { label: string; onClick: () => void };
1519
headerContent?: ReactNode;
20+
className?: string;
21+
titleTooltip?: string;
1622
children: ReactNode;
1723
}) {
1824
return (
19-
<div className="bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1">
25+
<div
26+
className={classNames(
27+
"bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1",
28+
className
29+
)}>
2030
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
2131
{typeof title === "string" ? (
22-
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
32+
<div className="mr-4 flex shrink-0 items-center gap-1">
33+
<h2 className="text-emphasis shrink-0 text-sm font-semibold">{title}</h2>
34+
{titleTooltip && <InfoBadge content={titleTooltip} />}
35+
</div>
2336
) : (
2437
title
2538
)}

0 commit comments

Comments
 (0)