Compare commits

..
Author SHA1 Message Date
github-actions[bot] 2965450929 chore: version packages (#22890)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-05 06:15:25 +00:00
ddoemonnandKartik Saini db9b9c45dc fix(event-types): remove gap in URL input for event creation (#22123)
* fix(event-types): remove gap in URL input for event creation

* fix: event setup tab spacing

* fix: space in profile view

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-05 03:51:30 +00:00
emrysal 37f500526c chore: release v5.5.11 2025-08-05 00:46:56 +00:00
Eunjae Lee 006aecb066 fix: add backfill migration for routing response denormalized tables (#21474)
* fix: add backfill migration for routing response denormalized tables

* update script

* implement select type

* improve number handling

* clean up queries

* rename

* handle zero record case

* revert

* rename

* fix division problem

* log clean up

* force adding new rows after removing existing rows

* backfill only missing or wrong data

* remove sleep
2025-08-05 01:40:22 +01:00
Amit Sharma 0829929e98 perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC (#21781)
* perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC

* fix type check

* invalidate cache on webhook list item actions

* fix review comment

* fix merge conflicts
2025-08-05 01:25:31 +01:00
a61148cb51 test: add no show webhooks integration tests (#21697)
* test: add no show webhooks integration tests

* fix

* chore

* chore

* chore

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-08-05 01:22:44 +01:00
Eunjae Lee 593c48c8b4 refactor: replace tremor with recharts (#22791)
* refactor: replace tremor with recharts

* update tooltip

* clean up types

* replace tremor with recharts

* replace BarList with recharts

* replace ProgressBar

* remove tremor from the repository

* clean up

* fix UserStatsTable

* fix type error

* add explicit return type
2025-08-05 01:21:23 +01:00
Lauris Skraucis fb364971fa fix: atoms fonts for non next apps (#22886)
* fix: atoms fonts for non next apps

* chore: add changeset
2025-08-04 16:51:05 +00:00
Eunjae LeeandAlex van Andel a2205d497d feat: add booking status filter to /insights (#22842)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-04 13:48:17 +00:00
Eunjae LeeandDevanshu Sharma cf4e7a5d2c refactor: add useInsightsRoutingParameters() for InsightsRoutingService (#22792)
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-04 14:51:23 +02:00
Anik Dhabal Babu 0e2ee3adb2 chore: add logging for office365 calendar (#22867) 2025-08-04 12:26:57 +00:00
Anik Dhabal Babu dbe8a85cf5 fix: enable auto reording for organizer (#22882) 2025-08-04 12:23:56 +00:00
Anik Dhabal Babu 84210dc1b4 fix: build docs workflow failing in ci (#22883)
* fix: flaky e2e

* fix: build docs failure
2025-08-04 17:36:35 +05:30
Benny Joo 4061bfb3fe fix: refactor i18n loadTranslations and set timeout to 3s (#22878)
* refactor

* use abort controller

* address comment

* fix tests

* fix time

* fix tests

* fix platform library build
2025-08-04 10:30:18 +01:00
85 changed files with 1714 additions and 952 deletions
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
@@ -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");
}
@@ -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} />;
};
@@ -0,0 +1,7 @@
"use server";
import { revalidateTag } from "next/cache";
export async function revalidateWebhookById(id: string) {
revalidateTag(`viewer.webhook.get:${id}`);
}
@@ -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
@@ -0,0 +1,7 @@
"use server";
import { revalidateTag } from "next/cache";
export async function revalidateWebhookList() {
revalidateTag("viewer.webhook.list");
}
@@ -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} />;
@@ -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 -2
View File
@@ -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",
+7 -15
View File
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+1 -5
View File
@@ -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;
},
};
});
+1 -1
View File
@@ -109,7 +109,7 @@
"group": "Embeds",
"icon": "code-simple",
"pages": ["developing/guides/embeds/embed-events"]
}
},
{
"group": "Insights",
"icon": "chart-bar",
@@ -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[] };
}
@@ -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);
});
});
@@ -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 {
@@ -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 : (
<>
@@ -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>
);
};
@@ -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]);
@@ -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,
};
}
-9
View File
@@ -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
View File
@@ -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) {
@@ -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) {
+13
View File
@@ -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);
}
}
+24 -47
View File
@@ -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;
}
/**
+5 -2
View File
@@ -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;
},
};
});
+83
View File
@@ -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,
});
}
}
@@ -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,
},
},
],
},
});
+56 -17
View File
@@ -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;
},
};
});
+6
View File
@@ -1,5 +1,11 @@
## 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
+8 -8
View File
@@ -7,7 +7,7 @@
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/_next/static/media/55c55f0601d81cf3-s.woff2) format('woff2');
src: url(./fonts/55c55f0601d81cf3-s.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@@ -17,7 +17,7 @@
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/_next/static/media/26a46d62cd723877-s.woff2) format('woff2');
src: url(./fonts/26a46d62cd723877-s.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@@ -27,7 +27,7 @@
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/_next/static/media/97e0cb1ae144a2a9-s.woff2) format('woff2');
src: url(./fonts/97e0cb1ae144a2a9-s.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
@@ -37,7 +37,7 @@
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/_next/static/media/581909926a08bbc8-s.woff2) format('woff2');
src: url(./fonts/581909926a08bbc8-s.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
@@ -47,7 +47,7 @@
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/_next/static/media/df0a9ae256c0569c-s.woff2) format('woff2');
src: url(./fonts/df0a9ae256c0569c-s.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@@ -57,7 +57,7 @@
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/_next/static/media/6d93bde91c0c2823-s.woff2) format('woff2');
src: url(./fonts/6d93bde91c0c2823-s.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -67,7 +67,7 @@
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(/_next/static/media/a34f9d1faa5f3315-s.p.woff2) format('woff2');
src: url(./fonts/a34f9d1faa5f3315-s.p.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -94,7 +94,7 @@
\********************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************/
@font-face {
font-family: '__calFont_a90182';
src: url(/_next/static/media/90475aac776488b6-s.p.woff2) format('woff2');
src: url(./fonts/90475aac776488b6-s.p.woff2) format('woff2');
font-display: block;
font-weight: 600;
}
+3 -2
View File
@@ -4,7 +4,7 @@
"type": "module",
"description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.",
"version": "1.1.2",
"version": "1.2.0",
"scripts": {
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
@@ -37,7 +37,8 @@
},
"files": [
"dist",
"globals.min.css"
"globals.min.css",
"fonts"
],
"module": "./dist/cal-atoms.js",
"exports": {
+1 -6
View File
@@ -2,12 +2,7 @@ const base = require("@calcom/config/tailwind-preset");
/** @type {import('tailwindcss').Config} */
module.exports = {
...base,
content: [
...base.content,
"../../../packages/ui/!(node_modules)/**/*.{js,ts,jsx,tsx,mdx}",
"../../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
"./**/*.tsx",
],
content: [...base.content, "../../../packages/ui/!(node_modules)/**/*.{js,ts,jsx,tsx,mdx}", "./**/*.tsx"],
plugins: [...base.plugins, require("tailwindcss-animate")],
theme: {
...base.theme,
Binary file not shown.
@@ -0,0 +1,197 @@
DO $$
DECLARE
chunk_size INTEGER := 1000;
start_id INTEGER := 1; -- Starting ID
end_id INTEGER; -- Will be set dynamically
current_id INTEGER;
processed_count INTEGER := 0;
chunk_updated_count INTEGER := 0;
total_count INTEGER;
BEGIN
-- Get the maximum ID and total count from the App_RoutingForms_FormResponse table
SELECT COALESCE(MAX(id), 0) INTO end_id FROM "App_RoutingForms_FormResponse";
SELECT COUNT(*) INTO total_count FROM "App_RoutingForms_FormResponse";
-- Handle case where there are no records to process
IF total_count = 0 THEN
RAISE NOTICE 'No records found in App_RoutingForms_FormResponse table. Migration completed.';
RETURN;
END IF;
RAISE NOTICE 'Starting migration: processing up to ID % (total responses: %)', end_id, total_count;
FOR current_id IN SELECT * FROM generate_series(start_id, end_id, chunk_size)
LOOP
-- Use UPSERT to only update records that are missing or have incorrect data
WITH expected_data AS (
SELECT
r.id,
r."formId" as expected_form_id,
f.name as expected_form_name,
f."teamId" as expected_form_team_id,
f."userId" as expected_form_user_id,
b.uid as expected_booking_uid,
b.id as expected_booking_id,
b.status as expected_booking_status,
calculate_booking_status_order(b.status::text) as expected_booking_status_order,
b."createdAt" as expected_booking_created_at,
b."startTime" as expected_booking_start_time,
b."endTime" as expected_booking_end_time,
b."userId" as expected_booking_user_id,
u.name as expected_booking_user_name,
u.email as expected_booking_user_email,
u."avatarUrl" as expected_booking_user_avatar_url,
COALESCE(
(
SELECT ar."reasonString"
FROM "AssignmentReason" ar
WHERE ar."bookingId" = b.id
LIMIT 1
),
''
) as expected_booking_assignment_reason,
et.id as expected_event_type_id,
et."parentId" as expected_event_type_parent_id,
et."schedulingType"::text as expected_event_type_scheduling_type,
r."createdAt" as expected_created_at,
t.utm_source as expected_utm_source,
t.utm_medium as expected_utm_medium,
t.utm_campaign as expected_utm_campaign,
t.utm_term as expected_utm_term,
t.utm_content as expected_utm_content
FROM "App_RoutingForms_FormResponse" r
INNER JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
LEFT JOIN "users" u ON f."userId" = u.id
LEFT JOIN "Booking" b ON b.uid = r."routedToBookingUid"
LEFT JOIN "EventType" et ON b."eventTypeId" = et.id
LEFT JOIN "Tracking" t ON t."bookingId" = b.id
WHERE r.id BETWEEN current_id AND current_id + chunk_size - 1
),
records_to_update AS (
SELECT e.*
FROM expected_data e
LEFT JOIN "RoutingFormResponseDenormalized" d ON e.id = d.id
WHERE CASE
WHEN d.id IS NULL THEN true -- Include missing records
WHEN d."formId" IS DISTINCT FROM e.expected_form_id THEN true
WHEN d."formName" IS DISTINCT FROM e.expected_form_name THEN true
WHEN d."formTeamId" IS DISTINCT FROM e.expected_form_team_id THEN true
WHEN d."formUserId" IS DISTINCT FROM e.expected_form_user_id THEN true
WHEN d."bookingUid" IS DISTINCT FROM e.expected_booking_uid THEN true
WHEN d."bookingId" IS DISTINCT FROM e.expected_booking_id THEN true
WHEN d."bookingStatus" IS DISTINCT FROM e.expected_booking_status THEN true
WHEN d."bookingStatusOrder" IS DISTINCT FROM e.expected_booking_status_order THEN true
WHEN d."bookingCreatedAt" IS DISTINCT FROM e.expected_booking_created_at THEN true
WHEN d."bookingStartTime" IS DISTINCT FROM e.expected_booking_start_time THEN true
WHEN d."bookingEndTime" IS DISTINCT FROM e.expected_booking_end_time THEN true
WHEN d."bookingUserId" IS DISTINCT FROM e.expected_booking_user_id THEN true
WHEN d."bookingUserName" IS DISTINCT FROM e.expected_booking_user_name THEN true
WHEN d."bookingUserEmail" IS DISTINCT FROM e.expected_booking_user_email THEN true
WHEN d."bookingUserAvatarUrl" IS DISTINCT FROM e.expected_booking_user_avatar_url THEN true
WHEN d."bookingAssignmentReason" IS DISTINCT FROM e.expected_booking_assignment_reason THEN true
WHEN d."eventTypeId" IS DISTINCT FROM e.expected_event_type_id THEN true
WHEN d."eventTypeParentId" IS DISTINCT FROM e.expected_event_type_parent_id THEN true
WHEN d."eventTypeSchedulingType"::text IS DISTINCT FROM e.expected_event_type_scheduling_type THEN true
WHEN d."createdAt" IS DISTINCT FROM e.expected_created_at THEN true
WHEN d."utm_source" IS DISTINCT FROM e.expected_utm_source THEN true
WHEN d."utm_medium" IS DISTINCT FROM e.expected_utm_medium THEN true
WHEN d."utm_campaign" IS DISTINCT FROM e.expected_utm_campaign THEN true
WHEN d."utm_term" IS DISTINCT FROM e.expected_utm_term THEN true
WHEN d."utm_content" IS DISTINCT FROM e.expected_utm_content THEN true
ELSE false -- Don't include valid records
END
)
INSERT INTO "RoutingFormResponseDenormalized" (
id,
"formId",
"formName",
"formTeamId",
"formUserId",
"bookingUid",
"bookingId",
"bookingStatus",
"bookingStatusOrder",
"bookingCreatedAt",
"bookingStartTime",
"bookingEndTime",
"bookingUserId",
"bookingUserName",
"bookingUserEmail",
"bookingUserAvatarUrl",
"bookingAssignmentReason",
"eventTypeId",
"eventTypeParentId",
"eventTypeSchedulingType",
"createdAt",
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content"
)
SELECT
r.id,
r.expected_form_id,
r.expected_form_name,
r.expected_form_team_id,
r.expected_form_user_id,
r.expected_booking_uid,
r.expected_booking_id,
r.expected_booking_status,
r.expected_booking_status_order,
r.expected_booking_created_at,
r.expected_booking_start_time,
r.expected_booking_end_time,
r.expected_booking_user_id,
r.expected_booking_user_name,
r.expected_booking_user_email,
r.expected_booking_user_avatar_url,
r.expected_booking_assignment_reason,
r.expected_event_type_id,
r.expected_event_type_parent_id,
r.expected_event_type_scheduling_type::text,
r.expected_created_at,
r.expected_utm_source,
r.expected_utm_medium,
r.expected_utm_campaign,
r.expected_utm_term,
r.expected_utm_content
FROM records_to_update r
ON CONFLICT (id) DO UPDATE SET
"formId" = EXCLUDED."formId",
"formName" = EXCLUDED."formName",
"formTeamId" = EXCLUDED."formTeamId",
"formUserId" = EXCLUDED."formUserId",
"bookingUid" = EXCLUDED."bookingUid",
"bookingId" = EXCLUDED."bookingId",
"bookingStatus" = EXCLUDED."bookingStatus",
"bookingStatusOrder" = EXCLUDED."bookingStatusOrder",
"bookingCreatedAt" = EXCLUDED."bookingCreatedAt",
"bookingStartTime" = EXCLUDED."bookingStartTime",
"bookingEndTime" = EXCLUDED."bookingEndTime",
"bookingUserId" = EXCLUDED."bookingUserId",
"bookingUserName" = EXCLUDED."bookingUserName",
"bookingUserEmail" = EXCLUDED."bookingUserEmail",
"bookingUserAvatarUrl" = EXCLUDED."bookingUserAvatarUrl",
"bookingAssignmentReason" = EXCLUDED."bookingAssignmentReason",
"eventTypeId" = EXCLUDED."eventTypeId",
"eventTypeParentId" = EXCLUDED."eventTypeParentId",
"eventTypeSchedulingType" = EXCLUDED."eventTypeSchedulingType",
"createdAt" = EXCLUDED."createdAt",
"utm_source" = EXCLUDED."utm_source",
"utm_medium" = EXCLUDED."utm_medium",
"utm_campaign" = EXCLUDED."utm_campaign",
"utm_term" = EXCLUDED."utm_term",
"utm_content" = EXCLUDED."utm_content";
GET DIAGNOSTICS chunk_updated_count = ROW_COUNT;
processed_count := processed_count + chunk_updated_count;
RAISE NOTICE 'Chunk processed: IDs %-% (updated/inserted: % records, total updated: %)',
current_id, current_id + chunk_size - 1, chunk_updated_count, processed_count;
END LOOP;
RAISE NOTICE 'Migration completed: processed up to ID % (total updated/inserted: % records out of % total records)',
end_id, processed_count, total_count;
END $$;
@@ -0,0 +1,125 @@
DO $$
DECLARE
chunk_size INTEGER := 1000;
sleep_interval FLOAT := 1;
start_id INTEGER := 1;
end_id INTEGER;
current_id INTEGER;
response_record RECORD;
processed_count INTEGER := 0;
chunk_updated_count INTEGER := 0;
total_count INTEGER;
BEGIN
-- Get the maximum ID and total count from the App_RoutingForms_FormResponse table
SELECT COALESCE(MAX(id), 0) INTO end_id FROM "App_RoutingForms_FormResponse";
SELECT COUNT(*) INTO total_count FROM "App_RoutingForms_FormResponse";
-- Handle case where there are no records to process
IF total_count = 0 THEN
RAISE NOTICE 'No records found in App_RoutingForms_FormResponse table. Migration completed.';
RETURN;
END IF;
RAISE NOTICE 'Starting migration: processing up to ID % (total responses: %)', end_id, total_count;
FOR current_id IN SELECT * FROM generate_series(start_id, end_id, chunk_size)
LOOP
-- Process only responses that have missing or incorrect field data
FOR response_record IN
WITH form_response_fields AS (
-- Extract expected fields from the response data
SELECT
r.id as "responseId",
r."formId",
field->>'id' as "fieldId",
field->>'type' as "fieldType",
response_value->>'value' as raw_value,
jsonb_typeof(response_value->'value') as value_type,
-- Extract array values for multiselect
CASE
WHEN field->>'type' = 'multiselect' AND jsonb_typeof(response_value->'value') = 'array'
THEN ARRAY(SELECT jsonb_array_elements_text(response_value->'value'))
END as expected_array,
-- Extract first element for select type when it's an array
CASE
WHEN field->>'type' = 'select' AND jsonb_typeof(response_value->'value') = 'array'
THEN (response_value->'value'->0)::text
WHEN field->>'type' = 'select' AND jsonb_typeof(response_value->'value') = 'string'
THEN response_value->>'value'
END as expected_select_value
FROM "App_RoutingForms_FormResponse" r
CROSS JOIN LATERAL jsonb_array_elements(
(
SELECT fields::jsonb
FROM "App_RoutingForms_Form" f
WHERE f.id = r."formId"
)
) as field
CROSS JOIN LATERAL (
SELECT r.response::jsonb->(field->>'id') as response_value
) as rv
WHERE r.id BETWEEN current_id AND current_id + chunk_size - 1
AND r.response::jsonb ? (field->>'id') -- Only include fields that exist in response
),
responses_needing_update AS (
SELECT DISTINCT f."responseId"
FROM form_response_fields f
LEFT JOIN "RoutingFormResponseField" rf ON
rf."responseId" = f."responseId" AND
rf."fieldId" = f."fieldId"
WHERE
-- Case 1: Field doesn't exist in denormalized table
rf.id IS NULL OR
-- Case 2: Multiselect field validation - compare actual array contents
(f."fieldType" = 'multiselect' AND f.value_type = 'array' AND (
rf."valueStringArray" IS NULL OR
rf."valueStringArray" != f.expected_array OR
array_length(rf."valueStringArray", 1) != array_length(f.expected_array, 1)
)) OR
-- Case 3: Number field validation - compare as numbers
(f."fieldType" = 'number' AND f.value_type = 'number' AND (
rf."valueNumber" IS NULL OR
rf."valueNumber" != (f.raw_value)::decimal
)) OR
-- Case 4: Select field validation - compare expected select value
(f."fieldType" = 'select' AND (
rf."valueString" IS NULL OR
rf."valueString" != f.expected_select_value
)) OR
-- Case 5: Other string field validation
(f."fieldType" NOT IN ('multiselect', 'number', 'select') AND (
rf."valueString" IS NULL OR
rf."valueString" != f.raw_value
))
)
SELECT r.id
FROM responses_needing_update rnu
INNER JOIN "App_RoutingForms_FormResponse" r ON r.id = rnu."responseId"
ORDER BY r.id
LOOP
BEGIN
-- Use the reprocess_routing_form_response_fields function
PERFORM reprocess_routing_form_response_fields(response_record.id);
chunk_updated_count := chunk_updated_count + 1;
EXCEPTION WHEN OTHERS THEN
RAISE WARNING 'Failed to process responseId %: %', response_record.id, SQLERRM;
CONTINUE;
END;
END LOOP;
processed_count := processed_count + chunk_updated_count;
RAISE NOTICE 'Chunk processed: IDs %-% (updated: % records, total updated: %)',
current_id, current_id + chunk_size - 1, chunk_updated_count, processed_count;
-- Reset chunk counter for next iteration
chunk_updated_count := 0;
-- Sleep after each chunk (outside transaction to avoid holding locks)
PERFORM pg_sleep(sleep_interval);
END LOOP;
RAISE NOTICE 'Migration completed: processed up to ID % (total updated: % records out of % total records)',
end_id, processed_count, total_count;
END $$;
@@ -1,6 +1,4 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@calcom/prisma";
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import type { TListInputSchema } from "./list.schema";
@@ -13,53 +11,5 @@ type ListOptions = {
};
export const listHandler = async ({ ctx, input }: ListOptions) => {
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: ctx.user.id,
},
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: ctx.user.id }, { teamId: { in: user?.teams.map((membership) => membership.teamId) } }],
});
}
if (input?.eventTriggers) {
where.AND?.push({ eventTriggers: { hasEvery: input.eventTriggers } });
}
}
return await prisma.webhook.findMany({
where,
});
return await WebhookRepository.findWebhooksByFilters({ userId: ctx.user.id, input });
};
@@ -0,0 +1,38 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import classNames from "@calcom/ui/classNames";
const progressBarStyles = cva("h-2 rounded-full", {
variants: {
color: {
green: "bg-green-500",
blue: "bg-blue-500",
red: "bg-red-500",
yellow: "bg-yellow-500",
gray: "bg-gray-500",
},
},
defaultVariants: {
color: "gray",
},
});
export interface ProgressBarProps extends VariantProps<typeof progressBarStyles> {
percentageValue: number;
label?: string;
className?: string;
}
export function ProgressBar({ color, percentageValue, label, className }: ProgressBarProps) {
const clampedPercentage = Math.min(100, Math.max(0, percentageValue));
return (
<div className={classNames("flex w-full items-center gap-3", className)}>
<div className="h-2 flex-1 rounded-full bg-gray-200">
<div className={progressBarStyles({ color })} style={{ width: `${clampedPercentage}%` }} />
</div>
{label && <span className="text-sm font-medium text-gray-700">{label}</span>}
</div>
);
}
@@ -0,0 +1,2 @@
export { ProgressBar } from "./ProgressBar";
export type { ProgressBarProps } from "./ProgressBar";
+1
View File
@@ -43,6 +43,7 @@
"./components/organization-banner": "./components/organization-banner/index.ts",
"./components/pagination": "./components/pagination/index.ts",
"./components/popover": "./components/popover/index.ts",
"./components/progress-bar": "./components/progress-bar/index.ts",
"./components/radio": "./components/radio/index.ts",
"./components/scrollable": "./components/scrollable/index.ts",
"./components/sheet": "./components/sheet/index.ts",
+3 -180
View File
@@ -2096,15 +2096,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.21.0":
version: 7.26.10
resolution: "@babel/runtime@npm:7.26.10"
dependencies:
regenerator-runtime: ^0.14.0
checksum: 22d2e0abb86e90de489ab16bb578db6fe2b63a88696db431198b24963749820c723f1982298cdbbea187f7b2b80fb4d98a514faf114ddb2fdc14a4b96277b955
languageName: node
linkType: hard
"@babel/runtime@npm:^7.23.2":
version: 7.23.5
resolution: "@babel/runtime@npm:7.23.5"
@@ -4061,7 +4052,6 @@ __metadata:
"@tanstack/react-query": ^5.17.15
"@team-plain/typescript-sdk": ^5.9.0
"@testing-library/react": ^13.3.0
"@tremor/react": ^2.11.0
"@types/accept-language-parser": 1.5.2
"@types/async": ^3.2.15
"@types/bcryptjs": ^2.4.2
@@ -5843,7 +5833,7 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/dom@npm:^1.0.1, @floating-ui/dom@npm:^1.2.1":
"@floating-ui/dom@npm:^1.0.1":
version: 1.2.6
resolution: "@floating-ui/dom@npm:1.2.6"
dependencies:
@@ -5871,18 +5861,6 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/react-dom@npm:^1.3.0":
version: 1.3.0
resolution: "@floating-ui/react-dom@npm:1.3.0"
dependencies:
"@floating-ui/dom": ^1.2.1
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: ce0ad3e3bbe43cfd15a6a0d5cccede02175c845862bfab52027995ab99c6b29630180dc7d146f76ebb34730f90a6ab9bf193c8984fe8d7f56062308e4ca98f77
languageName: node
linkType: hard
"@floating-ui/react-dom@npm:^2.0.0":
version: 2.0.1
resolution: "@floating-ui/react-dom@npm:2.0.1"
@@ -5895,20 +5873,6 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/react@npm:^0.19.1":
version: 0.19.2
resolution: "@floating-ui/react@npm:0.19.2"
dependencies:
"@floating-ui/react-dom": ^1.3.0
aria-hidden: ^1.1.3
tabbable: ^6.0.1
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 00fd827c2dcf879fec221d89ef5b90836bbecacc236ce2acc787db32ae7311d490cd136b13a8d0b6ab12842554a2ee1110605aa832af71a45c0a7297e342072c
languageName: node
linkType: hard
"@floating-ui/utils@npm:^0.2.9":
version: 0.2.9
resolution: "@floating-ui/utils@npm:0.2.9"
@@ -16884,22 +16848,6 @@ __metadata:
languageName: node
linkType: hard
"@tremor/react@npm:^2.11.0":
version: 2.11.0
resolution: "@tremor/react@npm:2.11.0"
dependencies:
"@floating-ui/react": ^0.19.1
date-fns: ^2.28.0
react-transition-group: ^4.4.5
recharts: ^2.3.2
tailwind-merge: ^1.9.1
peerDependencies:
react: ^18.0.0
react-dom: ">=16.6.0"
checksum: 76bee230a819289a5e87204f51161724b49bcc281308b4c41a5918e57e93497e2e97eb85b951bd750b918093784fdb28a2d1060b89502544f1a9cf76232da6d3
languageName: node
linkType: hard
"@trivago/prettier-plugin-sort-imports@npm:4.1.1":
version: 4.1.1
resolution: "@trivago/prettier-plugin-sort-imports@npm:4.1.1"
@@ -20088,15 +20036,6 @@ __metadata:
languageName: node
linkType: hard
"aria-hidden@npm:^1.1.3":
version: 1.2.3
resolution: "aria-hidden@npm:1.2.3"
dependencies:
tslib: ^2.0.0
checksum: 7d7d211629eef315e94ed3b064c6823d13617e609d3f9afab1c2ed86399bb8e90405f9bdd358a85506802766f3ecb468af985c67c846045a34b973bcc0289db9
languageName: node
linkType: hard
"aria-hidden@npm:^1.2.4":
version: 1.2.4
resolution: "aria-hidden@npm:1.2.4"
@@ -23796,15 +23735,6 @@ __metadata:
languageName: node
linkType: hard
"date-fns@npm:^2.28.0":
version: 2.30.0
resolution: "date-fns@npm:2.30.0"
dependencies:
"@babel/runtime": ^7.21.0
checksum: f7be01523282e9bb06c0cd2693d34f245247a29098527d4420628966a2d9aad154bd0e90a6b1cf66d37adcb769cd108cf8a7bd49d76db0fb119af5cdd13644f4
languageName: node
linkType: hard
"date-fns@npm:^3.6.0":
version: 3.6.0
resolution: "date-fns@npm:3.6.0"
@@ -23927,7 +23857,7 @@ __metadata:
languageName: node
linkType: hard
"decimal.js-light@npm:^2.4.1, decimal.js-light@npm:^2.5.1":
"decimal.js-light@npm:^2.5.1":
version: 2.5.1
resolution: "decimal.js-light@npm:2.5.1"
checksum: f5a2c7eac1c4541c8ab8a5c8abea64fc1761cefc7794bd5f8afd57a8a78d1b51785e0c4e4f85f4895a043eaa90ddca1edc3981d1263eb6ddce60f32bf5fe66c9
@@ -26641,7 +26571,7 @@ __metadata:
languageName: node
linkType: hard
"eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.1, eventemitter3@npm:^4.0.4":
"eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.4":
version: 4.0.7
resolution: "eventemitter3@npm:4.0.7"
checksum: 1875311c42fcfe9c707b2712c32664a245629b42bb0a5a84439762dd0fd637fc54d078155ea83c2af9e0323c9ac13687e03cfba79b03af9f40c89b4960099374
@@ -26900,13 +26830,6 @@ __metadata:
languageName: node
linkType: hard
"fast-equals@npm:^5.0.1":
version: 5.2.2
resolution: "fast-equals@npm:5.2.2"
checksum: 7156bcade0be5ee4dc335969d255a5815348d57080e1876fa1584451eafd0c92588de5f5840e55f81841b6d907ade2a49a46e4ec33e6f7a283a209c0fd8f8a59
languageName: node
linkType: hard
"fast-glob@npm:3.3.1":
version: 3.3.1
resolution: "fast-glob@npm:3.3.1"
@@ -39389,13 +39312,6 @@ __metadata:
languageName: node
linkType: hard
"react-is@npm:^18.3.1":
version: 18.3.1
resolution: "react-is@npm:18.3.1"
checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21
languageName: node
linkType: hard
"react-live-chat-loader@npm:^2.8.1":
version: 2.8.1
resolution: "react-live-chat-loader@npm:2.8.1"
@@ -39709,20 +39625,6 @@ __metadata:
languageName: node
linkType: hard
"react-smooth@npm:^4.0.4":
version: 4.0.4
resolution: "react-smooth@npm:4.0.4"
dependencies:
fast-equals: ^5.0.1
prop-types: ^15.8.1
react-transition-group: ^4.4.5
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 909305d40bae79a011ff21a10c4bc7ddadc87ac5ff093b4a5f827f730f093ec4e044c4330688d29b3ad2db83aab8997c3bb1bae550a9c66de74521b8ed52cc53
languageName: node
linkType: hard
"react-sticky-box@npm:^2.0.4":
version: 2.0.4
resolution: "react-sticky-box@npm:2.0.4"
@@ -39811,21 +39713,6 @@ __metadata:
languageName: node
linkType: hard
"react-transition-group@npm:^4.4.5":
version: 4.4.5
resolution: "react-transition-group@npm:4.4.5"
dependencies:
"@babel/runtime": ^7.5.5
dom-helpers: ^5.0.1
loose-envify: ^1.4.0
prop-types: ^15.6.2
peerDependencies:
react: ">=16.6.0"
react-dom: ">=16.6.0"
checksum: 75602840106aa9c6545149d6d7ae1502fb7b7abadcce70a6954c4b64a438ff1cd16fc77a0a1e5197cdd72da398f39eb929ea06f9005c45b132ed34e056ebdeb1
languageName: node
linkType: hard
"react-turnstile@npm:^1.1.3":
version: 1.1.3
resolution: "react-turnstile@npm:1.1.3"
@@ -40079,34 +39966,6 @@ __metadata:
languageName: node
linkType: hard
"recharts-scale@npm:^0.4.4":
version: 0.4.5
resolution: "recharts-scale@npm:0.4.5"
dependencies:
decimal.js-light: ^2.4.1
checksum: e970377190a610e684a32c7461c7684ac3603c2e0ac0020bbba1eea9d099b38138143a8e80bf769bb49c0b7cecf22a2f5c6854885efed2d56f4540d4aa7052bd
languageName: node
linkType: hard
"recharts@npm:^2.3.2":
version: 2.15.1
resolution: "recharts@npm:2.15.1"
dependencies:
clsx: ^2.0.0
eventemitter3: ^4.0.1
lodash: ^4.17.21
react-is: ^18.3.1
react-smooth: ^4.0.4
recharts-scale: ^0.4.4
tiny-invariant: ^1.3.1
victory-vendor: ^36.6.8
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: c288012087424e7067bf4a48a3d50a7c0ea231188ecaaf17685d93711ef1846aedd37509dfc25c046052f671e7e19dd2de5d8b9c1e497a64c39995c9c338b40a
languageName: node
linkType: hard
"recharts@npm:^3.0.2":
version: 3.0.2
resolution: "recharts@npm:3.0.2"
@@ -43713,13 +43572,6 @@ __metadata:
languageName: node
linkType: hard
"tabbable@npm:^6.0.1":
version: 6.1.1
resolution: "tabbable@npm:6.1.1"
checksum: 348639497262241ce8e0ccb0664ea582a386183107299ee8f27cf7b56bc84f36e09eaf667d3cb4201e789634012a91f7129bcbd49760abe874fbace35b4cf429
languageName: node
linkType: hard
"tailwind-merge@npm:^1.13.2":
version: 1.13.2
resolution: "tailwind-merge@npm:1.13.2"
@@ -43727,13 +43579,6 @@ __metadata:
languageName: node
linkType: hard
"tailwind-merge@npm:^1.9.1":
version: 1.14.0
resolution: "tailwind-merge@npm:1.14.0"
checksum: 8cf5d37f51b3b32e4bdd5544feaed34357bfba2f64f14834cc4a21ac29b0ae22d7255386467a0f1dadb3e38499389efbbabeddcd0b16571af5a0d346a4921877
languageName: node
linkType: hard
"tailwind-merge@npm:^2.5.4":
version: 2.6.0
resolution: "tailwind-merge@npm:2.6.0"
@@ -46218,28 +46063,6 @@ __metadata:
languageName: node
linkType: hard
"victory-vendor@npm:^36.6.8":
version: 36.9.2
resolution: "victory-vendor@npm:36.9.2"
dependencies:
"@types/d3-array": ^3.0.3
"@types/d3-ease": ^3.0.0
"@types/d3-interpolate": ^3.0.1
"@types/d3-scale": ^4.0.2
"@types/d3-shape": ^3.1.0
"@types/d3-time": ^3.0.0
"@types/d3-timer": ^3.0.0
d3-array: ^3.1.6
d3-ease: ^3.0.1
d3-interpolate: ^3.0.1
d3-scale: ^4.0.2
d3-shape: ^3.1.0
d3-time: ^3.0.0
d3-timer: ^3.0.1
checksum: a755110e287b700202d08ac81982093ab100edaa9d61beef1476d59e9705605bd8299a3aa41fa04b933a12bd66737f4c8f7d18448dd6488c69d4f72480023a2e
languageName: node
linkType: hard
"victory-vendor@npm:^37.0.2":
version: 37.3.6
resolution: "victory-vendor@npm:37.3.6"