packages/features/insights/HOW_TO_ADD_BOOKING_CHARTS.md
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.
The insights booking system follows this architecture:
UI Component → tRPC Handler → Insights Service → Database Query → Response
Create your chart component in packages/features/insights/components/booking/:
// 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/react";
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, isError } = 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")} isPending={isPending} isError={isError}>
{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>
);
};
Update the booking components index file:
// packages/features/insights/components/booking/index.ts
export { AverageEventDurationChart } from "./AverageEventDurationChart";
export { BookingKPICards } from "./BookingKPICards";
// ... existing exports
export { MyNewChart } from "./MyNewChart"; // Add this line
Add your component to the main insights page:
// 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">
<BookingKPICards />
<EventTrendsChart />
<MyNewChart />
</div>
);
}
Add the tRPC endpoint in the insights router using the getInsightsBookingService() DI container function:
// 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 }) => {
// `createInsightsBookingService` is defined at the root level in this file
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMyNewChartData();
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
});
Add your new method to the InsightsBookingBaseService class:
// packages/lib/server/service/InsightsBookingBaseService.ts
export class InsightsBookingBaseService {
// ... existing methods
async getMyNewChartData() {
const baseConditions = await this.getBaseConditions();
// Example: Get booking counts by day using raw SQL for performance
// Note: Use Prisma.sql for the entire query (Prisma v6 requirement)
// Prisma v6 no longer allows mixing template literals with Prisma.sql fragments
const query = Prisma.sql`
SELECT
DATE("createdAt") as date,
COUNT(*)::int as "bookingsCount"
FROM "BookingTimeStatusDenormalized"
WHERE ${baseConditions}
GROUP BY DATE("createdAt")
ORDER BY date ASC
`;
const data = await this.prisma.$queryRaw<
Array<{
date: Date;
bookingsCount: number;
}>
>(query);
// 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,
}));
}
}
getInsightsBookingService(): Always use the DI container function for consistent service creation$queryRaw for complex aggregations and better performanceawait this.getBaseConditions() for proper filtering and permissionsTRPCErrorisPending and isError from the query and pass them to ChartCardisPending and isError to ChartCard - it will automatically calculate the loading staterecharts for new chartsgetDateRanges() and getTimeView() for time-based chartsPrisma.sql for the entire query instead of mixing template literals with SQL fragmentsChartCard automatically handles loading states. Simply pass isPending and isError:
const { data, isPending, isError } = trpc.viewer.insights.myData.useQuery(...);
// ChartCard will automatically show:
// - "loading" state when isPending is true
// - "error" state when isError is true
// - "loaded" state when both are false
return (
<ChartCard title={t("my_chart")} isPending={isPending} isError={isError}>
</ChartCard>
);
This enables E2E tests to verify that all charts load successfully by checking the data-loading-state attribute.