Skip to content

Commit e7535b9

Browse files
authored
feat: add hourly booking charts on /insights (#22619)
* feat: add hourly booking charts on /insights * safe guard * rename * update styles * fix data * clean up * clean up * re-order charts * update style * apply feedback * rename * update query
1 parent bb4260c commit e7535b9

File tree

14 files changed

+326
-17
lines changed

14 files changed

+326
-17
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import { BookingsByHourChartContent } from "@calcom/features/insights/components/BookingsByHourChart";
4+
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
5+
import { useLocale } from "@calcom/lib/hooks/useLocale";
6+
7+
// Sample data for playground testing
8+
const sampleBookingsByHourStats = [
9+
{ hour: 0, count: 4 },
10+
{ hour: 1, count: 10 },
11+
{ hour: 2, count: 3 },
12+
{ hour: 3, count: 12 },
13+
{ hour: 4, count: 3 },
14+
{ hour: 5, count: 7 },
15+
{ hour: 6, count: 6 },
16+
{ hour: 7, count: 4 },
17+
{ hour: 8, count: 9 },
18+
{ hour: 9, count: 7 },
19+
{ hour: 10, count: 6 },
20+
{ hour: 11, count: 5 },
21+
{ hour: 12, count: 8 },
22+
{ hour: 13, count: 5 },
23+
{ hour: 14, count: 9 },
24+
{ hour: 15, count: 9 },
25+
{ hour: 16, count: 4 },
26+
{ hour: 17, count: 5 },
27+
{ hour: 18, count: 4 },
28+
{ hour: 19, count: 6 },
29+
{ hour: 20, count: 6 },
30+
{ hour: 21, count: 12 },
31+
{ hour: 22, count: 4 },
32+
{ hour: 23, count: 10 },
33+
];
34+
35+
export default function BookingsByHourPlayground() {
36+
const { t } = useLocale();
37+
return (
38+
<div className="space-y-6 p-6">
39+
<div className="mb-6">
40+
<h1 className="text-3xl font-bold">Bookings by Hour Playground</h1>
41+
<p className="mt-2 text-gray-600">
42+
This page demonstrates the BookingsByHourChartContent component with sample data.
43+
</p>
44+
</div>
45+
46+
<div className="max-w-4xl">
47+
<ChartCard title={t("bookings_by_hour")}>
48+
<BookingsByHourChartContent data={sampleBookingsByHourStats} />
49+
</ChartCard>
50+
</div>
51+
52+
<div className="mt-8 rounded-lg bg-gray-50 p-4">
53+
<h2 className="mb-2 text-lg font-semibold">Sample Data Used:</h2>
54+
<pre className="overflow-auto text-sm text-gray-700">
55+
{JSON.stringify(sampleBookingsByHourStats, null, 2)}
56+
</pre>
57+
</div>
58+
</div>
59+
);
60+
}

apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ const LINKS = [
55
title: "Routing Funnel",
66
href: "/settings/admin/playground/routing-funnel",
77
},
8+
{
9+
title: "Bookings by Hour",
10+
href: "/settings/admin/playground/bookings-by-hour",
11+
},
812
];
913

1014
export default function Page() {

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
BookingStatusLineChart,
1414
HighestNoShowHostTable,
1515
HighestRatedMembersTable,
16+
BookingsByHourChart,
1617
LeastBookedTeamMembersTable,
1718
LowestRatedMembersTable,
1819
MostBookedTeamMembersTable,
@@ -71,23 +72,35 @@ function InsightsPageContent() {
7172

7273
<BookingStatusLineChart />
7374

74-
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
75-
<PopularEventsTable />
76-
<AverageEventDurationChart />
75+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
76+
<div className="sm:col-span-2">
77+
<BookingsByHourChart />
78+
</div>
79+
<div className="sm:col-span-2">
80+
<AverageEventDurationChart />
81+
</div>
7782
</div>
83+
7884
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
85+
<div className="sm:col-span-2">
86+
<PopularEventsTable />
87+
</div>
7988
<MostBookedTeamMembersTable />
8089
<LeastBookedTeamMembersTable />
81-
<MostCancelledBookingsTables />
82-
<HighestNoShowHostTable />
8390
</div>
91+
8492
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
93+
<MostCancelledBookingsTables />
94+
<HighestNoShowHostTable />
8595
<HighestRatedMembersTable />
8696
<LowestRatedMembersTable />
97+
</div>
98+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
8799
<div className="sm:col-span-2">
88100
<RecentFeedbackTable />
89101
</div>
90102
</div>
103+
91104
<small className="text-default block text-center">
92105
{t("looking_for_more_insights")}{" "}
93106
<a

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2168,6 +2168,7 @@
21682168
"popular_events": "Popular Events",
21692169
"no_event_types_found": "No event types found",
21702170
"average_event_duration": "Average Event Duration",
2171+
"bookings_by_hour": "Bookings by Hour",
21712172
"most_booked_members": "Most Booked",
21722173
"least_booked_members": "Least Booked",
21732174
"events_created": "Events Created",
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"use client";
2+
3+
import {
4+
BarChart,
5+
Bar,
6+
XAxis,
7+
YAxis,
8+
CartesianGrid,
9+
Tooltip,
10+
ResponsiveContainer,
11+
Rectangle,
12+
} from "recharts";
13+
14+
import { useDataTable } from "@calcom/features/data-table";
15+
import { useLocale } from "@calcom/lib/hooks/useLocale";
16+
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
17+
import { trpc } from "@calcom/trpc";
18+
19+
import { useInsightsParameters } from "../hooks/useInsightsParameters";
20+
import { ChartCard } from "./ChartCard";
21+
import { LoadingInsight } from "./LoadingInsights";
22+
23+
type BookingsByHourData = {
24+
hour: number;
25+
count: number;
26+
};
27+
28+
export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[] }) => {
29+
const { t } = useLocale();
30+
31+
const chartData = data.map((item) => ({
32+
hour: `${item.hour.toString().padStart(2, "0")}:00`,
33+
count: item.count,
34+
}));
35+
36+
const maxBookings = Math.max(...data.map((item) => item.count));
37+
const isEmpty = maxBookings === 0;
38+
39+
if (isEmpty) {
40+
return (
41+
<div className="text-default flex h-60 text-center">
42+
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
43+
</div>
44+
);
45+
}
46+
47+
return (
48+
<div className="mt-4 h-80">
49+
<ResponsiveContainer width="100%" height="100%">
50+
<BarChart data={chartData} margin={{ top: 20, right: 0, left: -10, bottom: 5 }}>
51+
<CartesianGrid strokeDasharray="3 3" vertical={false} />
52+
<XAxis dataKey="hour" className="text-xs" axisLine={false} tickLine={false} />
53+
<YAxis allowDecimals={false} className="text-xs opacity-50" axisLine={false} tickLine={false} />
54+
<Tooltip cursor={false} content={<CustomTooltip />} />
55+
<Bar
56+
dataKey="count"
57+
fill="var(--cal-bg-subtle)"
58+
radius={[2, 2, 0, 0]}
59+
activeBar={<Rectangle fill="var(--cal-bg-info)" />}
60+
/>
61+
</BarChart>
62+
</ResponsiveContainer>
63+
</div>
64+
);
65+
};
66+
67+
// Custom Tooltip component
68+
const CustomTooltip = ({
69+
active,
70+
payload,
71+
label,
72+
}: {
73+
active?: boolean;
74+
payload?: Array<{
75+
value: number;
76+
dataKey: string;
77+
name: string;
78+
color: string;
79+
payload: { hour: string; count: number };
80+
}>;
81+
label?: string;
82+
}) => {
83+
const { t } = useLocale();
84+
if (!active || !payload?.length) {
85+
return null;
86+
}
87+
88+
return (
89+
<div className="bg-default border-subtle rounded-lg border p-3 shadow-lg">
90+
<p className="text-default font-medium">{label}</p>
91+
{payload.map((entry, index: number) => (
92+
<p key={index}>
93+
{t("bookings")}: {entry.value}
94+
</p>
95+
))}
96+
</div>
97+
);
98+
};
99+
100+
export const BookingsByHourChart = () => {
101+
const { t } = useLocale();
102+
const { timeZone } = useDataTable();
103+
const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters();
104+
105+
const { data, isSuccess, isPending } = trpc.viewer.insights.bookingsByHourStats.useQuery(
106+
{
107+
scope,
108+
selectedTeamId,
109+
startDate,
110+
endDate,
111+
eventTypeId,
112+
memberUserId,
113+
timeZone: timeZone || CURRENT_TIMEZONE,
114+
},
115+
{
116+
staleTime: 30000,
117+
trpc: {
118+
context: { skipBatch: true },
119+
},
120+
}
121+
);
122+
123+
if (isPending) return <LoadingInsight />;
124+
125+
if (!isSuccess || !data) return null;
126+
127+
return (
128+
<ChartCard title={t("bookings_by_hour")}>
129+
<BookingsByHourChartContent data={data} />
130+
</ChartCard>
131+
);
132+
};

packages/features/insights/components/ChartCard.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,21 @@ export function ChartCard({
5151
);
5252
}
5353

54-
export function ChartCardItem({ count, children }: { count?: number | string; children: ReactNode }) {
54+
export function ChartCardItem({
55+
count,
56+
className,
57+
children,
58+
}: {
59+
count?: number | string;
60+
className?: string;
61+
children: ReactNode;
62+
}) {
5563
return (
56-
<div className="text-default border-muted flex items-center justify-between border-b px-3 py-3.5 last:border-b-0">
64+
<div
65+
className={classNames(
66+
"text-default border-muted flex items-center justify-between border-b px-3 py-3.5 last:border-b-0",
67+
className
68+
)}>
5769
<div className="text-sm font-medium">{children}</div>
5870
{count !== undefined && <div className="text-sm font-medium">{count}</div>}
5971
</div>

packages/features/insights/components/LoadingInsights.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { SkeletonText } from "@calcom/ui/components/skeleton";
21
import classNames from "@calcom/ui/classNames";
2+
import { SkeletonText } from "@calcom/ui/components/skeleton";
33

44
import { CardInsights } from "./Card";
55

packages/features/insights/components/TotalBookingUsersTable.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ export const TotalBookingUsersTable = ({
2121
return (
2222
<div className="overflow-hidden rounded-md">
2323
{filteredData.length > 0 ? (
24-
filteredData.map((item) => (
25-
<ChartCardItem key={item.userId} count={item.count}>
24+
filteredData.map((item, index) => (
25+
<ChartCardItem key={index} count={item.count} className="py-3">
2626
<div className="flex items-center">
2727
<Avatar
2828
alt={item.user.name || ""}

packages/features/insights/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export { AverageEventDurationChart } from "./AverageEventDurationChart";
22
export { BookingKPICards } from "./BookingKPICards";
3+
export { BookingsByHourChart } from "./BookingsByHourChart";
4+
35
export { BookingStatusLineChart } from "./BookingStatusLineChart";
46
export { FailedBookingsByField } from "./FailedBookingsByField";
57
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";

packages/features/insights/server/raw-data.schema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,13 @@ export const routingRepositoryBaseInputSchema = z.object({
6969
endDate: z.string(),
7070
columnFilters: z.array(ZColumnFilter).optional(),
7171
});
72+
73+
export const bookingRepositoryBaseInputSchema = z.object({
74+
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
75+
selectedTeamId: z.number().optional(),
76+
startDate: z.string(),
77+
endDate: z.string(),
78+
timeZone: z.string(),
79+
eventTypeId: z.coerce.number().optional().nullable(),
80+
memberUserId: z.coerce.number().optional().nullable(),
81+
});

0 commit comments

Comments
 (0)