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 ? (
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+ );
+};
+```
+
+## 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) => (
-
-
-
- ))
- ) : (
-
- )}
-
- );
-};
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";