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
This commit is contained in:
Eunjae Lee
2025-07-21 13:52:56 +00:00
committed by GitHub
parent bb4260c98b
commit e7535b9db3
14 changed files with 327 additions and 18 deletions
@@ -0,0 +1,60 @@
"use client";
import { BookingsByHourChartContent } from "@calcom/features/insights/components/BookingsByHourChart";
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
import { useLocale } from "@calcom/lib/hooks/useLocale";
// Sample data for playground testing
const sampleBookingsByHourStats = [
{ hour: 0, count: 4 },
{ hour: 1, count: 10 },
{ hour: 2, count: 3 },
{ hour: 3, count: 12 },
{ hour: 4, count: 3 },
{ hour: 5, count: 7 },
{ hour: 6, count: 6 },
{ hour: 7, count: 4 },
{ hour: 8, count: 9 },
{ hour: 9, count: 7 },
{ hour: 10, count: 6 },
{ hour: 11, count: 5 },
{ hour: 12, count: 8 },
{ hour: 13, count: 5 },
{ hour: 14, count: 9 },
{ hour: 15, count: 9 },
{ hour: 16, count: 4 },
{ hour: 17, count: 5 },
{ hour: 18, count: 4 },
{ hour: 19, count: 6 },
{ hour: 20, count: 6 },
{ hour: 21, count: 12 },
{ hour: 22, count: 4 },
{ hour: 23, count: 10 },
];
export default function BookingsByHourPlayground() {
const { t } = useLocale();
return (
<div className="space-y-6 p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold">Bookings by Hour Playground</h1>
<p className="mt-2 text-gray-600">
This page demonstrates the BookingsByHourChartContent component with sample data.
</p>
</div>
<div className="max-w-4xl">
<ChartCard title={t("bookings_by_hour")}>
<BookingsByHourChartContent data={sampleBookingsByHourStats} />
</ChartCard>
</div>
<div className="mt-8 rounded-lg bg-gray-50 p-4">
<h2 className="mb-2 text-lg font-semibold">Sample Data Used:</h2>
<pre className="overflow-auto text-sm text-gray-700">
{JSON.stringify(sampleBookingsByHourStats, null, 2)}
</pre>
</div>
</div>
);
}
@@ -5,6 +5,10 @@ const LINKS = [
title: "Routing Funnel",
href: "/settings/admin/playground/routing-funnel",
},
{
title: "Bookings by Hour",
href: "/settings/admin/playground/bookings-by-hour",
},
];
export default function Page() {
+19 -6
View File
@@ -13,6 +13,7 @@ import {
BookingStatusLineChart,
HighestNoShowHostTable,
HighestRatedMembersTable,
BookingsByHourChart,
LeastBookedTeamMembersTable,
LowestRatedMembersTable,
MostBookedTeamMembersTable,
@@ -71,23 +72,35 @@ function InsightsPageContent() {
<BookingStatusLineChart />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<div className="sm:col-span-2">
<BookingsByHourChart />
</div>
<div className="sm:col-span-2">
<AverageEventDurationChart />
</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>
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<MostCancelledBookingsTables />
<HighestNoShowHostTable />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<HighestRatedMembersTable />
<LowestRatedMembersTable />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<div className="sm:col-span-2">
<RecentFeedbackTable />
</div>
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
@@ -2168,6 +2168,7 @@
"popular_events": "Popular Events",
"no_event_types_found": "No event types found",
"average_event_duration": "Average Event Duration",
"bookings_by_hour": "Bookings by Hour",
"most_booked_members": "Most Booked",
"least_booked_members": "Least Booked",
"events_created": "Events Created",
@@ -0,0 +1,132 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Rectangle,
} from "recharts";
import { useDataTable } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
import { trpc } from "@calcom/trpc";
import { useInsightsParameters } from "../hooks/useInsightsParameters";
import { ChartCard } from "./ChartCard";
import { LoadingInsight } from "./LoadingInsights";
type BookingsByHourData = {
hour: number;
count: number;
};
export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[] }) => {
const { t } = useLocale();
const chartData = data.map((item) => ({
hour: `${item.hour.toString().padStart(2, "0")}:00`,
count: item.count,
}));
const maxBookings = Math.max(...data.map((item) => item.count));
const isEmpty = maxBookings === 0;
if (isEmpty) {
return (
<div className="text-default flex h-60 text-center">
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
</div>
);
}
return (
<div className="mt-4 h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 20, right: 0, left: -10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="hour" className="text-xs" axisLine={false} tickLine={false} />
<YAxis allowDecimals={false} className="text-xs opacity-50" axisLine={false} tickLine={false} />
<Tooltip cursor={false} content={<CustomTooltip />} />
<Bar
dataKey="count"
fill="var(--cal-bg-subtle)"
radius={[2, 2, 0, 0]}
activeBar={<Rectangle fill="var(--cal-bg-info)" />}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
};
// Custom Tooltip component
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
value: number;
dataKey: string;
name: string;
color: string;
payload: { hour: string; count: number };
}>;
label?: string;
}) => {
const { t } = useLocale();
if (!active || !payload?.length) {
return null;
}
return (
<div className="bg-default border-subtle rounded-lg border p-3 shadow-lg">
<p className="text-default font-medium">{label}</p>
{payload.map((entry, index: number) => (
<p key={index}>
{t("bookings")}: {entry.value}
</p>
))}
</div>
);
};
export const BookingsByHourChart = () => {
const { t } = useLocale();
const { timeZone } = useDataTable();
const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.bookingsByHourStats.useQuery(
{
scope,
selectedTeamId,
startDate,
endDate,
eventTypeId,
memberUserId,
timeZone: timeZone || CURRENT_TIMEZONE,
},
{
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("bookings_by_hour")}>
<BookingsByHourChartContent data={data} />
</ChartCard>
);
};
@@ -51,9 +51,21 @@ export function ChartCard({
);
}
export function ChartCardItem({ count, children }: { count?: number | string; children: ReactNode }) {
export function ChartCardItem({
count,
className,
children,
}: {
count?: number | string;
className?: string;
children: ReactNode;
}) {
return (
<div className="text-default border-muted flex items-center justify-between border-b px-3 py-3.5 last:border-b-0">
<div
className={classNames(
"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>
{count !== undefined && <div className="text-sm font-medium">{count}</div>}
</div>
@@ -1,5 +1,5 @@
import { SkeletonText } from "@calcom/ui/components/skeleton";
import classNames from "@calcom/ui/classNames";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { CardInsights } from "./Card";
@@ -21,8 +21,8 @@ export const TotalBookingUsersTable = ({
return (
<div className="overflow-hidden rounded-md">
{filteredData.length > 0 ? (
filteredData.map((item) => (
<ChartCardItem key={item.userId} count={item.count}>
filteredData.map((item, index) => (
<ChartCardItem key={index} count={item.count} className="py-3">
<div className="flex items-center">
<Avatar
alt={item.user.name || ""}
@@ -1,5 +1,7 @@
export { AverageEventDurationChart } from "./AverageEventDurationChart";
export { BookingKPICards } from "./BookingKPICards";
export { BookingsByHourChart } from "./BookingsByHourChart";
export { BookingStatusLineChart } from "./BookingStatusLineChart";
export { FailedBookingsByField } from "./FailedBookingsByField";
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
@@ -69,3 +69,13 @@ export const routingRepositoryBaseInputSchema = z.object({
endDate: z.string(),
columnFilters: z.array(ZColumnFilter).optional(),
});
export const bookingRepositoryBaseInputSchema = z.object({
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
selectedTeamId: z.number().optional(),
startDate: z.string(),
endDate: z.string(),
timeZone: z.string(),
eventTypeId: z.coerce.number().optional().nullable(),
memberUserId: z.coerce.number().optional().nullable(),
});
@@ -8,7 +8,9 @@ import {
routingFormResponsesInputSchema,
routingFormStatsInputSchema,
routingRepositoryBaseInputSchema,
bookingRepositoryBaseInputSchema,
} from "@calcom/features/insights/server/raw-data.schema";
import { InsightsBookingService } from "@calcom/lib/server/service/insightsBooking";
import { InsightsRoutingService } from "@calcom/lib/server/service/insightsRouting";
import type { readonlyPrisma } from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -1760,6 +1762,35 @@ export const insightsRouter = router({
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
bookingsByHourStats: userBelongsToTeamProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, timeZone } = input;
const insightsBookingService = new InsightsBookingService({
prisma: ctx.insightsDb,
options: {
scope,
userId: ctx.user.id,
orgId: ctx.user.organizationId ?? 0,
...(selectedTeamId && { teamId: selectedTeamId }),
},
filters: {
...(eventTypeId && { eventTypeId }),
...(memberUserId && { memberUserId }),
},
});
try {
return await insightsBookingService.getBookingsByHourStats({
startDate,
endDate,
timeZone,
});
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
});
async function getEventTypeList({
@@ -554,7 +554,7 @@ describe("InsightsRoutingService Integration Tests", () => {
const results = await service.getBaseConditions();
expect(results).toEqual(
Prisma.sql`("formUserId" = ${testData.user.id} AND "formTeamId" IS NULL) AND (${dateCondition})`
Prisma.sql`(("formUserId" = ${testData.user.id} AND "formTeamId" IS NULL) AND (${dateCondition}))`
);
await testData.cleanup();
+47 -3
View File
@@ -65,16 +65,60 @@ export class InsightsBookingService {
this.filters = filters;
}
async getBookingsByHourStats({
startDate,
endDate,
timeZone,
}: {
startDate: string;
endDate: string;
timeZone: string;
}) {
// Validate date formats
if (isNaN(Date.parse(startDate)) || isNaN(Date.parse(endDate))) {
throw new Error(`Invalid date format: ${startDate} - ${endDate}`);
}
const baseConditions = await this.getBaseConditions();
const results = await this.prisma.$queryRaw<
Array<{
hour: string;
count: number;
}>
>`
SELECT
EXTRACT(HOUR FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone}))::int as "hour",
COUNT(*)::int as "count"
FROM "BookingTimeStatusDenormalized"
WHERE ${baseConditions}
AND "startTime" >= ${startDate}::timestamp
AND "startTime" <= ${endDate}::timestamp
AND "status" = 'accepted'
GROUP BY 1
ORDER BY 1
`;
// Create a map of results by hour for easy lookup
const resultsMap = new Map(results.map((row) => [Number(row.hour), row.count]));
// Return all 24 hours (0-23), filling with 0 values for missing data
return Array.from({ length: 24 }, (_, hour) => ({
hour,
count: resultsMap.get(hour) || 0,
}));
}
async getBaseConditions(): Promise<Prisma.Sql> {
const authConditions = await this.getAuthorizationConditions();
const filterConditions = await this.getFilterConditions();
if (authConditions && filterConditions) {
return Prisma.sql`(${authConditions}) AND (${filterConditions})`;
return Prisma.sql`((${authConditions}) AND (${filterConditions}))`;
} else if (authConditions) {
return authConditions;
return Prisma.sql`(${authConditions})`;
} else if (filterConditions) {
return filterConditions;
return Prisma.sql`(${filterConditions})`;
} else {
return NOTHING_CONDITION;
}
@@ -169,11 +169,11 @@ export class InsightsRoutingService {
const filterConditions = await this.getFilterConditions();
if (authConditions && filterConditions) {
return Prisma.sql`(${authConditions}) AND (${filterConditions})`;
return Prisma.sql`((${authConditions}) AND (${filterConditions}))`;
} else if (authConditions) {
return authConditions;
return Prisma.sql`(${authConditions})`;
} else if (filterConditions) {
return filterConditions;
return Prisma.sql`(${filterConditions})`;
} else {
return NOTHING_CONDITION;
}