From 3e299f95115d868e9c091597d190d511a8116e7b Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 29 Jul 2025 12:37:18 +0200 Subject: [PATCH] refactor: update charts on /insights to use InsightsBookingService (#22744) * refactor: update PopuplarEvents chart to use InsightsBookingService * use buildHashMapForUsers * refactor least booked * refactor charts * combine methods * refactor booking kpi cards * remove unused code * fix booking kpi cards * restore code that was removed accidentally * Revert "restore code that was removed accidentally" This reverts commit 0c0b444b02498b94fb29ef470da065bad3ad7ece. * Revert "fix booking kpi cards" This reverts commit b1e8e9afbd3632a5239495566551ae12bd926b66. * refactor booking kpi cards * revert method and remove unused code * fix previous period * fix metrics * clean up trpc call params * clean up Download.tsx * split utils * restructure folders for components * clean up imports * rename TotalUserFeedbackTable to UserStatsTable * add guide * use client * add curly braces to case * fix tests --- .../playground/bookings-by-hour/page.tsx | 2 +- .../admin/playground/routing-funnel/page.tsx | 5 +- .../insights/insights-routing-view.tsx | 2 +- apps/web/modules/insights/insights-view.tsx | 4 +- .../insights/HOW_TO_ADD_BOOKING_CHARTS.md | 177 ++++ .../insights/components/BookingAtCell.tsx | 2 + .../insights/components/ChartCard.tsx | 2 + .../insights/components/EventTrendsChart.tsx | 55 - .../insights/components/FeedbackTable.tsx | 2 + .../components/HighestNoShowHostTable.tsx | 40 - .../features/insights/components/KPICard.tsx | 2 + .../MostCancelledBookingsTables.tsx | 36 - .../components/RecentFeedbackTable.tsx | 38 - .../components/TotalBookingUsersTable.tsx | 45 - ...erFeedbackTable.tsx => UserStatsTable.tsx} | 21 +- .../AverageEventDurationChart.tsx | 30 +- .../{ => booking}/BookingKPICards.tsx | 25 +- .../{ => booking}/BookingsByHourChart.tsx | 24 +- .../components/booking/EventTrendsChart.tsx | 44 + .../booking/HighestNoShowHostTable.tsx | 35 + .../HighestRatedMembersTable.tsx | 25 +- .../LeastBookedTeamMembersTable.tsx | 25 +- .../{ => booking}/LowestRatedMembersTable.tsx | 25 +- .../MostBookedTeamMembersTable.tsx | 25 +- .../booking/MostCancelledBookingsTables.tsx | 35 + .../{ => booking}/PopularEventsTable.tsx | 32 +- .../booking/RecentFeedbackTable.tsx | 32 + .../{ => booking}/TimezoneBadge.tsx | 2 + .../insights/components/booking/index.ts | 13 + .../features/insights/components/index.ts | 31 +- .../{ => routing}/FailedBookingsByField.tsx | 8 +- .../{ => routing}/RoutedToPerPeriod.tsx | 6 +- .../RoutingFormResponsesTable.tsx | 12 +- .../{ => routing}/RoutingFunnel.tsx | 2 +- .../{ => routing}/RoutingFunnelContent.tsx | 0 .../{ => routing}/RoutingFunnelSkeleton.tsx | 0 .../{ => routing}/RoutingKPICards.tsx | 8 +- .../insights/components/routing/index.ts | 7 + .../insights/components/tremor/BarList.tsx | 2 + .../insights/filters/Download/Download.tsx | 16 +- .../hooks/useInsightsBookingParameters.ts | 47 + .../insights/hooks/useInsightsBookings.ts | 4 +- .../insights/server/__tests__/events.test.ts | 102 +- packages/features/insights/server/events.ts | 390 +------ .../insights/server/insightsDateUtils.ts | 214 ++++ .../features/insights/server/trpc-router.ts | 986 +++--------------- .../lib/server/service/insightsBooking.ts | 398 ++++++- .../lib/server/service/insightsRoutingBase.ts | 2 +- 48 files changed, 1362 insertions(+), 1678 deletions(-) create mode 100644 packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md delete mode 100644 packages/features/insights/components/EventTrendsChart.tsx delete mode 100644 packages/features/insights/components/HighestNoShowHostTable.tsx delete mode 100644 packages/features/insights/components/MostCancelledBookingsTables.tsx delete mode 100644 packages/features/insights/components/RecentFeedbackTable.tsx delete mode 100644 packages/features/insights/components/TotalBookingUsersTable.tsx rename packages/features/insights/components/{TotalUserFeedbackTable.tsx => UserStatsTable.tsx} (69%) rename packages/features/insights/components/{ => booking}/AverageEventDurationChart.tsx (59%) rename packages/features/insights/components/{ => booking}/BookingKPICards.tsx (90%) rename packages/features/insights/components/{ => booking}/BookingsByHourChart.tsx (81%) create mode 100644 packages/features/insights/components/booking/EventTrendsChart.tsx create mode 100644 packages/features/insights/components/booking/HighestNoShowHostTable.tsx rename packages/features/insights/components/{ => booking}/HighestRatedMembersTable.tsx (53%) rename packages/features/insights/components/{ => booking}/LeastBookedTeamMembersTable.tsx (52%) rename packages/features/insights/components/{ => booking}/LowestRatedMembersTable.tsx (53%) rename packages/features/insights/components/{ => booking}/MostBookedTeamMembersTable.tsx (52%) create mode 100644 packages/features/insights/components/booking/MostCancelledBookingsTables.tsx rename packages/features/insights/components/{ => booking}/PopularEventsTable.tsx (65%) create mode 100644 packages/features/insights/components/booking/RecentFeedbackTable.tsx rename packages/features/insights/components/{ => booking}/TimezoneBadge.tsx (99%) create mode 100644 packages/features/insights/components/booking/index.ts rename packages/features/insights/components/{ => routing}/FailedBookingsByField.tsx (92%) rename packages/features/insights/components/{ => routing}/RoutedToPerPeriod.tsx (99%) rename packages/features/insights/components/{ => routing}/RoutingFormResponsesTable.tsx (90%) rename packages/features/insights/components/{ => routing}/RoutingFunnel.tsx (96%) rename packages/features/insights/components/{ => routing}/RoutingFunnelContent.tsx (100%) rename packages/features/insights/components/{ => routing}/RoutingFunnelSkeleton.tsx (100%) rename packages/features/insights/components/{ => routing}/RoutingKPICards.tsx (93%) create mode 100644 packages/features/insights/components/routing/index.ts create mode 100644 packages/features/insights/hooks/useInsightsBookingParameters.ts create mode 100644 packages/features/insights/server/insightsDateUtils.ts 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 f07d76de41..c0b7bc8093 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 @@ -1,7 +1,7 @@ "use client"; -import { BookingsByHourChartContent } from "@calcom/features/insights/components/BookingsByHourChart"; import { ChartCard } from "@calcom/features/insights/components/ChartCard"; +import { BookingsByHourChartContent } from "@calcom/features/insights/components/booking/BookingsByHourChart"; import { useLocale } from "@calcom/lib/hooks/useLocale"; // Sample data for playground testing diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/routing-funnel/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/routing-funnel/page.tsx index 3f3c61bbd8..ba92702cf1 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/routing-funnel/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/playground/routing-funnel/page.tsx @@ -1,7 +1,10 @@ "use client"; import { ChartCard } from "@calcom/features/insights/components/ChartCard"; -import { RoutingFunnelContent, legend } from "@calcom/features/insights/components/RoutingFunnelContent"; +import { + RoutingFunnelContent, + legend, +} from "@calcom/features/insights/components/routing/RoutingFunnelContent"; import { useLocale } from "@calcom/lib/hooks/useLocale"; // Random sample data for playground testing diff --git a/apps/web/modules/insights/insights-routing-view.tsx b/apps/web/modules/insights/insights-routing-view.tsx index e6501bc25c..097eb1b83b 100644 --- a/apps/web/modules/insights/insights-routing-view.tsx +++ b/apps/web/modules/insights/insights-routing-view.tsx @@ -7,7 +7,7 @@ import { FailedBookingsByField, RoutedToPerPeriod, RoutingFunnel, -} from "@calcom/features/insights/components"; +} from "@calcom/features/insights/components/routing"; import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index 98656bcd3a..fd70af8627 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -10,10 +10,10 @@ import { import { AverageEventDurationChart, BookingKPICards, + BookingsByHourChart, EventTrendsChart, HighestNoShowHostTable, HighestRatedMembersTable, - BookingsByHourChart, LeastBookedTeamMembersTable, LowestRatedMembersTable, MostBookedTeamMembersTable, @@ -21,7 +21,7 @@ import { PopularEventsTable, RecentFeedbackTable, TimezoneBadge, -} from "@calcom/features/insights/components"; +} from "@calcom/features/insights/components/booking"; import "@calcom/features/insights/components/tremor.css"; import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider"; import { Download } from "@calcom/features/insights/filters/Download"; diff --git a/packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md b/packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md new file mode 100644 index 0000000000..d20c8a12e7 --- /dev/null +++ b/packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md @@ -0,0 +1,177 @@ +# How to Add a New Booking Chart to Cal.com Insights Page + +This guide walks you through creating a new booking chart component for the insights page, covering the entire stack from UI component to backend service. + +## Overview + +The insights booking system follows this architecture: + +``` +UI Component → tRPC Handler → Insights Service → Database Query → Response +``` + +## Step 1: Create the UI Component + +Create your chart component in `packages/features/insights/components/booking/`: + +```typescript +// packages/features/insights/components/booking/MyNewChart.tsx +import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer } from "recharts"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; + +export const MyNewChart = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { data, isSuccess, isPending } = trpc.viewer.insights.myNewChartData.useQuery(insightsBookingParams, { + staleTime: 180000, // 3 minutes + refetchOnWindowFocus: false, + trpc: { context: { skipBatch: true } }, + }); + + if (isPending) return ; + + return ( + + {isSuccess && data?.length > 0 ? ( + + + + + + + + + + ) : ( +
+

{t("no_data_yet")}

+
+ )} +
+ ); +}; +``` + +## Step 2: Add Component to Barrel Export + +Update the booking components index file: + +```typescript +// packages/features/insights/components/booking/index.ts +export { AverageEventDurationChart } from "./AverageEventDurationChart"; +export { BookingKPICards } from "./BookingKPICards"; +// ... existing exports +export { MyNewChart } from "./MyNewChart"; // Add this line +``` + +## Step 3: Add Component to Insights View + +Add your component to the main insights page: + +```typescript +// apps/web/modules/insights/insights-view.tsx +import { + AverageEventDurationChart, + BookingKPICards, // ... existing imports + MyNewChart, // Add this import +} from "@calcom/features/insights/components/booking"; + +export default function InsightsPage() { + // ... existing code + + return ( +
+ {/* Existing components */} + + + + {/* Add your new chart */} + + + {/* Other existing components */} +
+ ); +} +``` + +## Step 4: Create tRPC Handler + +Add the tRPC endpoint in the insights router using the `createInsightsBookingService()` helper: + +```typescript +// packages/features/insights/server/trpc-router.ts +import { bookingRepositoryBaseInputSchema } from "@calcom/features/insights/server/raw-data.schema"; +import { userBelongsToTeamProcedure } from "@calcom/trpc/server/procedures/authedProcedure"; + +import { TRPCError } from "@trpc/server"; + +export const insightsRouter = router({ + // ... existing procedures + + myNewChartData: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ ctx, input }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); + + try { + return await insightsBookingService.getMyNewChartData(); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + }), +}); +``` + +## Step 5: Add Service Method to InsightsBookingService + +Add your new method to the `InsightsBookingService` class: + +```typescript +// packages/lib/server/service/insightsBooking.ts +export class InsightsBookingService { + // ... existing methods + + async getMyNewChartData() { + const baseConditions = await this.getBaseConditions(); + + // Example: Get booking counts by day using raw SQL for performance + const data = await this.prisma.$queryRaw< + Array<{ + date: Date; + bookingsCount: number; + }> + >` + SELECT + DATE("createdAt") as date, + COUNT(*)::int as "bookingsCount" + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} + GROUP BY DATE("createdAt") + ORDER BY date ASC + `; + + // Transform the data for the chart + return data.map((item) => ({ + date: item.date.toISOString().split("T")[0], // Format as YYYY-MM-DD + value: item.bookingsCount, + })); + } +} +``` + +## Best Practices + +1. **Use `createInsightsBookingService()`**: Always use the helper function for consistent service creation +2. **Raw SQL for Performance**: Use `$queryRaw` for complex aggregations and better performance +3. **Base Conditions**: Always use `await this.getBaseConditions()` for proper filtering and permissions +4. **Error Handling**: Wrap service calls in try-catch blocks with `TRPCError` +5. **Loading States**: Always show loading indicators with `LoadingInsight` +6. **Consistent Styling**: Use `recharts` for new charts. +7. **Date Handling**: Use `getDateRanges()` and `getTimeView()` for time-based charts diff --git a/packages/features/insights/components/BookingAtCell.tsx b/packages/features/insights/components/BookingAtCell.tsx index 3dfc722153..51813b065b 100644 --- a/packages/features/insights/components/BookingAtCell.tsx +++ b/packages/features/insights/components/BookingAtCell.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; import { useId } from "react"; diff --git a/packages/features/insights/components/ChartCard.tsx b/packages/features/insights/components/ChartCard.tsx index 415313b60c..d2ae63fd79 100644 --- a/packages/features/insights/components/ChartCard.tsx +++ b/packages/features/insights/components/ChartCard.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Fragment, type ReactNode } from "react"; import classNames from "@calcom/ui/classNames"; diff --git a/packages/features/insights/components/EventTrendsChart.tsx b/packages/features/insights/components/EventTrendsChart.tsx deleted file mode 100644 index c5bdf1b521..0000000000 --- a/packages/features/insights/components/EventTrendsChart.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 { valueFormatter } from "../lib/valueFormatter"; -import { ChartCard } from "./ChartCard"; -import { LineChart } from "./LineChart"; -import { LoadingInsight } from "./LoadingInsights"; - -export const EventTrendsChart = () => { - const { t } = useLocale(); - const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); - const { timeZone } = useDataTable(); - - const { - data: eventTrends, - isSuccess, - isPending, - } = trpc.viewer.insights.eventTrends.useQuery( - { - scope, - selectedTeamId, - startDate, - endDate, - timeZone: timeZone || CURRENT_TIMEZONE, - eventTypeId, - memberUserId, - }, - { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - } - ); - - if (isPending) return ; - - if (!isSuccess) return null; - - return ( - - - - ); -}; diff --git a/packages/features/insights/components/FeedbackTable.tsx b/packages/features/insights/components/FeedbackTable.tsx index ea2675f250..af48a82ca8 100644 --- a/packages/features/insights/components/FeedbackTable.tsx +++ b/packages/features/insights/components/FeedbackTable.tsx @@ -1,3 +1,5 @@ +"use client"; + import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { User } from "@calcom/prisma/client"; diff --git a/packages/features/insights/components/HighestNoShowHostTable.tsx b/packages/features/insights/components/HighestNoShowHostTable.tsx deleted file mode 100644 index 7b939dc578..0000000000 --- a/packages/features/insights/components/HighestNoShowHostTable.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc"; - -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; - -export const HighestNoShowHostTable = () => { - const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); - - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostNoShow.useQuery( - { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - } - ); - - if (isPending) return ; - - if (!isSuccess || !data) return null; - - return data && data.length > 0 ? ( - - - - ) : ( - <> - ); -}; diff --git a/packages/features/insights/components/KPICard.tsx b/packages/features/insights/components/KPICard.tsx index c7ef294d45..6f1d1ed444 100644 --- a/packages/features/insights/components/KPICard.tsx +++ b/packages/features/insights/components/KPICard.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Flex, Text, Metric, BadgeDelta } from "@tremor/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; diff --git a/packages/features/insights/components/MostCancelledBookingsTables.tsx b/packages/features/insights/components/MostCancelledBookingsTables.tsx deleted file mode 100644 index da5ca0d768..0000000000 --- a/packages/features/insights/components/MostCancelledBookingsTables.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc"; - -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; - -export const MostCancelledBookingsTables = () => { - const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); - - const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCancelledBookings.useQuery( - { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - } - ); - - if (isPending) return ; - - return ( - - {!isSuccess || !startDate || !endDate || !teamId ? null : } - - ); -}; diff --git a/packages/features/insights/components/RecentFeedbackTable.tsx b/packages/features/insights/components/RecentFeedbackTable.tsx deleted file mode 100644 index e72b49eaa8..0000000000 --- a/packages/features/insights/components/RecentFeedbackTable.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc"; - -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { FeedbackTable } from "./FeedbackTable"; -import { LoadingInsight } from "./LoadingInsights"; - -export const RecentFeedbackTable = () => { - const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); - - const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery( - { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - } - ); - - if (isPending) return ; - - if (!isSuccess || !data) return null; - - return ( - - - - ); -}; diff --git a/packages/features/insights/components/TotalBookingUsersTable.tsx b/packages/features/insights/components/TotalBookingUsersTable.tsx deleted file mode 100644 index eb740cfaac..0000000000 --- a/packages/features/insights/components/TotalBookingUsersTable.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; -import type { User } from "@calcom/prisma/client"; -import { Avatar } from "@calcom/ui/components/avatar"; - -import { ChartCardItem } from "./ChartCard"; - -export const TotalBookingUsersTable = ({ - data, -}: { - data: - | { - userId: number | null; - user: Pick; - emailMd5?: string; - count: number; - username?: string; - }[] - | undefined; -}) => { - const filteredData = data && data?.length > 0 ? data?.filter((item) => !!item.user) : []; - return ( -
- {filteredData.length > 0 ? ( - filteredData.map((item, index) => ( - -
- -
{item.user.name}
-
-
- )) - ) : ( -
-

No members found

-
- )} -
- ); -}; diff --git a/packages/features/insights/components/TotalUserFeedbackTable.tsx b/packages/features/insights/components/UserStatsTable.tsx similarity index 69% rename from packages/features/insights/components/TotalUserFeedbackTable.tsx rename to packages/features/insights/components/UserStatsTable.tsx index 4559e3982c..6783a651fb 100644 --- a/packages/features/insights/components/TotalUserFeedbackTable.tsx +++ b/packages/features/insights/components/UserStatsTable.tsx @@ -1,10 +1,13 @@ +"use client"; + import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { User } from "@calcom/prisma/client"; import { Avatar } from "@calcom/ui/components/avatar"; import { ChartCardItem } from "./ChartCard"; -export const TotalUserFeedbackTable = ({ +export const UserStatsTable = ({ data, }: { data: @@ -18,13 +21,19 @@ export const TotalUserFeedbackTable = ({ }[] | undefined; }) => { + const { t } = useLocale(); + + // Filter out items without user data + const filteredData = data && data?.length > 0 ? data?.filter((item) => !!item.user) : []; + return (
- {data && data?.length > 0 ? ( - data?.map((item) => ( + {filteredData.length > 0 ? ( + filteredData.map((item) => ( + key={item.userId || `user-${Math.random()}`} + count={item.averageRating ? item.averageRating.toFixed(1) : item.count} + className="py-3">
-

No data found

+

{t("no_data_yet")}

)}
diff --git a/packages/features/insights/components/AverageEventDurationChart.tsx b/packages/features/insights/components/booking/AverageEventDurationChart.tsx similarity index 59% rename from packages/features/insights/components/AverageEventDurationChart.tsx rename to packages/features/insights/components/booking/AverageEventDurationChart.tsx index 9e0c40eb6e..799226b9ea 100644 --- a/packages/features/insights/components/AverageEventDurationChart.tsx +++ b/packages/features/insights/components/booking/AverageEventDurationChart.tsx @@ -1,31 +1,23 @@ -import { useDataTable } from "@calcom/features/data-table"; +"use client"; + 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 { valueFormatter } from "../lib/valueFormatter"; -import { ChartCard } from "./ChartCard"; -import { LineChart } from "./LineChart"; -import { LoadingInsight } from "./LoadingInsights"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { valueFormatter } from "../../lib/valueFormatter"; +import { ChartCard } from "../ChartCard"; +import { LineChart } from "../LineChart"; +import { LoadingInsight } from "../LoadingInsights"; export const AverageEventDurationChart = () => { const { t } = useLocale(); - const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); - const { timeZone } = useDataTable(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.averageEventDuration.useQuery( + insightsBookingParams, { - scope, - selectedTeamId, - startDate, - endDate, - timeZone: timeZone || CURRENT_TIMEZONE, - eventTypeId, - memberUserId, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, diff --git a/packages/features/insights/components/BookingKPICards.tsx b/packages/features/insights/components/booking/BookingKPICards.tsx similarity index 90% rename from packages/features/insights/components/BookingKPICards.tsx rename to packages/features/insights/components/booking/BookingKPICards.tsx index 7d5030a1f3..43d24f8523 100644 --- a/packages/features/insights/components/BookingKPICards.tsx +++ b/packages/features/insights/components/booking/BookingKPICards.tsx @@ -1,28 +1,23 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import classNames from "@calcom/ui/classNames"; import { SkeletonText } from "@calcom/ui/components/skeleton"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { KPICard } from "./KPICard"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { KPICard } from "../KPICard"; export const BookingKPICards = () => { const { t } = useLocale(); - const { startDate, endDate, teamId, userId, isAll, memberUserId, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.eventsByStatus.useQuery( + const { data, isSuccess, isPending } = trpc.viewer.insights.bookingKPIStats.useQuery( + insightsBookingParams, { - startDate, - endDate, - teamId, - eventTypeId, - memberUserId, - userId, - isAll, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, diff --git a/packages/features/insights/components/BookingsByHourChart.tsx b/packages/features/insights/components/booking/BookingsByHourChart.tsx similarity index 81% rename from packages/features/insights/components/BookingsByHourChart.tsx rename to packages/features/insights/components/booking/BookingsByHourChart.tsx index c344b1c5fa..d3b1f27b98 100644 --- a/packages/features/insights/components/BookingsByHourChart.tsx +++ b/packages/features/insights/components/booking/BookingsByHourChart.tsx @@ -11,14 +11,12 @@ import { 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"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; type BookingsByHourData = { hour: number; @@ -99,21 +97,13 @@ const CustomTooltip = ({ export const BookingsByHourChart = () => { const { t } = useLocale(); - const { timeZone } = useDataTable(); - const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.bookingsByHourStats.useQuery( + insightsBookingParams, { - scope, - selectedTeamId, - startDate, - endDate, - eventTypeId, - memberUserId, - timeZone: timeZone || CURRENT_TIMEZONE, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, diff --git a/packages/features/insights/components/booking/EventTrendsChart.tsx b/packages/features/insights/components/booking/EventTrendsChart.tsx new file mode 100644 index 0000000000..a15cc380c5 --- /dev/null +++ b/packages/features/insights/components/booking/EventTrendsChart.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { valueFormatter } from "../../lib/valueFormatter"; +import { ChartCard } from "../ChartCard"; +import { LineChart } from "../LineChart"; +import { LoadingInsight } from "../LoadingInsights"; + +export const EventTrendsChart = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { + data: eventTrends, + isSuccess, + isPending, + } = trpc.viewer.insights.eventTrends.useQuery(insightsBookingParams, { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, + }, + }); + + if (isPending) return ; + + if (!isSuccess) return null; + + return ( + + + + ); +}; diff --git a/packages/features/insights/components/booking/HighestNoShowHostTable.tsx b/packages/features/insights/components/booking/HighestNoShowHostTable.tsx new file mode 100644 index 0000000000..96c16b52cc --- /dev/null +++ b/packages/features/insights/components/booking/HighestNoShowHostTable.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; + +export const HighestNoShowHostTable = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostNoShow.useQuery( + insightsBookingParams, + { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, + }, + } + ); + + if (isPending) return ; + + if (!isSuccess || !data) return null; + + return ( + + + + ); +}; diff --git a/packages/features/insights/components/HighestRatedMembersTable.tsx b/packages/features/insights/components/booking/HighestRatedMembersTable.tsx similarity index 53% rename from packages/features/insights/components/HighestRatedMembersTable.tsx rename to packages/features/insights/components/booking/HighestRatedMembersTable.tsx index 3655c13cfe..8c0a7d8db2 100644 --- a/packages/features/insights/components/HighestRatedMembersTable.tsx +++ b/packages/features/insights/components/booking/HighestRatedMembersTable.tsx @@ -1,25 +1,22 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; export const HighestRatedMembersTable = () => { const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithHighestRatings.useQuery( + insightsBookingParams, { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, @@ -32,7 +29,7 @@ export const HighestRatedMembersTable = () => { return data && data.length > 0 ? ( - + ) : ( <> diff --git a/packages/features/insights/components/LeastBookedTeamMembersTable.tsx b/packages/features/insights/components/booking/LeastBookedTeamMembersTable.tsx similarity index 52% rename from packages/features/insights/components/LeastBookedTeamMembersTable.tsx rename to packages/features/insights/components/booking/LeastBookedTeamMembersTable.tsx index f7c47d0d13..a44e462515 100644 --- a/packages/features/insights/components/LeastBookedTeamMembersTable.tsx +++ b/packages/features/insights/components/booking/LeastBookedTeamMembersTable.tsx @@ -1,25 +1,22 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; export const LeastBookedTeamMembersTable = () => { const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastBookings.useQuery( + insightsBookingParams, { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, @@ -32,7 +29,7 @@ export const LeastBookedTeamMembersTable = () => { return ( - + ); }; diff --git a/packages/features/insights/components/LowestRatedMembersTable.tsx b/packages/features/insights/components/booking/LowestRatedMembersTable.tsx similarity index 53% rename from packages/features/insights/components/LowestRatedMembersTable.tsx rename to packages/features/insights/components/booking/LowestRatedMembersTable.tsx index 1708150b1b..64dcdeffe2 100644 --- a/packages/features/insights/components/LowestRatedMembersTable.tsx +++ b/packages/features/insights/components/booking/LowestRatedMembersTable.tsx @@ -1,25 +1,22 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; export const LowestRatedMembersTable = () => { const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLowestRatings.useQuery( + insightsBookingParams, { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, @@ -32,7 +29,7 @@ export const LowestRatedMembersTable = () => { return data && data.length > 0 ? ( - + ) : ( <> diff --git a/packages/features/insights/components/MostBookedTeamMembersTable.tsx b/packages/features/insights/components/booking/MostBookedTeamMembersTable.tsx similarity index 52% rename from packages/features/insights/components/MostBookedTeamMembersTable.tsx rename to packages/features/insights/components/booking/MostBookedTeamMembersTable.tsx index e7fb459e75..f2009810f5 100644 --- a/packages/features/insights/components/MostBookedTeamMembersTable.tsx +++ b/packages/features/insights/components/booking/MostBookedTeamMembersTable.tsx @@ -1,25 +1,22 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; -import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; export const MostBookedTeamMembersTable = () => { const { t } = useLocale(); - const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostBookings.useQuery( + insightsBookingParams, { - startDate, - endDate, - teamId, - eventTypeId, - isAll, - }, - { - staleTime: 30000, + staleTime: 180000, + refetchOnWindowFocus: false, trpc: { context: { skipBatch: true }, }, @@ -32,7 +29,7 @@ export const MostBookedTeamMembersTable = () => { return ( - + ); }; diff --git a/packages/features/insights/components/booking/MostCancelledBookingsTables.tsx b/packages/features/insights/components/booking/MostCancelledBookingsTables.tsx new file mode 100644 index 0000000000..022802e2e6 --- /dev/null +++ b/packages/features/insights/components/booking/MostCancelledBookingsTables.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; +import { UserStatsTable } from "../UserStatsTable"; + +export const MostCancelledBookingsTables = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCancelledBookings.useQuery( + insightsBookingParams, + { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, + }, + } + ); + + if (isPending) return ; + + if (!isSuccess || !data) return null; + + return ( + + + + ); +}; diff --git a/packages/features/insights/components/PopularEventsTable.tsx b/packages/features/insights/components/booking/PopularEventsTable.tsx similarity index 65% rename from packages/features/insights/components/PopularEventsTable.tsx rename to packages/features/insights/components/booking/PopularEventsTable.tsx index 6c23dbf041..584513ff4e 100644 --- a/packages/features/insights/components/PopularEventsTable.tsx +++ b/packages/features/insights/components/booking/PopularEventsTable.tsx @@ -1,31 +1,23 @@ +"use client"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard, ChartCardItem } from "./ChartCard"; -import { LoadingInsight } from "./LoadingInsights"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard, ChartCardItem } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; export const PopularEventsTable = () => { const { t } = useLocale(); - const { isAll, teamId, userId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); - const { data, isSuccess, isPending } = trpc.viewer.insights.popularEventTypes.useQuery( - { - startDate, - endDate, - teamId, - userId, - eventTypeId, - memberUserId, - isAll, + const { data, isSuccess, isPending } = trpc.viewer.insights.popularEvents.useQuery(insightsBookingParams, { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, }, - { - staleTime: 30000, - trpc: { - context: { skipBatch: true }, - }, - } - ); + }); if (isPending) return ; diff --git a/packages/features/insights/components/booking/RecentFeedbackTable.tsx b/packages/features/insights/components/booking/RecentFeedbackTable.tsx new file mode 100644 index 0000000000..69d7fca3ba --- /dev/null +++ b/packages/features/insights/components/booking/RecentFeedbackTable.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard } from "../ChartCard"; +import { FeedbackTable } from "../FeedbackTable"; +import { LoadingInsight } from "../LoadingInsights"; + +export const RecentFeedbackTable = () => { + const { t } = useLocale(); + const insightsBookingParams = useInsightsBookingParameters(); + + const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery(insightsBookingParams, { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, + }, + }); + + if (isPending) return ; + + if (!isSuccess || !data) return null; + + return ( + + + + ); +}; diff --git a/packages/features/insights/components/TimezoneBadge.tsx b/packages/features/insights/components/booking/TimezoneBadge.tsx similarity index 99% rename from packages/features/insights/components/TimezoneBadge.tsx rename to packages/features/insights/components/booking/TimezoneBadge.tsx index 94946756f5..2cdd93b36a 100644 --- a/packages/features/insights/components/TimezoneBadge.tsx +++ b/packages/features/insights/components/booking/TimezoneBadge.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useMemo } from "react"; import { useDataTable } from "@calcom/features/data-table"; diff --git a/packages/features/insights/components/booking/index.ts b/packages/features/insights/components/booking/index.ts new file mode 100644 index 0000000000..33009e88a7 --- /dev/null +++ b/packages/features/insights/components/booking/index.ts @@ -0,0 +1,13 @@ +export { AverageEventDurationChart } from "./AverageEventDurationChart"; +export { BookingKPICards } from "./BookingKPICards"; +export { EventTrendsChart } from "./EventTrendsChart"; +export { HighestNoShowHostTable } from "./HighestNoShowHostTable"; +export { HighestRatedMembersTable } from "./HighestRatedMembersTable"; +export { BookingsByHourChart, BookingsByHourChartContent } from "./BookingsByHourChart"; +export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable"; +export { LowestRatedMembersTable } from "./LowestRatedMembersTable"; +export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable"; +export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables"; +export { PopularEventsTable } from "./PopularEventsTable"; +export { RecentFeedbackTable } from "./RecentFeedbackTable"; +export { TimezoneBadge } from "./TimezoneBadge"; diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts index 3d1724439b..23a7b02fee 100644 --- a/packages/features/insights/components/index.ts +++ b/packages/features/insights/components/index.ts @@ -1,19 +1,12 @@ -export { AverageEventDurationChart } from "./AverageEventDurationChart"; -export { BookingKPICards } from "./BookingKPICards"; -export { BookingsByHourChart } from "./BookingsByHourChart"; - -export { EventTrendsChart } from "./EventTrendsChart"; -export { FailedBookingsByField } from "./FailedBookingsByField"; -export { HighestNoShowHostTable } from "./HighestNoShowHostTable"; -export { HighestRatedMembersTable } from "./HighestRatedMembersTable"; -export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable"; -export { LowestRatedMembersTable } from "./LowestRatedMembersTable"; -export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable"; -export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables"; -export { PopularEventsTable } from "./PopularEventsTable"; -export { RecentFeedbackTable } from "./RecentFeedbackTable"; -export { RoutedToPerPeriod } from "./RoutedToPerPeriod"; -export { RoutingFunnel } from "./RoutingFunnel"; -export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable"; -export { RoutingKPICards } from "./RoutingKPICards"; -export { TimezoneBadge } from "./TimezoneBadge"; +export { BookedByCell } from "./BookedByCell"; +export { BookingAtCell } from "./BookingAtCell"; +export { BookingStatusBadge } from "./BookingStatusBadge"; +export { CardInsights } from "./Card"; +export { CellWithOverflowX } from "./CellWithOverflowX"; +export { ChartCard } from "./ChartCard"; +export { FeedbackTable } from "./FeedbackTable"; +export { KPICard } from "./KPICard"; +export { LineChart } from "./LineChart"; +export { LoadingInsight } from "./LoadingInsights"; +export { ResponseValueCell } from "./ResponseValueCell"; +export { UserStatsTable } from "./UserStatsTable"; diff --git a/packages/features/insights/components/FailedBookingsByField.tsx b/packages/features/insights/components/routing/FailedBookingsByField.tsx similarity index 92% rename from packages/features/insights/components/FailedBookingsByField.tsx rename to packages/features/insights/components/routing/FailedBookingsByField.tsx index 27fd463775..022d34b2c7 100644 --- a/packages/features/insights/components/FailedBookingsByField.tsx +++ b/packages/features/insights/components/routing/FailedBookingsByField.tsx @@ -1,12 +1,14 @@ +"use client"; + import { useState } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import { ToggleGroup } from "@calcom/ui/components/form"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; -import { BarList } from "./tremor/BarList"; +import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +import { ChartCard } from "../ChartCard"; +import { BarList } from "../tremor/BarList"; interface FormCardProps { formName: string; diff --git a/packages/features/insights/components/RoutedToPerPeriod.tsx b/packages/features/insights/components/routing/RoutedToPerPeriod.tsx similarity index 99% rename from packages/features/insights/components/RoutedToPerPeriod.tsx rename to packages/features/insights/components/routing/RoutedToPerPeriod.tsx index 793e50c3dd..a3ee01fa8f 100644 --- a/packages/features/insights/components/RoutedToPerPeriod.tsx +++ b/packages/features/insights/components/routing/RoutedToPerPeriod.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { TFunction } from "i18next"; import { useQueryState } from "nuqs"; import { type ReactNode, useMemo, useRef, useState } from "react"; @@ -23,8 +25,8 @@ import { } from "@calcom/ui/components/table"; import { Tooltip } from "@calcom/ui/components/tooltip"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { ChartCard } from "./ChartCard"; +import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +import { ChartCard } from "../ChartCard"; interface DownloadButtonProps { teamId?: number; diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx similarity index 90% rename from packages/features/insights/components/RoutingFormResponsesTable.tsx rename to packages/features/insights/components/routing/RoutingFormResponsesTable.tsx index e8b69331d0..936493791d 100644 --- a/packages/features/insights/components/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/routing/RoutingFormResponsesTable.tsx @@ -18,12 +18,12 @@ import { } from "@calcom/features/data-table"; import { trpc } from "@calcom/trpc"; -import { RoutingFormResponsesDownload } from "../filters/Download"; -import { OrgTeamsFilter } from "../filters/OrgTeamsFilter"; -import { useInsightsColumns } from "../hooks/useInsightsColumns"; -import { useInsightsFacetedUniqueValues } from "../hooks/useInsightsFacetedUniqueValues"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import type { RoutingFormTableRow } from "../lib/types"; +import { RoutingFormResponsesDownload } from "../../filters/Download"; +import { OrgTeamsFilter } from "../../filters/OrgTeamsFilter"; +import { useInsightsColumns } from "../../hooks/useInsightsColumns"; +import { useInsightsFacetedUniqueValues } from "../../hooks/useInsightsFacetedUniqueValues"; +import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +import type { RoutingFormTableRow } from "../../lib/types"; import { RoutingKPICards } from "./RoutingKPICards"; export type RoutingFormTableType = ReturnType>; diff --git a/packages/features/insights/components/RoutingFunnel.tsx b/packages/features/insights/components/routing/RoutingFunnel.tsx similarity index 96% rename from packages/features/insights/components/RoutingFunnel.tsx rename to packages/features/insights/components/routing/RoutingFunnel.tsx index 43a4bdeddf..a6258be0a0 100644 --- a/packages/features/insights/components/RoutingFunnel.tsx +++ b/packages/features/insights/components/routing/RoutingFunnel.tsx @@ -5,7 +5,7 @@ import { useInsightsParameters } from "@calcom/features/insights/hooks/useInsigh import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { ChartCard } from "./ChartCard"; +import { ChartCard } from "../ChartCard"; import { RoutingFunnelContent, legend } from "./RoutingFunnelContent"; import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton"; diff --git a/packages/features/insights/components/RoutingFunnelContent.tsx b/packages/features/insights/components/routing/RoutingFunnelContent.tsx similarity index 100% rename from packages/features/insights/components/RoutingFunnelContent.tsx rename to packages/features/insights/components/routing/RoutingFunnelContent.tsx diff --git a/packages/features/insights/components/RoutingFunnelSkeleton.tsx b/packages/features/insights/components/routing/RoutingFunnelSkeleton.tsx similarity index 100% rename from packages/features/insights/components/RoutingFunnelSkeleton.tsx rename to packages/features/insights/components/routing/RoutingFunnelSkeleton.tsx diff --git a/packages/features/insights/components/RoutingKPICards.tsx b/packages/features/insights/components/routing/RoutingKPICards.tsx similarity index 93% rename from packages/features/insights/components/RoutingKPICards.tsx rename to packages/features/insights/components/routing/RoutingKPICards.tsx index a912dd3cb6..87f180ce75 100644 --- a/packages/features/insights/components/RoutingKPICards.tsx +++ b/packages/features/insights/components/routing/RoutingKPICards.tsx @@ -1,12 +1,14 @@ +"use client"; + import { Grid } from "@tremor/react"; import { Flex, Text, Metric } from "@tremor/react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; -import { useInsightsParameters } from "../hooks/useInsightsParameters"; -import { valueFormatter } from "../lib"; -import { CardInsights } from "./Card"; +import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +import { valueFormatter } from "../../lib"; +import { CardInsights } from "../Card"; export const RoutingKPICards = () => { const { t } = useLocale(); diff --git a/packages/features/insights/components/routing/index.ts b/packages/features/insights/components/routing/index.ts new file mode 100644 index 0000000000..cbb60157a6 --- /dev/null +++ b/packages/features/insights/components/routing/index.ts @@ -0,0 +1,7 @@ +export { FailedBookingsByField } from "./FailedBookingsByField"; +export { RoutedToPerPeriod } from "./RoutedToPerPeriod"; +export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable"; +export { RoutingFunnel } from "./RoutingFunnel"; +export { RoutingFunnelContent } from "./RoutingFunnelContent"; +export { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton"; +export { RoutingKPICards } from "./RoutingKPICards"; diff --git a/packages/features/insights/components/tremor/BarList.tsx b/packages/features/insights/components/tremor/BarList.tsx index ea76ba61f4..c6b2d476ce 100644 --- a/packages/features/insights/components/tremor/BarList.tsx +++ b/packages/features/insights/components/tremor/BarList.tsx @@ -1,3 +1,5 @@ +"use client"; + // Tremor BarList [v0.1.1] import React from "react"; diff --git a/packages/features/insights/filters/Download/Download.tsx b/packages/features/insights/filters/Download/Download.tsx index 91b7ecd32d..3a40d7602d 100644 --- a/packages/features/insights/filters/Download/Download.tsx +++ b/packages/features/insights/filters/Download/Download.tsx @@ -1,10 +1,8 @@ import { useState } from "react"; import dayjs from "@calcom/dayjs"; -import { useDataTable } from "@calcom/features/data-table"; import { downloadAsCsv } from "@calcom/lib/csvUtils"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; import { trpc } from "@calcom/trpc"; import type { RouterOutputs } from "@calcom/trpc/react"; import { Button } from "@calcom/ui/components/button"; @@ -16,7 +14,7 @@ import { } from "@calcom/ui/components/dropdown"; import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast"; -import { useInsightsParameters } from "../../hooks/useInsightsParameters"; +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; type RawData = RouterOutputs["viewer"]["insights"]["rawData"]["data"][number]; @@ -24,8 +22,8 @@ const BATCH_SIZE = 100; const Download = () => { const { t } = useLocale(); - const { timeZone } = useDataTable(); - const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId } = useInsightsParameters(); + const insightsBookingParams = useInsightsBookingParameters(); + const { startDate, endDate } = insightsBookingParams; const [isDownloading, setIsDownloading] = useState(false); const utils = trpc.useUtils(); @@ -37,13 +35,7 @@ const Download = () => { const fetchBatch = async (offset: number): Promise => { try { const result = await utils.viewer.insights.rawData.fetch({ - scope, - selectedTeamId, - startDate, - endDate, - timeZone: timeZone || CURRENT_TIMEZONE, - eventTypeId, - memberUserId, + ...insightsBookingParams, limit: BATCH_SIZE, offset, }); diff --git a/packages/features/insights/hooks/useInsightsBookingParameters.ts b/packages/features/insights/hooks/useInsightsBookingParameters.ts new file mode 100644 index 0000000000..f3fd61a1b7 --- /dev/null +++ b/packages/features/insights/hooks/useInsightsBookingParameters.ts @@ -0,0 +1,47 @@ +import { useMemo } from "react"; + +import dayjs from "@calcom/dayjs"; +import { ZSingleSelectFilterValue, ZDateRangeFilterValue } from "@calcom/features/data-table"; +import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime"; +import { useDataTable } from "@calcom/features/data-table/hooks/useDataTable"; +import { useFilterValue } from "@calcom/features/data-table/hooks/useFilterValue"; +import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange"; +import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants"; + +import { useInsightsOrgTeams } from "./useInsightsOrgTeams"; + +export function useInsightsBookingParameters() { + const { scope, selectedTeamId } = useInsightsOrgTeams(); + const { timeZone } = useDataTable(); + + const memberUserId = useFilterValue("bookingUserId", ZSingleSelectFilterValue)?.data as number | undefined; + const eventTypeId = useFilterValue("eventTypeId", ZSingleSelectFilterValue)?.data as number | undefined; + const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data; + // TODO for future: this preserving local time & startOf & endOf should be handled + // from DateRangeFilter out of the box. + // When we do it, we also need to remove those timezone handling logic from the backend side at the same time. + const startDate = useChangeTimeZoneWithPreservedLocalTime( + useMemo(() => { + return dayjs(createdAtRange?.startDate ?? getDefaultStartDate().toISOString()) + .startOf("day") + .toISOString(); + }, [createdAtRange?.startDate]) + ); + const endDate = useChangeTimeZoneWithPreservedLocalTime( + useMemo(() => { + return dayjs(createdAtRange?.endDate ?? getDefaultEndDate().toISOString()) + .endOf("day") + .toISOString(); + }, [createdAtRange?.endDate]) + ); + + return { + scope, + selectedTeamId, + startDate, + endDate, + timeZone: timeZone || CURRENT_TIMEZONE, + eventTypeId, + memberUserId, + }; +} diff --git a/packages/features/insights/hooks/useInsightsBookings.ts b/packages/features/insights/hooks/useInsightsBookings.ts index 42025670b0..256bf12937 100644 --- a/packages/features/insights/hooks/useInsightsBookings.ts +++ b/packages/features/insights/hooks/useInsightsBookings.ts @@ -6,7 +6,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { HeaderRow, RoutingFormTableRow } from "../lib/types"; import { useInsightsFacetedUniqueValues } from "./useInsightsFacetedUniqueValues"; -import { useInsightsParameters } from "./useInsightsParameters"; +import { useInsightsOrgTeams } from "./useInsightsOrgTeams"; type DummyTableRow = { bookingUserId: RoutingFormTableRow["bookingUserId"]; @@ -18,7 +18,7 @@ const dummyHeaders: HeaderRow[] = []; export const useInsightsBookings = () => { const { t } = useLocale(); - const { isAll, teamId, userId } = useInsightsParameters(); + const { isAll, teamId, userId } = useInsightsOrgTeams(); const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers: dummyHeaders, diff --git a/packages/features/insights/server/__tests__/events.test.ts b/packages/features/insights/server/__tests__/events.test.ts index 2a49f02f9f..c48df41ad8 100644 --- a/packages/features/insights/server/__tests__/events.test.ts +++ b/packages/features/insights/server/__tests__/events.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import dayjs from "@calcom/dayjs"; -import { EventsInsights } from "../events"; +import { getTimeView, getDateRanges, formatPeriod } from "../insightsDateUtils"; describe("EventsInsights", () => { describe("getDateRanges", () => { @@ -13,7 +13,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; // Beginning of May 1st UTC const endDate = "2025-05-03T23:59:59.999Z"; // End of May 3rd UTC - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -50,7 +50,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; // Thursday const endDate = "2025-05-25T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -99,7 +99,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-15T00:00:00.000Z"; const endDate = "2025-07-15T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -136,7 +136,7 @@ describe("EventsInsights", () => { const startDate = "2025-06-15T00:00:00.000Z"; const endDate = "2027-03-15T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -173,7 +173,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; const endDate = "2025-05-01T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -202,7 +202,7 @@ describe("EventsInsights", () => { const startDate = "2025-03-29T23:00:00.000Z"; // March 30th 00:00 Paris time const endDate = "2025-03-31T21:59:59.999Z"; // March 31st 23:59:59 Paris time - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -237,7 +237,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-15T22:00:00.000Z"; // May 16th 00:00 Paris time const endDate = "2025-05-29T21:59:59.999Z"; // May 29th 23:59:59 Paris time - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -274,7 +274,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-31T22:00:00.000Z"; // June 1st 00:00 Paris time const endDate = "2025-07-31T21:59:59.999Z"; // July 31st 23:59:59 Paris time - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -309,7 +309,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-14T15:00:00.000Z"; // May 15th 00:00 Seoul time const endDate = "2025-05-16T14:59:59.999Z"; // May 16th 23:59:59 Seoul time - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -340,7 +340,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-11T15:00:00.000Z"; // May 12th 00:00 Seoul time (Monday) const endDate = "2025-05-25T14:59:59.999Z"; // May 25th 23:59:59 Seoul time (Sunday) - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -377,7 +377,7 @@ describe("EventsInsights", () => { const startDate = "2025-04-30T15:00:00.000Z"; // May 1st 00:00 Seoul time const endDate = "2025-06-30T14:59:59.999Z"; // June 30th 23:59:59 Seoul time - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -410,7 +410,7 @@ describe("EventsInsights", () => { const endDate = "2025-05-03T23:59:59.999Z"; const timeZone = "UTC"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -433,7 +433,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; // Thursday const endDate = "2025-05-14T23:59:59.999Z"; // Wednesday - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -470,7 +470,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; // Thursday const endDate = "2025-05-14T23:59:59.999Z"; // Wednesday - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -507,7 +507,7 @@ describe("EventsInsights", () => { const startDate = "2025-05-01T00:00:00.000Z"; // Thursday const endDate = "2025-05-14T23:59:59.999Z"; // Wednesday - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -546,7 +546,7 @@ describe("EventsInsights", () => { describe("Day View", () => { describe("Beginning of data (wholeStart === start)", () => { it("should always show month for the first day when same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-15"), end: dayjs("2024-01-15"), timeView: "day", @@ -557,7 +557,7 @@ describe("EventsInsights", () => { }); it("should always show month for the first day when different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-15"), end: dayjs("2024-01-15"), timeView: "day", @@ -570,7 +570,7 @@ describe("EventsInsights", () => { describe("First day of month (start.date() === 1)", () => { it("should show month for 1st day of month when same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-02-01"), end: dayjs("2024-02-01"), timeView: "day", @@ -581,7 +581,7 @@ describe("EventsInsights", () => { }); it("should show month for 1st day of month when different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-02-01"), end: dayjs("2024-02-01"), timeView: "day", @@ -594,7 +594,7 @@ describe("EventsInsights", () => { describe("Regular days (not first day, not 1st of month)", () => { it("should omit month for regular days when same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-16"), end: dayjs("2024-01-16"), timeView: "day", @@ -605,7 +605,7 @@ describe("EventsInsights", () => { }); it("should omit month but show year for regular days when different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-16"), end: dayjs("2024-01-16"), timeView: "day", @@ -618,7 +618,7 @@ describe("EventsInsights", () => { describe("Edge cases", () => { it("should show month when start is both beginning of data AND 1st of month", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-01"), timeView: "day", @@ -630,7 +630,7 @@ describe("EventsInsights", () => { it("should show month for last day of month if it's 1st of month (edge case)", () => { // This tests the case where start.date() === 1 takes precedence - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-03-01"), end: dayjs("2024-03-01"), timeView: "day", @@ -641,7 +641,7 @@ describe("EventsInsights", () => { }); it("should omit month for end of month when not 1st and not beginning", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-31"), end: dayjs("2024-01-31"), timeView: "day", @@ -662,7 +662,7 @@ describe("EventsInsights", () => { for (let i = 0; i <= 5; i++) { const currentDay = wholeStart.add(i, "day"); results.push( - EventsInsights.formatPeriod({ + formatPeriod({ start: currentDay, end: currentDay, timeView: "day", @@ -684,7 +684,7 @@ describe("EventsInsights", () => { let currentDay = wholeStart; while (currentDay.isBefore(wholeEnd) || currentDay.isSame(wholeEnd)) { results.push( - EventsInsights.formatPeriod({ + formatPeriod({ start: currentDay, end: currentDay, timeView: "day", @@ -707,7 +707,7 @@ describe("EventsInsights", () => { let currentDay = wholeStart; while (currentDay.isBefore(wholeEnd) || currentDay.isSame(wholeEnd)) { results.push( - EventsInsights.formatPeriod({ + formatPeriod({ start: currentDay, end: currentDay, timeView: "day", @@ -722,7 +722,7 @@ describe("EventsInsights", () => { }); it("should format a single day range correctly", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-15"), end: dayjs("2024-01-15"), timeView: "day", @@ -738,7 +738,7 @@ describe("EventsInsights", () => { describe("Week View", () => { describe("Same month", () => { it("should format dates without year when wholeStart and wholeEnd are same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-07"), timeView: "week", @@ -749,7 +749,7 @@ describe("EventsInsights", () => { }); it("should format dates with year when wholeStart and wholeEnd are different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-07"), timeView: "week", @@ -762,7 +762,7 @@ describe("EventsInsights", () => { describe("Different months", () => { it("should format dates without year when wholeStart and wholeEnd are same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-29"), end: dayjs("2024-02-04"), timeView: "week", @@ -773,7 +773,7 @@ describe("EventsInsights", () => { }); it("should format dates with year when wholeStart and wholeEnd are different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-29"), end: dayjs("2024-02-04"), timeView: "week", @@ -786,7 +786,7 @@ describe("EventsInsights", () => { describe("Different years", () => { it("should format dates with respective years when start and end span different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2023-12-31"), end: dayjs("2024-01-06"), timeView: "week", @@ -800,7 +800,7 @@ describe("EventsInsights", () => { describe("Month View", () => { it("should format month without year when wholeStart and wholeEnd are same year", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-31"), timeView: "month", @@ -811,7 +811,7 @@ describe("EventsInsights", () => { }); it("should format month with year when wholeStart and wholeEnd are different years", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-31"), timeView: "month", @@ -824,7 +824,7 @@ describe("EventsInsights", () => { describe("Year View", () => { it("should format year regardless of wholeStart and wholeEnd values", () => { - const resultWithSameYear = EventsInsights.formatPeriod({ + const resultWithSameYear = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-12-31"), timeView: "year", @@ -833,7 +833,7 @@ describe("EventsInsights", () => { }); expect(resultWithSameYear).toBe("2024"); - const resultWithDifferentYears = EventsInsights.formatPeriod({ + const resultWithDifferentYears = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-12-31"), timeView: "year", @@ -846,7 +846,7 @@ describe("EventsInsights", () => { describe("Invalid View", () => { it("should return empty string for invalid timeView", () => { - const result = EventsInsights.formatPeriod({ + const result = formatPeriod({ start: dayjs("2024-01-01"), end: dayjs("2024-01-01"), timeView: "invalid" as any, @@ -860,27 +860,27 @@ describe("EventsInsights", () => { describe("getTimeView", () => { it("should return year for ranges over 365 days", () => { - const result = EventsInsights.getTimeView("2024-01-01T00:00:00.000Z", "2025-02-01T00:00:00.000Z"); + const result = getTimeView("2024-01-01T00:00:00.000Z", "2025-02-01T00:00:00.000Z"); expect(result).toBe("year"); }); it("should return month for ranges between 90 and 365 days", () => { - const result = EventsInsights.getTimeView("2024-01-01T00:00:00.000Z", "2024-05-01T00:00:00.000Z"); + const result = getTimeView("2024-01-01T00:00:00.000Z", "2024-05-01T00:00:00.000Z"); expect(result).toBe("month"); }); it("should return week for ranges between 14 and 90 days", () => { - const result = EventsInsights.getTimeView("2024-01-01T00:00:00.000Z", "2024-02-01T00:00:00.000Z"); + const result = getTimeView("2024-01-01T00:00:00.000Z", "2024-02-01T00:00:00.000Z"); expect(result).toBe("week"); }); it("should return day for ranges under 14 days", () => { - const result = EventsInsights.getTimeView("2024-01-01T00:00:00.000Z", "2024-01-10T00:00:00.000Z"); + const result = getTimeView("2024-01-01T00:00:00.000Z", "2024-01-10T00:00:00.000Z"); expect(result).toBe("day"); }); it("should handle same day range", () => { - const result = EventsInsights.getTimeView("2024-01-01T00:00:00.000Z", "2024-01-01T23:59:59.999Z"); + const result = getTimeView("2024-01-01T00:00:00.000Z", "2024-01-01T23:59:59.999Z"); expect(result).toBe("day"); }); }); @@ -893,7 +893,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-15T00:00:00.000Z"; const endDate = "2024-01-19T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -919,7 +919,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-30T00:00:00.000Z"; const endDate = "2024-02-02T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -947,7 +947,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-15T00:00:00.000Z"; // Monday const endDate = "2024-01-28T23:59:59.999Z"; // Sunday - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -973,7 +973,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-29T00:00:00.000Z"; // Monday const endDate = "2024-02-11T23:59:59.999Z"; // Sunday - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -1001,7 +1001,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-15T00:00:00.000Z"; const endDate = "2024-03-15T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -1030,7 +1030,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-15T00:00:00.000Z"; const endDate = "2024-01-21T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, @@ -1063,7 +1063,7 @@ describe("EventsInsights", () => { const startDate = "2024-01-30T00:00:00.000Z"; const endDate = "2024-02-05T23:59:59.999Z"; - const ranges = EventsInsights.getDateRanges({ + const ranges = getDateRanges({ startDate, endDate, timeZone, diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts index 1e045176de..99e0df2a3d 100644 --- a/packages/features/insights/server/events.ts +++ b/packages/features/insights/server/events.ts @@ -1,100 +1,7 @@ -import dayjs from "@calcom/dayjs"; import { readonlyPrisma as prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; -type TimeViewType = "week" | "month" | "year" | "day"; - -type StatusAggregate = { - completed: number; - rescheduled: number; - cancelled: number; - noShowHost: number; - noShowGuests: number; - _all: number; - uncompleted: number; -}; - -type AggregateResult = { - [date: string]: StatusAggregate; -}; - -// Recursive function to convert a JSON condition object into SQL -// Helper type guard function to check if value has 'in' property -function isInCondition(value: any): value is { in: any[] } { - return typeof value === "object" && value !== null && "in" in value && Array.isArray(value.in); -} - -// Helper type guard function to check if value has 'gte' property -function isGteCondition(value: any): value is { gte: any } { - return typeof value === "object" && value !== null && "gte" in value; -} - -// Helper type guard function to check if value has 'lte' property -function isLteCondition(value: any): value is { lte: any } { - return typeof value === "object" && value !== null && "lte" in value; -} - -function buildSqlCondition(condition: any): string { - if (Array.isArray(condition.OR)) { - return `(${condition.OR.map(buildSqlCondition).join(" OR ")})`; - } else if (Array.isArray(condition.AND)) { - return `(${condition.AND.map(buildSqlCondition).join(" AND ")})`; - } else { - const clauses: string[] = []; - for (const [key, value] of Object.entries(condition)) { - if (isInCondition(value)) { - const valuesList = value.in.map((v) => `'${v}'`).join(", "); - clauses.push(`"${key}" IN (${valuesList})`); - } else if (isGteCondition(value)) { - clauses.push(`"${key}" >= '${value.gte}'`); - } else if (isLteCondition(value)) { - clauses.push(`"${key}" <= '${value.lte}'`); - } else { - const formattedValue = typeof value === "string" ? `'${value}'` : value; - clauses.push(`"${key}" = ${formattedValue}`); - } - } - return clauses.join(" AND "); - } -} - -export interface DateRange { - startDate: string; - endDate: string; - formattedDate: string; - formattedDateFull: string; -} - -export interface GetDateRangesParams { - startDate: string; - endDate: string; - timeZone: string; - timeView: "day" | "week" | "month" | "year"; - weekStart: "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | string; -} - -class EventsInsights { - static getTotalNoShowGuests = async (where: Prisma.BookingTimeStatusDenormalizedWhereInput) => { - const bookings = await prisma.bookingTimeStatusDenormalized.findMany({ - where, - select: { - id: true, - }, - }); - - const { _count: totalNoShowGuests } = await prisma.attendee.aggregate({ - where: { - bookingId: { - in: bookings.map((booking) => booking.id), - }, - noShow: true, - }, - _count: true, - }); - - return totalNoShowGuests; - }; - +export class EventsInsights { static countGroupedByStatus = async (where: Prisma.BookingTimeStatusDenormalizedWhereInput) => { const data = await prisma.bookingTimeStatusDenormalized.groupBy({ where, @@ -125,299 +32,4 @@ class EventsInsights { } ); }; - - static getAverageRating = async (whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput) => { - return await prisma.bookingTimeStatusDenormalized.aggregate({ - _avg: { - rating: true, - }, - where: { - ...whereConditional, - rating: { - not: null, // Exclude null ratings - }, - }, - }); - }; - - static getTotalCSAT = async (whereConditional: Prisma.BookingTimeStatusDenormalizedWhereInput) => { - const result = await prisma.bookingTimeStatusDenormalized.findMany({ - where: { - ...whereConditional, - rating: { - not: null, - }, - }, - select: { rating: true }, - }); - - const totalResponses = result.length; - const satisfactoryResponses = result.filter((item) => item.rating && item.rating > 3).length; - const csat = totalResponses > 0 ? (satisfactoryResponses / totalResponses) * 100 : 0; - - return csat; - }; - - static getTimeView = (startDate: string, endDate: string) => { - const diff = dayjs(endDate).diff(dayjs(startDate), "day"); - if (diff > 365) { - return "year"; - } else if (diff > 90) { - return "month"; - } else if (diff > 30) { - return "week"; - } else { - return "day"; - } - }; - - static getPercentage = (actualMetric: number, previousMetric: number) => { - const differenceActualVsPrevious = actualMetric - previousMetric; - if (differenceActualVsPrevious === 0) { - return 0; - } - const result = (differenceActualVsPrevious * 100) / previousMetric; - - if (isNaN(result) || !isFinite(result)) { - return 0; - } - - return result; - }; - - static getDateRanges({ - startDate: _startDate, - endDate: _endDate, - timeZone, - timeView, - weekStart, - }: GetDateRangesParams): DateRange[] { - if (!["day", "week", "month", "year"].includes(timeView)) { - return []; - } - - const startDate = dayjs(_startDate).tz(timeZone); - const endDate = dayjs(_endDate).tz(timeZone); - const ranges: DateRange[] = []; - let currentStartDate = startDate; - - while (currentStartDate.isBefore(endDate)) { - let currentEndDate = currentStartDate.endOf(timeView).tz(timeZone); - - // Adjust week boundaries based on weekStart parameter - if (timeView === "week") { - const weekStartNum = - { - Sunday: 0, - Monday: 1, - Tuesday: 2, - Wednesday: 3, - Thursday: 4, - Friday: 5, - Saturday: 6, - }[weekStart] ?? 0; - - currentEndDate = currentEndDate.add(weekStartNum, "day"); - if (currentEndDate.subtract(7, "day").isAfter(currentStartDate)) { - currentEndDate = currentEndDate.subtract(7, "day"); - } - } - - if (currentEndDate.isAfter(endDate)) { - currentEndDate = endDate; - ranges.push({ - startDate: currentStartDate.toISOString(), - endDate: currentEndDate.toISOString(), - formattedDate: this.formatPeriod({ - start: currentStartDate, - end: currentEndDate, - timeView, - wholeStart: startDate, - wholeEnd: endDate, - }), - formattedDateFull: this.formatPeriodFull({ - start: currentStartDate, - end: currentEndDate, - timeView, - wholeStart: startDate, - wholeEnd: endDate, - }), - }); - break; - } - - ranges.push({ - startDate: currentStartDate.toISOString(), - endDate: currentEndDate.toISOString(), - formattedDate: this.formatPeriod({ - start: currentStartDate, - end: currentEndDate, - timeView, - wholeStart: startDate, - wholeEnd: endDate, - }), - formattedDateFull: this.formatPeriodFull({ - start: currentStartDate, - end: currentEndDate, - timeView, - wholeStart: startDate, - wholeEnd: endDate, - }), - }); - - currentStartDate = currentEndDate.add(1, "day").startOf("day").tz(timeZone); - } - - return ranges; - } - - static formatPeriod({ - start, - end, - timeView, - wholeStart, - wholeEnd, - }: { - start: dayjs.Dayjs; - end: dayjs.Dayjs; - timeView: TimeViewType; - wholeStart: dayjs.Dayjs; - wholeEnd: dayjs.Dayjs; - }): string { - const omitYear = wholeStart.year() === wholeEnd.year(); - - switch (timeView) { - case "day": - const shouldShowMonth = wholeStart.isSame(start, "day") || start.date() === 1; - - if (shouldShowMonth) { - return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY"); - } else { - return omitYear ? start.format("D") : start.format("D, YYYY"); - } - case "week": - const startFormat = "MMM D"; - let endFormat = "MMM D"; - if (start.format("MMM") === end.format("MMM")) { - endFormat = "D"; - } - - if (start.format("YYYY") !== end.format("YYYY")) { - return `${start.format(`${startFormat} , YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`; - } - - if (omitYear) { - return `${start.format(startFormat)} - ${end.format(endFormat)}`; - } else { - return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`; - } - case "month": - return omitYear ? start.format("MMM") : start.format("MMM YYYY"); - case "year": - return start.format("YYYY"); - default: - return ""; - } - } - - static formatPeriodFull({ - start, - end, - timeView, - wholeStart, - wholeEnd, - }: { - start: dayjs.Dayjs; - end: dayjs.Dayjs; - timeView: TimeViewType; - wholeStart: dayjs.Dayjs; - wholeEnd: dayjs.Dayjs; - }): string { - const omitYear = wholeStart.year() === wholeEnd.year(); - - switch (timeView) { - case "day": - return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY"); - case "week": - const startFormat = "MMM D"; - const endFormat = "MMM D"; - - if (start.format("YYYY") !== end.format("YYYY")) { - return `${start.format(`${startFormat}, YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`; - } - - if (omitYear) { - return `${start.format(startFormat)} - ${end.format(endFormat)}`; - } else { - return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`; - } - case "month": - return omitYear ? start.format("MMM") : start.format("MMM YYYY"); - case "year": - return start.format("YYYY"); - default: - return ""; - } - } - - static userIsOwnerAdminOfTeam = async ({ - sessionUserId, - teamId, - }: { - sessionUserId: number; - teamId: number; - }) => { - const isOwnerAdminOfTeam = await prisma.membership.findUnique({ - where: { - userId_teamId: { - userId: sessionUserId, - teamId, - }, - accepted: true, - role: { - in: ["OWNER", "ADMIN"], - }, - }, - }); - - return !!isOwnerAdminOfTeam; - }; - - static userIsOwnerAdminOfParentTeam = async ({ - sessionUserId, - teamId, - }: { - sessionUserId: number; - teamId: number; - }) => { - const team = await prisma.team.findFirst({ - select: { - parentId: true, - }, - where: { - id: teamId, - }, - }); - - if (!team || team.parentId === null) { - return false; - } - - const isOwnerAdminOfParentTeam = await prisma.membership.findUnique({ - where: { - userId_teamId: { - userId: sessionUserId, - teamId: team.parentId, - }, - accepted: true, - role: { - in: ["OWNER", "ADMIN"], - }, - }, - }); - - return !!isOwnerAdminOfParentTeam; - }; } - -export { EventsInsights }; diff --git a/packages/features/insights/server/insightsDateUtils.ts b/packages/features/insights/server/insightsDateUtils.ts new file mode 100644 index 0000000000..d9498cba83 --- /dev/null +++ b/packages/features/insights/server/insightsDateUtils.ts @@ -0,0 +1,214 @@ +import dayjs from "@calcom/dayjs"; + +type TimeViewType = "week" | "month" | "year" | "day"; + +export interface DateRange { + startDate: string; + endDate: string; + formattedDate: string; + formattedDateFull: string; +} + +export interface GetDateRangesParams { + startDate: string; + endDate: string; + timeZone: string; + timeView: "day" | "week" | "month" | "year"; + weekStart: "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | string; +} + +export const getTimeView = (startDate: string, endDate: string) => { + const diff = dayjs(endDate).diff(dayjs(startDate), "day"); + if (diff > 365) { + return "year"; + } else if (diff > 90) { + return "month"; + } else if (diff > 30) { + return "week"; + } else { + return "day"; + } +}; + +export const getDateRanges = ({ + startDate: _startDate, + endDate: _endDate, + timeZone, + timeView, + weekStart, +}: GetDateRangesParams): DateRange[] => { + if (!["day", "week", "month", "year"].includes(timeView)) { + return []; + } + + const startDate = dayjs(_startDate).tz(timeZone); + const endDate = dayjs(_endDate).tz(timeZone); + const ranges: DateRange[] = []; + let currentStartDate = startDate; + + while (currentStartDate.isBefore(endDate)) { + let currentEndDate = currentStartDate.endOf(timeView).tz(timeZone); + + // Adjust week boundaries based on weekStart parameter + if (timeView === "week") { + const weekStartNum = + { + Sunday: 0, + Monday: 1, + Tuesday: 2, + Wednesday: 3, + Thursday: 4, + Friday: 5, + Saturday: 6, + }[weekStart] ?? 0; + + currentEndDate = currentEndDate.add(weekStartNum, "day"); + if (currentEndDate.subtract(7, "day").isAfter(currentStartDate)) { + currentEndDate = currentEndDate.subtract(7, "day"); + } + } + + if (currentEndDate.isAfter(endDate)) { + currentEndDate = endDate; + ranges.push({ + startDate: currentStartDate.toISOString(), + endDate: currentEndDate.toISOString(), + formattedDate: formatPeriod({ + start: currentStartDate, + end: currentEndDate, + timeView, + wholeStart: startDate, + wholeEnd: endDate, + }), + formattedDateFull: formatPeriodFull({ + start: currentStartDate, + end: currentEndDate, + timeView, + wholeStart: startDate, + wholeEnd: endDate, + }), + }); + break; + } + + ranges.push({ + startDate: currentStartDate.toISOString(), + endDate: currentEndDate.toISOString(), + formattedDate: formatPeriod({ + start: currentStartDate, + end: currentEndDate, + timeView, + wholeStart: startDate, + wholeEnd: endDate, + }), + formattedDateFull: formatPeriodFull({ + start: currentStartDate, + end: currentEndDate, + timeView, + wholeStart: startDate, + wholeEnd: endDate, + }), + }); + + currentStartDate = currentEndDate.add(1, "day").startOf("day").tz(timeZone); + } + + return ranges; +}; + +export const formatPeriod = ({ + start, + end, + timeView, + wholeStart, + wholeEnd, +}: { + start: dayjs.Dayjs; + end: dayjs.Dayjs; + timeView: TimeViewType; + wholeStart: dayjs.Dayjs; + wholeEnd: dayjs.Dayjs; +}): string => { + const omitYear = wholeStart.year() === wholeEnd.year(); + + switch (timeView) { + case "day": { + const shouldShowMonth = wholeStart.isSame(start, "day") || start.date() === 1; + + if (shouldShowMonth) { + return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY"); + } else { + return omitYear ? start.format("D") : start.format("D, YYYY"); + } + } + case "week": { + const startFormat = "MMM D"; + let endFormat = "MMM D"; + if (start.format("MMM") === end.format("MMM")) { + endFormat = "D"; + } + + if (start.format("YYYY") !== end.format("YYYY")) { + return `${start.format(`${startFormat} , YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`; + } + + if (omitYear) { + return `${start.format(startFormat)} - ${end.format(endFormat)}`; + } else { + return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`; + } + } + case "month": { + return omitYear ? start.format("MMM") : start.format("MMM YYYY"); + } + case "year": { + return start.format("YYYY"); + } + default: + return ""; + } +}; + +export const formatPeriodFull = ({ + start, + end, + timeView, + wholeStart, + wholeEnd, +}: { + start: dayjs.Dayjs; + end: dayjs.Dayjs; + timeView: TimeViewType; + wholeStart: dayjs.Dayjs; + wholeEnd: dayjs.Dayjs; +}): string => { + const omitYear = wholeStart.year() === wholeEnd.year(); + + switch (timeView) { + case "day": { + return omitYear ? start.format("MMM D") : start.format("MMM D, YYYY"); + } + case "week": { + const startFormat = "MMM D"; + const endFormat = "MMM D"; + + if (start.format("YYYY") !== end.format("YYYY")) { + return `${start.format(`${startFormat}, YYYY`)} - ${end.format(`${endFormat}, YYYY`)}`; + } + + if (omitYear) { + return `${start.format(startFormat)} - ${end.format(endFormat)}`; + } else { + return `${start.format(startFormat)} - ${end.format(endFormat)}, ${end.format("YYYY")}`; + } + } + case "month": { + return omitYear ? start.format("MMM") : start.format("MMM YYYY"); + } + case "year": { + return start.format("YYYY"); + } + default: + return ""; + } +}; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index de7f4bf72e..cd980ec852 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -1,5 +1,4 @@ import type { Prisma } from "@prisma/client"; -import md5 from "md5"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; @@ -19,7 +18,7 @@ import { router } from "@calcom/trpc/server/trpc"; import { TRPCError } from "@trpc/server"; -import { EventsInsights, type GetDateRangesParams } from "./events"; +import { getTimeView, getDateRanges, type GetDateRangesParams } from "./insightsDateUtils"; import { RoutingEventsInsights } from "./routing-events"; import { VirtualQueuesInsights } from "./virtual-queues"; @@ -262,7 +261,7 @@ const userSelect = { avatarUrl: true, }; -const emptyResponseEventsByStatus = { +const emptyResponseBookingKPIStats = { empty: true, created: { count: 0, @@ -343,151 +342,114 @@ function createInsightsBookingService( }); } export const insightsRouter = router({ - eventsByStatus: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, memberUserId, userId, isAll } = input; - if (userId && userId !== ctx.user.id) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } + bookingKPIStats: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ ctx, input }) => { + const currentPeriodService = createInsightsBookingService(ctx, input); - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); + // Get current period stats + const currentStats = await currentPeriodService.getBookingStats(); - const { whereCondition: whereConditional } = r; + // Calculate previous period dates and create service for previous period + const previousPeriodDates = currentPeriodService.calculatePreviousPeriodDates(); + const previousPeriodService = createInsightsBookingService(ctx, { + ...input, + startDate: previousPeriodDates.startDate, + endDate: previousPeriodDates.endDate, + }); - const baseWhereCondition = { - ...whereConditional, - createdAt: { - gte: startDate, - lte: endDate, - }, - }; + // Get previous period stats + const previousStats = await previousPeriodService.getBookingStats(); - const startTimeEndTimeDiff = dayjs(endDate).diff(dayjs(startDate), "day"); + // Helper function to calculate percentage change + const getPercentage = (current: number, previous: number): number => { + if (previous === 0) return current > 0 ? 100 : 0; + return ((current - previous) / previous) * 100; + }; - const lastPeriodStartDate = dayjs(startDate).subtract(startTimeEndTimeDiff, "day"); - const lastPeriodEndDate = dayjs(endDate).subtract(startTimeEndTimeDiff, "day"); + // Calculate percentages and CSAT + const currentCSAT = + currentStats.total_ratings > 0 + ? (currentStats.ratings_above_3 / currentStats.total_ratings) * 100 + : 0; + const previousCSAT = + previousStats.total_ratings > 0 + ? (previousStats.ratings_above_3 / previousStats.total_ratings) * 100 + : 0; - const lastPeriodBaseCondition = { - ...whereConditional, - createdAt: { - gte: lastPeriodStartDate.toDate(), - lte: lastPeriodEndDate.toDate(), - }, - }; + const currentRating = currentStats.avg_rating ? parseFloat(currentStats.avg_rating.toFixed(1)) : 0; + const previousRating = previousStats.avg_rating ? parseFloat(previousStats.avg_rating.toFixed(1)) : 0; - const [ - countGroupedByStatus, - totalRatingsAggregate, - totalCSAT, - totalNoShowGuests, - lastPeriodCountGroupedByStatus, - lastPeriodTotalRatingsAggregate, - lastPeriodTotalCSAT, - lastPeriodTotalNoShowGuests, - ] = await Promise.all([ - EventsInsights.countGroupedByStatus(baseWhereCondition), - EventsInsights.getAverageRating(baseWhereCondition), - EventsInsights.getTotalCSAT(baseWhereCondition), - EventsInsights.getTotalNoShowGuests(baseWhereCondition), - EventsInsights.countGroupedByStatus(lastPeriodBaseCondition), - EventsInsights.getAverageRating(lastPeriodBaseCondition), - EventsInsights.getTotalCSAT(lastPeriodBaseCondition), - EventsInsights.getTotalNoShowGuests(lastPeriodBaseCondition), - ]); + // Check if all metrics are zero for empty state + const isEmpty = + currentStats.total_bookings === 0 && + currentStats.completed_bookings === 0 && + currentStats.rescheduled_bookings === 0 && + currentStats.cancelled_bookings === 0 && + currentStats.no_show_host_bookings === 0 && + currentStats.no_show_guests === 0 && + currentRating === 0; - const baseBookingsCount = countGroupedByStatus["_all"]; - const totalCompleted = countGroupedByStatus["completed"]; - const totalRescheduled = countGroupedByStatus["rescheduled"]; - const totalCancelled = countGroupedByStatus["cancelled"]; - const totalNoShow = countGroupedByStatus["noShowHost"]; + if (isEmpty) { + return emptyResponseBookingKPIStats; + } - const averageRating = totalRatingsAggregate._avg.rating - ? parseFloat(totalRatingsAggregate._avg.rating.toFixed(1)) - : 0; - - const lastPeriodBaseBookingsCount = lastPeriodCountGroupedByStatus["_all"]; - const lastPeriodTotalRescheduled = lastPeriodCountGroupedByStatus["rescheduled"]; - const lastPeriodTotalCancelled = lastPeriodCountGroupedByStatus["cancelled"]; - const lastPeriodTotalNoShow = lastPeriodCountGroupedByStatus["noShowHost"]; - - const lastPeriodAverageRating = lastPeriodTotalRatingsAggregate._avg.rating - ? parseFloat(lastPeriodTotalRatingsAggregate._avg.rating.toFixed(1)) - : 0; - - const result = { - empty: false, - created: { - count: baseBookingsCount, - deltaPrevious: EventsInsights.getPercentage(baseBookingsCount, lastPeriodBaseBookingsCount), - }, - completed: { - count: totalCompleted, - deltaPrevious: EventsInsights.getPercentage( - baseBookingsCount - totalCancelled - totalRescheduled, - lastPeriodBaseBookingsCount - lastPeriodTotalCancelled - lastPeriodTotalRescheduled - ), - }, - rescheduled: { - count: totalRescheduled, - deltaPrevious: EventsInsights.getPercentage(totalRescheduled, lastPeriodTotalRescheduled), - }, - cancelled: { - count: totalCancelled, - deltaPrevious: EventsInsights.getPercentage(totalCancelled, lastPeriodTotalCancelled), - }, - no_show: { - count: totalNoShow, - deltaPrevious: EventsInsights.getPercentage(totalNoShow, lastPeriodTotalNoShow), - }, - no_show_guest: { - count: totalNoShowGuests, - deltaPrevious: EventsInsights.getPercentage(totalNoShowGuests, lastPeriodTotalNoShowGuests), - }, - rating: { - count: averageRating, - deltaPrevious: EventsInsights.getPercentage(averageRating, lastPeriodAverageRating), - }, - csat: { - count: totalCSAT, - deltaPrevious: EventsInsights.getPercentage(totalCSAT, lastPeriodTotalCSAT), - }, - previousRange: { - startDate: lastPeriodStartDate.format("YYYY-MM-DD"), - endDate: lastPeriodEndDate.format("YYYY-MM-DD"), - }, - }; - if ( - result.created.count === 0 && - result.completed.count === 0 && - result.rescheduled.count === 0 && - result.cancelled.count === 0 && - result.no_show.count === 0 && - result.no_show_guest.count === 0 && - result.rating.count === 0 - ) { - return emptyResponseEventsByStatus; - } - - return result; - }), + return { + empty: false, + created: { + count: currentStats.total_bookings, + deltaPrevious: getPercentage(currentStats.total_bookings, previousStats.total_bookings), + }, + completed: { + count: currentStats.completed_bookings, + deltaPrevious: getPercentage( + currentStats.total_bookings - currentStats.cancelled_bookings - currentStats.rescheduled_bookings, + previousStats.total_bookings - + previousStats.cancelled_bookings - + previousStats.rescheduled_bookings + ), + }, + rescheduled: { + count: currentStats.rescheduled_bookings, + deltaPrevious: getPercentage(currentStats.rescheduled_bookings, previousStats.rescheduled_bookings), + }, + cancelled: { + count: currentStats.cancelled_bookings, + deltaPrevious: getPercentage(currentStats.cancelled_bookings, previousStats.cancelled_bookings), + }, + no_show: { + count: currentStats.no_show_host_bookings, + deltaPrevious: getPercentage( + currentStats.no_show_host_bookings, + previousStats.no_show_host_bookings + ), + }, + no_show_guest: { + count: currentStats.no_show_guests, + deltaPrevious: getPercentage(currentStats.no_show_guests, previousStats.no_show_guests), + }, + rating: { + count: currentRating, + deltaPrevious: getPercentage(currentRating, previousRating), + }, + csat: { + count: currentCSAT, + deltaPrevious: getPercentage(currentCSAT, previousCSAT), + }, + previousRange: { + startDate: previousPeriodDates.formattedStartDate, + endDate: previousPeriodDates.formattedEndDate, + }, + }; + }), eventTrends: userBelongsToTeamProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { const { startDate, endDate, timeZone } = input; // Calculate timeView and dateRanges - const timeView = EventsInsights.getTimeView(startDate, endDate); - const dateRanges = EventsInsights.getDateRanges({ + const timeView = getTimeView(startDate, endDate); + const dateRanges = getDateRanges({ startDate, endDate, timeView, @@ -505,135 +467,17 @@ export const insightsRouter = router({ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), - popularEventTypes: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, memberUserId, userId, isAll, eventTypeId } = input; + popularEvents: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ input, ctx }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); - const user = ctx.user; - - if (userId && user?.id !== userId) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - if (!teamId && !userId) { - return []; - } - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - }; - - const bookingsFromSelected = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["eventTypeId"], - where: bookingWhere, - _count: { - id: true, - }, - orderBy: { - _count: { - id: "desc", - }, - }, - take: 10, - }); - - const eventTypeIds = bookingsFromSelected - .filter((booking) => typeof booking.eventTypeId === "number") - .map((booking) => booking.eventTypeId); - - const eventTypeWhereConditional: Prisma.EventTypeWhereInput = { - id: { - in: eventTypeIds as number[], - }, - }; - - const eventTypesFrom = await ctx.insightsDb.eventType.findMany({ - select: { - id: true, - title: true, - teamId: true, - userId: true, - slug: true, - users: { - select: { - username: true, - }, - }, - team: { - select: { - slug: true, - }, - }, - }, - where: eventTypeWhereConditional, - }); - - const eventTypeHashMap: Map< - number, - Prisma.EventTypeGetPayload<{ - select: { - id: true; - title: true; - teamId: true; - userId: true; - slug: true; - users: { - select: { - username: true; - }; - }; - team: { - select: { - slug: true; - }; - }; - }; - }> - > = new Map(); - eventTypesFrom.forEach((eventType) => { - eventTypeHashMap.set(eventType.id, eventType); - }); - - const result = bookingsFromSelected.map((booking) => { - const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0); - if (!eventTypeSelected) { - return {}; + try { + return await insightsBookingService.getPopularEventsStats(); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - - let eventSlug = ""; - if (eventTypeSelected.userId) { - eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`; - } - if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) { - eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`; - } - return { - eventTypeId: booking.eventTypeId, - eventTypeName: eventSlug, - count: booking._count.id, - }; - }); - - return result; - }), + }), averageEventDuration: userBelongsToTeamProcedure .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { @@ -642,8 +486,8 @@ export const insightsRouter = router({ const insightsBookingService = createInsightsBookingService(ctx, input); try { - const timeView = EventsInsights.getTimeView(startDate, endDate); - const dateRanges = EventsInsights.getDateRanges({ + const timeView = getTimeView(startDate, endDate); + const dateRanges = getDateRanges({ startDate, endDate, timeView, @@ -691,247 +535,37 @@ export const insightsRouter = router({ } }), membersWithMostCancelledBookings: userBelongsToTeamProcedure - .input(rawDataInputSchema) - .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input; + .input(bookingRepositoryBaseInputSchema) + .query(async ({ input, ctx }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); - if (!teamId) { - return []; + try { + return await insightsBookingService.getMembersStatsWithCount("cancelled", "DESC"); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - status: "CANCELLED", - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _count: { - id: true, - }, - orderBy: { - _count: { - id: "desc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); - - if (userIds.length === 0) { - return []; - } - - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => { - return { - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - count: booking._count.id, - }; - }); - - return result; }), membersWithMostBookings: userBelongsToTeamProcedure - .input(rawDataInputSchema) - .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input; + .input(bookingRepositoryBaseInputSchema) + .query(async ({ input, ctx }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); - if (!teamId) { - return []; + try { + return await insightsBookingService.getMembersStatsWithCount("all", "DESC"); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _count: { - id: true, - }, - orderBy: { - _count: { - id: "desc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); - - if (userIds.length === 0) { - return []; - } - - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => { - return { - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - count: booking._count.id, - }; - }); - - return result; }), membersWithLeastBookings: userBelongsToTeamProcedure - .input(rawDataInputSchema) - .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input; - if (!teamId) { - return []; + .input(bookingRepositoryBaseInputSchema) + .query(async ({ input, ctx }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); + + try { + return await insightsBookingService.getMembersStatsWithCount("all", "ASC"); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _count: { - id: true, - }, - orderBy: { - _count: { - id: "asc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); - - if (userIds.length === 0) { - return []; - } - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => ({ - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - count: booking._count.id, - })); - - return result; }), teamListForUser: authedProcedure.query(async ({ ctx }) => { const user = ctx.user; @@ -1112,354 +746,34 @@ export const insightsRouter = router({ return eventTypeList; }), - recentRatings: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input; - if (!teamId) { - return []; - } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - ratingFeedback: { not: null }, - }; - - if (isAll && user.isOwnerAdminOfParentTeam) { - delete bookingWhere.teamId; - const teamsFromOrg = await ctx.insightsDb.team.findMany({ - where: { - parentId: user?.organizationId, - }, - select: { - id: true, - }, - }); - const usersFromTeam = await ctx.insightsDb.membership.findMany({ - where: { - teamId: { - in: teamsFromOrg.map((t) => t.id), - }, - accepted: true, - }, - select: { - userId: true, - }, - }); - - bookingWhere["OR"] = [ - ...(bookingWhere.OR || []), - { - teamId: { - in: teamsFromOrg.map((t) => t.id), - }, - isTeamBooking: true, - }, - { - userId: { - in: usersFromTeam.map((u) => u.userId), - }, - isTeamBooking: false, - }, - ]; - } - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.findMany({ - where: bookingWhere, - orderBy: { - endTime: "desc", - }, - select: { - userId: true, - rating: true, - ratingFeedback: true, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (!!booking.userId && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); - - if (userIds.length === 0) { - return []; - } - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => ({ - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - rating: booking.rating, - feedback: booking.ratingFeedback, - })); - - return result; - }), - membersWithMostNoShow: userBelongsToTeamProcedure - .input(rawDataInputSchema) + recentRatings: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input; - if (!teamId) { - return []; + const insightsBookingService = createInsightsBookingService(ctx, input); + return await insightsBookingService.getRecentRatingsStats(); + }), + membersWithMostNoShow: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ input, ctx }) => { + const insightsBookingService = createInsightsBookingService(ctx, input); + + try { + return await insightsBookingService.getMembersStatsWithCount("noShow", "DESC"); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } - const user = ctx.user; - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - noShowHost: true, - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _count: { - id: true, - }, - orderBy: { - _count: { - id: "asc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); - - if (userIds.length === 0) { - return []; - } - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => ({ - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - count: booking._count.id, - })); - - return result; }), membersWithHighestRatings: userBelongsToTeamProcedure - .input(rawDataInputSchema) + .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, memberUserId, userId } = input; - if (!teamId) { - return []; - } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - let { whereCondition: bookingWhere } = r; - - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - rating: { not: null }, - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _avg: { - rating: true, - }, - orderBy: { - _avg: { - rating: "desc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); - - if (userIds.length === 0) { - return []; - } - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => ({ - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - averageRating: booking._avg.rating, - })); - - return result; + const insightsBookingService = createInsightsBookingService(ctx, input); + return await insightsBookingService.getMembersRatingStats("DESC"); }), membersWithLowestRatings: userBelongsToTeamProcedure - .input(rawDataInputSchema) + .input(bookingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const { teamId, startDate, endDate, eventTypeId, isAll, memberUserId, userId } = input; - if (!teamId) { - return []; - } - const user = ctx.user; - - const r = await buildBaseWhereCondition({ - teamId, - eventTypeId: eventTypeId ?? undefined, - memberUserId: memberUserId ?? undefined, - userId: userId ?? undefined, - isAll: isAll ?? false, - ctx: { - userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, - userOrganizationId: ctx.user.organizationId, - insightsDb: ctx.insightsDb, - }, - }); - - let { whereCondition: bookingWhere } = r; - bookingWhere = { - ...bookingWhere, - createdAt: { - gte: startDate, - lte: endDate, - }, - rating: { not: null }, - }; - - const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatusDenormalized.groupBy({ - by: ["userId"], - where: bookingWhere, - _avg: { - rating: true, - }, - orderBy: { - _avg: { - rating: "asc", - }, - }, - take: 10, - }); - - const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { - if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { - userIds.push(booking.userId); - } - return userIds; - }, []); - - if (userIds.length === 0) { - return []; - } - const usersFromTeam = await ctx.insightsDb.user.findMany({ - where: { - id: { - in: userIds, - }, - }, - select: userSelect, - }); - - const userHashMap = buildHashMapForUsers(usersFromTeam); - - const result = bookingsFromTeam.map((booking) => ({ - userId: booking.userId, - // We know with 100% certainty that userHashMap.get(...) will retrieve a user - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - user: userHashMap.get(booking.userId)!, - emailMd5: md5(user?.email), - averageRating: booking._avg.rating, - })); - - return result; + const insightsBookingService = createInsightsBookingService(ctx, input); + return await insightsBookingService.getMembersRatingStats("ASC"); }), rawData: userBelongsToTeamProcedure .input( @@ -1680,8 +994,8 @@ export const insightsRouter = router({ getRoutingFunnelData: userBelongsToTeamProcedure .input(routingRepositoryBaseInputSchema) .query(async ({ ctx, input }) => { - const timeView = EventsInsights.getTimeView(input.startDate, input.endDate); - const dateRanges = EventsInsights.getDateRanges({ + const timeView = getTimeView(input.startDate, input.endDate); + const dateRanges = getDateRanges({ startDate: input.startDate, endDate: input.endDate, timeZone: ctx.user.timeZone, diff --git a/packages/lib/server/service/insightsBooking.ts b/packages/lib/server/service/insightsBooking.ts index aa76df25b3..d76e02a373 100644 --- a/packages/lib/server/service/insightsBooking.ts +++ b/packages/lib/server/service/insightsBooking.ts @@ -1,13 +1,32 @@ import { Prisma } from "@prisma/client"; +import md5 from "md5"; import { z } from "zod"; -import type { DateRange } from "@calcom/features/insights/server/events"; +import dayjs from "@calcom/dayjs"; +import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils"; import type { readonlyPrisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { MembershipRepository } from "../repository/membership"; import { TeamRepository } from "../repository/team"; +// Utility function to build user hash map with avatar URL fallback +export const buildHashMapForUsers = < + T extends { avatarUrl: string | null; id: number; username: string | null; [key: string]: unknown } +>( + usersFromTeam: T[] +) => { + const userHashMap = new Map & { avatarUrl: string }>(); + usersFromTeam.forEach((user) => { + userHashMap.set(user.id, { + ...user, + // TODO: Use AVATAR_FALLBACK when avatar.png endpoint is fased out + avatarUrl: user.avatarUrl || `/${user.username}/avatar.png`, + }); + }); + return userHashMap; +}; + // Type definition for BookingTimeStatusDenormalized view export type BookingTimeStatusDenormalized = z.infer; @@ -665,6 +684,383 @@ export class InsightsBookingService { return result; } + async getPopularEventsStats() { + const baseConditions = await this.getBaseConditions(); + + const bookingsFromSelected = await this.prisma.$queryRaw< + Array<{ + eventTypeId: number; + count: number; + }> + >` + SELECT + "eventTypeId", + COUNT(id)::int as count + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} AND "eventTypeId" IS NOT NULL + GROUP BY "eventTypeId" + ORDER BY count DESC + LIMIT 10 + `; + + const eventTypeIds = bookingsFromSelected.map((booking) => booking.eventTypeId); + + if (eventTypeIds.length === 0) { + return []; + } + + const eventTypesFrom = await this.prisma.eventType.findMany({ + select: { + id: true, + title: true, + teamId: true, + userId: true, + slug: true, + users: { + select: { + username: true, + }, + }, + team: { + select: { + slug: true, + }, + }, + }, + where: { + id: { + in: eventTypeIds, + }, + }, + }); + + const eventTypeHashMap = new Map(eventTypesFrom.map((eventType) => [eventType.id, eventType])); + + const result = bookingsFromSelected + .map((booking) => { + const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId); + if (!eventTypeSelected) { + return null; + } + + let eventSlug = ""; + if (eventTypeSelected.userId) { + eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`; + } + if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) { + eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`; + } + return { + eventTypeId: booking.eventTypeId, + eventTypeName: eventSlug, + count: booking.count, + }; + }) + .filter((item): item is NonNullable => item !== null); + + return result; + } + + async getMembersStatsWithCount( + type: "all" | "cancelled" | "noShow" = "all", + sortOrder: "ASC" | "DESC" = "DESC" + ) { + const baseConditions = await this.getBaseConditions(); + + let additionalCondition = Prisma.sql``; + if (type === "cancelled") { + additionalCondition = Prisma.sql`AND status = 'cancelled'`; + } else if (type === "noShow") { + additionalCondition = Prisma.sql`AND "noShowHost" = true`; + } + + const bookingsFromTeam = await this.prisma.$queryRaw< + Array<{ + userId: number; + count: number; + }> + >` + SELECT + "userId", + COUNT(id)::int as count + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} AND "userId" IS NOT NULL ${additionalCondition} + GROUP BY "userId" + ORDER BY count ${sortOrder === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`} + LIMIT 10 + `; + + if (bookingsFromTeam.length === 0) { + return []; + } + + const userIds = bookingsFromTeam.map((booking) => booking.userId); + + const usersFromTeam = await this.prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: { + id: true, + name: true, + email: true, + username: true, + avatarUrl: true, + }, + }); + + const userHashMap = buildHashMapForUsers(usersFromTeam); + + const result = bookingsFromTeam + .map((booking) => { + const user = userHashMap.get(booking.userId); + if (!user) { + return null; + } + + return { + userId: booking.userId, + user, + emailMd5: md5(user.email || ""), + count: booking.count, + }; + }) + .filter((item): item is NonNullable => item !== null); + + return result; + } + + async getMembersRatingStats(sortOrder: "ASC" | "DESC" = "DESC") { + const baseConditions = await this.getBaseConditions(); + + const bookingsFromTeam = await this.prisma.$queryRaw< + Array<{ + userId: number; + averageRating: number; + }> + >` + SELECT + "userId", + AVG("rating")::float as "averageRating" + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} AND "userId" IS NOT NULL AND "rating" IS NOT NULL + GROUP BY "userId" + ORDER BY "averageRating" ${sortOrder === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`} + LIMIT 10 + `; + + if (bookingsFromTeam.length === 0) { + return []; + } + + const userIds = bookingsFromTeam.map((booking) => booking.userId); + + const usersFromTeam = await this.prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: { + id: true, + name: true, + email: true, + username: true, + avatarUrl: true, + }, + }); + + const userHashMap = buildHashMapForUsers(usersFromTeam); + + const result = bookingsFromTeam + .map((booking) => { + const user = userHashMap.get(booking.userId); + if (!user) { + return null; + } + + return { + userId: booking.userId, + user, + emailMd5: md5(user.email), + averageRating: booking.averageRating, + }; + }) + .filter((item): item is NonNullable => item !== null); + + return result; + } + + async getRecentRatingsStats() { + const baseConditions = await this.getBaseConditions(); + + const bookingsFromTeam = await this.prisma.$queryRaw< + Array<{ + userId: number | null; + rating: number | null; + ratingFeedback: string | null; + }> + >` + SELECT + "userId", + "rating", + "ratingFeedback" + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} AND "ratingFeedback" IS NOT NULL + ORDER BY "endTime" DESC + LIMIT 10 + `; + + if (bookingsFromTeam.length === 0) { + return []; + } + + const userIds = bookingsFromTeam + .filter((booking) => booking.userId !== null) + .map((booking) => booking.userId as number) + .filter((userId, index, array) => array.indexOf(userId) === index); + + if (userIds.length === 0) { + return []; + } + + const usersFromTeam = await this.prisma.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: { + id: true, + name: true, + email: true, + username: true, + avatarUrl: true, + }, + }); + + const userHashMap = buildHashMapForUsers(usersFromTeam); + + const result = bookingsFromTeam + .map((booking) => { + if (!booking.userId) { + return null; + } + + const user = userHashMap.get(booking.userId); + if (!user) { + return null; + } + + return { + userId: booking.userId, + user, + emailMd5: md5(user.email), + rating: booking.rating, + feedback: booking.ratingFeedback, + }; + }) + .filter((item): item is NonNullable => item !== null); + + return result; + } + + async getBookingStats() { + const baseConditions = await this.getBaseConditions(); + + const stats = await this.prisma.$queryRaw< + Array<{ + total_bookings: bigint; + completed_bookings: bigint; + rescheduled_bookings: bigint; + cancelled_bookings: bigint; + no_show_host_bookings: bigint; + avg_rating: number | null; + total_ratings: bigint; + ratings_above_3: bigint; + no_show_guests: bigint; + }> + >` + WITH booking_stats AS ( + SELECT + COUNT(*) as total_bookings, + COUNT(CASE WHEN "timeStatus" = 'completed' THEN 1 END) as completed_bookings, + COUNT(CASE WHEN "timeStatus" = 'rescheduled' THEN 1 END) as rescheduled_bookings, + COUNT(CASE WHEN "timeStatus" = 'cancelled' THEN 1 END) as cancelled_bookings, + COUNT(CASE WHEN "noShowHost" = true THEN 1 END) as no_show_host_bookings, + AVG(CASE WHEN "rating" IS NOT NULL THEN "rating" END) as avg_rating, + COUNT(CASE WHEN "rating" IS NOT NULL THEN 1 END) as total_ratings, + COUNT(CASE WHEN "rating" > 3 THEN 1 END) as ratings_above_3 + FROM "BookingTimeStatusDenormalized" + WHERE ${baseConditions} + ), + guest_stats AS ( + SELECT COUNT(*) as no_show_guests + FROM "Attendee" a + INNER JOIN "BookingTimeStatusDenormalized" b ON a."bookingId" = b.id + WHERE ${baseConditions} AND a."noShow" = true + ) + SELECT + bs.total_bookings, + bs.completed_bookings, + bs.rescheduled_bookings, + bs.cancelled_bookings, + bs.no_show_host_bookings, + bs.avg_rating, + bs.total_ratings, + bs.ratings_above_3, + gs.no_show_guests + FROM booking_stats bs, guest_stats gs + `; + + const rawStats = stats[0]; + return rawStats + ? { + total_bookings: Number(rawStats.total_bookings), + completed_bookings: Number(rawStats.completed_bookings), + rescheduled_bookings: Number(rawStats.rescheduled_bookings), + cancelled_bookings: Number(rawStats.cancelled_bookings), + no_show_host_bookings: Number(rawStats.no_show_host_bookings), + avg_rating: rawStats.avg_rating, + total_ratings: Number(rawStats.total_ratings), + ratings_above_3: Number(rawStats.ratings_above_3), + no_show_guests: Number(rawStats.no_show_guests), + } + : { + total_bookings: 0, + completed_bookings: 0, + rescheduled_bookings: 0, + cancelled_bookings: 0, + no_show_host_bookings: 0, + avg_rating: 0, + total_ratings: 0, + ratings_above_3: 0, + no_show_guests: 0, + }; + } + + calculatePreviousPeriodDates() { + if (!this.filters?.dateRange) { + throw new Error("Date range is required for calculating previous period"); + } + + const startDate = dayjs(this.filters.dateRange.startDate); + const endDate = dayjs(this.filters.dateRange.endDate); + const startTimeEndTimeDiff = endDate.diff(startDate, "day"); + + const lastPeriodStartDate = startDate.subtract(startTimeEndTimeDiff, "day"); + const lastPeriodEndDate = endDate.subtract(startTimeEndTimeDiff, "day"); + + return { + startDate: lastPeriodStartDate.toISOString(), + endDate: lastPeriodEndDate.toISOString(), + formattedStartDate: lastPeriodStartDate.format("YYYY-MM-DD"), + formattedEndDate: lastPeriodEndDate.format("YYYY-MM-DD"), + }; + } + private async isOwnerOrAdmin(userId: number, targetId: number): Promise { // Check if the user is an owner or admin of the organization or team const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: targetId }); diff --git a/packages/lib/server/service/insightsRoutingBase.ts b/packages/lib/server/service/insightsRoutingBase.ts index a8137a9d83..e5c8fe3b04 100644 --- a/packages/lib/server/service/insightsRoutingBase.ts +++ b/packages/lib/server/service/insightsRoutingBase.ts @@ -10,7 +10,7 @@ import { isNumberFilterValue, isSingleSelectFilterValue, } from "@calcom/features/data-table/lib/utils"; -import type { DateRange } from "@calcom/features/insights/server/events"; +import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils"; import type { readonlyPrisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums";