fix: integer to text comparison in routing insights query (#25019)

* fix: integer to text comparison in routing insights query

Add explicit integer array cast to prevent PostgreSQL type mismatch error when comparing bookingUserId (integer) with user_id array values in getRoutedToPerPeriodData query

* add e2e tests

* refactor: remove LoadingInsight component and handle loading in ChartCard

- Enhanced ChartCard to render default loading UI when isPending is true
- Replaced all LoadingInsight usages with ChartCard that accepts isPending/isError props
- Removed LoadingInsight component and updated exports
- Updated 16 chart components to use the new pattern
- ChartCard now shows spinner and skeleton title during loading state

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: make children prop optional in ChartCard when isPending is true

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: remove unused loadingState prop from ChartCard

- Remove loadingState prop and ChartLoadingState type export
- Simplify computedLoadingState to derive state only from isPending/isError
- No functional changes - loadingState was not being used by any components
- data-loading-state attribute behavior remains unchanged for E2E tests

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: remove duplicate isPending early returns from chart components

- Update all 16 chart components to use single ChartCard return pattern
- Gate children rendering with !isPending && isSuccess && data checks
- Prevents data processing code from executing during loading state
- Improves code consistency and maintainability across all charts

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: remove redundant !isPending check from chart conditionals

- Simplify conditional rendering to use just 'isSuccess && data' or 'isSuccess'
- In TanStack Query, isSuccess and isPending are mutually exclusive
- The !isPending check was redundant since isSuccess already implies !isPending
- Applied to all 16 chart components for consistency
- Components with safe defaults (data ?? []) use just 'isSuccess'
- Components requiring data check use 'isSuccess && data'

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* revert the mistake

* apply feedback

* apply feedback

* fix e2e

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae LeeandDevin AI authored and GitHub committed 2025-12-01 15:07:57 +01:00
1 parent 3c46c35b69
commit 6479adf14e
30 files changed
+794 -404

No files matched your search

+288
View File
@@ -0,0 +1,288 @@
import { expect } from "@playwright/test";
import { v4 as uuidv4 } from "uuid";
import { BookingStatus } from "@calcom/prisma/enums";
import { expectAllChartsToLoad, getAllChartIds } from "./lib/chart-helpers";
import { test } from "./lib/fixtures";
test.afterEach(({ users }) => users.deleteAll());
test.describe("Insights > Charts Loading", () => {
test.describe("Routing Insights", () => {
test("all charts should load successfully with data", async ({ page, users, routingForms, prisma }) => {
// Setup: Create routing form with complete data
const owner = await users.create(
{ name: "Chart Test Owner" },
{
hasTeam: true,
isUnpublished: true,
isOrg: true,
hasSubteam: true,
}
);
await owner.apiLogin();
const membership = await owner.getFirstTeamMembership();
// Create a routing form with fields
const formName = "Chart Loading Test Form";
const form = await routingForms.create({
name: formName,
userId: owner.id,
teamId: membership.teamId,
fields: [
{
type: "text",
label: "Name",
identifier: "name",
required: true,
},
{
type: "email",
label: "Email",
identifier: "email",
required: true,
},
{
type: "select",
label: "Department",
identifier: "department",
required: true,
options: [
{
id: uuidv4(),
label: "Sales",
},
{
id: uuidv4(),
label: "Support",
},
],
},
],
});
const fieldIds = (form.fields as Array<{ id: string }>).map((f) => f.id);
// Create users for routing
const user1 = await users.create({ name: "Sales User" });
const user2 = await users.create({ name: "Support User" });
// Add users to the team
await prisma.membership.createMany({
data: [
{
teamId: membership.teamId,
userId: user1.id,
role: "MEMBER",
accepted: true,
},
{
teamId: membership.teamId,
userId: user2.id,
role: "MEMBER",
accepted: true,
},
],
});
// Create bookings for both users
const booking1 = await prisma.booking.create({
data: {
uid: `booking-${uuidv4()}`,
title: "Sales Booking",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: user1.id,
status: BookingStatus.ACCEPTED,
},
});
const booking2 = await prisma.booking.create({
data: {
uid: `booking-${uuidv4()}`,
title: "Support Booking",
startTime: new Date(),
endTime: new Date(Date.now() + 60 * 60 * 1000),
userId: user2.id,
status: BookingStatus.ACCEPTED,
},
});
// Create form responses with bookings
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "filler-1",
formId: form.id,
response: {
[fieldIds[0]]: { label: "Name", value: "John Doe" },
[fieldIds[1]]: { label: "Email", value: "john@example.com" },
[fieldIds[2]]: { label: "Department", value: "Sales" },
},
routedToBookingUid: booking1.uid,
},
});
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "filler-2",
formId: form.id,
response: {
[fieldIds[0]]: { label: "Name", value: "Jane Smith" },
[fieldIds[1]]: { label: "Email", value: "jane@example.com" },
[fieldIds[2]]: { label: "Department", value: "Support" },
},
routedToBookingUid: booking2.uid,
},
});
// Create a response without booking (for "Failed Bookings" chart)
await prisma.app_RoutingForms_FormResponse.create({
data: {
formFillerId: "filler-3",
formId: form.id,
response: {
[fieldIds[0]]: { label: "Name", value: "Bob Wilson" },
[fieldIds[1]]: { label: "Email", value: "bob@example.com" },
[fieldIds[2]]: { label: "Department", value: "Sales" },
},
routedToBookingUid: null,
},
});
// Navigate to insights routing page
await page.goto("/insights/routing");
await expect(page).toHaveURL(/\/insights\/routing/);
// Wait for the page to be fully loaded
await page.waitForLoadState("networkidle");
// Get all chart IDs before checking
const chartIds = await getAllChartIds(page);
console.log(`[Routing] Found ${chartIds.length} charts: ${chartIds.join(", ")}`);
// Assert all charts load successfully
await expectAllChartsToLoad(page, 20000); // 20 second timeout for all charts
// Verify specific expected charts are present and loaded
const expectedCharts = ["stats", "routing-funnel", "routed-to-per-period", "failed-bookings-by-field"];
for (const chartId of expectedCharts) {
const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`);
await expect(chart).toBeVisible();
await expect(chart).toHaveAttribute("data-loading-state", "loaded");
}
// Cleanup
await prisma.booking.delete({ where: { id: booking1.id } });
await prisma.booking.delete({ where: { id: booking2.id } });
});
});
test.describe("Bookings Insights", () => {
test("all charts should load successfully with booking data", async ({
page,
users,
bookings,
prisma,
}) => {
// Setup: Create users with team membership
const owner = await users.create({ name: "Chart Test Owner" });
const member = await users.create({ name: "Chart Test Member" });
// Create team and add both users
const team = await prisma.team.create({
data: {
name: "test-insights-bookings",
slug: `test-insights-bookings-${Date.now()}`,
},
});
await prisma.membership.createMany({
data: [
{
userId: owner.id,
teamId: team.id,
accepted: true,
role: "ADMIN",
},
{
userId: member.id,
teamId: team.id,
accepted: true,
role: "MEMBER",
},
],
});
await owner.apiLogin();
// Create some bookings for the user
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const dayAfterTomorrow = new Date(tomorrow);
dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 1);
const threeDaysFromNow = new Date(dayAfterTomorrow);
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 1);
// Create bookings at different times to ensure unique idempotency keys
await bookings.create(owner.id, owner.username, owner.eventTypes[0].id, {
status: BookingStatus.ACCEPTED,
startTime: now,
endTime: tomorrow,
});
await bookings.create(owner.id, owner.username, owner.eventTypes[0].id, {
status: BookingStatus.ACCEPTED,
startTime: tomorrow,
endTime: dayAfterTomorrow,
});
await bookings.create(owner.id, owner.username, owner.eventTypes[0].id, {
status: BookingStatus.CANCELLED,
startTime: dayAfterTomorrow,
endTime: threeDaysFromNow,
});
// Navigate to insights bookings page
await page.goto("/insights");
await expect(page).toHaveURL(/\/insights/);
// Wait for the page to be fully loaded
await page.waitForLoadState("networkidle");
// Get all chart IDs before checking
const chartIds = await getAllChartIds(page);
console.log(`[Bookings] Found ${chartIds.length} charts: ${chartIds.join(", ")}`);
// Assert all charts load successfully
await expectAllChartsToLoad(page, 20000); // 20 second timeout for all charts
// Verify specific expected charts are present and loaded
const expectedCharts = [
"events",
"performance",
"event-trends",
"no-show-hosts-over-time",
"csat-over-time",
"bookings-by-hour",
"average-event-duration",
];
for (const chartId of expectedCharts) {
const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`);
// Chart should exist and be loaded
await expect(chart).toHaveCount(1);
await expect(chart).toHaveAttribute("data-loading-state", "loaded");
}
// Verify no charts are in error state
const errorCharts = page.locator('[data-testid="chart-card"][data-loading-state="error"]');
await expect(errorCharts).toHaveCount(0);
});
});
});
+1 -1
View File
@@ -272,7 +272,7 @@ test.describe("Insights", async () => {
for (const title of expectedChartTitles) {
const chartCard = page
.locator("[data-testid='panel-card'] h2")
.locator("[data-testid='chart-card'] h2")
.filter({ hasText: new RegExp(`^${title}$`) });
await expect(chartCard).toBeVisible();
}
+134
View File
@@ -0,0 +1,134 @@
import { expect, type Page } from "@playwright/test";
export type ChartLoadingState = "initial" | "loading" | "loaded" | "error";
export interface ChartLoadingResult {
chartId: string | null;
loadingState: ChartLoadingState;
success: boolean;
error?: string;
}
/**
* Wait for all chart cards on the page to reach "loaded" state
* @param page Playwright page object
* @param timeout Maximum time to wait for all charts (default: 15000ms)
* @returns Array of chart loading results
*/
export async function waitForAllChartsToLoad(page: Page, timeout = 15000): Promise<ChartLoadingResult[]> {
const startTime = Date.now();
const results: ChartLoadingResult[] = [];
// Wait for at least one chart card to be visible
await page.locator('[data-testid="chart-card"]').first().waitFor({ state: "visible", timeout });
// Get all chart cards
const chartCards = await page.locator('[data-testid="chart-card"]').all();
if (chartCards.length === 0) {
throw new Error("No chart cards found on the page");
}
// Wait for each chart to reach loaded state
for (let i = 0; i < chartCards.length; i++) {
const chart = chartCards[i];
const remainingTime = timeout - (Date.now() - startTime);
if (remainingTime <= 0) {
throw new Error(`Timeout waiting for charts to load after ${timeout}ms`);
}
const chartId = await chart.getAttribute("data-chart-id");
const result: ChartLoadingResult = {
chartId,
loadingState: "initial",
success: false,
};
try {
// Wait for this specific chart to reach loaded state
await expect(chart).toHaveAttribute("data-loading-state", "loaded", {
timeout: remainingTime,
});
result.loadingState = "loaded";
result.success = true;
} catch {
// Get the actual state if it failed
const actualState = await chart.getAttribute("data-loading-state");
result.loadingState = (actualState as ChartLoadingState) || "initial";
result.error = `Chart "${chartId}" failed to load. Current state: ${actualState}`;
result.success = false;
}
results.push(result);
}
return results;
}
/**
* Assert that all charts on the page have loaded successfully
* @param page Playwright page object
* @param timeout Maximum time to wait for all charts (default: 15000ms)
*/
export async function expectAllChartsToLoad(page: Page, timeout = 15000): Promise<void> {
const results = await waitForAllChartsToLoad(page, timeout);
const failedCharts = results.filter((r) => !r.success);
if (failedCharts.length > 0) {
const errorMessages = failedCharts.map((r) => r.error).join("\n");
throw new Error(`${failedCharts.length} chart(s) failed to load:\n${errorMessages}`);
}
// Additional check: ensure no charts are in error state
const errorCharts = page.locator('[data-testid="chart-card"][data-loading-state="error"]');
const errorCount = await errorCharts.count();
if (errorCount > 0) {
const errorChartIds = await Promise.all(
(await errorCharts.all()).map((chart) => chart.getAttribute("data-chart-id"))
);
throw new Error(`${errorCount} chart(s) are in error state: ${errorChartIds.join(", ")}`);
}
}
/**
* Get the loading state of a specific chart by its ID
* @param page Playwright page object
* @param chartId The chart ID (derived from title)
* @returns The loading state of the chart
*/
export async function getChartLoadingState(page: Page, chartId: string): Promise<ChartLoadingState | null> {
const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`);
const state = await chart.getAttribute("data-loading-state");
return state as ChartLoadingState | null;
}
/**
* Wait for a specific chart to reach loaded state
* @param page Playwright page object
* @param chartId The chart ID (derived from title)
* @param timeout Maximum time to wait (default: 10000ms)
*/
export async function waitForChartToLoad(page: Page, chartId: string, timeout = 10000): Promise<void> {
const chart = page.locator(`[data-testid="chart-card"][data-chart-id="${chartId}"]`);
await expect(chart).toHaveAttribute("data-loading-state", "loaded", { timeout });
}
/**
* Get all chart IDs on the page
* @param page Playwright page object
* @returns Array of chart IDs
*/
export async function getAllChartIds(page: Page): Promise<string[]> {
const chartCards = await page.locator('[data-testid="chart-card"]').all();
const chartIds = await Promise.all(
chartCards.map(async (chart) => {
const id = await chart.getAttribute("data-chart-id");
return id || "unknown";
})
);
return chartIds;
}
@@ -29,16 +29,19 @@ 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 } },
});
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")}>
<ChartCard title={t("my_new_chart_title")} isPending={isPending} isError={isError}>
{isSuccess && data?.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
@@ -177,7 +180,28 @@ export class InsightsBookingBaseService {
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
8. **Prisma v6 Compatibility**: Use `Prisma.sql` for the entire query instead of mixing template literals with SQL fragments
5. **Loading States**: Always destructure `isPending` and `isError` from the query and pass them to `ChartCard`
6. **ChartCard Props**: Pass `isPending` and `isError` to `ChartCard` - it will automatically calculate the loading state
7. **Consistent Styling**: Use `recharts` for new charts
8. **Date Handling**: Use `getDateRanges()` and `getTimeView()` for time-based charts
9. **Prisma v6 Compatibility**: Use `Prisma.sql` for the entire query instead of mixing template literals with SQL fragments
## Loading State Management
`ChartCard` automatically handles loading states. Simply pass `isPending` and `isError`:
```typescript
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}>
{/* Your chart content */}
</ChartCard>
);
```
This enables E2E tests to verify that all charts load successfully by checking the `data-loading-state` attribute.
@@ -4,6 +4,8 @@ import { Fragment, useMemo, type ReactNode } from "react";
import classNames from "@calcom/ui/classNames";
import { PanelCard } from "@calcom/ui/components/card";
import { Spinner } from "@calcom/ui/components/icon";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { Tooltip } from "@calcom/ui/components/tooltip";
type PanelCardProps = React.ComponentProps<typeof PanelCard>;
@@ -20,21 +22,49 @@ export function ChartCard({
legendSize,
enabledLegend,
onSeriesToggle,
isPending,
isError,
...panelCardProps
}: PanelCardProps & {
}: Omit<PanelCardProps, 'children'> & {
legend?: Array<LegendItem>;
legendSize?: LegendSize;
enabledLegend?: Array<LegendItem>;
onSeriesToggle?: (label: string) => void;
isPending?: boolean;
isError?: boolean;
children?: ReactNode;
}) {
const legendComponent =
legend && legend.length > 0 ? (
<Legend items={legend} size={legendSize} enabledItems={enabledLegend} onItemToggle={onSeriesToggle} />
) : null;
// Generate a chart ID from the title for testing purposes
const chartId = useMemo(() => {
if (typeof panelCardProps.title === "string") {
return panelCardProps.title.toLowerCase().replace(/\s+/g, "-");
}
return undefined;
}, [panelCardProps.title]);
// Calculate loading state from isPending/isError
const computedLoadingState = useMemo(() => {
if (isPending) return "loading";
if (isError) return "error";
return "loaded";
}, [isPending, isError]);
const shouldShowDefaultLoading = isPending && !panelCardProps.children;
const displayTitle = shouldShowDefaultLoading ? <SkeletonText className="w-32" /> : panelCardProps.title;
return (
<PanelCard
{...panelCardProps}
title={displayTitle}
data-testid="chart-card"
data-chart-id={chartId}
data-loading-state={computedLoadingState}
headerContent={
panelCardProps.headerContent ? (
<div className="flex items-center gap-2">
@@ -45,7 +75,13 @@ export function ChartCard({
legendComponent
)
}>
{panelCardProps.children}
{shouldShowDefaultLoading ? (
<div className="m-auto flex h-80 flex-col items-center justify-center">
<Spinner className="h-6 w-6" />
</div>
) : (
panelCardProps.children
)}
</PanelCard>
);
}
@@ -1,25 +0,0 @@
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { ChartCard } from "./ChartCard";
export const LoadingInsight = () => {
return (
<ChartCard title={<SkeletonText className="w-32" />}>
<div className="m-auto flex h-80 flex-col items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="animate-spin">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</div>
</ChartCard>
);
};
@@ -8,7 +8,6 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
const COLOR = {
AVERAGE: "#3b82f6",
@@ -69,7 +68,7 @@ export const AverageEventDurationChart = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.averageEventDuration.useQuery(
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.averageEventDuration.useQuery(
insightsBookingParams,
{
staleTime: 180000,
@@ -80,45 +79,42 @@ export const AverageEventDurationChart = () => {
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
const isNoData = data.every((item) => item["Average"] === 0);
return (
<ChartCard title={t("average_event_duration")}>
{isNoData && (
<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>
)}
{data && data.length > 0 && !isNoData && (
<div className="mt-4 h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Date" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={false}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={formatDuration}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="linear"
dataKey="Average"
name={t("average_event_duration")}
stroke={COLOR.AVERAGE}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
<ChartCard title={t("average_event_duration")} isPending={isPending} isError={isError}>
{isSuccess && data ? (
data.every((item) => item["Average"] === 0) ? (
<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>
) : (
<div className="mt-4 h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Date" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={false}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={formatDuration}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="linear"
dataKey="Average"
name={t("average_event_duration")}
stroke={COLOR.AVERAGE}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
)
) : null}
</ChartCard>
);
};
@@ -13,7 +13,7 @@ export const BookingKPICards = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.bookingKPIStats.useQuery(
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.bookingKPIStats.useQuery(
insightsBookingParams,
{
staleTime: 180000,
@@ -70,7 +70,12 @@ export const BookingKPICards = () => {
if (isPending) {
return (
<LoadingKPICards eventCategories={eventCategories} performanceCategories={performanceCategories} />
<LoadingKPICards
eventCategories={eventCategories}
performanceCategories={performanceCategories}
isPending={isPending}
isError={isError}
/>
);
}
@@ -78,7 +83,7 @@ export const BookingKPICards = () => {
return (
<div className="stack-y-4">
<ChartCard title={t("events")}>
<ChartCard title={t("events")} isPending={isPending} isError={isError}>
<StatContainer>
{eventCategories.map((item, index) => (
<StatItem key={item.title} index={index}>
@@ -92,7 +97,7 @@ export const BookingKPICards = () => {
</StatContainer>
</ChartCard>
<ChartCard title={t("performance")}>
<ChartCard title={t("performance")} isPending={isPending} isError={isError}>
<StatContainer>
{performanceCategories.map((item, index) => (
<StatItem key={item.title} index={index}>
@@ -112,13 +117,15 @@ export const BookingKPICards = () => {
const LoadingKPICards = (props: {
eventCategories: { title: string; index: string }[];
performanceCategories: { title: string; index: string }[];
isPending: boolean;
isError: boolean;
}) => {
const { eventCategories, performanceCategories } = props;
const { eventCategories, performanceCategories, isPending, isError } = props;
const { t } = useLocale();
return (
<div className="stack-y-4">
<ChartCard title={t("events")}>
<ChartCard title={t("events")} isPending={isPending} isError={isError}>
<StatContainer>
{eventCategories.map((item, index) => (
<StatItem key={item.title} index={index}>
@@ -136,7 +143,7 @@ const LoadingKPICards = (props: {
</StatContainer>
</ChartCard>
<ChartCard title={t("performance")}>
<ChartCard title={t("performance")} isPending={isPending} isError={isError}>
<StatContainer>
{performanceCategories.map((item, index) => (
<StatItem key={item.title} index={index}>
@@ -17,7 +17,6 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
type BookingsByHourData = RouterOutputs["viewer"]["insights"]["bookingsByHourStats"][number];
@@ -97,7 +96,7 @@ export const BookingsByHourChart = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.bookingsByHourStats.useQuery(
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.bookingsByHourStats.useQuery(
insightsBookingParams,
{
staleTime: 180000,
@@ -108,13 +107,9 @@ export const BookingsByHourChart = () => {
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("bookings_by_hour")}>
<BookingsByHourChartContent data={data} />
<ChartCard title={t("bookings_by_hour")} isPending={isPending} isError={isError}>
{isSuccess && data ? <BookingsByHourChartContent data={data} /> : null}
</ChartCard>
);
};
@@ -8,7 +8,6 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
const COLOR = {
CSAT: "#22c55e",
@@ -57,6 +56,7 @@ export const CSATOverTimeChart = () => {
data: csatData,
isSuccess,
isPending,
isError,
} = trpc.viewer.insights.csatOverTime.useQuery(insightsBookingParams, {
staleTime: 180000,
refetchOnWindowFocus: false,
@@ -65,39 +65,38 @@ export const CSATOverTimeChart = () => {
},
});
if (isPending) return <LoadingInsight />;
if (!isSuccess) return null;
return (
<ChartCard title={t("csat_over_time")} className="h-full">
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={csatData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={true}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={(value) => `${value}%`}
domain={[0, 100]}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="linear"
dataKey="CSAT"
name={t("csat")}
stroke={COLOR.CSAT}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
<ChartCard title={t("csat_over_time")} className="h-full" isPending={isPending} isError={isError}>
{isSuccess ? (
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={csatData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={true}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={(value) => `${value}%`}
domain={[0, 100]}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="linear"
dataKey="CSAT"
name={t("csat")}
stroke={COLOR.CSAT}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
) : null}
</ChartCard>
);
};
@@ -10,7 +10,6 @@ import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingPara
import { useToggleableLegend } from "../../hooks/useToggleableLegend";
import { valueFormatter } from "../../lib/valueFormatter";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
const COLOR = {
CREATED: "#a855f7",
@@ -73,6 +72,7 @@ export const EventTrendsChart = () => {
data: eventTrends,
isSuccess,
isPending,
isError,
} = trpc.viewer.insights.eventTrends.useQuery(insightsBookingParams, {
staleTime: 180000,
refetchOnWindowFocus: false,
@@ -81,45 +81,45 @@ export const EventTrendsChart = () => {
},
});
if (isPending) return <LoadingInsight />;
if (!isSuccess) return null;
return (
<ChartCard
title={t("event_trends")}
legend={legend}
enabledLegend={enabledLegend}
onSeriesToggle={toggleSeries}>
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={eventTrends ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={false}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={valueFormatter}
/>
<Tooltip content={<CustomTooltip />} />
{enabledLegend.map((item) => (
<Line
key={item.label}
type="linear"
dataKey={item.label}
name={item.label}
stroke={item.color}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
onSeriesToggle={toggleSeries}
isPending={isPending}
isError={isError}>
{isSuccess ? (
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={eventTrends ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={false}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={valueFormatter}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<Tooltip content={<CustomTooltip />} />
{enabledLegend.map((item) => (
<Line
key={item.label}
type="linear"
dataKey={item.label}
name={item.label}
stroke={item.color}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
) : null}
</ChartCard>
);
};
@@ -5,14 +5,13 @@ 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(
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithMostNoShow.useQuery(
insightsBookingParams,
{
staleTime: 180000,
@@ -23,13 +22,9 @@ export const HighestNoShowHostTable = () => {
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("most_no_show_host")}>
<UserStatsTable data={data} />
<ChartCard title={t("most_no_show_host")} isPending={isPending} isError={isError}>
{isSuccess && data ? <UserStatsTable data={data} /> : null}
</ChartCard>
);
};
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
import { UserStatsTable } from "../UserStatsTable";
export const HighestRatedMembersTable = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithHighestRatings.useQuery(
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithHighestRatings.useQuery(
insightsBookingParams,
{
staleTime: 180000,
@@ -23,13 +22,10 @@ export const HighestRatedMembersTable = () => {
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("highest_rated")}>
<UserStatsTable data={data} />
<ChartCard title={t("highest_rated")} isPending={isPending} isError={isError}>
{isSuccess && data ? <UserStatsTable data={data} /> : null}
</ChartCard>
);
};
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
import { UserStatsTable } from "../UserStatsTable";
export const LeastBookedTeamMembersTable = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastBookings.useQuery(
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithLeastBookings.useQuery(
insightsBookingParams,
{
staleTime: 180000,
@@ -23,13 +22,9 @@ export const LeastBookedTeamMembersTable = () => {
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("least_bookings_scheduled")}>
<UserStatsTable data={data} />
<ChartCard title={t("least_bookings_scheduled")} isPending={isPending} isError={isError}>
{isSuccess && data ? <UserStatsTable data={data} /> : null}
</ChartCard>
);
};
@@ -1,38 +1,28 @@
"use client";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
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 LeastCompletedTeamMembersTable = () => {
const { t } = useLocale();
let insightsBookingParams = useInsightsBookingParameters();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLeastCompletedBookings.useQuery(
insightsBookingParams,
{
const { data, isSuccess, isPending, isError } =
trpc.viewer.insights.membersWithLeastCompletedBookings.useQuery(insightsBookingParams, {
staleTime: 180000,
refetchOnWindowFocus: false,
trpc: {
context: { skipBatch: true },
},
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
});
return (
<ChartCard title={t("least_bookings_completed")}>
<UserStatsTable data={data} />
<ChartCard title={t("least_bookings_completed")} isPending={isPending} isError={isError}>
{isSuccess && data ? <UserStatsTable data={data} /> : null}
</ChartCard>
);
};
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
import { UserStatsTable } from "../UserStatsTable";
export const LowestRatedMembersTable = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLowestRatings.useQuery(
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithLowestRatings.useQuery(
insightsBookingParams,
{
staleTime: 180000,
@@ -23,13 +22,10 @@ export const LowestRatedMembersTable = () => {
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("lowest_rated")}>
<UserStatsTable data={data} />
<ChartCard title={t("lowest_rated")} isPending={isPending} isError={isError}>
{isSuccess && data ? <UserStatsTable data={data} /> : null}
</ChartCard>
);
};
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
import { UserStatsTable } from "../UserStatsTable";
export const MostBookedTeamMembersTable = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostBookings.useQuery(
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithMostBookings.useQuery(
insightsBookingParams,
{
staleTime: 180000,
@@ -23,13 +22,10 @@ export const MostBookedTeamMembersTable = () => {
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("most_bookings_scheduled")}>
<UserStatsTable data={data} />
<ChartCard title={t("most_bookings_scheduled")} isPending={isPending} isError={isError}>
{isSuccess && data ? <UserStatsTable data={data} /> : null}
</ChartCard>
);
};
@@ -5,14 +5,13 @@ 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(
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.membersWithMostCancelledBookings.useQuery(
insightsBookingParams,
{
staleTime: 180000,
@@ -23,13 +22,9 @@ export const MostCancelledBookingsTables = () => {
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("most_cancelled_bookings")}>
<UserStatsTable data={data} />
<ChartCard title={t("most_cancelled_bookings")} isPending={isPending} isError={isError}>
{isSuccess && data ? <UserStatsTable data={data} /> : null}
</ChartCard>
);
};
@@ -1,38 +1,28 @@
"use client";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
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 MostCompletedTeamMembersTable = () => {
const { t } = useLocale();
let insightsBookingParams = useInsightsBookingParameters();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCompletedBookings.useQuery(
insightsBookingParams,
{
const { data, isSuccess, isPending, isError } =
trpc.viewer.insights.membersWithMostCompletedBookings.useQuery(insightsBookingParams, {
staleTime: 180000,
refetchOnWindowFocus: false,
trpc: {
context: { skipBatch: true },
},
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
});
return (
<ChartCard title={t("most_bookings_completed")}>
<UserStatsTable data={data} />
<ChartCard title={t("most_bookings_completed")} isPending={isPending} isError={isError}>
{isSuccess && data ? <UserStatsTable data={data} /> : null}
</ChartCard>
);
};
@@ -9,7 +9,6 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { valueFormatter } from "../../lib/valueFormatter";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
const COLOR = {
NO_SHOW_HOST: "#64748b",
@@ -58,6 +57,7 @@ export const NoShowHostsOverTimeChart = () => {
data: noShowHostsData,
isSuccess,
isPending,
isError,
} = trpc.viewer.insights.noShowHostsOverTime.useQuery(insightsBookingParams, {
staleTime: 180000,
refetchOnWindowFocus: false,
@@ -66,38 +66,36 @@ export const NoShowHostsOverTimeChart = () => {
},
});
if (isPending) return <LoadingInsight />;
if (!isSuccess) return null;
return (
<ChartCard title={t("no_show_hosts_over_time")} className="h-full">
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={noShowHostsData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={false}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={valueFormatter}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="linear"
dataKey="Count"
name={t("no_show_hosts")}
stroke={COLOR.NO_SHOW_HOST}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
<ChartCard title={t("no_show_hosts_over_time")} className="h-full" isPending={isPending} isError={isError}>
{isSuccess ? (
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={noShowHostsData ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={false}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={valueFormatter}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="linear"
dataKey="Count"
name={t("no_show_hosts")}
stroke={COLOR.NO_SHOW_HOST}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
) : null}
</ChartCard>
);
};
@@ -5,13 +5,12 @@ import { trpc } from "@calcom/trpc";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard, ChartCardItem } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
export const PopularEventsTable = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.popularEvents.useQuery(insightsBookingParams, {
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.popularEvents.useQuery(insightsBookingParams, {
staleTime: 180000,
refetchOnWindowFocus: false,
trpc: {
@@ -19,29 +18,29 @@ export const PopularEventsTable = () => {
},
});
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("popular_events")}>
<div>
{data
.filter((item) => item.eventTypeId)
.map((item) => (
<ChartCardItem key={item.eventTypeId} count={item.count}>
<div className="flex">
<div className="bg-subtle mr-1.5 h-5 w-[2px] shrink-0 rounded-sm" />
<p>{item.eventTypeName}</p>
</div>
</ChartCardItem>
))}
</div>
{data.length === 0 && (
<div className="flex h-60 text-center">
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
</div>
)}
<ChartCard title={t("popular_events")} isPending={isPending} isError={isError}>
{isSuccess && data ? (
<>
<div>
{data
.filter((item) => item.eventTypeId)
.map((item) => (
<ChartCardItem key={item.eventTypeId} count={item.count}>
<div className="flex">
<div className="bg-subtle mr-1.5 h-5 w-[2px] shrink-0 rounded-sm" />
<p>{item.eventTypeName}</p>
</div>
</ChartCardItem>
))}
</div>
{data.length === 0 && (
<div className="flex h-60 text-center">
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
</div>
)}
</>
) : null}
</ChartCard>
);
};
@@ -5,14 +5,13 @@ import { trpc } from "@calcom/trpc";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
import { RecentFeedbackTableContent } from "./RecentFeedbackTableContent";
export const RecentFeedbackTable = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery(insightsBookingParams, {
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.recentRatings.useQuery(insightsBookingParams, {
staleTime: 180000,
refetchOnWindowFocus: false,
trpc: {
@@ -20,13 +19,10 @@ export const RecentFeedbackTable = () => {
},
});
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
return (
<ChartCard title={t("recent_ratings")}>
<RecentFeedbackTableContent data={data} />
<ChartCard title={t("recent_ratings")} isPending={isPending} isError={isError}>
{isSuccess && data ? <RecentFeedbackTableContent data={data} /> : null}
</ChartCard>
);
};
@@ -8,7 +8,6 @@ import { showToast } from "@calcom/ui/components/toast";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard, ChartCardItem } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
export const RecentNoShowGuestsChart = () => {
const { t } = useLocale();
@@ -16,7 +15,7 @@ export const RecentNoShowGuestsChart = () => {
const insightsBookingParams = useInsightsBookingParameters();
const timeZone = insightsBookingParams.timeZone;
const { data, isSuccess, isPending } = trpc.viewer.insights.recentNoShowGuests.useQuery(
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.recentNoShowGuests.useQuery(
insightsBookingParams,
{
staleTime: 180000,
@@ -27,10 +26,6 @@ export const RecentNoShowGuestsChart = () => {
}
);
if (isPending) return <LoadingInsight />;
if (!isSuccess || !data) return null;
const handleCopyEmail = (email: string) => {
copyToClipboard(email);
showToast(t("email_copied"), "success");
@@ -40,45 +35,49 @@ export const RecentNoShowGuestsChart = () => {
<ChartCard
title={t("recent_no_show_guests")}
titleTooltip={t("recent_no_show_guests_tooltip")}
className="h-full">
<div className="h-full">
<div className="sm:max-h-[30.6rem] sm:overflow-y-auto">
{data.map((item) => (
<ChartCardItem key={item.bookingId}>
<div className="flex w-full items-center justify-between">
<div className="flex gap-2">
<div className="bg-subtle h-16 w-[2px] shrink-0 rounded-sm" />
<div className="flex flex-col stack-y-1">
<p className="text-sm font-medium">{item.guestName}</p>
<div className="text-subtle text-sm leading-tight">
<p>{item.eventTypeName}</p>
<p>
{Intl.DateTimeFormat(undefined, {
timeZone,
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(item.startTime))}
</p>
className="h-full"
isPending={isPending}
isError={isError}>
{isSuccess && data ? (
<div className="h-full">
<div className="sm:max-h-[30.6rem] sm:overflow-y-auto">
{data.map((item) => (
<ChartCardItem key={item.bookingId}>
<div className="flex w-full items-center justify-between">
<div className="flex gap-2">
<div className="bg-subtle h-16 w-[2px] shrink-0 rounded-sm" />
<div className="flex flex-col stack-y-1">
<p className="text-sm font-medium">{item.guestName}</p>
<div className="text-subtle text-sm leading-tight">
<p>{item.eventTypeName}</p>
<p>
{Intl.DateTimeFormat(undefined, {
timeZone,
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(item.startTime))}
</p>
</div>
</div>
</div>
<Button
color="minimal"
size="sm"
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
onClick={() => handleCopyEmail(item.guestEmail)}>
{!isCopied ? t("email") : t("copied")}
</Button>
</div>
<Button
color="minimal"
size="sm"
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
onClick={() => handleCopyEmail(item.guestEmail)}>
{!isCopied ? t("email") : t("copied")}
</Button>
</div>
</ChartCardItem>
))}
</div>
{data.length === 0 && (
<div className="flex h-60 text-center">
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
</ChartCardItem>
))}
</div>
)}
</div>
{data.length === 0 && (
<div className="flex h-60 text-center">
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
</div>
)}
</div>
) : null}
</ChartCard>
);
};
@@ -5,6 +5,5 @@ export { CellWithOverflowX } from "./CellWithOverflowX";
export { ChartCard } from "./ChartCard";
export { KPICard } from "./KPICard";
export { LineChart } from "./LineChart";
export { LoadingInsight } from "./LoadingInsights";
export { ResponseValueCell } from "./ResponseValueCell";
export { UserStatsTable } from "./UserStatsTable";
@@ -57,7 +57,7 @@ interface FormCardProps {
fields: Record<string, { optionId: string; count: number; optionLabel: string }[]>;
}
function FormCard({ formName, fields }: FormCardProps) {
function FormCard({ fields }: FormCardProps) {
const { t } = useLocale();
const fieldNames = Object.keys(fields);
const [selectedField, setSelectedField] = useState(fieldNames[0]);
@@ -132,16 +132,17 @@ function FormCard({ formName, fields }: FormCardProps) {
export function FailedBookingsByField() {
const { t } = useLocale();
const insightsRoutingParams = useInsightsRoutingParameters();
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery(insightsRoutingParams);
const { data, isLoading, isError } =
trpc.viewer.insights.failedBookingsByField.useQuery(insightsRoutingParams);
if (!data || Object.entries(data).length === 0) return null;
if (!isLoading && !isError && (!data || Object.entries(data).length === 0)) return null;
// routingFormId is always set, meaning data has only one entry.
const [formName, fields] = Object.entries(data)[0];
const [formName, fields] = data ? Object.entries(data)[0] : ["", {}];
return (
<ChartCard title={t("failed_bookings_by_field")}>
<FormCard key={formName} formName={formName} fields={fields} />
<ChartCard title={t("failed_bookings_by_field")} isPending={isLoading} isError={isError}>
{data && <FormCard key={formName} formName={formName} fields={fields} />}
</ChartCard>
);
}
@@ -5,7 +5,6 @@ import { useQueryState } from "nuqs";
import { type ReactNode, useMemo, useRef, useState } from "react";
import posthog from "posthog-js";
import { DataTableSkeleton } from "@calcom/features/data-table";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -179,7 +178,7 @@ export function RoutedToPerPeriod() {
const tableContainerRef = useRef<HTMLDivElement>(null);
const debouncedSearchQuery = useDebounce(searchQuery, 500);
const { data, isLoading } = trpc.viewer.insights.routedToPerPeriod.useQuery(
const { data, isLoading, isError } = trpc.viewer.insights.routedToPerPeriod.useQuery(
{
...routingParams,
period: selectedPeriod,
@@ -240,26 +239,6 @@ export function RoutedToPerPeriod() {
});
}, [data?.periodStats.data, flattenedUsers, uniquePeriods]);
if (isLoading) {
return (
<div className="w-full text-sm">
<div className="flex h-12 items-center">
<h2 className="text-emphasis text-md font-semibold">{t("routed_to_per_period")}</h2>
</div>
<FormCard
selectedPeriod={selectedPeriod}
onPeriodChange={setSelectedPeriod}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}>
<div className="mt-6">
<DataTableSkeleton columns={5} columnWidths={[200, 120, 120, 120, 120]} />
</div>
</FormCard>
</div>
);
}
const isCurrentPeriod = (date: Date, today: Date, selectedPeriod: string): boolean => {
if (selectedPeriod === "perDay") {
return (
@@ -280,7 +259,7 @@ export function RoutedToPerPeriod() {
};
return (
<ChartCard title={t("routed_to_per_period")}>
<ChartCard title={t("routed_to_per_period")} isPending={isLoading} isError={isError}>
<div className="w-full text-sm">
<FormCard
selectedPeriod={selectedPeriod}
@@ -13,7 +13,7 @@ export function RoutingFunnel() {
const { t } = useLocale();
const insightsRoutingParams = useInsightsRoutingParameters();
const { enabledLegend, toggleSeries } = useToggleableLegend(legend);
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
const { data, isSuccess, isLoading, isError } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
insightsRoutingParams,
{
staleTime: 30000,
@@ -29,7 +29,9 @@ export function RoutingFunnel() {
title={t("routing_funnel")}
legend={legend}
enabledLegend={enabledLegend}
onSeriesToggle={toggleSeries}>
onSeriesToggle={toggleSeries}
isPending={isLoading}
isError={isError}>
<RoutingFunnelSkeleton />
</ChartCard>
);
@@ -40,7 +42,9 @@ export function RoutingFunnel() {
title={t("routing_funnel")}
legend={legend}
enabledLegend={enabledLegend}
onSeriesToggle={toggleSeries}>
onSeriesToggle={toggleSeries}
isPending={isLoading}
isError={isError}>
<RoutingFunnelContent data={data} enabledLegend={enabledLegend} />
</ChartCard>
);
@@ -13,12 +13,15 @@ export const RoutingKPICards = () => {
const { t } = useLocale();
const insightsRoutingParameters = useInsightsRoutingParameters();
const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(insightsRoutingParameters, {
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
});
const { data, isSuccess, isPending, isError } = trpc.viewer.insights.routingFormsByStatus.useQuery(
insightsRoutingParameters,
{
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
}
);
const categories: {
title: string;
@@ -39,38 +42,41 @@ export const RoutingKPICards = () => {
];
if (isPending) {
return <LoadingKPICards categories={categories} />;
}
if (!data) {
return null;
return <LoadingKPICards categories={categories} isPending={isPending} isError={isError} />;
}
return (
<ChartCard title={t("stats")}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{categories.map((item) => (
<div
key={item.title}
className={classNames(
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
)}>
<div className="text-default text-sm">{item.title}</div>
<div className="flex items-baseline justify-start space-x-3 truncate">
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(data[item.index])}</div>
<ChartCard title={t("stats")} isPending={isPending} isError={isError}>
{isSuccess && data ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{categories.map((item) => (
<div
key={item.title}
className={classNames(
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
)}>
<div className="text-default text-sm">{item.title}</div>
<div className="flex items-baseline justify-start space-x-3 truncate">
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(data[item.index])}</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
) : null}
</ChartCard>
);
};
const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
const LoadingKPICards = (props: {
categories: { title: string; index: string }[];
isPending: boolean;
isError: boolean;
}) => {
const { t } = useLocale();
const { categories } = props;
const { categories, isPending, isError } = props;
return (
<ChartCard title={t("stats")}>
<ChartCard title={t("stats")} isPending={isPending} isError={isError}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{categories.map((item) => (
<div
@@ -508,7 +508,7 @@ export class InsightsRoutingBaseService {
END) <= date_trunc(${dayjsPeriod}, ${endDate}::timestamp)
),
all_users AS (
SELECT unnest(ARRAY[${Prisma.join(users.map((u) => u.id))}]) as user_id
SELECT unnest(ARRAY[${Prisma.join(users.map((u) => u.id))}]::integer[]) as user_id
),
paginated_periods AS (
SELECT date as period_start
+4 -2
View File
@@ -20,6 +20,7 @@ export function PanelCard({
children,
collapsible = false,
defaultCollapsed = false,
...dataAttributes
}: {
title: string | ReactNode;
subtitle?: string;
@@ -30,7 +31,7 @@ export function PanelCard({
children: ReactNode;
collapsible?: boolean;
defaultCollapsed?: boolean;
}) {
} & Record<`data-${string}`, string | undefined>) {
const { t } = useLocale();
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const contentId = useId();
@@ -51,7 +52,8 @@ export function PanelCard({
"bg-cal-muted group relative flex w-full flex-col items-center rounded-2xl px-1",
!isCollapsed && "pb-1",
className
)}>
)}
{...dataAttributes}>
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
<div className="flex shrink-0 items-center gap-1">
{collapsible && (