Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2965450929 | ||
|
|
db9b9c45dc | ||
|
|
37f500526c | ||
|
|
006aecb066 | ||
|
|
0829929e98 | ||
|
|
a61148cb51 | ||
|
|
593c48c8b4 | ||
|
|
fb364971fa | ||
|
|
a2205d497d | ||
|
|
cf4e7a5d2c | ||
|
|
0e2ee3adb2 | ||
|
|
dbe8a85cf5 | ||
|
|
84210dc1b4 | ||
|
|
4061bfb3fe | ||
|
|
18059e50fa | ||
|
|
852eb346ed | ||
|
|
b7e11dc112 | ||
|
|
35a373e70a | ||
|
|
02c0b721b9 | ||
|
|
29349845f8 | ||
|
|
28cb2cff64 | ||
|
|
9cb2eb3b8c | ||
|
|
6ff20b98f4 | ||
|
|
aa618dbaa2 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
Added new startTime prop to the Booker component that allows users to decide the first available date when the booker loads
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
This change fixes an issue in the CalendarSettings atom where the redirect urls were getting only one search param
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
This change fixes date overrides breaking for availability settings atom
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
@@ -38,7 +38,7 @@
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
"next-validations": "^0.2.0",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.9.0-beta",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.22.4"
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.1.0",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "../../../packages/prisma/schema.prisma"
|
||||
|
||||
@@ -34,6 +34,6 @@
|
||||
"eslint-config-next": "15.1.6",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.9.0-beta"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function revalidateWebhooksList() {
|
||||
revalidatePath("/settings/developer/webhooks");
|
||||
export async function revalidateWebhooksListGetByViewer() {
|
||||
revalidateTag("viewer.webhook.getByViewer");
|
||||
}
|
||||
|
||||
+16
-4
@@ -1,13 +1,13 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -20,6 +20,17 @@ export const generateMetadata = async () =>
|
||||
"/settings/developer/webhooks"
|
||||
);
|
||||
|
||||
const getCachedWebhooksList = unstable_cache(
|
||||
async ({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) => {
|
||||
return await WebhookRepository.getAllWebhooksByUserId({
|
||||
userId,
|
||||
userRole,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: ["viewer.webhook.getByViewer"] }
|
||||
);
|
||||
|
||||
const WebhooksViewServerWrapper = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
@@ -27,8 +38,9 @@ const WebhooksViewServerWrapper = async () => {
|
||||
}
|
||||
|
||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||
const caller = await createRouterCaller(webhookRouter);
|
||||
const data = await caller.getByViewer();
|
||||
const userRole = session.user.role !== "INACTIVE_ADMIN" ? session.user.role : undefined;
|
||||
|
||||
const data = await getCachedWebhooksList({ userId: session.user.id, userRole });
|
||||
|
||||
return <WebhooksView data={data} isAdmin={isAdmin} />;
|
||||
};
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function revalidateWebhookById(id: string) {
|
||||
revalidateTag(`viewer.webhook.get:${id}`);
|
||||
}
|
||||
+14
-1
@@ -1,5 +1,6 @@
|
||||
import type { PageProps } from "app/_types";
|
||||
import { getTranslate, _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
||||
@@ -15,12 +16,24 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
`/settings/developer/webhooks/${(await params).id}`
|
||||
);
|
||||
|
||||
const getCachedWebhook = (id?: string) => {
|
||||
const fn = unstable_cache(
|
||||
async () => {
|
||||
return await WebhookRepository.findByWebhookId(id);
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: [`viewer.webhook.get:${id}`] }
|
||||
);
|
||||
|
||||
return fn();
|
||||
};
|
||||
|
||||
const Page = async ({ params: _params }: PageProps) => {
|
||||
const t = await getTranslate();
|
||||
const params = await _params;
|
||||
const id = typeof params?.id === "string" ? params.id : undefined;
|
||||
|
||||
const webhook = await WebhookRepository.findByWebhookId(id);
|
||||
const webhook = await getCachedWebhook(id);
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function revalidateWebhookList() {
|
||||
revalidateTag("viewer.webhook.list");
|
||||
}
|
||||
+22
-6
@@ -1,10 +1,16 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -15,15 +21,25 @@ export const generateMetadata = async () =>
|
||||
"/settings/developer/webhooks/new"
|
||||
);
|
||||
|
||||
const getCachedWebhooksList = unstable_cache(
|
||||
async ({ userId }: { userId: number }) => {
|
||||
return await WebhookRepository.findWebhooksByFilters({ userId });
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: ["viewer.webhook.list"] }
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const [appsCaller, webhookCaller] = await Promise.all([
|
||||
createRouterCaller(appsRouter),
|
||||
createRouterCaller(webhookRouter),
|
||||
]);
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const appsCaller = await createRouterCaller(appsRouter);
|
||||
|
||||
const [installedApps, webhooks] = await Promise.all([
|
||||
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
|
||||
webhookCaller.list(),
|
||||
getCachedWebhooksList({ userId: session.user.id }),
|
||||
]);
|
||||
|
||||
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
|
||||
|
||||
@@ -208,7 +208,7 @@ async function getHandler(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Create a new response with the image buffer
|
||||
const imageResponse = new NextResponse(buffer);
|
||||
const imageResponse = new NextResponse(buffer as BodyInit);
|
||||
|
||||
// Set the appropriate headers
|
||||
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
|
||||
|
||||
@@ -124,8 +124,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
autoComplete="none"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
containerClassName="[&>div]:gap-0"
|
||||
className={classNames(
|
||||
"mb-0 mt-0 rounded-md rounded-l-none",
|
||||
"mb-0 mt-0 rounded-md rounded-l-none pl-0",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
|
||||
@@ -117,7 +117,7 @@ const checkIfUserIsHost = async ({
|
||||
});
|
||||
|
||||
// If eventType exists, it means user is either owner, host or user
|
||||
return !eventType;
|
||||
return !!eventType;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
RecentFeedbackTable,
|
||||
TimezoneBadge,
|
||||
} 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";
|
||||
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ProgressBar } from "@tremor/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
@@ -16,6 +15,7 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||
import { ProgressBar } from "@calcom/ui/components/progress-bar";
|
||||
import { showToast } from "@calcom/ui/toast";
|
||||
|
||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.5.10",
|
||||
"version": "5.5.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -76,7 +76,6 @@
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
"@team-plain/typescript-sdk": "^5.9.0",
|
||||
"@tremor/react": "^2.11.0",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@unkey/ratelimit": "^0.1.1",
|
||||
"@upstash/redis": "^1.21.0",
|
||||
@@ -195,7 +194,7 @@
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
},
|
||||
"nextBundleAnalysis": {
|
||||
"budget": 358400,
|
||||
|
||||
@@ -203,32 +203,24 @@ test.describe("Insights", async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Choose User filter item from dropdown
|
||||
await addFilter(page, "bookingUserId");
|
||||
await addFilter(page, "userId");
|
||||
|
||||
// Wait for the URL to include bookingUserId
|
||||
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
|
||||
// Wait for the URL to include userId
|
||||
await page.waitForURL((url) => url.toString().includes("userId"));
|
||||
|
||||
// Click User filter to see a user list
|
||||
await openFilter(page, "bookingUserId");
|
||||
await openFilter(page, "userId");
|
||||
|
||||
await page
|
||||
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
||||
.getByRole("option")
|
||||
.nth(0)
|
||||
.click();
|
||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
|
||||
|
||||
await page
|
||||
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
||||
.getByRole("option")
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
|
||||
|
||||
// press escape button to close the filter
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await clearFilters(page);
|
||||
|
||||
await expect(page.url()).not.toContain("bookingUserId");
|
||||
await expect(page.url()).not.toContain("userId");
|
||||
});
|
||||
|
||||
test("should test download button", async ({ page, users }) => {
|
||||
|
||||
@@ -3402,6 +3402,7 @@
|
||||
"license_key_saved": "License key saved successfully",
|
||||
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
|
||||
"failed_bookings_by_field": "Failed Bookings By Field",
|
||||
"failed_bookings": "Failed bookings",
|
||||
"event_type_no_hosts": "No hosts are assigned to event type",
|
||||
"cache_status": "Cache Status",
|
||||
"cache_last_updated": "Last updated: {{timestamp}}",
|
||||
@@ -3429,5 +3430,7 @@
|
||||
"usage_based_expiration_description": "This link can be used for {{count}} booking",
|
||||
"usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
|
||||
"usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
|
||||
"stats": "Stats",
|
||||
"booking_status": "Booking status",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -2,10 +2,6 @@ const base = require("@calcom/config/tailwind-preset");
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
...base,
|
||||
content: [
|
||||
...base.content,
|
||||
"../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}",
|
||||
"../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
content: [...base.content, "../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}"],
|
||||
plugins: [...base.plugins, require("tailwindcss-animate")],
|
||||
};
|
||||
|
||||
@@ -26,7 +26,12 @@ vi.mock("@calcom/emails/email-manager", () => {
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: (key: string) => key,
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
title: "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
|
||||
```
|
||||
|
||||
<Steps>
|
||||
<Step title="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>
|
||||
<Step title="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>
|
||||
<Step title="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>
|
||||
<Step title="Create tRPC Handler">
|
||||
|
||||
Add the tRPC endpoint in the insights router using the `getInsightsBookingService()` DI container function:
|
||||
|
||||
```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 }) => {
|
||||
// `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" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
```
|
||||
</Step>
|
||||
<Step title="Add Service Method to InsightsBookingBaseService">
|
||||
Add your new method to the `InsightsBookingBaseService` class:
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `getInsightsBookingService()`**: Always use the DI container 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
|
||||
@@ -109,6 +109,11 @@
|
||||
"group": "Embeds",
|
||||
"icon": "code-simple",
|
||||
"pages": ["developing/guides/embeds/embed-events"]
|
||||
},
|
||||
{
|
||||
"group": "Insights",
|
||||
"icon": "chart-bar",
|
||||
"pages": ["developing/guides/insights/add-new-booking-charts"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@
|
||||
"prismock": "^1.33.4",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"tsc-absolute": "^1.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "5.9.0-beta",
|
||||
"vitest": "^2.1.1",
|
||||
"vitest-fetch-mock": "^0.3.0",
|
||||
"vitest-mock-extended": "^2.0.2"
|
||||
|
||||
@@ -31,6 +31,6 @@
|
||||
"eslint-plugin-react": "^7.30.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar
|
||||
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { handleErrorsJson } from "@calcom/lib/errors";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@@ -91,8 +92,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Office365 Calendar: Received calendar response", {
|
||||
userId: req.session?.user?.id,
|
||||
status: calRequest.status,
|
||||
statusText: calRequest.statusText,
|
||||
url: calRequest.url,
|
||||
});
|
||||
|
||||
let calBody = await handleErrorsJson<{ value: OfficeCalendar[]; "@odata.nextLink"?: string }>(calRequest);
|
||||
|
||||
logger.info("Office365 Calendar: handleErrorsJson completed", {
|
||||
userId: req.session?.user?.id,
|
||||
calendarCount: calBody.value.length ?? 0,
|
||||
});
|
||||
|
||||
if (typeof responseBody === "string") {
|
||||
calBody = JSON.parse(responseBody) as { value: OfficeCalendar[] };
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@calcom/eslint-plugin-eslint": "*",
|
||||
"@todesktop/tailwind-variants": "^1.0.0",
|
||||
"eslint-config-next": "^13.2.1",
|
||||
"eslint-config-next": "^15.4.5",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-config-turbo": "^0.0.7",
|
||||
"eslint-plugin-playwright": "^0.12.0",
|
||||
@@ -33,6 +33,6 @@
|
||||
"prettier-plugin-tailwindcss": "^0.2.5",
|
||||
"tailwind-scrollbar": "^2.0.1",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.18",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.9.0-beta",
|
||||
"vite": "^4.1.2",
|
||||
"vite-plugin-environment": "^1.1.3"
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"@vitejs/plugin-react": "^2.2.0",
|
||||
"eslint": "^8.34.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.9.0-beta",
|
||||
"vite": "^4.5.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
],
|
||||
"types": "./dist/index.d.ts",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2",
|
||||
"typescript": "^5.9.0-beta",
|
||||
"vite": "^4.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"@typescript-eslint/parser": "^5.52.0",
|
||||
"@typescript-eslint/utils": "^5.52.0",
|
||||
"ts-node-maintained": "^10.9.5",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/eslint": "^8.4.5"
|
||||
|
||||
@@ -387,6 +387,23 @@ export type BookingHandlerInput = {
|
||||
forcedSlug?: string;
|
||||
} & PlatformParams;
|
||||
|
||||
function formatAvailabilitySnapshot(data: {
|
||||
dateRanges: { start: dayjs.Dayjs; end: dayjs.Dayjs }[];
|
||||
oooExcludedDateRanges: { start: dayjs.Dayjs; end: dayjs.Dayjs }[];
|
||||
}) {
|
||||
return {
|
||||
...data,
|
||||
dateRanges: data.dateRanges.map(({ start, end }) => ({
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
})),
|
||||
oooExcludedDateRanges: data.oooExcludedDateRanges.map(({ start, end }) => ({
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function handler(
|
||||
input: BookingHandlerInput,
|
||||
bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema
|
||||
@@ -1397,7 +1414,10 @@ async function handler(
|
||||
|
||||
logger.info(`Booking created`, {
|
||||
bookingUid: booking.uid,
|
||||
availabilitySnapshot: organizerUserAvailability?.availabilityData,
|
||||
selectedCalendarIds: organizerUser.allSelectedCalendars?.map((c) => c.id) ?? [],
|
||||
availabilitySnapshot: organizerUserAvailability?.availabilityData
|
||||
? formatAvailabilitySnapshot(organizerUserAvailability.availabilityData)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
import type { Booking, User, Webhook } from "@prisma/client";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||
import { getMeetingSessionsFromRoomName } from "@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName";
|
||||
import { triggerHostNoShow } from "@calcom/features/tasker/tasks/triggerNoShow/triggerHostNoShow";
|
||||
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
import { scheduleNoShowTriggers } from "./scheduleNoShowTriggers";
|
||||
|
||||
vi.mock("@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", () => ({
|
||||
getMeetingSessionsFromRoomName: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/webhooks/lib/sendPayload", () => ({
|
||||
sendGenericWebhookPayload: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
describe("scheduleNoShowTriggers Integration", () => {
|
||||
let testUser: User;
|
||||
let hostWebhook: Webhook;
|
||||
let guestWebhook: Webhook;
|
||||
const testEventTypeId = 67890;
|
||||
const testBookingIds = [98765, 98766];
|
||||
const testBookingId = 123456;
|
||||
|
||||
beforeAll(async () => {
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
email: "test-user-no-show@example.com",
|
||||
username: "test-user-no-show",
|
||||
name: "Test User No Show",
|
||||
},
|
||||
});
|
||||
|
||||
[hostWebhook, guestWebhook] = await Promise.all([
|
||||
prisma.webhook.create({
|
||||
data: {
|
||||
id: "test-host-webhook-id",
|
||||
userId: testUser.id,
|
||||
subscriberUrl: "https://example.com/host-webhook",
|
||||
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
|
||||
active: true,
|
||||
time: 5,
|
||||
timeUnit: TimeUnit.MINUTE,
|
||||
},
|
||||
}),
|
||||
prisma.webhook.create({
|
||||
data: {
|
||||
id: "test-guest-webhook-id",
|
||||
userId: testUser.id,
|
||||
subscriberUrl: "https://example.com/guest-webhook",
|
||||
eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
|
||||
active: true,
|
||||
time: 10,
|
||||
timeUnit: TimeUnit.MINUTE,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.task.deleteMany({
|
||||
where: {
|
||||
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.webhook.deleteMany({
|
||||
where: {
|
||||
userId: testUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.booking.deleteMany({
|
||||
where: {
|
||||
id: testBookingId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
id: testUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventType.deleteMany({
|
||||
where: {
|
||||
id: testEventTypeId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("scheduling a webhook creates the correct Tasker jobs for no-show webhooks", async () => {
|
||||
const currentDate = new Date("2023-01-01T10:00:00.000Z");
|
||||
const bookingData: Partial<Booking> & { startTime: Date; id: number; location: string } = {
|
||||
id: testBookingIds[0],
|
||||
startTime: currentDate,
|
||||
endTime: new Date("2023-01-01T10:30:00.000Z"),
|
||||
location: DailyLocationType,
|
||||
};
|
||||
|
||||
await scheduleNoShowTriggers({
|
||||
booking: bookingData,
|
||||
organizerUser: { id: testUser.id },
|
||||
eventTypeId: testEventTypeId,
|
||||
triggerForUser: true,
|
||||
});
|
||||
|
||||
const createdTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createdTasks.length).toBe(2);
|
||||
|
||||
const hostTask = createdTasks.find((task) => task.type === "triggerHostNoShowWebhook");
|
||||
expect(hostTask).toBeDefined();
|
||||
|
||||
const guestTask = createdTasks.find((task) => task.type === "triggerGuestNoShowWebhook");
|
||||
expect(guestTask).toBeDefined();
|
||||
});
|
||||
|
||||
test("created task payloads for no-show webhooks are correct", async () => {
|
||||
const currentDate = new Date("2023-01-01T11:00:00.000Z");
|
||||
const bookingData: Partial<Booking> & { startTime: Date; id: number; location: string } = {
|
||||
id: testBookingIds[1],
|
||||
startTime: currentDate,
|
||||
endTime: new Date("2023-01-01T11:30:00.000Z"),
|
||||
location: DailyLocationType,
|
||||
};
|
||||
|
||||
await scheduleNoShowTriggers({
|
||||
booking: bookingData,
|
||||
organizerUser: { id: testUser.id },
|
||||
eventTypeId: testEventTypeId,
|
||||
triggerForUser: true,
|
||||
});
|
||||
|
||||
const createdTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
|
||||
payload: {
|
||||
contains: bookingData.id.toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hostTask = createdTasks.find((task) => task.type === "triggerHostNoShowWebhook");
|
||||
expect(hostTask).toBeDefined();
|
||||
|
||||
let hostPayload;
|
||||
try {
|
||||
hostPayload = JSON.parse(hostTask?.payload ?? "");
|
||||
} catch (e) {
|
||||
console.error("Failed to parse host task payload.");
|
||||
throw e;
|
||||
}
|
||||
|
||||
expect(hostPayload).toEqual({
|
||||
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
bookingId: bookingData.id,
|
||||
webhook: expect.objectContaining({
|
||||
id: hostWebhook.id,
|
||||
subscriberUrl: "https://example.com/host-webhook",
|
||||
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
|
||||
time: 5,
|
||||
timeUnit: TimeUnit.MINUTE,
|
||||
}),
|
||||
});
|
||||
|
||||
const guestTask = createdTasks.find((task) => task.type === "triggerGuestNoShowWebhook");
|
||||
expect(guestTask).toBeDefined();
|
||||
|
||||
let guestPayload;
|
||||
try {
|
||||
guestPayload = JSON.parse(guestTask?.payload ?? "");
|
||||
} catch (e) {
|
||||
console.error("Failed to parse guest task payload.");
|
||||
throw e;
|
||||
}
|
||||
|
||||
expect(guestPayload).toEqual({
|
||||
triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
bookingId: bookingData.id,
|
||||
webhook: expect.objectContaining({
|
||||
id: guestWebhook.id,
|
||||
subscriberUrl: "https://example.com/guest-webhook",
|
||||
eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
|
||||
time: 10,
|
||||
timeUnit: TimeUnit.MINUTE,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("task handler runs properly with the correct payload", async () => {
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
id: testEventTypeId,
|
||||
title: "Test Event Type",
|
||||
slug: "test-event-type",
|
||||
length: 30,
|
||||
userId: testUser.id,
|
||||
description: "Test description",
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await prisma.booking.create({
|
||||
data: {
|
||||
id: testBookingId,
|
||||
uid: "test-uid-no-show",
|
||||
title: "Test No-Show",
|
||||
eventTypeId: testEventTypeId,
|
||||
status: "ACCEPTED",
|
||||
startTime: now,
|
||||
endTime: new Date(now.getTime() + 30 * 60 * 1000),
|
||||
location: DailyLocationType,
|
||||
userId: testUser.id,
|
||||
references: {
|
||||
create: {
|
||||
type: "daily_video",
|
||||
uid: "meeting-test-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(getMeetingSessionsFromRoomName).mockResolvedValue({
|
||||
total_count: 1,
|
||||
data: [
|
||||
{
|
||||
id: "meeting-test-id",
|
||||
room: "meeting-test-uid",
|
||||
start_time: Math.floor(now.getTime() / 1000),
|
||||
duration: 30,
|
||||
max_participants: 1,
|
||||
participants: [
|
||||
{
|
||||
user_id: "999999",
|
||||
participant_id: "participant-test-id",
|
||||
user_name: "Guest User",
|
||||
join_time: Math.floor(now.getTime() / 1000),
|
||||
duration: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const taskPayload = JSON.stringify({
|
||||
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
bookingId: testBookingId,
|
||||
webhook: {
|
||||
id: hostWebhook.id,
|
||||
subscriberUrl: "https://example.com/host-webhook",
|
||||
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
|
||||
active: true,
|
||||
time: 5,
|
||||
timeUnit: TimeUnit.MINUTE,
|
||||
payloadTemplate: null,
|
||||
secret: null,
|
||||
appId: null,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerHostNoShow(taskPayload);
|
||||
|
||||
expect(sendGenericWebhookPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
webhook: expect.objectContaining({
|
||||
id: hostWebhook.id,
|
||||
subscriberUrl: "https://example.com/host-webhook",
|
||||
}),
|
||||
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
data: expect.objectContaining({
|
||||
bookingId: testBookingId,
|
||||
bookingUid: "test-uid-no-show",
|
||||
message: expect.stringContaining("Host with email"),
|
||||
participants: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
user_id: "999999",
|
||||
user_name: "Guest User",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const updatedBooking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: testBookingId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updatedBooking?.noShowHost).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -194,8 +194,10 @@ const Days = ({
|
||||
const getPadding = (day: number) => (browsingDate.set("date", day).day() - weekStart + 7) % 7;
|
||||
const totalDays = daysInMonth(browsingDate);
|
||||
|
||||
const showNextMonthDays = isSecondWeekOver && !isCompact;
|
||||
|
||||
// Only apply end-of-month logic for main monthly view (not compact sidebar)
|
||||
if (isSecondWeekOver && !isCompact) {
|
||||
if (showNextMonthDays) {
|
||||
const startDay = 8;
|
||||
const pad = getPadding(startDay);
|
||||
days = Array(pad).fill(null);
|
||||
@@ -337,7 +339,7 @@ const Days = ({
|
||||
active={isActive(day)}
|
||||
away={away}
|
||||
emoji={emoji}
|
||||
showMonthTooltip={isSecondWeekOver && !isCompact}
|
||||
showMonthTooltip={showNextMonthDays && !disabled && day.month() !== browsingDate.month()}
|
||||
isFirstDayOfNextMonth={isFirstDayOfNextMonth}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,17 @@ vi.mock("@calcom/prisma", () => {
|
||||
|
||||
vi.mock("stripe");
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/constants", async () => {
|
||||
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
|
||||
return {
|
||||
|
||||
+11
@@ -43,6 +43,17 @@ vi.mock("@calcom/lib/domainManager/organization", () => ({
|
||||
createDomain: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockOrganizationOnboarding = {
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
|
||||
@@ -17,6 +17,16 @@ vi.mock("@calcom/features/ee/workflows/lib/reminders/providers/emailProvider", (
|
||||
sendOrScheduleWorkflowEmails: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
describe("reminderScheduler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createInstance } from "i18next";
|
||||
import { expect, test, describe } from "vitest";
|
||||
import { vi, expect, test, describe } from "vitest";
|
||||
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
@@ -10,6 +10,17 @@ import { getTemplateBodyForAction } from "../actionHelperFunctions";
|
||||
import compareReminderBodyToTemplate from "../compareReminderBodyToTemplate";
|
||||
import plainTextReminderTemplates from "../reminders/templates/plainTextTemplates";
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const translation = async () => {
|
||||
const _i18n = createInstance();
|
||||
await _i18n.init({
|
||||
|
||||
@@ -66,11 +66,13 @@ export default function CreateEventTypeForm({
|
||||
!isPlatform ? (
|
||||
<Tooltip content={!isManagedEventType ? pageSlug : t("username_placeholder")}>
|
||||
<span className="max-w-24 md:max-w-56">
|
||||
/{!isManagedEventType ? pageSlug : t("username_placeholder")}/
|
||||
{`/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
containerClassName="[&>div]:gap-0"
|
||||
className="pl-0"
|
||||
{...register("slug")}
|
||||
onChange={(e) => {
|
||||
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
|
||||
@@ -91,11 +93,13 @@ export default function CreateEventTypeForm({
|
||||
<Tooltip
|
||||
content={`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}>
|
||||
<span className="max-w-24 md:max-w-56">
|
||||
{urlPrefix}/{!isManagedEventType ? pageSlug : t("username_placeholder")}/
|
||||
{`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
containerClassName="[&>div]:gap-0"
|
||||
className="pl-0"
|
||||
{...register("slug")}
|
||||
onChange={(e) => {
|
||||
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
|
||||
|
||||
@@ -208,9 +208,12 @@ export const EventSetupTab = (
|
||||
{...(isManagedEventType || isChildrenManagedEventType ? urlLockedProps : {})}
|
||||
defaultValue={eventType.slug}
|
||||
data-testid="event-slug"
|
||||
containerClassName={classNames(customClassNames?.titleSection?.urlInput?.container)}
|
||||
containerClassName={classNames(
|
||||
"[&>div]:gap-0",
|
||||
customClassNames?.titleSection?.urlInput?.container
|
||||
)}
|
||||
labelClassName={classNames(customClassNames?.titleSection?.urlInput?.label)}
|
||||
className={classNames(customClassNames?.titleSection?.urlInput?.input)}
|
||||
className={classNames("pl-0", customClassNames?.titleSection?.urlInput?.input)}
|
||||
addOnLeading={
|
||||
isPlatform ? undefined : (
|
||||
<>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { InfoBadge } from "@calcom/ui/components/badge";
|
||||
import { Label } from "@calcom/ui/components/form";
|
||||
@@ -19,6 +20,19 @@ import {
|
||||
} from "./useShouldBeDisabledDueToPrefill";
|
||||
import { getTranslatedConfig as getTranslatedVariantsConfig } from "./utils/variantsConfig";
|
||||
|
||||
// helper to render markdown label safely
|
||||
const renderLabel = (field: Partial<RhfFormField>) => {
|
||||
if (field.labelAsSafeHtml) {
|
||||
return (
|
||||
<span
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(field.labelAsSafeHtml) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span>{field.label}</span>;
|
||||
};
|
||||
|
||||
type RhfForm = {
|
||||
fields: z.infer<typeof fieldsSchema>;
|
||||
};
|
||||
@@ -155,7 +169,7 @@ const WithLabel = ({
|
||||
field.label && (
|
||||
<div className="mb-2 flex items-center">
|
||||
<Label className="!mb-0 flex items-center" htmlFor={htmlFor}>
|
||||
<span>{field.label}</span>
|
||||
{renderLabel(field)}
|
||||
<span className="text-emphasis -mb-1 ml-1 text-sm font-medium leading-none">
|
||||
{!readOnly && field.required ? "*" : ""}
|
||||
</span>
|
||||
|
||||
@@ -4,4 +4,4 @@ import type { FieldType } from "./schema";
|
||||
* Once a component supports `labelAsSafeHtml`, add it's field's type here
|
||||
* A whitelist is needed because unless we explicitly use dangerouslySetInnerHTML, React will escape the HTML
|
||||
*/
|
||||
export const fieldsThatSupportLabelAsSafeHtml: FieldType[] = ["boolean"];
|
||||
export const fieldsThatSupportLabelAsSafeHtml: FieldType[] = ["boolean", "radio"];
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Card } from "@tremor/react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
interface ICardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CardInsights = (props: ICardProps) => {
|
||||
const { children, className = "", ...rest } = props;
|
||||
|
||||
return (
|
||||
<Card className={classNames(`ring-subtle bg-muted shadow-none `, className)} {...rest}>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -21,7 +21,7 @@ export function ChartCard({
|
||||
legendSize,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
title: string | React.ReactNode;
|
||||
subtitle?: string;
|
||||
cta?: { label: string; onClick: () => void };
|
||||
legend?: Array<LegendItem>;
|
||||
@@ -31,7 +31,11 @@ export function ChartCard({
|
||||
return (
|
||||
<div className="bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1">
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
|
||||
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
|
||||
{typeof title === "string" ? (
|
||||
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
<div className="no-scrollbar flex items-center gap-2 overflow-x-auto">
|
||||
{legend && legend.length > 0 && <Legend items={legend} size={legendSize} />}
|
||||
{cta && (
|
||||
@@ -80,13 +84,13 @@ function Legend({ items, size = "default" }: { items: LegendItem[]; size?: Legen
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.label}>
|
||||
<div
|
||||
className="relative flex items-center gap-2 rounded-md px-1.5 py-1"
|
||||
className="relative flex items-center gap-2 rounded-md px-1.5 py-0.5"
|
||||
style={{ backgroundColor: `${item.color}33` }}>
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<Tooltip content={item.label}>
|
||||
<p
|
||||
className={classNames(
|
||||
"text-default truncate text-sm font-medium leading-none",
|
||||
"text-default truncate py-0.5 text-sm font-medium leading-none",
|
||||
size === "sm" ? "w-16" : ""
|
||||
)}>
|
||||
{item.label}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Flex, Text, Metric, BadgeDelta } from "@tremor/react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import { calculateDeltaType, colors, valueFormatter } from "../lib";
|
||||
import { calculateDeltaType, valueFormatter } from "../lib";
|
||||
|
||||
export const KPICard = ({
|
||||
title,
|
||||
@@ -13,7 +13,6 @@ export const KPICard = ({
|
||||
previousDateRange,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
previousMetricData: {
|
||||
count: number;
|
||||
deltaPrevious: number;
|
||||
@@ -21,22 +20,54 @@ export const KPICard = ({
|
||||
previousDateRange: { startDate: string; endDate: string };
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const deltaType = calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count);
|
||||
const deltaValue = Number(previousMetricData.deltaPrevious).toFixed(0);
|
||||
|
||||
const getBadgeVariant = (type: string) => {
|
||||
switch (type) {
|
||||
case "increase":
|
||||
case "moderateIncrease":
|
||||
return "success" as const;
|
||||
case "decrease":
|
||||
case "moderateDecrease":
|
||||
return "error" as const;
|
||||
case "unchanged":
|
||||
default:
|
||||
return "gray" as const;
|
||||
}
|
||||
};
|
||||
|
||||
// Get appropriate icon for delta
|
||||
const getDeltaIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "increase":
|
||||
case "moderateIncrease":
|
||||
return "arrow-up" as const;
|
||||
case "decrease":
|
||||
case "moderateDecrease":
|
||||
return "arrow-down" as const;
|
||||
case "unchanged":
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const badgeVariant = getBadgeVariant(deltaType);
|
||||
const deltaIcon = getDeltaIcon(deltaType);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text className="text-default">{title}</Text>
|
||||
<Flex className="items-baseline justify-start space-x-3 truncate">
|
||||
<Metric className="text-emphasis">{valueFormatter(previousMetricData.count)}</Metric>
|
||||
</Flex>
|
||||
<Flex className="mt-4 justify-start space-x-2">
|
||||
<BadgeDelta
|
||||
deltaType={calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)}
|
||||
/>
|
||||
<Flex className="justify-start space-x-1 truncate">
|
||||
<Text
|
||||
color={colors[calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)]}>
|
||||
{Number(previousMetricData.deltaPrevious).toFixed(0)}%
|
||||
</Text>
|
||||
|
||||
<div className="text-default text-sm">{title}</div>
|
||||
<div className="flex items-baseline justify-start space-x-3 truncate">
|
||||
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(previousMetricData.count)}</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-start space-x-2">
|
||||
<Badge variant={badgeVariant} className="flex items-center gap-1">
|
||||
{deltaIcon && <Icon name={deltaIcon} className="h-3 w-3" />}
|
||||
{deltaValue}%
|
||||
</Badge>
|
||||
<div className="flex justify-start space-x-1 truncate">
|
||||
<Tooltip
|
||||
content={t("from_to_date_period", {
|
||||
startDate: previousDateRange.startDate,
|
||||
@@ -46,8 +77,8 @@ export const KPICard = ({
|
||||
{t("from_last_period")}
|
||||
</small>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { LineChart as ExternalLineChart } from "@tremor/react";
|
||||
|
||||
import type { LineChartProps } from "./tremor.types";
|
||||
|
||||
// Honestly this is a mess. Why are all chart libraries in existance horrible to theme
|
||||
export const LineChart = (props: LineChartProps) => {
|
||||
return <ExternalLineChart {...props} />;
|
||||
};
|
||||
@@ -1,26 +1,25 @@
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import { CardInsights } from "./Card";
|
||||
import { ChartCard } from "./ChartCard";
|
||||
|
||||
export const LoadingInsight = () => {
|
||||
return (
|
||||
<CardInsights>
|
||||
<SkeletonText className="w-32" />
|
||||
<ChartCard title={<SkeletonText className="w-32" />}>
|
||||
<div className="m-auto flex h-80 flex-col items-center justify-center">
|
||||
<svg
|
||||
className={classNames("mx-4 h-8 w-8 animate-spin", "text-black dark:text-white")}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
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>
|
||||
</CardInsights>
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,25 +2,24 @@
|
||||
|
||||
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 UserStatsTable = ({
|
||||
data,
|
||||
}: {
|
||||
data:
|
||||
| {
|
||||
userId: number | null;
|
||||
user: Pick<User, "avatarUrl" | "name">;
|
||||
emailMd5?: string;
|
||||
count?: number;
|
||||
averageRating?: number | null;
|
||||
username?: string;
|
||||
}[]
|
||||
| undefined;
|
||||
}) => {
|
||||
type UserStatsData = {
|
||||
userId: number;
|
||||
user: {
|
||||
id: number;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
emailMd5: string;
|
||||
count: number;
|
||||
}[];
|
||||
|
||||
export const UserStatsTable = ({ data }: { data: UserStatsData }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Filter out items without user data
|
||||
@@ -32,7 +31,7 @@ export const UserStatsTable = ({
|
||||
filteredData.map((item) => (
|
||||
<ChartCardItem
|
||||
key={item.userId || `user-${Math.random()}`}
|
||||
count={item.averageRating ? item.averageRating.toFixed(1) : item.count}
|
||||
count={Number.isInteger(item.count) ? item.count : item.count.toFixed(1)}
|
||||
className="py-3">
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
|
||||
@@ -1,14 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { valueFormatter } from "../../lib/valueFormatter";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LineChart } from "../LineChart";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
const COLOR = {
|
||||
AVERAGE: "#3b82f6",
|
||||
};
|
||||
|
||||
type AverageEventDurationData = RouterOutputs["viewer"]["insights"]["averageEventDuration"][number];
|
||||
|
||||
// Custom duration formatter that shows hours and minutes
|
||||
const formatDuration = (minutes: number) => {
|
||||
if (!minutes) return "0m";
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
const remainingMinutesStr = remainingMinutes.toFixed(1);
|
||||
|
||||
if (hours > 0 && remainingMinutes > 0) {
|
||||
return `${hours}h ${remainingMinutesStr}m`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${remainingMinutesStr}m`;
|
||||
}
|
||||
};
|
||||
|
||||
// Custom Tooltip component
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value: number;
|
||||
dataKey: string;
|
||||
name: string;
|
||||
color: string;
|
||||
payload: AverageEventDurationData;
|
||||
}>;
|
||||
label?: string;
|
||||
}) => {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default text-inverted 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} style={{ color: entry.color }}>
|
||||
{entry.name}: {formatDuration(entry.value)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AverageEventDurationChart = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
@@ -36,14 +92,32 @@ export const AverageEventDurationChart = () => {
|
||||
</div>
|
||||
)}
|
||||
{data && data.length > 0 && !isNoData && (
|
||||
<LineChart
|
||||
className="mt-4 h-80"
|
||||
data={data}
|
||||
index="Date"
|
||||
categories={["Average"]}
|
||||
colors={["blue"]}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -84,7 +84,6 @@ export const BookingKPICards = () => {
|
||||
<StatItem key={item.title} index={index}>
|
||||
<KPICard
|
||||
title={item.title}
|
||||
value={data[item.index].count}
|
||||
previousMetricData={data[item.index]}
|
||||
previousDateRange={data.previousRange}
|
||||
/>
|
||||
@@ -99,7 +98,6 @@ export const BookingKPICards = () => {
|
||||
<StatItem key={item.title} index={index}>
|
||||
<KPICard
|
||||
title={item.title}
|
||||
value={data[item.index].count}
|
||||
previousMetricData={data[item.index]}
|
||||
previousDateRange={data.previousRange}
|
||||
/>
|
||||
|
||||
@@ -13,15 +13,13 @@ import {
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
type BookingsByHourData = {
|
||||
hour: number;
|
||||
count: number;
|
||||
};
|
||||
type BookingsByHourData = RouterOutputs["viewer"]["insights"]["bookingsByHourStats"][number];
|
||||
|
||||
export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[] }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -1,14 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { valueFormatter } from "../../lib/valueFormatter";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LineChart } from "../LineChart";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
const COLOR = {
|
||||
CREATED: "#a855f7",
|
||||
COMPLETED: "#22c55e",
|
||||
RESCHEDULED: "#3b82f6",
|
||||
CANCELLED: "#ef4444",
|
||||
NO_SHOW_HOST: "#64748b",
|
||||
NO_SHOW_GUEST: "#f97316",
|
||||
};
|
||||
|
||||
export const legend = [
|
||||
{ label: "Created", color: COLOR.CREATED },
|
||||
{ label: "Completed", color: COLOR.COMPLETED },
|
||||
{ label: "Rescheduled", color: COLOR.RESCHEDULED },
|
||||
{ label: "Cancelled", color: COLOR.CANCELLED },
|
||||
{ label: "No-Show (Host)", color: COLOR.NO_SHOW_HOST },
|
||||
{ label: "No-Show (Guest)", color: COLOR.NO_SHOW_GUEST },
|
||||
];
|
||||
|
||||
type EventTrendsData = RouterOutputs["viewer"]["insights"]["eventTrends"][number];
|
||||
|
||||
// Custom Tooltip component
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value: number;
|
||||
dataKey: string;
|
||||
name: string;
|
||||
color: string;
|
||||
payload: EventTrendsData;
|
||||
}>;
|
||||
label?: string;
|
||||
}) => {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
|
||||
<p className="text-default font-medium">{payload[0].payload.formattedDateFull}</p>
|
||||
{payload.map((entry, index: number) => (
|
||||
<p key={index} style={{ color: entry.color }}>
|
||||
{entry.name}: {valueFormatter ? valueFormatter(entry.value) : entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventTrendsChart = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
@@ -30,15 +84,36 @@ export const EventTrendsChart = () => {
|
||||
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 title={t("event_trends")} legend={legend}>
|
||||
<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 />} />
|
||||
{legend.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>
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { FeedbackTable } from "../FeedbackTable";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
import { RecentFeedbackTableContent } from "./RecentFeedbackTableContent";
|
||||
|
||||
export const RecentFeedbackTable = () => {
|
||||
const { t } = useLocale();
|
||||
@@ -26,7 +26,7 @@ export const RecentFeedbackTable = () => {
|
||||
|
||||
return (
|
||||
<ChartCard title={t("recent_ratings")}>
|
||||
<FeedbackTable data={data} />
|
||||
<RecentFeedbackTableContent data={data} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-17
@@ -2,31 +2,20 @@
|
||||
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
export const FeedbackTable = ({
|
||||
data,
|
||||
}: {
|
||||
data:
|
||||
| {
|
||||
userId: number | null;
|
||||
user: Pick<User, "avatarUrl" | "name">;
|
||||
emailMd5?: string;
|
||||
username?: string;
|
||||
rating: number | null;
|
||||
feedback: string | null;
|
||||
}[]
|
||||
| undefined;
|
||||
}) => {
|
||||
type FeedbackData = RouterOutputs["viewer"]["insights"]["recentRatings"];
|
||||
|
||||
export const RecentFeedbackTableContent = ({ data }: { data: FeedbackData }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md">
|
||||
{data && data?.length > 0 ? (
|
||||
data?.map((item) => (
|
||||
{data && data.length > 0 ? (
|
||||
data.map((item) => (
|
||||
<div
|
||||
key={item.userId}
|
||||
className="border-subtle flex items-center justify-between border-b px-4 py-3 last:border-b-0">
|
||||
@@ -1,10 +1,8 @@
|
||||
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";
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Rectangle,
|
||||
} from "recharts";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
@@ -8,7 +18,39 @@ import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
|
||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { BarList } from "../tremor/BarList";
|
||||
|
||||
// Custom Tooltip component
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value: number;
|
||||
dataKey: string;
|
||||
name: string;
|
||||
color: string;
|
||||
payload: { name: string; value: 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("failed_bookings")}: {entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FormCardProps {
|
||||
formName: string;
|
||||
@@ -16,6 +58,7 @@ interface FormCardProps {
|
||||
}
|
||||
|
||||
function FormCard({ formName, fields }: FormCardProps) {
|
||||
const { t } = useLocale();
|
||||
const fieldNames = Object.keys(fields);
|
||||
const [selectedField, setSelectedField] = useState(fieldNames[0]);
|
||||
|
||||
@@ -29,6 +72,17 @@ function FormCard({ formName, fields }: FormCardProps) {
|
||||
value: option.count,
|
||||
}));
|
||||
|
||||
const maxCount = Math.max(...(selectedFieldData?.map((item) => item.value) || [0]));
|
||||
const isEmpty = maxCount === 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="flex flex-col">
|
||||
<div className="p-4">
|
||||
@@ -38,13 +92,36 @@ function FormCard({ formName, fields }: FormCardProps) {
|
||||
className="w-fit"
|
||||
onValueChange={(value) => value && setSelectedField(value)}
|
||||
/>
|
||||
{selectedFieldData && (
|
||||
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
|
||||
<BarList
|
||||
data={selectedFieldData}
|
||||
valueFormatter={(value: number) => value.toString()}
|
||||
className="mt-2"
|
||||
/>
|
||||
{selectedFieldData && selectedFieldData.length > 0 ? (
|
||||
<div className="mt-4 h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={selectedFieldData}
|
||||
layout="vertical"
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 10,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
barCategoryGap="10%">
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis dataKey="name" type="category" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={false} content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill="var(--cal-bg-subtle)"
|
||||
radius={[0, 2, 2, 0]}
|
||||
activeBar={<Rectangle fill="var(--cal-bg-info)" />}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 p-4 text-center text-gray-500">
|
||||
No data available for selected field
|
||||
<div className="mt-2 text-xs">Data: {JSON.stringify(selectedFieldData, null, 2)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -66,8 +143,9 @@ export function FailedBookingsByField() {
|
||||
|
||||
// routingFormId is always set, meaning data has only one entry.
|
||||
const [formName, fields] = Object.entries(data)[0];
|
||||
|
||||
return (
|
||||
<ChartCard title={t("failed_bookings_by_field")} subtitle={formName}>
|
||||
<ChartCard title={t("failed_bookings_by_field")}>
|
||||
<FormCard key={formName} formName={formName} fields={fields} />
|
||||
</ChartCard>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
@@ -165,14 +166,7 @@ function FormCard({
|
||||
);
|
||||
}
|
||||
|
||||
type RoutedToTableRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
stats: { [key: string]: number };
|
||||
performance: "above_average" | "at_average" | "below_average" | "median" | "no_data";
|
||||
totalBookings: number;
|
||||
};
|
||||
type RoutedToTableRow = RouterOutputs["viewer"]["insights"]["routedToPerPeriod"]["users"]["data"][number];
|
||||
|
||||
const getPerformanceBadge = (performance: RoutedToTableRow["performance"], t: TFunction) => {
|
||||
switch (performance) {
|
||||
@@ -292,7 +286,7 @@ export function RoutedToPerPeriod() {
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
}, [data?.pages]);
|
||||
|
||||
const processedData: RoutedToTableRow[] = useMemo(() => {
|
||||
const processedData = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
|
||||
// Create a map for quick lookup of stats
|
||||
@@ -419,7 +413,7 @@ export function RoutedToPerPeriod() {
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name} />
|
||||
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name || ""} />
|
||||
<div className="flex flex-col gap-1 truncate">
|
||||
<span>{row.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@ 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 { useInsightsRoutingFacetedUniqueValues } from "../../hooks/useInsightsRoutingFacetedUniqueValues";
|
||||
import type { RoutingFormTableRow } from "../../lib/types";
|
||||
import { RoutingKPICards } from "./RoutingKPICards";
|
||||
|
||||
@@ -38,19 +38,21 @@ export function RoutingFormResponsesTable() {
|
||||
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
|
||||
useInsightsParameters();
|
||||
|
||||
const {
|
||||
data: headers,
|
||||
isLoading: isHeadersLoading,
|
||||
isSuccess: isHeadersSuccess,
|
||||
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
|
||||
const { data: headers, isSuccess: isHeadersSuccess } =
|
||||
trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const getInsightsFacetedUniqueValues = useInsightsRoutingFacetedUniqueValues({
|
||||
headers,
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers, userId, teamId, isAll });
|
||||
|
||||
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
|
||||
|
||||
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
|
||||
@@ -109,10 +111,6 @@ export function RoutingFormResponsesTable() {
|
||||
}
|
||||
}, [table, getInsightsFacetedUniqueValues, routingFormId]);
|
||||
|
||||
if (isHeadersLoading && !headers) {
|
||||
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useColumnFilters } from "@calcom/features/data-table";
|
||||
import { useInsightsParameters } from "@calcom/features/insights/hooks/useInsightsParameters";
|
||||
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
@@ -11,18 +10,9 @@ import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
|
||||
|
||||
export function RoutingFunnel() {
|
||||
const { t } = useLocale();
|
||||
const { scope, selectedTeamId, startDate, endDate } = useInsightsParameters();
|
||||
const columnFilters = useColumnFilters({
|
||||
exclude: ["createdAt"],
|
||||
});
|
||||
const insightsRoutingParams = useInsightsRoutingParameters();
|
||||
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
|
||||
{
|
||||
scope,
|
||||
selectedTeamId,
|
||||
startDate,
|
||||
endDate,
|
||||
columnFilters,
|
||||
},
|
||||
insightsRoutingParams,
|
||||
{
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"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 classNames from "@calcom/ui/classNames";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||
import { valueFormatter } from "../../lib";
|
||||
import { CardInsights } from "../Card";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
|
||||
export const RoutingKPICards = () => {
|
||||
const { t } = useLocale();
|
||||
@@ -61,35 +60,46 @@ export const RoutingKPICards = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid numColsSm={1} numColsLg={3} className="mt-4 gap-x-4 gap-y-4">
|
||||
<ChartCard title={t("stats")}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{categories.map((item) => (
|
||||
<CardInsights key={item.title}>
|
||||
<Text className="text-default">{item.title}</Text>
|
||||
<Flex className="items-baseline justify-start space-x-3 truncate">
|
||||
<Metric className="text-emphasis">{valueFormatter(data[item.index])}</Metric>
|
||||
</Flex>
|
||||
</CardInsights>
|
||||
<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>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
</div>
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
|
||||
const { t } = useLocale();
|
||||
const { categories } = props;
|
||||
return (
|
||||
<Grid numColsSm={2} numColsLg={4} className="mt-4 gap-x-4 gap-y-4">
|
||||
{categories.map((item) => (
|
||||
<CardInsights key={item.title}>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||
<div className="mt-4 flex items-baseline space-x-3">
|
||||
<div className="h-8 w-16 rounded bg-gray-200" />
|
||||
<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>
|
||||
<SkeletonText className="mt-1 h-5 w-32" />
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="h-6 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
</CardInsights>
|
||||
))}
|
||||
</Grid>
|
||||
))}
|
||||
</div>
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Media query for screens larger than 768px */
|
||||
@media (max-width: 639) {
|
||||
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.recharts-cartesian-grid-horizontal line {
|
||||
@apply stroke-emphasis;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-button button {
|
||||
@apply border-default hover:border-emphasis !h-9 !max-h-9;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarButton,
|
||||
.tremor-DateRangePicker-dropdownButton {
|
||||
@apply border-subtle bg-default focus-within:ring-emphasis hover:border-subtle dark:focus-within:ring-emphasis hover:bg-subtle text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-dropdownModal {
|
||||
@apply divide-none;
|
||||
}
|
||||
|
||||
.tremor-DropdownItem-root {
|
||||
@apply bg-default hover:bg-subtle text-default hover:text-emphasis !h-9 !max-h-9;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarButtonText,
|
||||
.tremor-DateRangePicker-dropdownButtonText {
|
||||
@apply text-default;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarHeaderText {
|
||||
@apply !text-default;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarHeader svg {
|
||||
@apply text-default;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarHeader button {
|
||||
@apply hover:bg-emphasis shadow-none focus:ring-0;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarHeader button:hover svg {
|
||||
@apply text-emphasis;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarButtonIcon {
|
||||
@apply text-default;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarModal,
|
||||
.tremor-DateRangePicker-dropdownModal {
|
||||
@apply bg-default border-subtle shadow-dropdown;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarBodyDate button {
|
||||
@apply text-default hover:bg-emphasis;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarBodyDate button:disabled,
|
||||
.tremor-DateRangePicker-calendarBodyDate button[disabled] {
|
||||
@apply opacity-25;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarHeader button {
|
||||
@apply border-default text-default;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarBodyDate .bg-gray-100 {
|
||||
@apply bg-subtle;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarBodyDate .bg-gray-500 {
|
||||
@apply !bg-brand-default text-inverted;
|
||||
}
|
||||
|
||||
.tremor-Card-root {
|
||||
@apply bg-default p-5;
|
||||
}
|
||||
|
||||
.tremor-TableCell-root {
|
||||
@apply pl-0;
|
||||
}
|
||||
|
||||
.recharts-responsive-container {
|
||||
@apply -mx-4;
|
||||
}
|
||||
.tremor-Card-root > p {
|
||||
@apply mb-2 text-base font-semibold;
|
||||
}
|
||||
|
||||
.tremor-Legend-legendItem {
|
||||
@apply ml-2;
|
||||
}
|
||||
|
||||
.tremor-TableBody-root {
|
||||
@apply divide-subtle;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
type ValueFormatter = {
|
||||
(value: number): string;
|
||||
};
|
||||
|
||||
type Color =
|
||||
| "slate"
|
||||
| "gray"
|
||||
| "zinc"
|
||||
| "neutral"
|
||||
| "stone"
|
||||
| "red"
|
||||
| "orange"
|
||||
| "amber"
|
||||
| "yellow"
|
||||
| "lime"
|
||||
| "green"
|
||||
| "emerald"
|
||||
| "teal"
|
||||
| "cyan"
|
||||
| "sky"
|
||||
| "blue"
|
||||
| "indigo"
|
||||
| "violet"
|
||||
| "purple"
|
||||
| "fuchsia"
|
||||
| "pink"
|
||||
| "rose";
|
||||
|
||||
interface BaseChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
data: any[];
|
||||
categories: string[];
|
||||
index: string;
|
||||
colors?: Color[];
|
||||
valueFormatter?: ValueFormatter;
|
||||
startEndOnly?: boolean;
|
||||
showXAxis?: boolean;
|
||||
showYAxis?: boolean;
|
||||
yAxisWidth?: number;
|
||||
showAnimation?: boolean;
|
||||
showTooltip?: boolean;
|
||||
showGradient?: boolean;
|
||||
showLegend?: boolean;
|
||||
showGridLines?: boolean;
|
||||
autoMinValue?: boolean;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
}
|
||||
|
||||
export type LineChartProps = BaseChartProps & React.RefAttributes<HTMLDivElement>;
|
||||
@@ -1,167 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Tremor BarList [v0.1.1]
|
||||
import React from "react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
type Bar<T> = T & {
|
||||
key?: string;
|
||||
href?: string;
|
||||
value: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const focusRing = [
|
||||
// base
|
||||
"outline outline-offset-2 outline-0 focus-visible:outline-2",
|
||||
// outline color
|
||||
"outline-brand",
|
||||
];
|
||||
|
||||
interface BarListProps<T = unknown> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
data: Bar<T>[];
|
||||
valueFormatter?: (value: number) => string;
|
||||
showAnimation?: boolean;
|
||||
onValueChange?: (payload: Bar<T>) => void;
|
||||
sortOrder?: "ascending" | "descending" | "none";
|
||||
}
|
||||
|
||||
function BarListInner<T>(
|
||||
{
|
||||
data = [],
|
||||
valueFormatter = (value) => value.toString(),
|
||||
showAnimation = false,
|
||||
onValueChange,
|
||||
sortOrder = "descending",
|
||||
className,
|
||||
...props
|
||||
}: BarListProps<T>,
|
||||
forwardedRef: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const Component = onValueChange ? "button" : "div";
|
||||
const sortedData = React.useMemo(() => {
|
||||
if (sortOrder === "none") {
|
||||
return data;
|
||||
}
|
||||
return [...data].sort((a, b) => {
|
||||
return sortOrder === "ascending" ? a.value - b.value : b.value - a.value;
|
||||
});
|
||||
}, [data, sortOrder]);
|
||||
|
||||
const widths = React.useMemo(() => {
|
||||
const maxValue = Math.max(...sortedData.map((item) => item.value), 0);
|
||||
return sortedData.map((item) => (item.value === 0 ? 0 : Math.max((item.value / maxValue) * 100, 2)));
|
||||
}, [sortedData]);
|
||||
|
||||
const rowHeight = "h-8";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={classNames("flex justify-between space-x-6", className)}
|
||||
aria-sort={sortOrder}
|
||||
tremor-id="tremor-raw"
|
||||
{...props}>
|
||||
<div className="relative w-full space-y-1.5">
|
||||
{sortedData.map((item, index) => (
|
||||
<Component
|
||||
key={item.key ?? item.name}
|
||||
onClick={() => {
|
||||
onValueChange?.(item);
|
||||
}}
|
||||
className={classNames(
|
||||
// base
|
||||
"group w-full rounded",
|
||||
// focus
|
||||
focusRing,
|
||||
onValueChange
|
||||
? [
|
||||
"!-m-0 cursor-pointer",
|
||||
// hover
|
||||
"hover:bg-gray-50 hover:dark:bg-gray-900",
|
||||
]
|
||||
: ""
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
// base
|
||||
"flex items-center rounded transition-all",
|
||||
rowHeight,
|
||||
// background color
|
||||
"bg-blue-200 dark:bg-blue-900",
|
||||
onValueChange ? "group-hover:bg-blue-300 group-hover:dark:bg-blue-800" : "",
|
||||
// margin and duration
|
||||
{
|
||||
"mb-0": index === sortedData.length - 1,
|
||||
"duration-800": showAnimation,
|
||||
}
|
||||
)}
|
||||
style={{ width: `${widths[index]}%` }}>
|
||||
<div className={classNames("absolute left-2 flex max-w-full pr-2")}>
|
||||
{item.href ? (
|
||||
<a
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
// base
|
||||
"truncate whitespace-nowrap rounded text-sm",
|
||||
// text color
|
||||
"text-default",
|
||||
// hover
|
||||
"hover:underline hover:underline-offset-2",
|
||||
// focus
|
||||
focusRing
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(event) => event.stopPropagation()}>
|
||||
{item.name}
|
||||
</a>
|
||||
) : (
|
||||
<p
|
||||
className={classNames(
|
||||
// base
|
||||
"truncate whitespace-nowrap text-sm",
|
||||
// text color
|
||||
"text-default"
|
||||
)}>
|
||||
{item.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
{sortedData.map((item, index) => (
|
||||
<div
|
||||
key={item.key ?? item.name}
|
||||
className={classNames(
|
||||
"flex items-center justify-end",
|
||||
rowHeight,
|
||||
index === sortedData.length - 1 ? "mb-0" : "mb-1.5"
|
||||
)}>
|
||||
<p
|
||||
className={classNames(
|
||||
// base
|
||||
"truncate whitespace-nowrap text-sm leading-none",
|
||||
// text color
|
||||
"text-default"
|
||||
)}>
|
||||
{valueFormatter(item.value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BarListInner.displayName = "BarList";
|
||||
|
||||
const BarList = React.forwardRef(BarListInner) as <T>(
|
||||
p: BarListProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
|
||||
) => ReturnType<typeof BarListInner>;
|
||||
|
||||
export { BarList, type BarListProps };
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { convertFacetedValuesToMap, type FacetedValue } from "@calcom/features/data-table";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { bookingStatusToText } from "../lib/bookingStatusToText";
|
||||
|
||||
const statusOrder: Record<BookingStatus, number> = {
|
||||
[BookingStatus.ACCEPTED]: 1,
|
||||
[BookingStatus.PENDING]: 2,
|
||||
[BookingStatus.AWAITING_HOST]: 3,
|
||||
[BookingStatus.CANCELLED]: 4,
|
||||
[BookingStatus.REJECTED]: 5,
|
||||
};
|
||||
|
||||
export const useInsightsBookingFacetedUniqueValues = ({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
}: {
|
||||
userId: number | undefined;
|
||||
teamId: number | undefined;
|
||||
isAll: boolean;
|
||||
}) => {
|
||||
const { data: users } = trpc.viewer.insights.userList.useQuery(
|
||||
{
|
||||
teamId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: eventTypes } = trpc.viewer.insights.eventTypeList.useQuery(
|
||||
{
|
||||
teamId,
|
||||
userId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
(_: Table<any>, columnId: string) => (): Map<FacetedValue, number> => {
|
||||
if (columnId === "status") {
|
||||
return convertFacetedValuesToMap(
|
||||
Object.keys(statusOrder).map((status) => ({
|
||||
value: status.toLowerCase(),
|
||||
label: bookingStatusToText(status as BookingStatus),
|
||||
}))
|
||||
);
|
||||
} else if (columnId === "userId") {
|
||||
return convertFacetedValuesToMap(
|
||||
users?.map((user) => ({
|
||||
label: user.name ?? user.email,
|
||||
value: user.id,
|
||||
})) ?? []
|
||||
);
|
||||
} else if (columnId === "eventTypeId") {
|
||||
return convertFacetedValuesToMap(
|
||||
eventTypes?.map((eventType) => ({
|
||||
value: eventType.id,
|
||||
label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
return new Map<FacetedValue, number>();
|
||||
},
|
||||
[users, eventTypes]
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ZSingleSelectFilterValue, ZDateRangeFilterValue } from "@calcom/features/data-table";
|
||||
import { ZDateRangeFilterValue } from "@calcom/features/data-table";
|
||||
import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime";
|
||||
import { useColumnFilters } from "@calcom/features/data-table/hooks/useColumnFilters";
|
||||
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";
|
||||
@@ -14,8 +15,6 @@ 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.
|
||||
@@ -34,6 +33,7 @@ export function useInsightsBookingParameters() {
|
||||
.toISOString();
|
||||
}, [createdAtRange?.endDate])
|
||||
);
|
||||
const columnFilters = useColumnFilters({ exclude: ["createdAt"] });
|
||||
|
||||
return {
|
||||
scope,
|
||||
@@ -41,7 +41,6 @@ export function useInsightsBookingParameters() {
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone: timeZone || CURRENT_TIMEZONE,
|
||||
eventTypeId,
|
||||
memberUserId,
|
||||
columnFilters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,25 +3,24 @@ import { useMemo } from "react";
|
||||
|
||||
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type { HeaderRow, RoutingFormTableRow } from "../lib/types";
|
||||
import { useInsightsFacetedUniqueValues } from "./useInsightsFacetedUniqueValues";
|
||||
import { useInsightsBookingFacetedUniqueValues } from "./useInsightsBookingFacetedUniqueValues";
|
||||
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
|
||||
|
||||
type DummyTableRow = {
|
||||
bookingUserId: RoutingFormTableRow["bookingUserId"];
|
||||
userId: number | null;
|
||||
eventTypeId: number | null;
|
||||
status: BookingStatus;
|
||||
};
|
||||
|
||||
const emptyData: DummyTableRow[] = [];
|
||||
const dummyHeaders: HeaderRow[] = [];
|
||||
|
||||
export const useInsightsBookings = () => {
|
||||
const { t } = useLocale();
|
||||
const { isAll, teamId, userId } = useInsightsOrgTeams();
|
||||
|
||||
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({
|
||||
headers: dummyHeaders,
|
||||
const getInsightsFacetedUniqueValues = useInsightsBookingFacetedUniqueValues({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
@@ -30,18 +29,6 @@ export const useInsightsBookings = () => {
|
||||
const columns = useMemo(() => {
|
||||
const columnHelper = createColumnHelper<DummyTableRow>();
|
||||
return [
|
||||
columnHelper.accessor("bookingUserId", {
|
||||
id: "bookingUserId",
|
||||
header: t("user"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
},
|
||||
},
|
||||
cell: () => null,
|
||||
}),
|
||||
columnHelper.accessor("eventTypeId", {
|
||||
id: "eventTypeId",
|
||||
header: t("event_type"),
|
||||
@@ -55,6 +42,31 @@ export const useInsightsBookings = () => {
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
}),
|
||||
columnHelper.accessor("status", {
|
||||
id: "status",
|
||||
header: t("booking_status"),
|
||||
size: 200,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
}),
|
||||
columnHelper.accessor("userId", {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
},
|
||||
},
|
||||
cell: () => null,
|
||||
}),
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ const statusOrder: Record<BookingStatus, number> = {
|
||||
[BookingStatus.REJECTED]: 5,
|
||||
};
|
||||
|
||||
export const useInsightsFacetedUniqueValues = ({
|
||||
export const useInsightsRoutingFacetedUniqueValues = ({
|
||||
headers,
|
||||
userId,
|
||||
teamId,
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useFilterValue, useColumnFilters, ZDateRangeFilterValue } from "@calcom/features/data-table";
|
||||
import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime";
|
||||
import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange";
|
||||
|
||||
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
|
||||
|
||||
export function useInsightsRoutingParameters() {
|
||||
const { scope, selectedTeamId } = useInsightsOrgTeams();
|
||||
|
||||
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])
|
||||
);
|
||||
|
||||
const columnFilters = useColumnFilters({
|
||||
exclude: ["createdAt"],
|
||||
});
|
||||
|
||||
return {
|
||||
scope,
|
||||
selectedTeamId,
|
||||
startDate,
|
||||
endDate,
|
||||
columnFilters,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Color } from "@tremor/react";
|
||||
|
||||
export const colors: { [key: string]: Color } = {
|
||||
increase: "emerald",
|
||||
moderateIncrease: "emerald",
|
||||
unchanged: "orange",
|
||||
moderateDecrease: "rose",
|
||||
decrease: "rose",
|
||||
};
|
||||
@@ -1,3 +1,2 @@
|
||||
export { calculateDeltaType } from "./calculateDeltaType";
|
||||
export { colors } from "./colors";
|
||||
export { valueFormatter } from "./valueFormatter";
|
||||
|
||||
@@ -76,6 +76,5 @@ export const bookingRepositoryBaseInputSchema = z.object({
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
timeZone: z.string(),
|
||||
eventTypeId: z.coerce.number().optional().nullable(),
|
||||
memberUserId: z.coerce.number().optional().nullable(),
|
||||
columnFilters: z.array(ZColumnFilter).optional(),
|
||||
});
|
||||
|
||||
@@ -320,7 +320,7 @@ function createInsightsBookingService(
|
||||
input: z.infer<typeof bookingRepositoryBaseInputSchema>,
|
||||
dateTarget: "createdAt" | "startTime" = "createdAt"
|
||||
) {
|
||||
const { scope, selectedTeamId, eventTypeId, memberUserId, startDate, endDate } = input;
|
||||
const { scope, selectedTeamId, startDate, endDate, columnFilters } = input;
|
||||
|
||||
return new InsightsBookingService({
|
||||
prisma: ctx.insightsDb,
|
||||
@@ -331,8 +331,7 @@ function createInsightsBookingService(
|
||||
...(selectedTeamId && { teamId: selectedTeamId }),
|
||||
},
|
||||
filters: {
|
||||
...(eventTypeId && { eventTypeId }),
|
||||
...(memberUserId && { memberUserId }),
|
||||
...(columnFilters && { columnFilters }),
|
||||
dateRange: {
|
||||
target: dateTarget,
|
||||
startDate,
|
||||
@@ -341,6 +340,26 @@ function createInsightsBookingService(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createInsightsRoutingService(
|
||||
ctx: { insightsDb: typeof readonlyPrisma; user: { id: number; organizationId: number | null } },
|
||||
input: z.infer<typeof routingRepositoryBaseInputSchema>
|
||||
) {
|
||||
return getInsightsRoutingService({
|
||||
options: {
|
||||
scope: input.scope,
|
||||
teamId: input.selectedTeamId,
|
||||
userId: ctx.user.id,
|
||||
orgId: ctx.user.organizationId,
|
||||
},
|
||||
filters: {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
columnFilters: input.columnFilters,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const insightsRouter = router({
|
||||
bookingKPIStats: userBelongsToTeamProcedure
|
||||
.input(bookingRepositoryBaseInputSchema)
|
||||
@@ -1002,19 +1021,7 @@ export const insightsRouter = router({
|
||||
timeView,
|
||||
weekStart: ctx.user.weekStart,
|
||||
});
|
||||
const insightsRoutingService = getInsightsRoutingService({
|
||||
options: {
|
||||
scope: input.scope,
|
||||
teamId: input.selectedTeamId,
|
||||
userId: ctx.user.id,
|
||||
orgId: ctx.user.organizationId,
|
||||
},
|
||||
filters: {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
columnFilters: input.columnFilters,
|
||||
},
|
||||
});
|
||||
const insightsRoutingService = createInsightsRoutingService(ctx, input);
|
||||
try {
|
||||
return await insightsRoutingService.getRoutingFunnelData(dateRanges);
|
||||
} catch (e) {
|
||||
|
||||
@@ -95,6 +95,7 @@ describe("handleInstantMeeting", () => {
|
||||
responses: {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
attendeePhoneNumber: "+918888888888",
|
||||
},
|
||||
metadata: {},
|
||||
};
|
||||
@@ -118,13 +119,14 @@ describe("handleInstantMeeting", () => {
|
||||
|
||||
const booking = await prismock.booking.findUnique({
|
||||
where: { id: result.bookingId },
|
||||
include: { attendees: true, references: true },
|
||||
select: { status: true, attendees: true, references: true },
|
||||
});
|
||||
|
||||
expect(booking).toBeDefined();
|
||||
expect(booking?.status).toBe(BookingStatus.AWAITING_HOST);
|
||||
expect(booking?.attendees).toHaveLength(1);
|
||||
expect(booking?.attendees[0].email).toBe("test@example.com");
|
||||
expect(booking?.attendees[0].phoneNumber).toBe("+918888888888");
|
||||
expect(booking?.references).toHaveLength(1);
|
||||
expect(booking?.references[0].type).toBe("daily_video");
|
||||
});
|
||||
@@ -172,6 +174,7 @@ describe("handleInstantMeeting", () => {
|
||||
responses: {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
attendeePhoneNumber: "+918888888888",
|
||||
},
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
@@ -200,6 +200,7 @@ async function handler(req: NextApiRequest) {
|
||||
name: fullName,
|
||||
timeZone: attendeeTimezone,
|
||||
locale: attendeeLanguage ?? "en",
|
||||
phoneNumber: reqBody.attendeePhoneNumber ?? null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -209,6 +210,7 @@ async function handler(req: NextApiRequest) {
|
||||
name: "",
|
||||
timeZone: attendeeTimezone,
|
||||
locale: "en",
|
||||
phoneNumber: null,
|
||||
});
|
||||
return guestArray;
|
||||
}, [] as typeof invitee);
|
||||
|
||||
@@ -18,7 +18,8 @@ import { Switch } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions";
|
||||
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhooksListGetByViewer } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhookList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/actions";
|
||||
|
||||
type WebhookProps = {
|
||||
id: string;
|
||||
@@ -46,21 +47,19 @@ export default function WebhookListItem(props: {
|
||||
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
|
||||
async onSuccess() {
|
||||
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
|
||||
revalidateWebhooksList();
|
||||
revalidateWebhooksListGetByViewer();
|
||||
revalidateWebhookList();
|
||||
showToast(t("webhook_removed_successfully"), "success");
|
||||
await utils.viewer.webhook.getByViewer.invalidate();
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
});
|
||||
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
|
||||
async onSuccess(data) {
|
||||
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
|
||||
revalidateWebhooksList();
|
||||
revalidateWebhooksListGetByViewer();
|
||||
revalidateWebhookList();
|
||||
// TODO: Better success message
|
||||
showToast(t(data?.active ? "enabled" : "disabled"), "success");
|
||||
await utils.viewer.webhook.getByViewer.invalidate();
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhooksListGetByViewer } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhookById } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/[id]/actions";
|
||||
import { revalidateWebhookList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/actions";
|
||||
|
||||
import type { WebhookFormSubmitData } from "../components/WebhookForm";
|
||||
import WebhookForm from "../components/WebhookForm";
|
||||
@@ -43,10 +45,10 @@ export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) {
|
||||
});
|
||||
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
await utils.viewer.webhook.get.invalidate({ webhookId: webhook?.id });
|
||||
revalidateWebhookById(webhook?.id ?? "");
|
||||
revalidateWebhookList();
|
||||
showToast(t("webhook_updated_successfully"), "success");
|
||||
revalidateWebhooksList();
|
||||
revalidateWebhooksListGetByViewer();
|
||||
router.push("/settings/developer/webhooks");
|
||||
},
|
||||
onError(error) {
|
||||
|
||||
@@ -10,7 +10,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhooksListGetByViewer } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhookList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/actions";
|
||||
|
||||
import type { WebhookFormSubmitData } from "../components/WebhookForm";
|
||||
import WebhookForm from "../components/WebhookForm";
|
||||
@@ -35,7 +36,8 @@ export const NewWebhookView = ({ webhooks, installedApps }: Props) => {
|
||||
async onSuccess() {
|
||||
showToast(t("webhook_created_successfully"), "success");
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
revalidateWebhooksList();
|
||||
revalidateWebhookList();
|
||||
revalidateWebhooksListGetByViewer();
|
||||
router.push("/settings/developer/webhooks");
|
||||
},
|
||||
onError(error) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -252,6 +252,10 @@ const _getBusyTimes = async (params: {
|
||||
console.log("videoBusyTimes", videoBusyTimes);
|
||||
busyTimes.push(...videoBusyTimes);
|
||||
*/
|
||||
} else {
|
||||
logger.warn(`No credentials found for user ${userId}`, {
|
||||
selectedCalendarIds: selectedCalendars.map((calendar) => calendar.id),
|
||||
});
|
||||
}
|
||||
logger.debug(
|
||||
"getBusyTimes:",
|
||||
|
||||
@@ -9,11 +9,12 @@ const logger = new Logger({
|
||||
prettyErrorStackTemplate: " • {{fileName}}\t{{method}}\n\t{{filePathWithLine}}", // default
|
||||
prettyErrorTemplate: "\n{{errorName}} {{errorMessage}}\nerror stack:\n{{errorStack}}", // default
|
||||
prettyLogTemplate: "{{hh}}:{{MM}}:{{ss}}:{{ms}} [{{logLevelName}}] ", // default with exclusion of `{{filePathWithLine}}`
|
||||
stylePrettyLogs: true,
|
||||
stylePrettyLogs: !IS_PRODUCTION,
|
||||
prettyLogStyles: {
|
||||
name: "yellow",
|
||||
dateIsoStr: "blue",
|
||||
},
|
||||
type: IS_PRODUCTION ? "json" : "pretty",
|
||||
});
|
||||
|
||||
export default logger;
|
||||
|
||||
@@ -34,6 +34,6 @@
|
||||
"@calcom/tsconfig": "*",
|
||||
"@calcom/types": "*",
|
||||
"@faker-js/faker": "^7.3.0",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.9.0-beta"
|
||||
}
|
||||
}
|
||||
|
||||
+24
-47
@@ -1,39 +1,15 @@
|
||||
import { createInstance } from "i18next";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { i18n } = require("@calcom/config/next-i18next.config");
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { fetchWithTimeout } from "../fetchWithTimeout";
|
||||
import logger from "../logger";
|
||||
|
||||
const translationCache = new Map<string, Record<string, string>>();
|
||||
const i18nInstanceCache = new Map<string, any>();
|
||||
|
||||
/**
|
||||
* Loads English fallback translations for when requested locale translations fail
|
||||
* Implements caching to avoid redundant network requests
|
||||
* @returns {Promise<Record<string, string>>} English translations object or empty object on failure
|
||||
*/
|
||||
async function loadFallbackTranslations() {
|
||||
const cacheKey = "en-common";
|
||||
|
||||
if (translationCache.has(cacheKey)) {
|
||||
return translationCache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${WEBAPP_URL}/static/locales/en/common.json`, {
|
||||
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch fallback translations: ${res.status}`);
|
||||
}
|
||||
|
||||
const translations = await res.json();
|
||||
translationCache.set(cacheKey, translations);
|
||||
return translations;
|
||||
} catch (error) {
|
||||
console.error("Could not fetch fallback translations:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
const SUPPORTED_NAMESPACES = ["common"];
|
||||
|
||||
/**
|
||||
* Loads translations for a specific locale and namespace with optimized caching
|
||||
@@ -41,32 +17,33 @@ async function loadFallbackTranslations() {
|
||||
* @param {string} ns - The namespace for the translations
|
||||
* @returns {Promise<Record<string, string>>} Translations object or fallback translations on failure
|
||||
*/
|
||||
export async function loadTranslations(_locale: string, ns: string) {
|
||||
const locale = _locale === "zh" ? "zh-CN" : _locale;
|
||||
export async function loadTranslations(_locale: string, _ns: string) {
|
||||
let locale = _locale === "zh" ? "zh-CN" : _locale;
|
||||
locale = i18n.locales.includes(locale) ? locale : "en";
|
||||
const ns = SUPPORTED_NAMESPACES.includes(_ns) ? _ns : "common";
|
||||
const cacheKey = `${locale}-${ns}`;
|
||||
|
||||
if (translationCache.has(cacheKey)) {
|
||||
return translationCache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
|
||||
const response = await fetch(url, {
|
||||
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
|
||||
});
|
||||
},
|
||||
3000
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch translations: ${response.status}`);
|
||||
}
|
||||
|
||||
const translations = await response.json();
|
||||
translationCache.set(cacheKey, translations);
|
||||
return translations;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load translations for ${locale}/${ns}, falling back to English:`, error);
|
||||
const fallbackTranslations = await loadFallbackTranslations();
|
||||
return fallbackTranslations;
|
||||
if (!response.ok) {
|
||||
logger.error(`Failed to fetch translations: ${response.status}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const translations = await response.json();
|
||||
translationCache.set(cacheKey, translations);
|
||||
return translations;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,8 +9,11 @@ import { UserRepository } from "./user";
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: (key: string) => {
|
||||
return () => key;
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { compareMembership } from "@calcom/lib/event-types/getEventTypesByViewer";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
@@ -19,6 +23,24 @@ type WebhookGroup = {
|
||||
webhooks: Webhook[];
|
||||
};
|
||||
|
||||
const webhookIdAndEventTypeIdSchema = z.object({
|
||||
// Webhook ID
|
||||
id: z.string().optional(),
|
||||
eventTypeId: z.number().optional(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
const ZFindWebhooksByFiltersInputSchema = webhookIdAndEventTypeIdSchema
|
||||
.extend({
|
||||
appId: z.string().optional(),
|
||||
teamId: z.number().optional(),
|
||||
eventTypeId: z.number().optional(),
|
||||
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export type TFindWebhooksByFiltersInputSchema = z.infer<typeof ZFindWebhooksByFiltersInputSchema>;
|
||||
|
||||
const filterWebhooks = (webhook: Webhook) => {
|
||||
const appIds = [
|
||||
"zapier",
|
||||
@@ -180,4 +202,65 @@ export class WebhookRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async findWebhooksByFilters({
|
||||
userId,
|
||||
input,
|
||||
}: {
|
||||
userId: number;
|
||||
input?: TFindWebhooksByFiltersInputSchema;
|
||||
}) {
|
||||
const where: Prisma.WebhookWhereInput = {
|
||||
/* Don't mixup zapier webhooks with normal ones */
|
||||
AND: [{ appId: !input?.appId ? null : input.appId }],
|
||||
};
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
teams: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(where.AND)) {
|
||||
if (input?.eventTypeId) {
|
||||
const managedParentEvt = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
id: input.eventTypeId,
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (managedParentEvt?.parentId) {
|
||||
where.AND?.push({
|
||||
OR: [
|
||||
{ eventTypeId: input.eventTypeId },
|
||||
{ eventTypeId: managedParentEvt.parentId, active: true },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
where.AND?.push({ eventTypeId: input.eventTypeId });
|
||||
}
|
||||
} else {
|
||||
where.AND?.push({
|
||||
OR: [{ userId }, { teamId: { in: user?.teams.map((membership) => membership.teamId) } }],
|
||||
});
|
||||
}
|
||||
|
||||
if (input?.eventTriggers) {
|
||||
where.AND?.push({ eventTriggers: { hasEvery: input.eventTriggers } });
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.webhook.findMany({
|
||||
where,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Person } from "@calcom/types/Calendar";
|
||||
import type { JsonValue } from "@calcom/types/JsonObject";
|
||||
|
||||
function isObjectButNotArray(obj: unknown): obj is Record<string, unknown> {
|
||||
return typeof obj === "object" && !Array.isArray(obj);
|
||||
}
|
||||
|
||||
type DestinationCalendar = {
|
||||
id: number;
|
||||
integration: string;
|
||||
externalId: string;
|
||||
primaryEmail: string | null;
|
||||
userId: number | null;
|
||||
eventTypeId: number | null;
|
||||
credentialId: number | null;
|
||||
createdAt: Date | null;
|
||||
updatedAt: Date | null;
|
||||
delegationCredentialId: string | null;
|
||||
domainWideDelegationCredentialId: string | null;
|
||||
};
|
||||
|
||||
type Response = {
|
||||
label: string;
|
||||
value: string | boolean | string[] | { value: string; optionValue: string } | Record<string, string>;
|
||||
isHidden?: boolean | undefined;
|
||||
};
|
||||
|
||||
interface BaseWebhookPayload {
|
||||
bookingId: number;
|
||||
title: string;
|
||||
eventSlug: string | null;
|
||||
description: string | null;
|
||||
customInputs: JsonValue | null;
|
||||
responses: Record<string, Response>;
|
||||
userFieldsResponses: Record<string, Response>;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
organizer: Person;
|
||||
attendees: Person[];
|
||||
uid: string;
|
||||
location: string | null;
|
||||
destinationCalendar: DestinationCalendar | null;
|
||||
cancellationReason: string | null;
|
||||
iCalUID: string | null;
|
||||
smsReminderNumber?: string;
|
||||
cancelledBy: string | null;
|
||||
}
|
||||
|
||||
interface CancelledEventPayload extends BaseWebhookPayload {
|
||||
cancelledBy: string;
|
||||
cancellationReason: string;
|
||||
}
|
||||
|
||||
export class BookingWebhookFactory {
|
||||
private getType(params: BaseWebhookPayload) {
|
||||
return params.eventSlug || params.title || "";
|
||||
}
|
||||
|
||||
private getTitle(params: BaseWebhookPayload) {
|
||||
return params.title || "";
|
||||
}
|
||||
|
||||
private getDestinationCalendar(params: BaseWebhookPayload) {
|
||||
return params.destinationCalendar ? [params.destinationCalendar] : [];
|
||||
}
|
||||
|
||||
private getCustomInputs(params: BaseWebhookPayload) {
|
||||
return isObjectButNotArray(params.customInputs) ? params.customInputs : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates base webhook payload with common fields
|
||||
*/
|
||||
private createBasePayload(params: BaseWebhookPayload) {
|
||||
const {
|
||||
bookingId,
|
||||
title,
|
||||
eventSlug,
|
||||
description,
|
||||
customInputs,
|
||||
startTime,
|
||||
endTime,
|
||||
uid,
|
||||
location,
|
||||
organizer,
|
||||
attendees,
|
||||
responses,
|
||||
userFieldsResponses,
|
||||
destinationCalendar,
|
||||
smsReminderNumber,
|
||||
iCalUID,
|
||||
} = params;
|
||||
|
||||
const basePayload = {
|
||||
bookingId,
|
||||
type: this.getType(params),
|
||||
title: this.getTitle(params),
|
||||
description,
|
||||
customInputs: this.getCustomInputs(params),
|
||||
responses,
|
||||
userFieldsResponses,
|
||||
startTime,
|
||||
endTime,
|
||||
organizer,
|
||||
attendees,
|
||||
uid,
|
||||
location,
|
||||
destinationCalendar: this.getDestinationCalendar(params),
|
||||
iCalUID,
|
||||
smsReminderNumber,
|
||||
};
|
||||
|
||||
return basePayload;
|
||||
}
|
||||
|
||||
public createCancelledEventPayload(params: CancelledEventPayload) {
|
||||
const basePayload = this.createBasePayload({
|
||||
...params,
|
||||
});
|
||||
|
||||
return {
|
||||
...basePayload,
|
||||
cancelledBy: params.cancelledBy,
|
||||
cancellationReason: params.cancellationReason,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { BookingWebhookFactory } from "../BookingWebhookFactory";
|
||||
|
||||
const createTestOrganizer = (overrides?: Partial<Person>): Person => ({
|
||||
email: "organizer@example.com",
|
||||
name: "Test Organizer",
|
||||
timeZone: "UTC",
|
||||
language: { locale: "en", translate: (() => "") as any },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createTestAttendee = (overrides?: Partial<Person>): Person => ({
|
||||
email: "attendee@example.com",
|
||||
name: "Test Attendee",
|
||||
timeZone: "UTC",
|
||||
language: { locale: "en", translate: (() => "") as any },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createTestBooking = (overrides?: Record<string, any>) => ({
|
||||
id: 12345,
|
||||
uid: "test-uid-123",
|
||||
title: "Test Event",
|
||||
description: "Test Description",
|
||||
startTime: new Date("2025-01-24T10:00:00Z"),
|
||||
endTime: new Date("2025-01-24T11:00:00Z"),
|
||||
location: "https://meet.example.com",
|
||||
customInputs: { field1: "value1" },
|
||||
responses: { name: { value: "Test User" } },
|
||||
userFieldsResponses: { company: { value: "Test Corp" } },
|
||||
smsReminderNumber: "+1234567890",
|
||||
iCalUID: "ical-uid-123",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("BookingWebhookFactory", () => {
|
||||
describe("createCancelledEventPayload", () => {
|
||||
it("should create a basic cancelled event payload with required fields", () => {
|
||||
const factory = new BookingWebhookFactory();
|
||||
const booking = createTestBooking();
|
||||
const organizer = createTestOrganizer();
|
||||
const attendees = [createTestAttendee()];
|
||||
|
||||
const payload = factory.createCancelledEventPayload({
|
||||
bookingId: booking.id,
|
||||
uid: booking.uid,
|
||||
title: booking.title,
|
||||
eventSlug: "test-event",
|
||||
description: booking.description,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
location: booking.location,
|
||||
customInputs: booking.customInputs,
|
||||
responses: booking.responses,
|
||||
userFieldsResponses: booking.userFieldsResponses,
|
||||
smsReminderNumber: booking.smsReminderNumber,
|
||||
iCalUID: booking.iCalUID,
|
||||
organizer,
|
||||
attendees,
|
||||
destinationCalendar: null,
|
||||
cancelledBy: "test@example.com",
|
||||
cancellationReason: "User requested",
|
||||
});
|
||||
|
||||
expect(payload.bookingId).toBe(12345);
|
||||
expect(payload.uid).toBe("test-uid-123");
|
||||
expect(payload.title).toBe("Test Event");
|
||||
expect(payload.description).toBe("Test Description");
|
||||
expect(payload.organizer).toEqual(organizer);
|
||||
expect(payload.attendees).toEqual(attendees);
|
||||
expect(payload.type).toBe("test-event");
|
||||
expect(payload.startTime).toBe(booking.startTime.toISOString());
|
||||
expect(payload.endTime).toBe(booking.endTime.toISOString());
|
||||
expect(payload.location).toBe("https://meet.example.com");
|
||||
expect(payload.customInputs).toEqual({ field1: "value1" });
|
||||
expect(payload.responses).toEqual({ name: { value: "Test User" } });
|
||||
expect(payload.userFieldsResponses).toEqual({ company: { value: "Test Corp" } });
|
||||
expect(payload.smsReminderNumber).toBe("+1234567890");
|
||||
expect(payload.iCalUID).toBe("ical-uid-123");
|
||||
expect(payload.destinationCalendar).toEqual([]);
|
||||
expect(payload.cancellationReason).toBe("User requested");
|
||||
expect(payload.cancelledBy).toBe("test@example.com");
|
||||
|
||||
// Verify no extra properties exist
|
||||
const expectedKeys = [
|
||||
"bookingId",
|
||||
"type",
|
||||
"title",
|
||||
"description",
|
||||
"customInputs",
|
||||
"responses",
|
||||
"userFieldsResponses",
|
||||
"startTime",
|
||||
"endTime",
|
||||
"organizer",
|
||||
"attendees",
|
||||
"uid",
|
||||
"location",
|
||||
"destinationCalendar",
|
||||
"iCalUID",
|
||||
"smsReminderNumber",
|
||||
"cancellationReason",
|
||||
"cancelledBy",
|
||||
];
|
||||
const actualKeys = Object.keys(payload).sort();
|
||||
expect(actualKeys).toEqual(expectedKeys.sort());
|
||||
});
|
||||
|
||||
it("should handle responses and userFieldsResponses", () => {
|
||||
const factory = new BookingWebhookFactory();
|
||||
const booking = createTestBooking();
|
||||
const responses = { field1: "response1", field2: "response2" };
|
||||
const userFieldsResponses = { company: "Test Corp", department: "Engineering" };
|
||||
|
||||
const payload = factory.createCancelledEventPayload({
|
||||
bookingId: booking.id,
|
||||
uid: booking.uid,
|
||||
title: booking.title,
|
||||
eventSlug: null,
|
||||
description: null,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
location: null,
|
||||
customInputs: null,
|
||||
responses,
|
||||
userFieldsResponses,
|
||||
smsReminderNumber: null,
|
||||
iCalUID: null,
|
||||
organizer: createTestOrganizer(),
|
||||
attendees: [createTestAttendee()],
|
||||
destinationCalendar: null,
|
||||
cancelledBy: "user@example.com",
|
||||
cancellationReason: "Test",
|
||||
});
|
||||
|
||||
expect(payload.responses).toEqual(responses);
|
||||
expect(payload.userFieldsResponses).toEqual(userFieldsResponses);
|
||||
});
|
||||
|
||||
it("should handle destination calendar as single object", () => {
|
||||
const factory = new BookingWebhookFactory();
|
||||
const booking = createTestBooking();
|
||||
const destinationCalendar = {
|
||||
id: 1,
|
||||
integration: "google",
|
||||
externalId: "cal123",
|
||||
primaryEmail: null,
|
||||
userId: null,
|
||||
eventTypeId: null,
|
||||
credentialId: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
};
|
||||
|
||||
const payload = factory.createCancelledEventPayload({
|
||||
bookingId: booking.id,
|
||||
uid: booking.uid,
|
||||
title: booking.title,
|
||||
eventSlug: null,
|
||||
description: null,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
location: null,
|
||||
customInputs: null,
|
||||
responses: {},
|
||||
userFieldsResponses: {},
|
||||
smsReminderNumber: null,
|
||||
iCalUID: null,
|
||||
organizer: createTestOrganizer(),
|
||||
attendees: [],
|
||||
destinationCalendar,
|
||||
cancelledBy: "user@example.com",
|
||||
cancellationReason: "Test",
|
||||
});
|
||||
|
||||
expect(payload.destinationCalendar).toEqual([destinationCalendar]);
|
||||
});
|
||||
|
||||
it("should handle null destination calendar", () => {
|
||||
const factory = new BookingWebhookFactory();
|
||||
const booking = createTestBooking();
|
||||
|
||||
const payload = factory.createCancelledEventPayload({
|
||||
bookingId: booking.id,
|
||||
uid: booking.uid,
|
||||
title: booking.title,
|
||||
eventSlug: null,
|
||||
description: null,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
location: null,
|
||||
customInputs: null,
|
||||
responses: {},
|
||||
userFieldsResponses: {},
|
||||
smsReminderNumber: null,
|
||||
iCalUID: null,
|
||||
organizer: createTestOrganizer(),
|
||||
attendees: [],
|
||||
destinationCalendar: null,
|
||||
cancelledBy: "user@example.com",
|
||||
cancellationReason: "Test",
|
||||
});
|
||||
|
||||
expect(payload.destinationCalendar).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle sms reminder number and iCal UID", () => {
|
||||
const factory = new BookingWebhookFactory();
|
||||
const booking = createTestBooking();
|
||||
|
||||
const payload = factory.createCancelledEventPayload({
|
||||
bookingId: booking.id,
|
||||
uid: booking.uid,
|
||||
title: booking.title,
|
||||
eventSlug: null,
|
||||
description: null,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
location: null,
|
||||
customInputs: null,
|
||||
responses: {},
|
||||
userFieldsResponses: {},
|
||||
smsReminderNumber: "+1234567890",
|
||||
iCalUID: "ical-uid-123",
|
||||
organizer: createTestOrganizer(),
|
||||
attendees: [],
|
||||
destinationCalendar: null,
|
||||
cancelledBy: "user@example.com",
|
||||
cancellationReason: "Test",
|
||||
});
|
||||
|
||||
expect(payload.smsReminderNumber).toBe("+1234567890");
|
||||
expect(payload.iCalUID).toBe("ical-uid-123");
|
||||
});
|
||||
|
||||
it("should derive type from eventSlug when available, otherwise from title", () => {
|
||||
const factory = new BookingWebhookFactory();
|
||||
const booking = createTestBooking();
|
||||
|
||||
// Test with eventSlug provided
|
||||
const payloadWithSlug = factory.createCancelledEventPayload({
|
||||
bookingId: booking.id,
|
||||
uid: booking.uid,
|
||||
title: "Meeting Title",
|
||||
eventSlug: "team-standup",
|
||||
description: null,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
location: null,
|
||||
customInputs: null,
|
||||
responses: {},
|
||||
userFieldsResponses: {},
|
||||
smsReminderNumber: null,
|
||||
iCalUID: null,
|
||||
organizer: createTestOrganizer(),
|
||||
attendees: [],
|
||||
destinationCalendar: null,
|
||||
cancelledBy: "user@example.com",
|
||||
cancellationReason: "Test",
|
||||
});
|
||||
|
||||
expect(payloadWithSlug.type).toBe("team-standup");
|
||||
|
||||
// Test with eventSlug null, should use title
|
||||
const payloadWithoutSlug = factory.createCancelledEventPayload({
|
||||
bookingId: booking.id,
|
||||
uid: booking.uid,
|
||||
title: "Meeting Title",
|
||||
eventSlug: null,
|
||||
description: null,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
location: null,
|
||||
customInputs: null,
|
||||
responses: {},
|
||||
userFieldsResponses: {},
|
||||
smsReminderNumber: null,
|
||||
iCalUID: null,
|
||||
organizer: createTestOrganizer(),
|
||||
attendees: [],
|
||||
destinationCalendar: null,
|
||||
cancelledBy: "user@example.com",
|
||||
cancellationReason: "Test",
|
||||
});
|
||||
|
||||
expect(payloadWithoutSlug.type).toBe("Meeting Title");
|
||||
|
||||
// Test with both eventSlug and title empty
|
||||
const payloadEmpty = factory.createCancelledEventPayload({
|
||||
bookingId: booking.id,
|
||||
uid: booking.uid,
|
||||
title: "",
|
||||
eventSlug: "",
|
||||
description: null,
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
location: null,
|
||||
customInputs: null,
|
||||
responses: {},
|
||||
userFieldsResponses: {},
|
||||
smsReminderNumber: null,
|
||||
iCalUID: null,
|
||||
organizer: createTestOrganizer(),
|
||||
attendees: [],
|
||||
destinationCalendar: null,
|
||||
cancelledBy: "user@example.com",
|
||||
cancellationReason: "Test",
|
||||
});
|
||||
|
||||
expect(payloadEmpty.type).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { Team, User, Membership } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -376,7 +377,15 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
eventTypeId: testData.eventType.id,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "eventTypeId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: testData.eventType.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -388,7 +397,7 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build memberUserId filter conditions", async () => {
|
||||
it("should build userId filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
@@ -399,7 +408,15 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
memberUserId: testData.user.id,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "userId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: testData.user.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -420,8 +437,22 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
eventTypeId: testData.eventType.id,
|
||||
memberUserId: testData.user.id,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "eventTypeId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: testData.eventType.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "userId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: testData.user.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -432,6 +463,40 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build status filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
columnFilters: [
|
||||
{
|
||||
id: "status",
|
||||
value: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: ["pending", "accepted"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual(
|
||||
Prisma.sql`"status" IN (${Prisma.join([
|
||||
Prisma.sql`${"pending"}::"BookingStatus"`,
|
||||
Prisma.sql`${"accepted"}::"BookingStatus"`,
|
||||
])})`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBaseConditions", () => {
|
||||
@@ -473,7 +538,15 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
eventTypeId: userEventType.id,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "eventTypeId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: userEventType.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import md5 from "md5";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ZColumnFilter } from "@calcom/features/data-table/lib/types";
|
||||
import type { ColumnFilter } from "@calcom/features/data-table/lib/types";
|
||||
import { isSingleSelectFilterValue, isMultiSelectFilterValue } from "@calcom/features/data-table/lib/utils";
|
||||
import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
@@ -44,6 +47,19 @@ export type SelectedFields<T> = T extends undefined
|
||||
: never;
|
||||
};
|
||||
|
||||
type UserStatsData = {
|
||||
userId: number;
|
||||
user: {
|
||||
id: number;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
emailMd5: string;
|
||||
count: number;
|
||||
}[];
|
||||
|
||||
export const bookingDataSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
@@ -105,8 +121,6 @@ export type InsightsBookingServiceOptions = z.infer<typeof insightsBookingServic
|
||||
export type InsightsBookingServiceFilterOptions = z.infer<typeof insightsBookingServiceFilterOptionsSchema>;
|
||||
|
||||
export const insightsBookingServiceFilterOptionsSchema = z.object({
|
||||
eventTypeId: z.number().optional(),
|
||||
memberUserId: z.number().optional(),
|
||||
dateRange: z
|
||||
.object({
|
||||
target: z.enum(["createdAt", "startTime"]),
|
||||
@@ -114,6 +128,7 @@ export const insightsBookingServiceFilterOptionsSchema = z.object({
|
||||
endDate: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
columnFilters: z.array(ZColumnFilter).optional(),
|
||||
});
|
||||
|
||||
const NOTHING_CONDITION = Prisma.sql`1=0`;
|
||||
@@ -238,14 +253,14 @@ export class InsightsBookingService {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.filters.eventTypeId) {
|
||||
conditions.push(
|
||||
Prisma.sql`("eventTypeId" = ${this.filters.eventTypeId}) OR ("eventParentId" = ${this.filters.eventTypeId})`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.filters.memberUserId) {
|
||||
conditions.push(Prisma.sql`"userId" = ${this.filters.memberUserId}`);
|
||||
// Process columnFilters using type-safe utility functions
|
||||
if (this.filters.columnFilters) {
|
||||
for (const filter of this.filters.columnFilters) {
|
||||
const condition = this.buildColumnFilterCondition(filter);
|
||||
if (condition) {
|
||||
conditions.push(condition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use dateRange object for date filtering
|
||||
@@ -276,6 +291,29 @@ export class InsightsBookingService {
|
||||
});
|
||||
}
|
||||
|
||||
private buildColumnFilterCondition(filter: ColumnFilter): Prisma.Sql | null {
|
||||
const { id, value } = filter;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (id === "eventTypeId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
|
||||
return Prisma.sql`("eventTypeId" = ${value.data}) OR ("eventParentId" = ${value.data})`;
|
||||
}
|
||||
|
||||
if (id === "userId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
|
||||
return Prisma.sql`"userId" = ${value.data}`;
|
||||
}
|
||||
|
||||
if (id === "status" && isMultiSelectFilterValue(value)) {
|
||||
const statusValues = value.data.map((status) => Prisma.sql`${status}::"BookingStatus"`);
|
||||
return Prisma.sql`"status" IN (${Prisma.join(statusValues)})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async buildAuthorizationConditions(): Promise<Prisma.Sql> {
|
||||
if (!this.options) {
|
||||
return NOTHING_CONDITION;
|
||||
@@ -657,8 +695,9 @@ export class InsightsBookingService {
|
||||
});
|
||||
|
||||
// Transform aggregate data into the expected format
|
||||
const result = dateRanges.map(({ formattedDate }) => {
|
||||
const result = dateRanges.map(({ formattedDate, formattedDateFull }) => {
|
||||
const eventData = {
|
||||
formattedDateFull: formattedDateFull,
|
||||
Month: formattedDate,
|
||||
Created: 0,
|
||||
Completed: 0,
|
||||
@@ -764,7 +803,7 @@ export class InsightsBookingService {
|
||||
async getMembersStatsWithCount(
|
||||
type: "all" | "cancelled" | "noShow" = "all",
|
||||
sortOrder: "ASC" | "DESC" = "DESC"
|
||||
) {
|
||||
): Promise<UserStatsData> {
|
||||
const baseConditions = await this.getBaseConditions();
|
||||
|
||||
let additionalCondition = Prisma.sql``;
|
||||
@@ -832,22 +871,22 @@ export class InsightsBookingService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getMembersRatingStats(sortOrder: "ASC" | "DESC" = "DESC") {
|
||||
async getMembersRatingStats(sortOrder: "ASC" | "DESC" = "DESC"): Promise<UserStatsData> {
|
||||
const baseConditions = await this.getBaseConditions();
|
||||
|
||||
const bookingsFromTeam = await this.prisma.$queryRaw<
|
||||
Array<{
|
||||
userId: number;
|
||||
averageRating: number;
|
||||
count: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
"userId",
|
||||
AVG("rating")::float as "averageRating"
|
||||
AVG("rating")::float as "count"
|
||||
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`}
|
||||
ORDER BY "count" ${sortOrder === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`}
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
@@ -885,7 +924,7 @@ export class InsightsBookingService {
|
||||
userId: booking.userId,
|
||||
user,
|
||||
emailMd5: md5(user.email),
|
||||
averageRating: booking.averageRating,
|
||||
count: booking.count,
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
|
||||
@@ -11,8 +11,11 @@ import { UserCreationService } from "./userCreationService";
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: (key: string) => {
|
||||
return () => key;
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
## 1.0.114
|
||||
## 1.1.2
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#22886](https://github.com/calcom/cal.com/pull/22886) [`fb36497`](https://github.com/calcom/cal.com/commit/fb364971fadc1a6796e739956200f12af74e565c) Thanks [@supalarry](https://github.com/supalarry)! - bundle fonts to fix atoms in non-next apps
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#22731](https://github.com/calcom/cal.com/pull/22731) [`ef66187`](https://github.com/calcom/cal.com/commit/ef6618743d5c1367fadf7b98cf15f7ba829535e3) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Added new startTime prop to the Booker component that allows users to decide the first available date when the booker loads
|
||||
|
||||
- [#22815](https://github.com/calcom/cal.com/pull/22815) [`dc967cf`](https://github.com/calcom/cal.com/commit/dc967cffbecdf573aa48777ca7a245a6e633356c) Thanks [@Ryukemeister](https://github.com/Ryukemeister)! - This change fixes an issue in the CalendarSettings atom where the redirect urls were getting only one search param
|
||||
|
||||
- [#22701](https://github.com/calcom/cal.com/pull/22701) [`babd514`](https://github.com/calcom/cal.com/commit/babd514c64d0a4b8e05a46619808f201d3e8b0a5) Thanks [@Ryukemeister](https://github.com/Ryukemeister)! - This change fixes date overrides breaking for availability settings atom
|
||||
|
||||
## 1.1.0
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user