diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/bookings-by-hour/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/bookings-by-hour/page.tsx
new file mode 100644
index 00000000000000..f07d76de41a385
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/bookings-by-hour/page.tsx
@@ -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 (
+
+
+
Bookings by Hour Playground
+
+ This page demonstrates the BookingsByHourChartContent component with sample data.
+
+
+
+
+
+
+
+
+
+
+
Sample Data Used:
+
+ {JSON.stringify(sampleBookingsByHourStats, null, 2)}
+
+
+
+ );
+}
diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx
index bb5f742915cae8..5a92c6dbea875b 100644
--- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/page.tsx
@@ -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() {
diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx
index 379400f08ca256..1b17fba8f17c50 100644
--- a/apps/web/modules/insights/insights-view.tsx
+++ b/apps/web/modules/insights/insights-view.tsx
@@ -13,6 +13,7 @@ import {
BookingStatusLineChart,
HighestNoShowHostTable,
HighestRatedMembersTable,
+ BookingsByHourChart,
LeastBookedTeamMembersTable,
LowestRatedMembersTable,
MostBookedTeamMembersTable,
@@ -71,23 +72,35 @@ function InsightsPageContent() {
-
-
-
+
+
+
+
+
+
+
+
{t("looking_for_more_insights")}{" "}
{
+ 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 (
+
+
{t("insights_no_data_found_for_filter")}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ } />
+ }
+ />
+
+
+
+ );
+};
+
+// 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 (
+
+
{label}
+ {payload.map((entry, index: number) => (
+
+ {t("bookings")}: {entry.value}
+
+ ))}
+
+ );
+};
+
+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 ;
+
+ if (!isSuccess || !data) return null;
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/features/insights/components/ChartCard.tsx b/packages/features/insights/components/ChartCard.tsx
index af50d4be3dc7b5..415313b60c70cb 100644
--- a/packages/features/insights/components/ChartCard.tsx
+++ b/packages/features/insights/components/ChartCard.tsx
@@ -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 (
-
+
{children}
{count !== undefined &&
{count}
}
diff --git a/packages/features/insights/components/LoadingInsights.tsx b/packages/features/insights/components/LoadingInsights.tsx
index 4d9605bf38d07a..0a937154104a11 100644
--- a/packages/features/insights/components/LoadingInsights.tsx
+++ b/packages/features/insights/components/LoadingInsights.tsx
@@ -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";
diff --git a/packages/features/insights/components/TotalBookingUsersTable.tsx b/packages/features/insights/components/TotalBookingUsersTable.tsx
index 0f9d1f9db16151..eb740cfaace18c 100644
--- a/packages/features/insights/components/TotalBookingUsersTable.tsx
+++ b/packages/features/insights/components/TotalBookingUsersTable.tsx
@@ -21,8 +21,8 @@ export const TotalBookingUsersTable = ({
return (
{filteredData.length > 0 ? (
- filteredData.map((item) => (
-
+ filteredData.map((item, index) => (
+
{
+ 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({
diff --git a/packages/lib/server/service/__tests__/insightsRouting.integration-test.ts b/packages/lib/server/service/__tests__/insightsRouting.integration-test.ts
index 13be9b42b8491b..db0d70294c188c 100644
--- a/packages/lib/server/service/__tests__/insightsRouting.integration-test.ts
+++ b/packages/lib/server/service/__tests__/insightsRouting.integration-test.ts
@@ -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();
diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts
index f0858f4142bfc7..5e841d20861005 100644
--- a/packages/lib/server/service/insightsBooking.ts
+++ b/packages/lib/server/service/insightsBooking.ts
@@ -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 {
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;
}
diff --git a/packages/lib/server/service/insightsRouting.ts b/packages/lib/server/service/insightsRouting.ts
index 435df66bbae686..4ae1e299e1e92a 100644
--- a/packages/lib/server/service/insightsRouting.ts
+++ b/packages/lib/server/service/insightsRouting.ts
@@ -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;
}