Skip to content
9 changes: 8 additions & 1 deletion apps/web/modules/insights/insights-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
MostCompletedTeamMembersTable,
LeastCompletedTeamMembersTable,
PopularEventsTable,
RecentNoShowGuestsChart,
RecentFeedbackTable,
TimezoneBadge,
} from "@calcom/features/insights/components/booking";
Expand Down Expand Up @@ -94,7 +95,7 @@ function InsightsPageContent() {
<MostCancelledBookingsTables />
<HighestNoShowHostTable />
<div className="sm:col-span-2">
<PopularEventsTable />
<RecentNoShowGuestsChart />
</div>
</div>

Expand All @@ -106,6 +107,12 @@ function InsightsPageContent() {
</div>
</div>

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<div className="sm:col-span-2">
<PopularEventsTable />
</div>
</div>

<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
Expand Down
2 changes: 2 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -3273,6 +3273,8 @@
"routing_form_select_members_to_email": "Send email responses to",
"routing_incomplete_booking_tab": "Incomplete Bookings",
"include_no_show_in_rr_calculation": "Include no show bookings in round robin calculations",
"recent_no_show_guests": "Recent No-Show Guests",
"recent_no_show_guests_tooltip": "Shows bookings where all attendees were no-shows, not partial no-shows",
"matching": "Matching",
"event_redirect": "Event Redirect",
"reset_form": "Reset Form",
Expand Down
14 changes: 12 additions & 2 deletions packages/features/insights/components/ChartCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,28 @@ export function ChartCard({
legend,
legendSize,
children,
className,
titleTooltip,
}: {
title: string | ReactNode;
subtitle?: string;
cta?: { label: string; onClick: () => void };
legend?: Array<LegendItem>;
legendSize?: LegendSize;
className?: string;
titleTooltip?: string;
children: ReactNode;
}) {
const legendComponent = legend && legend.length > 0 ? <Legend items={legend} size={legendSize} /> : null;

return (
<PanelCard title={title} subtitle={subtitle} cta={cta} headerContent={legendComponent}>
<PanelCard
title={title}
subtitle={subtitle}
cta={cta}
headerContent={legendComponent}
className={className}
titleTooltip={titleTooltip}>
{children}
</PanelCard>
);
Expand All @@ -52,7 +62,7 @@ export function ChartCardItem({
"text-default border-muted flex items-center justify-between border-b px-3 py-3.5 last:border-b-0",
className
)}>
<div className="text-sm font-medium">{children}</div>
<div className="grow text-sm font-medium">{children}</div>
{count !== undefined && <div className="text-sm font-medium">{count}</div>}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use client";

import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast";

import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard, ChartCardItem } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";

export const RecentNoShowGuestsChart = () => {
const { t } = useLocale();
const { copyToClipboard, isCopied } = useCopy();
const insightsBookingParams = useInsightsBookingParameters();
const timeZone = insightsBookingParams.timeZone;

const { data, isSuccess, isPending } = trpc.viewer.insights.recentNoShowGuests.useQuery(
insightsBookingParams,
{
staleTime: 180000,
refetchOnWindowFocus: false,
trpc: {
context: { skipBatch: true },
},
}
);

if (isPending) return <LoadingInsight />;

if (!isSuccess || !data) return null;

const handleCopyEmail = (email: string) => {
copyToClipboard(email);
showToast(t("email_copied"), "success");
};

return (
<ChartCard
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>
</div>
</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>
</div>
)}
</ChartCard>
);
};
1 change: 1 addition & 0 deletions packages/features/insights/components/booking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { LowestRatedMembersTable } from "./LowestRatedMembersTable";
export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable";
export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables";
export { PopularEventsTable } from "./PopularEventsTable";
export { RecentNoShowGuestsChart } from "./RecentNoShowGuestsChart";
export { RecentFeedbackTable } from "./RecentFeedbackTable";
export { TimezoneBadge } from "./TimezoneBadge";
export { MostCompletedTeamMembersTable } from "./MostCompletedBookings";
Expand Down
11 changes: 11 additions & 0 deletions packages/features/insights/server/trpc-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,17 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
recentNoShowGuests: userBelongsToTeamProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const insightsBookingService = createInsightsBookingService(ctx, input, "startTime");

try {
return await insightsBookingService.getRecentNoShowGuests();
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
});

async function getEventTypeList({
Expand Down
52 changes: 52 additions & 0 deletions packages/lib/server/service/InsightsBookingBaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,58 @@ export class InsightsBookingBaseService {
};
}

async getRecentNoShowGuests() {
const baseConditions = await this.getBaseConditions();

const recentNoShowBookings = await this.prisma.$queryRaw<
Array<{
bookingId: number;
startTime: Date;
eventTypeName: string;
guestName: string;
guestEmail: string;
}>
>`
WITH booking_attendee_stats AS (
SELECT
b.id as booking_id,
b."startTime",
b.title as event_type_name,
COUNT(a.id) as total_attendees,
COUNT(CASE WHEN a."noShow" = true THEN 1 END) as no_show_attendees
FROM "BookingTimeStatusDenormalized" b
INNER JOIN "Attendee" a ON a."bookingId" = b.id
WHERE ${baseConditions} and b.status = 'accepted'
GROUP BY b.id, b."startTime", b.title
HAVING COUNT(a.id) > 0 AND COUNT(a.id) = COUNT(CASE WHEN a."noShow" = true THEN 1 END)
),
recent_no_shows AS (
SELECT
bas.booking_id,
bas."startTime",
bas.event_type_name,
a.name as guest_name,
a.email as guest_email,
ROW_NUMBER() OVER (PARTITION BY bas.booking_id ORDER BY a.id) as rn
FROM booking_attendee_stats bas
INNER JOIN "Attendee" a ON a."bookingId" = bas.booking_id
WHERE a."noShow" = true
)
SELECT
booking_id as "bookingId",
"startTime",
event_type_name as "eventTypeName",
guest_name as "guestName",
guest_email as "guestEmail"
FROM recent_no_shows
WHERE rn = 1
ORDER BY "startTime" DESC
LIMIT 10
`;

return recentNoShowBookings;
}

calculatePreviousPeriodDates() {
if (!this.filters?.dateRange) {
throw new Error("Date range is required for calculating previous period");
Expand Down
17 changes: 15 additions & 2 deletions packages/ui/components/card/PanelCard.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,38 @@
import type { ReactNode } from "react";

import classNames from "@calcom/ui/classNames";
import { InfoBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";

export function PanelCard({
title,
subtitle,
cta,
headerContent,
className,
titleTooltip,
children,
}: {
title: string | ReactNode;
subtitle?: string;
cta?: { label: string; onClick: () => void };
headerContent?: ReactNode;
className?: string;
titleTooltip?: string;
children: ReactNode;
}) {
return (
<div className="bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1">
<div
className={classNames(
"bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1",
className
)}>
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
{typeof title === "string" ? (
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
<div className="mr-4 flex shrink-0 items-center gap-1">
<h2 className="text-emphasis shrink-0 text-sm font-semibold">{title}</h2>
{titleTooltip && <InfoBadge content={titleTooltip} />}
</div>
) : (
title
)}
Expand Down
Loading