Skip to content
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
23 changes: 18 additions & 5 deletions apps/web/modules/insights/insights-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
BookingStatusLineChart,
HighestNoShowHostTable,
HighestRatedMembersTable,
BookingsByHourChart,
LeastBookedTeamMembersTable,
LowestRatedMembersTable,
MostBookedTeamMembersTable,
Expand Down Expand Up @@ -71,23 +72,35 @@ function InsightsPageContent() {

<BookingStatusLineChart />

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
<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 />
<MostCancelledBookingsTables />
<HighestNoShowHostTable />
</div>

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<MostCancelledBookingsTables />
<HighestNoShowHostTable />
<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
Expand Down
1 change: 1 addition & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
132 changes: 132 additions & 0 deletions packages/features/insights/components/BookingsByHourChart.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
16 changes: 14 additions & 2 deletions packages/features/insights/components/ChartCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion packages/features/insights/components/LoadingInsights.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 || ""}
Expand Down
2 changes: 2 additions & 0 deletions packages/features/insights/components/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
10 changes: 10 additions & 0 deletions packages/features/insights/server/raw-data.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
31 changes: 31 additions & 0 deletions packages/features/insights/server/trpc-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading