refactor: update charts on /insights to use InsightsBookingService (#22744)
Post release / release (push) Canceled after 0s

* refactor: update PopuplarEvents chart to use InsightsBookingService

* use buildHashMapForUsers

* refactor least booked

* refactor charts

* combine methods

* refactor booking kpi cards

* remove unused code

* fix booking kpi cards

* restore code that was removed accidentally

* Revert "restore code that was removed accidentally"

This reverts commit 0c0b444b02498b94fb29ef470da065bad3ad7ece.

* Revert "fix booking kpi cards"

This reverts commit b1e8e9afbd3632a5239495566551ae12bd926b66.

* refactor booking kpi cards

* revert method and remove unused code

* fix previous period

* fix metrics

* clean up trpc call params

* clean up Download.tsx

* split utils

* restructure folders for components

* clean up imports

* rename TotalUserFeedbackTable to UserStatsTable

* add guide

* use client

* add curly braces to case

* fix tests
This commit is contained in:
Eunjae Lee
2025-07-29 12:37:18 +02:00
committed by GitHub
parent 1b9691ddaf
commit 3e299f9511
48 changed files with 1362 additions and 1678 deletions
@@ -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
@@ -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
@@ -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";
+2 -2
View File
@@ -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";
@@ -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 <LoadingInsight />;
return (
<ChartCard title={t("my_new_chart_title")}>
{isSuccess && data?.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="value" stroke="#8884d8" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-64 items-center justify-center">
<p className="text-gray-500">{t("no_data_yet")}</p>
</div>
)}
</ChartCard>
);
};
```
## 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 (
<div className="space-y-6">
{/* Existing components */}
<BookingKPICards />
<EventTrendsChart />
{/* Add your new chart */}
<MyNewChart />
{/* Other existing components */}
</div>
);
}
```
## 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
@@ -1,3 +1,5 @@
"use client";
import Link from "next/link";
import { useId } from "react";
@@ -1,3 +1,5 @@
"use client";
import { Fragment, type ReactNode } from "react";
import classNames from "@calcom/ui/classNames";
@@ -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 <LoadingInsight />;
if (!isSuccess) return null;
return (
<ChartCard title={t("event_trends")}>
<LineChart
className="linechart ml-4 mt-4 h-80 sm:ml-0"
data={eventTrends ?? []}
categories={["Created", "Completed", "Rescheduled", "Cancelled", "No-Show (Host)", "No-Show (Guest)"]}
index="Month"
colors={["purple", "green", "blue", "red", "slate", "orange"]}
valueFormatter={valueFormatter}
/>
</ChartCard>
);
};
@@ -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";
@@ -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 <LoadingInsight />;
if (!isSuccess || !data) return null;
return data && data.length > 0 ? (
<ChartCard title={t("most_no_show_host")}>
<TotalUserFeedbackTable data={data} />
</ChartCard>
) : (
<></>
);
};
@@ -1,3 +1,5 @@
"use client";
import { Flex, Text, Metric, BadgeDelta } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -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 <LoadingInsight />;
return (
<ChartCard title={t("most_cancelled_bookings")}>
{!isSuccess || !startDate || !endDate || !teamId ? null : <TotalBookingUsersTable data={data} />}
</ChartCard>
);
};
@@ -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 <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("recent_ratings")}>
<FeedbackTable data={data} />
</ChartCard>
);
};
@@ -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<User, "avatarUrl" | "name">;
emailMd5?: string;
count: number;
username?: string;
}[]
| undefined;
}) => {
const filteredData = data && data?.length > 0 ? data?.filter((item) => !!item.user) : [];
return (
<div className="overflow-hidden rounded-md">
{filteredData.length > 0 ? (
filteredData.map((item, index) => (
<ChartCardItem key={index} count={item.count} className="py-3">
<div className="flex items-center">
<Avatar
alt={item.user.name || ""}
size="sm"
imageSrc={getUserAvatarUrl({ avatarUrl: item.user.avatarUrl })}
title={item.user.name || ""}
className="mr-3"
/>
<div>{item.user.name}</div>
</div>
</ChartCardItem>
))
) : (
<div className="flex h-60 text-center">
<p className="m-auto text-sm font-light">No members found</p>
</div>
)}
</div>
);
};
@@ -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 (
<div className="overflow-hidden rounded-md">
{data && data?.length > 0 ? (
data?.map((item) => (
{filteredData.length > 0 ? (
filteredData.map((item) => (
<ChartCardItem
key={item.userId}
count={item.averageRating ? item.averageRating.toFixed(1) : item.count}>
key={item.userId || `user-${Math.random()}`}
count={item.averageRating ? item.averageRating.toFixed(1) : item.count}
className="py-3">
<div className="flex items-center">
<Avatar
alt={item.user.name || ""}
@@ -39,7 +48,7 @@ export const TotalUserFeedbackTable = ({
))
) : (
<div className="flex h-60 text-center">
<p className="m-auto text-sm font-light">No data found</p>
<p className="m-auto text-sm font-light">{t("no_data_yet")}</p>
</div>
)}
</div>
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 <LoadingInsight />;
if (!isSuccess) return null;
return (
<ChartCard title={t("event_trends")}>
<LineChart
className="linechart ml-4 mt-4 h-80 sm:ml-0"
data={eventTrends ?? []}
categories={["Created", "Completed", "Rescheduled", "Cancelled", "No-Show (Host)", "No-Show (Guest)"]}
index="Month"
colors={["purple", "green", "blue", "red", "slate", "orange"]}
valueFormatter={valueFormatter}
/>
</ChartCard>
);
};
@@ -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 <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("most_no_show_host")}>
<UserStatsTable data={data} />
</ChartCard>
);
};
@@ -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 ? (
<ChartCard title={t("highest_rated")}>
<TotalUserFeedbackTable data={data} />
<UserStatsTable data={data} />
</ChartCard>
) : (
<></>
@@ -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 (
<ChartCard title={t("least_booked_members")}>
<TotalBookingUsersTable data={data} />
<UserStatsTable data={data} />
</ChartCard>
);
};
@@ -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 ? (
<ChartCard title={t("lowest_rated")}>
<TotalUserFeedbackTable data={data} />
<UserStatsTable data={data} />
</ChartCard>
) : (
<></>
@@ -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 (
<ChartCard title={t("most_booked_members")}>
<TotalBookingUsersTable data={data} />
<UserStatsTable data={data} />
</ChartCard>
);
};
@@ -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 <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("most_cancelled_bookings")}>
<UserStatsTable data={data} />
</ChartCard>
);
};
@@ -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 <LoadingInsight />;
@@ -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 <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("recent_ratings")}>
<FeedbackTable data={data} />
</ChartCard>
);
};
@@ -1,3 +1,5 @@
"use client";
import { useMemo } from "react";
import { useDataTable } from "@calcom/features/data-table";
@@ -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";
+12 -19
View File
@@ -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";
@@ -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;
@@ -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;
@@ -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<typeof useReactTable<RoutingFormTableRow>>;
@@ -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";
@@ -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();
@@ -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";
@@ -1,3 +1,5 @@
"use client";
// Tremor BarList [v0.1.1]
import React from "react";
@@ -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<PaginatedResponse | null> => {
try {
const result = await utils.viewer.insights.rawData.fetch({
scope,
selectedTeamId,
startDate,
endDate,
timeZone: timeZone || CURRENT_TIMEZONE,
eventTypeId,
memberUserId,
...insightsBookingParams,
limit: BATCH_SIZE,
offset,
});
@@ -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,
};
}
@@ -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,
@@ -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,
+1 -389
View File
@@ -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 };
@@ -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 "";
}
};
File diff suppressed because it is too large Load Diff
+397 -1
View File
@@ -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<number | null, Omit<T, "avatarUrl"> & { 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<typeof bookingDataSchema>;
@@ -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<typeof item> => 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<typeof item> => 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<typeof item> => 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<typeof item> => 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<boolean> {
// Check if the user is an owner or admin of the organization or team
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: targetId });
@@ -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";