Skip to content

Commit 89cc175

Browse files
hackice20cubic-dev-ai[bot]eunjae-lee
authored
feat: add no-show hosts and csat graphs on Insights (calcom#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>
1 parent c7c8564 commit 89cc175

8 files changed

Lines changed: 444 additions & 49 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
AverageEventDurationChart,
1717
BookingKPICards,
1818
BookingsByHourChart,
19+
CSATOverTimeChart,
1920
EventTrendsChart,
2021
HighestNoShowHostTable,
2122
HighestRatedMembersTable,
@@ -25,6 +26,7 @@ import {
2526
MostCancelledBookingsTables,
2627
MostCompletedTeamMembersTable,
2728
LeastCompletedTeamMembersTable,
29+
NoShowHostsOverTimeChart,
2830
PopularEventsTable,
2931
RecentNoShowGuestsChart,
3032
RecentFeedbackTable,
@@ -105,6 +107,11 @@ function InsightsPageContent() {
105107

106108
<EventTrendsChart />
107109

110+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
111+
<NoShowHostsOverTimeChart />
112+
<CSATOverTimeChart />
113+
</div>
114+
108115
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
109116
<div className="sm:col-span-2">
110117
<BookingsByHourChart />

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2406,6 +2406,10 @@
24062406
"insights": "Insights",
24072407
"routing_forms": "Routing Forms",
24082408
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
2409+
"no_show_hosts_over_time": "No-Show Hosts Over Time",
2410+
"csat_over_time": "CSAT Over Time",
2411+
"no_show_hosts": "No-Show Hosts",
2412+
"csat": "CSAT",
24092413
"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.",
24102414
"days": "days",
24112415
"card_details": "Card details",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"use client";
2+
3+
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
4+
5+
import { useLocale } from "@calcom/lib/hooks/useLocale";
6+
import { trpc } from "@calcom/trpc";
7+
import type { RouterOutputs } from "@calcom/trpc/react";
8+
9+
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
10+
import { ChartCard } from "../ChartCard";
11+
import { LoadingInsight } from "../LoadingInsights";
12+
13+
const COLOR = {
14+
CSAT: "#22c55e",
15+
};
16+
17+
type CSATOverTimeData = RouterOutputs["viewer"]["insights"]["csatOverTime"][number];
18+
19+
// Custom Tooltip component
20+
const CustomTooltip = ({
21+
active,
22+
payload,
23+
label: _label,
24+
}: {
25+
active?: boolean;
26+
payload?: Array<{
27+
value: number;
28+
dataKey: string;
29+
name: string;
30+
color: string;
31+
payload: CSATOverTimeData;
32+
}>;
33+
label?: string;
34+
}) => {
35+
const { t } = useLocale();
36+
if (!active || !payload?.length) {
37+
return null;
38+
}
39+
40+
return (
41+
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
42+
<p className="text-default font-medium">{payload[0].payload.formattedDateFull}</p>
43+
{payload.map((entry, index: number) => (
44+
<p key={index} style={{ color: entry.color }}>
45+
{t("csat")}: {entry.value.toFixed(1)}%
46+
</p>
47+
))}
48+
</div>
49+
);
50+
};
51+
52+
export const CSATOverTimeChart = () => {
53+
const { t } = useLocale();
54+
const insightsBookingParams = useInsightsBookingParameters();
55+
56+
const {
57+
data: csatData,
58+
isSuccess,
59+
isPending,
60+
} = trpc.viewer.insights.csatOverTime.useQuery(insightsBookingParams, {
61+
staleTime: 180000,
62+
refetchOnWindowFocus: false,
63+
trpc: {
64+
context: { skipBatch: true },
65+
},
66+
});
67+
68+
if (isPending) return <LoadingInsight />;
69+
70+
if (!isSuccess) return null;
71+
72+
return (
73+
<ChartCard title={t("csat_over_time")} className="h-full">
74+
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
75+
<ResponsiveContainer width="100%" height="100%">
76+
<LineChart data={csatData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
77+
<CartesianGrid strokeDasharray="3 3" vertical={false} />
78+
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
79+
<YAxis
80+
allowDecimals={true}
81+
className="text-xs opacity-50"
82+
axisLine={false}
83+
tickLine={false}
84+
tickFormatter={(value) => `${value}%`}
85+
domain={[0, 100]}
86+
/>
87+
<Tooltip content={<CustomTooltip />} />
88+
<Line
89+
type="linear"
90+
dataKey="CSAT"
91+
name={t("csat")}
92+
stroke={COLOR.CSAT}
93+
strokeWidth={2}
94+
dot={{ r: 4 }}
95+
activeDot={{ r: 6 }}
96+
animationDuration={1000}
97+
/>
98+
</LineChart>
99+
</ResponsiveContainer>
100+
</div>
101+
</ChartCard>
102+
);
103+
};
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"use client";
2+
3+
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
4+
5+
import { useLocale } from "@calcom/lib/hooks/useLocale";
6+
import { trpc } from "@calcom/trpc";
7+
import type { RouterOutputs } from "@calcom/trpc/react";
8+
9+
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
10+
import { valueFormatter } from "../../lib/valueFormatter";
11+
import { ChartCard } from "../ChartCard";
12+
import { LoadingInsight } from "../LoadingInsights";
13+
14+
const COLOR = {
15+
NO_SHOW_HOST: "#64748b",
16+
};
17+
18+
type NoShowHostsOverTimeData = RouterOutputs["viewer"]["insights"]["noShowHostsOverTime"][number];
19+
20+
// Custom Tooltip component
21+
const CustomTooltip = ({
22+
active,
23+
payload,
24+
label: _label,
25+
}: {
26+
active?: boolean;
27+
payload?: Array<{
28+
value: number;
29+
dataKey: string;
30+
name: string;
31+
color: string;
32+
payload: NoShowHostsOverTimeData;
33+
}>;
34+
label?: string;
35+
}) => {
36+
const { t } = useLocale();
37+
if (!active || !payload?.length) {
38+
return null;
39+
}
40+
41+
return (
42+
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
43+
<p className="text-default font-medium">{payload[0].payload.formattedDateFull}</p>
44+
{payload.map((entry, index: number) => (
45+
<p key={index} style={{ color: entry.color }}>
46+
{t("no_show_hosts")}: {valueFormatter(entry.value)}
47+
</p>
48+
))}
49+
</div>
50+
);
51+
};
52+
53+
export const NoShowHostsOverTimeChart = () => {
54+
const { t } = useLocale();
55+
const insightsBookingParams = useInsightsBookingParameters();
56+
57+
const {
58+
data: noShowHostsData,
59+
isSuccess,
60+
isPending,
61+
} = trpc.viewer.insights.noShowHostsOverTime.useQuery(insightsBookingParams, {
62+
staleTime: 180000,
63+
refetchOnWindowFocus: false,
64+
trpc: {
65+
context: { skipBatch: true },
66+
},
67+
});
68+
69+
if (isPending) return <LoadingInsight />;
70+
71+
if (!isSuccess) return null;
72+
73+
return (
74+
<ChartCard title={t("no_show_hosts_over_time")} className="h-full">
75+
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
76+
<ResponsiveContainer width="100%" height="100%">
77+
<LineChart data={noShowHostsData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
78+
<CartesianGrid strokeDasharray="3 3" vertical={false} />
79+
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
80+
<YAxis
81+
allowDecimals={false}
82+
className="text-xs opacity-50"
83+
axisLine={false}
84+
tickLine={false}
85+
tickFormatter={valueFormatter}
86+
/>
87+
<Tooltip content={<CustomTooltip />} />
88+
<Line
89+
type="linear"
90+
dataKey="Count"
91+
name={t("no_show_hosts")}
92+
stroke={COLOR.NO_SHOW_HOST}
93+
strokeWidth={2}
94+
dot={{ r: 4 }}
95+
activeDot={{ r: 6 }}
96+
animationDuration={1000}
97+
/>
98+
</LineChart>
99+
</ResponsiveContainer>
100+
</div>
101+
</ChartCard>
102+
);
103+
};

packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -41,42 +41,44 @@ export const RecentNoShowGuestsChart = () => {
4141
title={t("recent_no_show_guests")}
4242
titleTooltip={t("recent_no_show_guests_tooltip")}
4343
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>
44+
<div className="h-full">
45+
<div className="sm:max-h-[30.6rem] sm:overflow-y-auto">
46+
{data.map((item) => (
47+
<ChartCardItem key={item.bookingId}>
48+
<div className="flex w-full items-center justify-between">
49+
<div className="flex gap-2">
50+
<div className="bg-subtle h-16 w-[2px] shrink-0 rounded-sm" />
51+
<div className="flex flex-col space-y-1">
52+
<p className="text-sm font-medium">{item.guestName}</p>
53+
<div className="text-subtle text-sm leading-tight">
54+
<p>{item.eventTypeName}</p>
55+
<p>
56+
{Intl.DateTimeFormat(undefined, {
57+
timeZone,
58+
dateStyle: "medium",
59+
timeStyle: "short",
60+
}).format(new Date(item.startTime))}
61+
</p>
62+
</div>
6163
</div>
6264
</div>
65+
<Button
66+
color="minimal"
67+
size="sm"
68+
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
69+
onClick={() => handleCopyEmail(item.guestEmail)}>
70+
{!isCopied ? t("email") : t("copied")}
71+
</Button>
6372
</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>
73+
</ChartCardItem>
74+
))}
7875
</div>
79-
)}
76+
{data.length === 0 && (
77+
<div className="flex h-60 text-center">
78+
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
79+
</div>
80+
)}
81+
</div>
8082
</ChartCard>
8183
);
8284
};

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { AverageEventDurationChart } from "./AverageEventDurationChart";
22
export { BookingKPICards } from "./BookingKPICards";
3+
export { CSATOverTimeChart } from "./CSATOverTimeChart";
34
export { EventTrendsChart } from "./EventTrendsChart";
45
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
56
export { HighestRatedMembersTable } from "./HighestRatedMembersTable";
@@ -8,6 +9,7 @@ export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable";
89
export { LowestRatedMembersTable } from "./LowestRatedMembersTable";
910
export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable";
1011
export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables";
12+
export { NoShowHostsOverTimeChart } from "./NoShowHostsOverTimeChart";
1113
export { PopularEventsTable } from "./PopularEventsTable";
1214
export { RecentNoShowGuestsChart } from "./RecentNoShowGuestsChart";
1315
export { RecentFeedbackTable } from "./RecentFeedbackTable";

0 commit comments

Comments
 (0)