feat: add hourly booking charts on /insights (#22619)
* feat: add hourly booking charts on /insights * safe guard * rename * update styles * fix data * clean up * clean up * re-order charts * update style * apply feedback * rename * update query
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { BookingsByHourChartContent } from "@calcom/features/insights/components/BookingsByHourChart";
|
||||
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
// Sample data for playground testing
|
||||
const sampleBookingsByHourStats = [
|
||||
{ hour: 0, count: 4 },
|
||||
{ hour: 1, count: 10 },
|
||||
{ hour: 2, count: 3 },
|
||||
{ hour: 3, count: 12 },
|
||||
{ hour: 4, count: 3 },
|
||||
{ hour: 5, count: 7 },
|
||||
{ hour: 6, count: 6 },
|
||||
{ hour: 7, count: 4 },
|
||||
{ hour: 8, count: 9 },
|
||||
{ hour: 9, count: 7 },
|
||||
{ hour: 10, count: 6 },
|
||||
{ hour: 11, count: 5 },
|
||||
{ hour: 12, count: 8 },
|
||||
{ hour: 13, count: 5 },
|
||||
{ hour: 14, count: 9 },
|
||||
{ hour: 15, count: 9 },
|
||||
{ hour: 16, count: 4 },
|
||||
{ hour: 17, count: 5 },
|
||||
{ hour: 18, count: 4 },
|
||||
{ hour: 19, count: 6 },
|
||||
{ hour: 20, count: 6 },
|
||||
{ hour: 21, count: 12 },
|
||||
{ hour: 22, count: 4 },
|
||||
{ hour: 23, count: 10 },
|
||||
];
|
||||
|
||||
export default function BookingsByHourPlayground() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">Bookings by Hour Playground</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
This page demonstrates the BookingsByHourChartContent component with sample data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl">
|
||||
<ChartCard title={t("bookings_by_hour")}>
|
||||
<BookingsByHourChartContent data={sampleBookingsByHourStats} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-lg bg-gray-50 p-4">
|
||||
<h2 className="mb-2 text-lg font-semibold">Sample Data Used:</h2>
|
||||
<pre className="overflow-auto text-sm text-gray-700">
|
||||
{JSON.stringify(sampleBookingsByHourStats, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,10 @@ const LINKS = [
|
||||
title: "Routing Funnel",
|
||||
href: "/settings/admin/playground/routing-funnel",
|
||||
},
|
||||
{
|
||||
title: "Bookings by Hour",
|
||||
href: "/settings/admin/playground/bookings-by-hour",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
BookingStatusLineChart,
|
||||
HighestNoShowHostTable,
|
||||
HighestRatedMembersTable,
|
||||
BookingsByHourChart,
|
||||
LeastBookedTeamMembersTable,
|
||||
LowestRatedMembersTable,
|
||||
MostBookedTeamMembersTable,
|
||||
@@ -71,23 +72,35 @@ function InsightsPageContent() {
|
||||
|
||||
<BookingStatusLineChart />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<PopularEventsTable />
|
||||
<AverageEventDurationChart />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<div className="sm:col-span-2">
|
||||
<BookingsByHourChart />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<AverageEventDurationChart />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<div className="sm:col-span-2">
|
||||
<PopularEventsTable />
|
||||
</div>
|
||||
<MostBookedTeamMembersTable />
|
||||
<LeastBookedTeamMembersTable />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<MostCancelledBookingsTables />
|
||||
<HighestNoShowHostTable />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<HighestRatedMembersTable />
|
||||
<LowestRatedMembersTable />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<div className="sm:col-span-2">
|
||||
<RecentFeedbackTable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small className="text-default block text-center">
|
||||
{t("looking_for_more_insights")}{" "}
|
||||
<a
|
||||
|
||||
@@ -2168,6 +2168,7 @@
|
||||
"popular_events": "Popular Events",
|
||||
"no_event_types_found": "No event types found",
|
||||
"average_event_duration": "Average Event Duration",
|
||||
"bookings_by_hour": "Bookings by Hour",
|
||||
"most_booked_members": "Most Booked",
|
||||
"least_booked_members": "Least Booked",
|
||||
"events_created": "Events Created",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
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";
|
||||
|
||||
type BookingsByHourData = {
|
||||
hour: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[] }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const chartData = data.map((item) => ({
|
||||
hour: `${item.hour.toString().padStart(2, "0")}:00`,
|
||||
count: item.count,
|
||||
}));
|
||||
|
||||
const maxBookings = Math.max(...data.map((item) => item.count));
|
||||
const isEmpty = maxBookings === 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="text-default flex h-60 text-center">
|
||||
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 20, right: 0, left: -10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="hour" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis allowDecimals={false} className="text-xs opacity-50" axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={false} content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="var(--cal-bg-subtle)"
|
||||
radius={[2, 2, 0, 0]}
|
||||
activeBar={<Rectangle fill="var(--cal-bg-info)" />}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom Tooltip component
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value: number;
|
||||
dataKey: string;
|
||||
name: string;
|
||||
color: string;
|
||||
payload: { hour: string; count: number };
|
||||
}>;
|
||||
label?: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle rounded-lg border p-3 shadow-lg">
|
||||
<p className="text-default font-medium">{label}</p>
|
||||
{payload.map((entry, index: number) => (
|
||||
<p key={index}>
|
||||
{t("bookings")}: {entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BookingsByHourChart = () => {
|
||||
const { t } = useLocale();
|
||||
const { timeZone } = useDataTable();
|
||||
const { scope, selectedTeamId, memberUserId, startDate, endDate, eventTypeId } = useInsightsParameters();
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.bookingsByHourStats.useQuery(
|
||||
{
|
||||
scope,
|
||||
selectedTeamId,
|
||||
startDate,
|
||||
endDate,
|
||||
eventTypeId,
|
||||
memberUserId,
|
||||
timeZone: timeZone || CURRENT_TIMEZONE,
|
||||
},
|
||||
{
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("bookings_by_hour")}>
|
||||
<BookingsByHourChartContent data={data} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -51,9 +51,21 @@ export function ChartCard({
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartCardItem({ count, children }: { count?: number | string; children: ReactNode }) {
|
||||
export function ChartCardItem({
|
||||
count,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
count?: number | string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="text-default border-muted flex items-center justify-between border-b px-3 py-3.5 last:border-b-0">
|
||||
<div
|
||||
className={classNames(
|
||||
"text-default border-muted flex items-center justify-between border-b px-3 py-3.5 last:border-b-0",
|
||||
className
|
||||
)}>
|
||||
<div className="text-sm font-medium">{children}</div>
|
||||
{count !== undefined && <div className="text-sm font-medium">{count}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import { CardInsights } from "./Card";
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ export const TotalBookingUsersTable = ({
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md">
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<ChartCardItem key={item.userId} count={item.count}>
|
||||
filteredData.map((item, index) => (
|
||||
<ChartCardItem key={index} count={item.count} className="py-3">
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
alt={item.user.name || ""}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { AverageEventDurationChart } from "./AverageEventDurationChart";
|
||||
export { BookingKPICards } from "./BookingKPICards";
|
||||
export { BookingsByHourChart } from "./BookingsByHourChart";
|
||||
|
||||
export { BookingStatusLineChart } from "./BookingStatusLineChart";
|
||||
export { FailedBookingsByField } from "./FailedBookingsByField";
|
||||
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
|
||||
|
||||
@@ -69,3 +69,13 @@ export const routingRepositoryBaseInputSchema = z.object({
|
||||
endDate: z.string(),
|
||||
columnFilters: z.array(ZColumnFilter).optional(),
|
||||
});
|
||||
|
||||
export const bookingRepositoryBaseInputSchema = z.object({
|
||||
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
|
||||
selectedTeamId: z.number().optional(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
timeZone: z.string(),
|
||||
eventTypeId: z.coerce.number().optional().nullable(),
|
||||
memberUserId: z.coerce.number().optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
routingFormResponsesInputSchema,
|
||||
routingFormStatsInputSchema,
|
||||
routingRepositoryBaseInputSchema,
|
||||
bookingRepositoryBaseInputSchema,
|
||||
} from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { InsightsBookingService } from "@calcom/lib/server/service/insightsBooking";
|
||||
import { InsightsRoutingService } from "@calcom/lib/server/service/insightsRouting";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -1760,6 +1762,35 @@ export const insightsRouter = router({
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
}),
|
||||
bookingsByHourStats: userBelongsToTeamProcedure
|
||||
.input(bookingRepositoryBaseInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { scope, selectedTeamId, startDate, endDate, eventTypeId, memberUserId, timeZone } = input;
|
||||
|
||||
const insightsBookingService = new InsightsBookingService({
|
||||
prisma: ctx.insightsDb,
|
||||
options: {
|
||||
scope,
|
||||
userId: ctx.user.id,
|
||||
orgId: ctx.user.organizationId ?? 0,
|
||||
...(selectedTeamId && { teamId: selectedTeamId }),
|
||||
},
|
||||
filters: {
|
||||
...(eventTypeId && { eventTypeId }),
|
||||
...(memberUserId && { memberUserId }),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
return await insightsBookingService.getBookingsByHourStats({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
async function getEventTypeList({
|
||||
|
||||
@@ -554,7 +554,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
|
||||
const results = await service.getBaseConditions();
|
||||
expect(results).toEqual(
|
||||
Prisma.sql`("formUserId" = ${testData.user.id} AND "formTeamId" IS NULL) AND (${dateCondition})`
|
||||
Prisma.sql`(("formUserId" = ${testData.user.id} AND "formTeamId" IS NULL) AND (${dateCondition}))`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
|
||||
@@ -65,16 +65,60 @@ export class InsightsBookingService {
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
async getBookingsByHourStats({
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeZone: string;
|
||||
}) {
|
||||
// Validate date formats
|
||||
if (isNaN(Date.parse(startDate)) || isNaN(Date.parse(endDate))) {
|
||||
throw new Error(`Invalid date format: ${startDate} - ${endDate}`);
|
||||
}
|
||||
|
||||
const baseConditions = await this.getBaseConditions();
|
||||
|
||||
const results = await this.prisma.$queryRaw<
|
||||
Array<{
|
||||
hour: string;
|
||||
count: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM ("startTime" AT TIME ZONE 'UTC' AT TIME ZONE ${timeZone}))::int as "hour",
|
||||
COUNT(*)::int as "count"
|
||||
FROM "BookingTimeStatusDenormalized"
|
||||
WHERE ${baseConditions}
|
||||
AND "startTime" >= ${startDate}::timestamp
|
||||
AND "startTime" <= ${endDate}::timestamp
|
||||
AND "status" = 'accepted'
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`;
|
||||
|
||||
// Create a map of results by hour for easy lookup
|
||||
const resultsMap = new Map(results.map((row) => [Number(row.hour), row.count]));
|
||||
|
||||
// Return all 24 hours (0-23), filling with 0 values for missing data
|
||||
return Array.from({ length: 24 }, (_, hour) => ({
|
||||
hour,
|
||||
count: resultsMap.get(hour) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async getBaseConditions(): Promise<Prisma.Sql> {
|
||||
const authConditions = await this.getAuthorizationConditions();
|
||||
const filterConditions = await this.getFilterConditions();
|
||||
|
||||
if (authConditions && filterConditions) {
|
||||
return Prisma.sql`(${authConditions}) AND (${filterConditions})`;
|
||||
return Prisma.sql`((${authConditions}) AND (${filterConditions}))`;
|
||||
} else if (authConditions) {
|
||||
return authConditions;
|
||||
return Prisma.sql`(${authConditions})`;
|
||||
} else if (filterConditions) {
|
||||
return filterConditions;
|
||||
return Prisma.sql`(${filterConditions})`;
|
||||
} else {
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
|
||||
@@ -169,11 +169,11 @@ export class InsightsRoutingService {
|
||||
const filterConditions = await this.getFilterConditions();
|
||||
|
||||
if (authConditions && filterConditions) {
|
||||
return Prisma.sql`(${authConditions}) AND (${filterConditions})`;
|
||||
return Prisma.sql`((${authConditions}) AND (${filterConditions}))`;
|
||||
} else if (authConditions) {
|
||||
return authConditions;
|
||||
return Prisma.sql`(${authConditions})`;
|
||||
} else if (filterConditions) {
|
||||
return filterConditions;
|
||||
return Prisma.sql`(${filterConditions})`;
|
||||
} else {
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user