From b3ab5d3365ab0889afa807814820acc693695465 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 18 Jul 2025 10:54:19 +0200 Subject: [PATCH 01/12] feat: add hourly booking charts on /insights --- apps/web/modules/insights/insights-view.tsx | 3 + apps/web/public/static/locales/en/common.json | 1 + .../components/HourlyBookingChart.tsx | 97 +++++++++++++++++++ .../insights/components/LoadingInsights.tsx | 2 +- .../features/insights/components/index.ts | 1 + .../insights/server/raw-data.schema.ts | 10 ++ .../features/insights/server/trpc-router.ts | 31 ++++++ .../lib/server/service/insightsBooking.ts | 55 +++++++++++ 8 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 packages/features/insights/components/HourlyBookingChart.tsx diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index 9f3e939ce74a51..65105f834ad102 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, + HourlyBookingChart, LeastBookedTeamMembersTable, LowestRatedMembersTable, MostBookedTeamMembersTable, @@ -75,6 +76,8 @@ function InsightsPageContent() { + +
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 16fe6157b7da1e..369a078449f9b2 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2167,6 +2167,7 @@ "popular_events": "Popular Events", "no_event_types_found": "No event types found", "average_event_duration": "Average Event Duration", + "hourly_bookings": "Hourly Bookings", "most_booked_members": "Most Booked Members", "least_booked_members": "Least Booked Members", "events_created": "Events Created", diff --git a/packages/features/insights/components/HourlyBookingChart.tsx b/packages/features/insights/components/HourlyBookingChart.tsx new file mode 100644 index 00000000000000..0f150ae4699438 --- /dev/null +++ b/packages/features/insights/components/HourlyBookingChart.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { Title } from "@tremor/react"; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } 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 { CardInsights } from "./Card"; +import { LoadingInsight } from "./LoadingInsights"; + +type HourlyBookingData = { + hour: number; + bookingCount: number; +}; + +const HourlyBookingChartPresentation = ({ data }: { data: HourlyBookingData[] }) => { + const { t } = useLocale(); + + const chartData = data.map((item) => ({ + hour: `${item.hour.toString().padStart(2, "0")}:00`, + bookings: item.bookingCount, + })); + + const maxBookings = Math.max(...data.map((item) => item.bookingCount)); + const isEmpty = maxBookings === 0; + + if (isEmpty) { + return ( +
+

{t("insights_no_data_found_for_filter")}

+
+ ); + } + + return ( +
+ + + + + + + + + +
+ ); +}; + +export const HourlyBookingChart = () => { + const { t } = useLocale(); + const { timeZone: userTimeZone } = useDataTable(); + const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); + + const timeZone = userTimeZone || CURRENT_TIMEZONE || "UTC"; + + const { data, isSuccess, isPending } = trpc.viewer.insights.hourlyBookingStats.useQuery( + { + scope, + selectedTeamId, + startDate, + endDate, + eventTypeId, + memberUserId, + timeZone, + }, + { + staleTime: 30000, + trpc: { + context: { skipBatch: true }, + }, + } + ); + + if (isPending) return ; + + if (!isSuccess || !data) return null; + + return ( + + {t("hourly_booking_distribution")} + + + ); +}; 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/index.ts b/packages/features/insights/components/index.ts index 733c7457365ee2..9bb32af0b8a390 100644 --- a/packages/features/insights/components/index.ts +++ b/packages/features/insights/components/index.ts @@ -1,5 +1,6 @@ export { AverageEventDurationChart } from "./AverageEventDurationChart"; export { BookingKPICards } from "./BookingKPICards"; +export { HourlyBookingChart } from "./HourlyBookingChart"; export { BookingStatusLineChart } from "./BookingStatusLineChart"; export { FailedBookingsByField } from "./FailedBookingsByField"; export { HighestNoShowHostTable } from "./HighestNoShowHostTable"; diff --git a/packages/features/insights/server/raw-data.schema.ts b/packages/features/insights/server/raw-data.schema.ts index 851e0295b237e5..1fd604dd07d2d7 100644 --- a/packages/features/insights/server/raw-data.schema.ts +++ b/packages/features/insights/server/raw-data.schema.ts @@ -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(), +}); diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 0388d01c765a55..d7f9c728bc292e 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -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" }); } }), + hourlyBookingStats: 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.getHourlyBookingStats({ + startDate, + endDate, + timeZone, + }); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + }), }); async function getEventTypeList({ diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index f0858f4142bfc7..fbb7d339feef42 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -207,6 +207,61 @@ export class InsightsBookingService { }); } + async getHourlyBookingStats({ + 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(); + + // Query to get booking counts by hour for ACCEPTED bookings only + // Convert timestamps to the specified timezone before extracting hour + // Note: Using 'accepted' (lowercase) as it's the actual database value due to @map("accepted") in Prisma schema + // Using CTE for better readability and maintainability + const results = await this.prisma.$queryRaw< + Array<{ + hour: number; + bookingCount: bigint; + }> + >` + WITH hourly_data AS ( + SELECT + EXTRACT(HOUR FROM ("createdAt" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})) as hour_extracted + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} + AND "startTime" >= ${startDate}::timestamp + AND "startTime" <= ${endDate}::timestamp + AND "status" = 'accepted' + ) + SELECT + hour_extracted as "hour", + COUNT(*) as "bookingCount" + FROM hourly_data + GROUP BY hour_extracted + ORDER BY "hour" + `; + + console.log("💡 results", results); + + // Create a map of results by hour for easy lookup + const resultsMap = new Map(results.map((row) => [row.hour, Number(row.bookingCount)])); + + // Return all 24 hours (0-23), filling with 0 values for missing data + return Array.from({ length: 24 }, (_, hour) => ({ + hour, + bookingCount: resultsMap.get(hour) || 0, + })); + } + private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise { // Check if the user is an owner or admin of the organization const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId }); From 2bd6a3df39e59ade01e6b61edfe4647414624525 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 18 Jul 2025 11:15:42 +0200 Subject: [PATCH 02/12] safe guard --- .../insights/components/HourlyBookingChart.tsx | 5 +---- .../__tests__/insightsRouting.integration-test.ts | 2 +- packages/lib/server/service/insightsBooking.ts | 14 ++++---------- packages/lib/server/service/insightsRouting.ts | 6 +++--- 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/features/insights/components/HourlyBookingChart.tsx b/packages/features/insights/components/HourlyBookingChart.tsx index 0f150ae4699438..05cce3e9db827a 100644 --- a/packages/features/insights/components/HourlyBookingChart.tsx +++ b/packages/features/insights/components/HourlyBookingChart.tsx @@ -5,7 +5,6 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContaine 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"; @@ -61,11 +60,9 @@ const HourlyBookingChartPresentation = ({ data }: { data: HourlyBookingData[] }) export const HourlyBookingChart = () => { const { t } = useLocale(); - const { timeZone: userTimeZone } = useDataTable(); + const { timeZone } = useDataTable(); const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); - const timeZone = userTimeZone || CURRENT_TIMEZONE || "UTC"; - const { data, isSuccess, isPending } = trpc.viewer.insights.hourlyBookingStats.useQuery( { scope, 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 fbb7d339feef42..037fb29b4ee29b 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -70,11 +70,11 @@ export class InsightsBookingService { 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; } @@ -223,10 +223,6 @@ export class InsightsBookingService { const baseConditions = await this.getBaseConditions(); - // Query to get booking counts by hour for ACCEPTED bookings only - // Convert timestamps to the specified timezone before extracting hour - // Note: Using 'accepted' (lowercase) as it's the actual database value due to @map("accepted") in Prisma schema - // Using CTE for better readability and maintainability const results = await this.prisma.$queryRaw< Array<{ hour: number; @@ -235,7 +231,7 @@ export class InsightsBookingService { >` WITH hourly_data AS ( SELECT - EXTRACT(HOUR FROM ("createdAt" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})) as hour_extracted + EXTRACT(HOUR FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})) as hour_extracted FROM "BookingTimeStatusDenormalized" WHERE ${baseConditions} AND "startTime" >= ${startDate}::timestamp @@ -250,8 +246,6 @@ export class InsightsBookingService { ORDER BY "hour" `; - console.log("💡 results", results); - // Create a map of results by hour for easy lookup const resultsMap = new Map(results.map((row) => [row.hour, Number(row.bookingCount)])); 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; } From e9ee29e11a2c011e9407272f1a6c00cae9344eec Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 18 Jul 2025 13:12:00 +0200 Subject: [PATCH 03/12] rename --- .../admin/playground/hourly-bookings/page.tsx | 60 +++++++++++++++++++ .../(admin-layout)/admin/playground/page.tsx | 4 ++ ...okingChart.tsx => HourlyBookingsChart.tsx} | 14 +++-- .../features/insights/server/trpc-router.ts | 2 +- 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/hourly-bookings/page.tsx rename packages/features/insights/components/{HourlyBookingChart.tsx => HourlyBookingsChart.tsx} (88%) diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/hourly-bookings/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/hourly-bookings/page.tsx new file mode 100644 index 00000000000000..594fad79a1da9a --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/hourly-bookings/page.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { ChartCard } from "@calcom/features/insights/components/ChartCard"; +import { HourlyBookingsChartContent } from "@calcom/features/insights/components/HourlyBookingsChart"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +// Sample data for playground testing +const sampleHourlyBookingsData = [ + { hour: 0, bookingCount: 4 }, + { hour: 1, bookingCount: 10 }, + { hour: 2, bookingCount: 3 }, + { hour: 3, bookingCount: 12 }, + { hour: 4, bookingCount: 3 }, + { hour: 5, bookingCount: 7 }, + { hour: 6, bookingCount: 6 }, + { hour: 7, bookingCount: 4 }, + { hour: 8, bookingCount: 9 }, + { hour: 9, bookingCount: 7 }, + { hour: 10, bookingCount: 6 }, + { hour: 11, bookingCount: 5 }, + { hour: 12, bookingCount: 8 }, + { hour: 13, bookingCount: 5 }, + { hour: 14, bookingCount: 9 }, + { hour: 15, bookingCount: 9 }, + { hour: 16, bookingCount: 4 }, + { hour: 17, bookingCount: 5 }, + { hour: 18, bookingCount: 4 }, + { hour: 19, bookingCount: 6 }, + { hour: 20, bookingCount: 6 }, + { hour: 21, bookingCount: 12 }, + { hour: 22, bookingCount: 4 }, + { hour: 23, bookingCount: 10 }, +]; + +export default function HourlyBookingsPlayground() { + const { t } = useLocale(); + return ( +
+
+

Hourly Bookings Playground

+

+ This page demonstrates the HourlyBookingChartContent component with sample data. +

+
+ +
+ + + +
+ +
+

Sample Data Used:

+
+          {JSON.stringify(sampleHourlyBookingsData, 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..ae64071c8fdb20 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: "Hourly Bookings", + href: "/settings/admin/playground/hourly-bookings", + }, ]; export default function Page() { diff --git a/packages/features/insights/components/HourlyBookingChart.tsx b/packages/features/insights/components/HourlyBookingsChart.tsx similarity index 88% rename from packages/features/insights/components/HourlyBookingChart.tsx rename to packages/features/insights/components/HourlyBookingsChart.tsx index 05cce3e9db827a..3bdd39fc209d05 100644 --- a/packages/features/insights/components/HourlyBookingChart.tsx +++ b/packages/features/insights/components/HourlyBookingsChart.tsx @@ -11,12 +11,12 @@ import { useInsightsParameters } from "../hooks/useInsightsParameters"; import { CardInsights } from "./Card"; import { LoadingInsight } from "./LoadingInsights"; -type HourlyBookingData = { +type HourlyBookingsData = { hour: number; bookingCount: number; }; -const HourlyBookingChartPresentation = ({ data }: { data: HourlyBookingData[] }) => { +const HourlyBookingsChartContent = ({ data }: { data: HourlyBookingsData[] }) => { const { t } = useLocale(); const chartData = data.map((item) => ({ @@ -58,7 +58,9 @@ const HourlyBookingChartPresentation = ({ data }: { data: HourlyBookingData[] }) ); }; -export const HourlyBookingChart = () => { +export { HourlyBookingsChartContent }; + +export const HourlyBookingsChart = () => { const { t } = useLocale(); const { timeZone } = useDataTable(); const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); @@ -71,7 +73,7 @@ export const HourlyBookingChart = () => { endDate, eventTypeId, memberUserId, - timeZone, + timeZone: timeZone || "UTC", }, { staleTime: 30000, @@ -87,8 +89,8 @@ export const HourlyBookingChart = () => { return ( - {t("hourly_booking_distribution")} - + {t("hourly_bookings")} + ); }; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index d7f9c728bc292e..62472f23b2e65a 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -1762,7 +1762,7 @@ export const insightsRouter = router({ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), - hourlyBookingStats: userBelongsToTeamProcedure + hourlyBookingsStats: userBelongsToTeamProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, timeZone } = input; From 3f0e3b5bf20cf86bce055ba2b4aec034ac35ab00 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 18 Jul 2025 14:16:57 +0200 Subject: [PATCH 04/12] update styles --- .../components/HourlyBookingsChart.tsx | 67 +++++++++++++++---- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/features/insights/components/HourlyBookingsChart.tsx b/packages/features/insights/components/HourlyBookingsChart.tsx index 3bdd39fc209d05..753bef43def3af 100644 --- a/packages/features/insights/components/HourlyBookingsChart.tsx +++ b/packages/features/insights/components/HourlyBookingsChart.tsx @@ -1,7 +1,16 @@ "use client"; import { Title } from "@tremor/react"; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"; +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"; @@ -39,25 +48,55 @@ const HourlyBookingsChartContent = ({ data }: { data: HourlyBookingsData[] }) =>
- - - - + + + } /> + } /> -
); }; +// Custom Tooltip component +const CustomTooltip = ({ + active, + payload, + label, +}: { + active?: boolean; + payload?: Array<{ + value: number; + dataKey: string; + name: string; + color: string; + payload: { hour: string; bookings: number }; + }>; + label?: string; +}) => { + const { t } = useLocale(); + if (!active || !payload?.length) { + return null; + } + + return ( +
+

{payload[0].payload.hour}

+ {payload.map((entry, index: number) => ( +

+ {t("bookings")}: {entry.value} +

+ ))} +
+ ); +}; + export { HourlyBookingsChartContent }; export const HourlyBookingsChart = () => { @@ -65,7 +104,7 @@ export const HourlyBookingsChart = () => { const { timeZone } = useDataTable(); const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.hourlyBookingStats.useQuery( + const { data, isSuccess, isPending } = trpc.viewer.insights.hourlyBookingsStats.useQuery( { scope, selectedTeamId, From bc5df0e38975ea2ee2e590592da963465a0017cb Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 18 Jul 2025 14:59:34 +0200 Subject: [PATCH 05/12] fix data --- .../admin/playground/hourly-bookings/page.tsx | 48 +++++++++---------- apps/web/modules/insights/insights-view.tsx | 4 +- .../components/HourlyBookingsChart.tsx | 25 +++++----- .../features/insights/components/index.ts | 2 +- .../lib/server/service/insightsBooking.ts | 10 ++-- 5 files changed, 43 insertions(+), 46 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/hourly-bookings/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/hourly-bookings/page.tsx index 594fad79a1da9a..037ed99b01f6cc 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/hourly-bookings/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/hourly-bookings/page.tsx @@ -6,30 +6,30 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; // Sample data for playground testing const sampleHourlyBookingsData = [ - { hour: 0, bookingCount: 4 }, - { hour: 1, bookingCount: 10 }, - { hour: 2, bookingCount: 3 }, - { hour: 3, bookingCount: 12 }, - { hour: 4, bookingCount: 3 }, - { hour: 5, bookingCount: 7 }, - { hour: 6, bookingCount: 6 }, - { hour: 7, bookingCount: 4 }, - { hour: 8, bookingCount: 9 }, - { hour: 9, bookingCount: 7 }, - { hour: 10, bookingCount: 6 }, - { hour: 11, bookingCount: 5 }, - { hour: 12, bookingCount: 8 }, - { hour: 13, bookingCount: 5 }, - { hour: 14, bookingCount: 9 }, - { hour: 15, bookingCount: 9 }, - { hour: 16, bookingCount: 4 }, - { hour: 17, bookingCount: 5 }, - { hour: 18, bookingCount: 4 }, - { hour: 19, bookingCount: 6 }, - { hour: 20, bookingCount: 6 }, - { hour: 21, bookingCount: 12 }, - { hour: 22, bookingCount: 4 }, - { hour: 23, bookingCount: 10 }, + { 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 HourlyBookingsPlayground() { diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index 8ea0ab976c7e0f..b7fcf10635ce63 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -13,7 +13,7 @@ import { BookingStatusLineChart, HighestNoShowHostTable, HighestRatedMembersTable, - HourlyBookingChart, + HourlyBookingsChart, LeastBookedTeamMembersTable, LowestRatedMembersTable, MostBookedTeamMembersTable, @@ -77,7 +77,7 @@ function InsightsPageContent() {
- +
diff --git a/packages/features/insights/components/HourlyBookingsChart.tsx b/packages/features/insights/components/HourlyBookingsChart.tsx index 753bef43def3af..6f4c6845ad428b 100644 --- a/packages/features/insights/components/HourlyBookingsChart.tsx +++ b/packages/features/insights/components/HourlyBookingsChart.tsx @@ -1,6 +1,5 @@ "use client"; -import { Title } from "@tremor/react"; import { BarChart, Bar, @@ -14,26 +13,27 @@ import { 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 { CardInsights } from "./Card"; +import { ChartCard } from "./ChartCard"; import { LoadingInsight } from "./LoadingInsights"; type HourlyBookingsData = { hour: number; - bookingCount: number; + count: number; }; -const HourlyBookingsChartContent = ({ data }: { data: HourlyBookingsData[] }) => { +export const HourlyBookingsChartContent = ({ data }: { data: HourlyBookingsData[] }) => { const { t } = useLocale(); const chartData = data.map((item) => ({ hour: `${item.hour.toString().padStart(2, "0")}:00`, - bookings: item.bookingCount, + count: item.count, })); - const maxBookings = Math.max(...data.map((item) => item.bookingCount)); + const maxBookings = Math.max(...data.map((item) => item.count)); const isEmpty = maxBookings === 0; if (isEmpty) { @@ -47,13 +47,13 @@ const HourlyBookingsChartContent = ({ data }: { data: HourlyBookingsData[] }) => return (
- + } /> } @@ -97,8 +97,6 @@ const CustomTooltip = ({ ); }; -export { HourlyBookingsChartContent }; - export const HourlyBookingsChart = () => { const { t } = useLocale(); const { timeZone } = useDataTable(); @@ -112,7 +110,7 @@ export const HourlyBookingsChart = () => { endDate, eventTypeId, memberUserId, - timeZone: timeZone || "UTC", + timeZone: timeZone || CURRENT_TIMEZONE, }, { staleTime: 30000, @@ -127,9 +125,8 @@ export const HourlyBookingsChart = () => { if (!isSuccess || !data) return null; return ( - - {t("hourly_bookings")} + - + ); }; diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts index 9bb32af0b8a390..aef1ef9e702b1c 100644 --- a/packages/features/insights/components/index.ts +++ b/packages/features/insights/components/index.ts @@ -1,6 +1,6 @@ export { AverageEventDurationChart } from "./AverageEventDurationChart"; export { BookingKPICards } from "./BookingKPICards"; -export { HourlyBookingChart } from "./HourlyBookingChart"; +export { HourlyBookingsChart } from "./HourlyBookingsChart"; export { BookingStatusLineChart } from "./BookingStatusLineChart"; export { FailedBookingsByField } from "./FailedBookingsByField"; export { HighestNoShowHostTable } from "./HighestNoShowHostTable"; diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index 037fb29b4ee29b..015b6d8618f79a 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -225,8 +225,8 @@ export class InsightsBookingService { const results = await this.prisma.$queryRaw< Array<{ - hour: number; - bookingCount: bigint; + hour: string; + count: bigint; }> >` WITH hourly_data AS ( @@ -240,19 +240,19 @@ export class InsightsBookingService { ) SELECT hour_extracted as "hour", - COUNT(*) as "bookingCount" + COUNT(*) as "count" FROM hourly_data GROUP BY hour_extracted ORDER BY "hour" `; // Create a map of results by hour for easy lookup - const resultsMap = new Map(results.map((row) => [row.hour, Number(row.bookingCount)])); + const resultsMap = new Map(results.map((row) => [Number(row.hour), Number(row.count)])); // Return all 24 hours (0-23), filling with 0 values for missing data return Array.from({ length: 24 }, (_, hour) => ({ hour, - bookingCount: resultsMap.get(hour) || 0, + count: resultsMap.get(hour) || 0, })); } From 16bbc2bb16b7bcfb8b6af4e4c11c6f07f219600a Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 18 Jul 2025 17:44:26 +0200 Subject: [PATCH 06/12] clean up --- .../page.tsx | 10 +-- .../(admin-layout)/admin/playground/page.tsx | 4 +- apps/web/modules/insights/insights-view.tsx | 5 +- ...kingsChart.tsx => BookingsByHourChart.tsx} | 6 +- .../features/insights/components/index.ts | 3 +- .../lib/server/service/insightsBooking.ts | 64 +++++++++++++++++++ 6 files changed, 79 insertions(+), 13 deletions(-) rename apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/{hourly-bookings => bookings-by-hour}/page.tsx (78%) rename packages/features/insights/components/{HourlyBookingsChart.tsx => BookingsByHourChart.tsx} (95%) diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/hourly-bookings/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/bookings-by-hour/page.tsx similarity index 78% rename from apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/hourly-bookings/page.tsx rename to apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/bookings-by-hour/page.tsx index 037ed99b01f6cc..45032182a39ebf 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/hourly-bookings/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/bookings-by-hour/page.tsx @@ -1,7 +1,7 @@ "use client"; +import { BookingsByHourChartContent } from "@calcom/features/insights/components/BookingsByHourChart"; import { ChartCard } from "@calcom/features/insights/components/ChartCard"; -import { HourlyBookingsChartContent } from "@calcom/features/insights/components/HourlyBookingsChart"; import { useLocale } from "@calcom/lib/hooks/useLocale"; // Sample data for playground testing @@ -32,20 +32,20 @@ const sampleHourlyBookingsData = [ { hour: 23, count: 10 }, ]; -export default function HourlyBookingsPlayground() { +export default function BookingsByHourPlayground() { const { t } = useLocale(); return (
-

Hourly Bookings Playground

+

Bookings by Hour Playground

- This page demonstrates the HourlyBookingChartContent component with sample data. + This page demonstrates the BookingsByHourChartContent component with sample data.

- +
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 ae64071c8fdb20..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 @@ -6,8 +6,8 @@ const LINKS = [ href: "/settings/admin/playground/routing-funnel", }, { - title: "Hourly Bookings", - href: "/settings/admin/playground/hourly-bookings", + title: "Bookings by Hour", + href: "/settings/admin/playground/bookings-by-hour", }, ]; diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index b7fcf10635ce63..f1b94665691591 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -13,7 +13,7 @@ import { BookingStatusLineChart, HighestNoShowHostTable, HighestRatedMembersTable, - HourlyBookingsChart, + BookingsByHourChart, LeastBookedTeamMembersTable, LowestRatedMembersTable, MostBookedTeamMembersTable, @@ -77,7 +77,8 @@ function InsightsPageContent() {
- + +
diff --git a/packages/features/insights/components/HourlyBookingsChart.tsx b/packages/features/insights/components/BookingsByHourChart.tsx similarity index 95% rename from packages/features/insights/components/HourlyBookingsChart.tsx rename to packages/features/insights/components/BookingsByHourChart.tsx index 6f4c6845ad428b..31c918d61d31f6 100644 --- a/packages/features/insights/components/HourlyBookingsChart.tsx +++ b/packages/features/insights/components/BookingsByHourChart.tsx @@ -25,7 +25,7 @@ type HourlyBookingsData = { count: number; }; -export const HourlyBookingsChartContent = ({ data }: { data: HourlyBookingsData[] }) => { +export const BookingsByHourChartContent = ({ data }: { data: HourlyBookingsData[] }) => { const { t } = useLocale(); const chartData = data.map((item) => ({ @@ -97,7 +97,7 @@ const CustomTooltip = ({ ); }; -export const HourlyBookingsChart = () => { +export const BookingsByHourChart = () => { const { t } = useLocale(); const { timeZone } = useDataTable(); const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); @@ -126,7 +126,7 @@ export const HourlyBookingsChart = () => { return ( - + ); }; diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts index aef1ef9e702b1c..bfe35c2cc28060 100644 --- a/packages/features/insights/components/index.ts +++ b/packages/features/insights/components/index.ts @@ -1,6 +1,7 @@ export { AverageEventDurationChart } from "./AverageEventDurationChart"; export { BookingKPICards } from "./BookingKPICards"; -export { HourlyBookingsChart } from "./HourlyBookingsChart"; +export { BookingsByHourChart } from "./BookingsByHourChart"; + export { BookingStatusLineChart } from "./BookingStatusLineChart"; export { FailedBookingsByField } from "./FailedBookingsByField"; export { HighestNoShowHostTable } from "./HighestNoShowHostTable"; diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index 015b6d8618f79a..c86a180f1f18c2 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -256,6 +256,70 @@ export class InsightsBookingService { })); } + async getHourlyDailyBookingStats({ + startDate, + endDate, + timeZone, + weekStart, + }: { + startDate: string; + endDate: string; + timeZone: string; + weekStart: number; + }) { + // 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<{ + day_of_week: string; + hour: string; + count: bigint; + }> + >` + WITH hourly_daily_data AS ( + SELECT + EXTRACT(DOW FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})) as day_of_week_extracted, + EXTRACT(HOUR FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})) as hour_extracted + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} + AND "startTime" >= ${startDate}::timestamp + AND "startTime" <= ${endDate}::timestamp + AND "status" = 'accepted' + ) + SELECT + day_of_week_extracted as "day_of_week", + hour_extracted as "hour", + COUNT(*) as "count" + FROM hourly_daily_data + GROUP BY day_of_week_extracted, hour_extracted + ORDER BY "day_of_week", "hour" + `; + + // Create a map of results by day and hour for easy lookup + const resultsMap = new Map(); + results.forEach((row) => { + const key = `${row.day_of_week}-${row.hour}`; + resultsMap.set(key, Number(row.count)); + }); + + // Adjust day of week based on weekStart (0 = Sunday, 1 = Monday) + const adjustedDays = Array.from({ length: 7 }, (_, i) => (i + weekStart) % 7); + + // Return all combinations of days (0-6) and hours (0-23), filling with 0 values for missing data + return adjustedDays.flatMap((day) => + Array.from({ length: 24 }, (_, hour) => ({ + dayOfWeek: day, + hour, + count: resultsMap.get(`${day}-${hour}`) || 0, + })) + ); + } + private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise { // Check if the user is an owner or admin of the organization const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId }); From 6c7a9ae8cef2f7ef0e7823431c0d0a334e844a62 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 18 Jul 2025 17:55:14 +0200 Subject: [PATCH 07/12] clean up --- .../playground/bookings-by-hour/page.tsx | 2 +- apps/web/public/static/locales/en/common.json | 2 +- .../components/BookingsByHourChart.tsx | 2 +- .../lib/server/service/insightsBooking.ts | 162 ++++++------------ 4 files changed, 52 insertions(+), 116 deletions(-) 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 index 45032182a39ebf..890b808510bb59 100644 --- 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 @@ -44,7 +44,7 @@ export default function BookingsByHourPlayground() {
- +
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 1c5db635f337fd..ffd43ca3ed7f54 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2167,7 +2167,7 @@ "popular_events": "Popular Events", "no_event_types_found": "No event types found", "average_event_duration": "Average Event Duration", - "hourly_bookings": "Hourly Bookings", + "bookings_by_hour": "Bookings by Hour", "most_booked_members": "Most Booked", "least_booked_members": "Least Booked", "events_created": "Events Created", diff --git a/packages/features/insights/components/BookingsByHourChart.tsx b/packages/features/insights/components/BookingsByHourChart.tsx index 31c918d61d31f6..8f6e34889a287a 100644 --- a/packages/features/insights/components/BookingsByHourChart.tsx +++ b/packages/features/insights/components/BookingsByHourChart.tsx @@ -125,7 +125,7 @@ export const BookingsByHourChart = () => { if (!isSuccess || !data) return null; return ( - + ); diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index c86a180f1f18c2..69c0df31ed23b1 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -65,6 +65,55 @@ export class InsightsBookingService { this.filters = filters; } + async getHourlyBookingStats({ + 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: bigint; + }> + >` + WITH hourly_data AS ( + SELECT + EXTRACT(HOUR FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})) as hour_extracted + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} + AND "startTime" >= ${startDate}::timestamp + AND "startTime" <= ${endDate}::timestamp + AND "status" = 'accepted' + ) + SELECT + hour_extracted as "hour", + COUNT(*) as "count" + FROM hourly_data + GROUP BY hour_extracted + ORDER BY "hour" + `; + + // Create a map of results by hour for easy lookup + const resultsMap = new Map(results.map((row) => [Number(row.hour), Number(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(); @@ -207,119 +256,6 @@ export class InsightsBookingService { }); } - async getHourlyBookingStats({ - 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: bigint; - }> - >` - WITH hourly_data AS ( - SELECT - EXTRACT(HOUR FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})) as hour_extracted - FROM "BookingTimeStatusDenormalized" - WHERE ${baseConditions} - AND "startTime" >= ${startDate}::timestamp - AND "startTime" <= ${endDate}::timestamp - AND "status" = 'accepted' - ) - SELECT - hour_extracted as "hour", - COUNT(*) as "count" - FROM hourly_data - GROUP BY hour_extracted - ORDER BY "hour" - `; - - // Create a map of results by hour for easy lookup - const resultsMap = new Map(results.map((row) => [Number(row.hour), Number(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 getHourlyDailyBookingStats({ - startDate, - endDate, - timeZone, - weekStart, - }: { - startDate: string; - endDate: string; - timeZone: string; - weekStart: number; - }) { - // 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<{ - day_of_week: string; - hour: string; - count: bigint; - }> - >` - WITH hourly_daily_data AS ( - SELECT - EXTRACT(DOW FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})) as day_of_week_extracted, - EXTRACT(HOUR FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})) as hour_extracted - FROM "BookingTimeStatusDenormalized" - WHERE ${baseConditions} - AND "startTime" >= ${startDate}::timestamp - AND "startTime" <= ${endDate}::timestamp - AND "status" = 'accepted' - ) - SELECT - day_of_week_extracted as "day_of_week", - hour_extracted as "hour", - COUNT(*) as "count" - FROM hourly_daily_data - GROUP BY day_of_week_extracted, hour_extracted - ORDER BY "day_of_week", "hour" - `; - - // Create a map of results by day and hour for easy lookup - const resultsMap = new Map(); - results.forEach((row) => { - const key = `${row.day_of_week}-${row.hour}`; - resultsMap.set(key, Number(row.count)); - }); - - // Adjust day of week based on weekStart (0 = Sunday, 1 = Monday) - const adjustedDays = Array.from({ length: 7 }, (_, i) => (i + weekStart) % 7); - - // Return all combinations of days (0-6) and hours (0-23), filling with 0 values for missing data - return adjustedDays.flatMap((day) => - Array.from({ length: 24 }, (_, hour) => ({ - dayOfWeek: day, - hour, - count: resultsMap.get(`${day}-${hour}`) || 0, - })) - ); - } - private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise { // Check if the user is an owner or admin of the organization const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId }); From 0adc3bad8fe2d240d2e856eb61244c5e575da0c1 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Mon, 21 Jul 2025 11:40:46 +0200 Subject: [PATCH 08/12] re-order charts --- apps/web/modules/insights/insights-view.tsx | 23 ++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index f1b94665691591..1b17fba8f17c50 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -72,26 +72,35 @@ function InsightsPageContent() { -
- - +
+
+ +
+
+ +
- -
+
+ +
- -
+
+ + +
+
+ {t("looking_for_more_insights")}{" "} Date: Mon, 21 Jul 2025 11:53:52 +0200 Subject: [PATCH 09/12] update style --- .../features/insights/components/ChartCard.tsx | 16 ++++++++++++++-- .../components/TotalBookingUsersTable.tsx | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) 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/TotalBookingUsersTable.tsx b/packages/features/insights/components/TotalBookingUsersTable.tsx index 0f9d1f9db16151..c6358283166d73 100644 --- a/packages/features/insights/components/TotalBookingUsersTable.tsx +++ b/packages/features/insights/components/TotalBookingUsersTable.tsx @@ -22,7 +22,7 @@ export const TotalBookingUsersTable = ({
{filteredData.length > 0 ? ( filteredData.map((item) => ( - +
Date: Mon, 21 Jul 2025 14:05:37 +0200 Subject: [PATCH 10/12] apply feedback --- packages/features/insights/components/BookingsByHourChart.tsx | 4 ++-- .../features/insights/components/TotalBookingUsersTable.tsx | 4 ++-- packages/features/insights/server/trpc-router.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/features/insights/components/BookingsByHourChart.tsx b/packages/features/insights/components/BookingsByHourChart.tsx index 8f6e34889a287a..afa6b331b92997 100644 --- a/packages/features/insights/components/BookingsByHourChart.tsx +++ b/packages/features/insights/components/BookingsByHourChart.tsx @@ -76,7 +76,7 @@ const CustomTooltip = ({ dataKey: string; name: string; color: string; - payload: { hour: string; bookings: number }; + payload: { hour: string; count: number }; }>; label?: string; }) => { @@ -87,7 +87,7 @@ const CustomTooltip = ({ return (
-

{payload[0].payload.hour}

+

{label}

{payload.map((entry, index: number) => (

{t("bookings")}: {entry.value} diff --git a/packages/features/insights/components/TotalBookingUsersTable.tsx b/packages/features/insights/components/TotalBookingUsersTable.tsx index c6358283166d73..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) => ( +
Date: Mon, 21 Jul 2025 14:07:16 +0200 Subject: [PATCH 11/12] rename --- .../admin/playground/bookings-by-hour/page.tsx | 6 +++--- .../features/insights/components/BookingsByHourChart.tsx | 6 +++--- packages/features/insights/server/trpc-router.ts | 4 ++-- packages/lib/server/service/insightsBooking.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) 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 index 890b808510bb59..f07d76de41a385 100644 --- 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 @@ -5,7 +5,7 @@ import { ChartCard } from "@calcom/features/insights/components/ChartCard"; import { useLocale } from "@calcom/lib/hooks/useLocale"; // Sample data for playground testing -const sampleHourlyBookingsData = [ +const sampleBookingsByHourStats = [ { hour: 0, count: 4 }, { hour: 1, count: 10 }, { hour: 2, count: 3 }, @@ -45,14 +45,14 @@ export default function BookingsByHourPlayground() {
- +

Sample Data Used:

-          {JSON.stringify(sampleHourlyBookingsData, null, 2)}
+          {JSON.stringify(sampleBookingsByHourStats, null, 2)}
         
diff --git a/packages/features/insights/components/BookingsByHourChart.tsx b/packages/features/insights/components/BookingsByHourChart.tsx index afa6b331b92997..dd0957f9f4490b 100644 --- a/packages/features/insights/components/BookingsByHourChart.tsx +++ b/packages/features/insights/components/BookingsByHourChart.tsx @@ -20,12 +20,12 @@ import { useInsightsParameters } from "../hooks/useInsightsParameters"; import { ChartCard } from "./ChartCard"; import { LoadingInsight } from "./LoadingInsights"; -type HourlyBookingsData = { +type BookingsByHourData = { hour: number; count: number; }; -export const BookingsByHourChartContent = ({ data }: { data: HourlyBookingsData[] }) => { +export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[] }) => { const { t } = useLocale(); const chartData = data.map((item) => ({ @@ -102,7 +102,7 @@ export const BookingsByHourChart = () => { const { timeZone } = useDataTable(); const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.hourlyBookingsStats.useQuery( + const { data, isSuccess, isPending } = trpc.viewer.insights.bookingsByHourStats.useQuery( { scope, selectedTeamId, diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 613da6516011bf..8efac9a30715ad 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -1762,7 +1762,7 @@ export const insightsRouter = router({ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), - hourlyBookingsStats: userBelongsToTeamProcedure + bookingsByHourStats: userBelongsToTeamProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, timeZone } = input; @@ -1782,7 +1782,7 @@ export const insightsRouter = router({ }); try { - return await insightsBookingService.getHourlyBookingStats({ + return await insightsBookingService.getBookingsByHourStats({ startDate, endDate, timeZone, diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index 69c0df31ed23b1..ef4c411ea1ac05 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -65,7 +65,7 @@ export class InsightsBookingService { this.filters = filters; } - async getHourlyBookingStats({ + async getBookingsByHourStats({ startDate, endDate, timeZone, From f62e301e2c7099169e6a8a4a5c3d91e888014b5a Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Mon, 21 Jul 2025 15:24:02 +0200 Subject: [PATCH 12/12] update query --- .../components/BookingsByHourChart.tsx | 2 +- .../lib/server/service/insightsBooking.ts | 27 ++++++++----------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/features/insights/components/BookingsByHourChart.tsx b/packages/features/insights/components/BookingsByHourChart.tsx index dd0957f9f4490b..c344b1c5fa4aea 100644 --- a/packages/features/insights/components/BookingsByHourChart.tsx +++ b/packages/features/insights/components/BookingsByHourChart.tsx @@ -47,7 +47,7 @@ export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[ return (
- + diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index ef4c411ea1ac05..5e841d20861005 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -84,28 +84,23 @@ export class InsightsBookingService { const results = await this.prisma.$queryRaw< Array<{ hour: string; - count: bigint; + count: number; }> >` - WITH hourly_data AS ( - SELECT - EXTRACT(HOUR FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone})) as hour_extracted - FROM "BookingTimeStatusDenormalized" - WHERE ${baseConditions} - AND "startTime" >= ${startDate}::timestamp - AND "startTime" <= ${endDate}::timestamp - AND "status" = 'accepted' - ) SELECT - hour_extracted as "hour", - COUNT(*) as "count" - FROM hourly_data - GROUP BY hour_extracted - ORDER BY "hour" + 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), Number(row.count)])); + 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) => ({