refactor: update charts on /insights to use InsightsBookingService (#22744)
Post release / release (push) Canceled after 0s
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:
+1
-1
@@ -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
|
||||
|
||||
+4
-1
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+15
-6
@@ -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>
|
||||
+11
-19
@@ -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 },
|
||||
},
|
||||
+10
-15
@@ -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 },
|
||||
},
|
||||
+7
-17
@@ -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>
|
||||
);
|
||||
};
|
||||
+11
-14
@@ -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>
|
||||
) : (
|
||||
<></>
|
||||
+11
-14
@@ -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>
|
||||
);
|
||||
};
|
||||
+11
-14
@@ -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>
|
||||
) : (
|
||||
<></>
|
||||
+11
-14
@@ -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>
|
||||
);
|
||||
};
|
||||
+12
-20
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
+5
-3
@@ -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;
|
||||
+4
-2
@@ -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;
|
||||
+6
-6
@@ -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>>;
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+5
-3
@@ -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,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
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user