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; }