diff --git a/apps/web/next.config.js b/apps/web/next.config.js index b7f670d9c41bb..e6b73eb8e3aab 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -99,6 +99,11 @@ const nextConfig = { skipDefaultConversion: true, preventFullImport: true, }, + "@calcom/features/insights/components": { + transform: "@calcom/features/insights/components/{{member}}", + skipDefaultConversion: true, + preventFullImport: true, + }, lodash: { transform: "lodash/{{member}}", }, diff --git a/apps/web/package.json b/apps/web/package.json index 8b79d0e707b7e..98758bbd1a798 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -60,6 +60,7 @@ "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.35.0", "@tanstack/react-query": "^4.3.9", + "@tremor/react": "^2.0.0", "@types/turndown": "^5.0.1", "@vercel/edge-config": "^0.1.1", "@vercel/edge-functions-ui": "^0.2.1", diff --git a/apps/web/pages/insights/index.tsx b/apps/web/pages/insights/index.tsx new file mode 100644 index 0000000000000..f47267dbcca06 --- /dev/null +++ b/apps/web/pages/insights/index.tsx @@ -0,0 +1,116 @@ +import { + AverageEventDurationChart, + BookingKPICards, + BookingStatusLineChart, + LeastBookedTeamMembersTable, + MostBookedTeamMembersTable, + PopularEventsTable, +} from "@calcom/features/insights/components"; +import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider"; +import { useFilterContext } from "@calcom/features/insights/context/provider"; +import { Filters } from "@calcom/features/insights/filters"; +import Shell from "@calcom/features/shell/Shell"; +import { UpgradeTip } from "@calcom/features/tips"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import { Button, ButtonGroup } from "@calcom/ui"; +import { FiRefreshCcw, FiUserPlus, FiUsers } from "@calcom/ui/components/icon"; + +const Heading = () => { + const { t } = useLocale(); + const { + filter: { selectedTeamName }, + } = useFilterContext(); + return ( +
+

+ {t("analytics_for_organisation", { + organisationName: selectedTeamName, + })} +

+

{t("subtitle_analytics")}

+
+ ); +}; + +export default function InsightsPage() { + const { t } = useLocale(); + const { data: user } = trpc.viewer.me.useQuery(); + const features = [ + { + icon: , + title: t("view_bookings_across"), + description: t("view_bookings_across_description"), + }, + { + icon: , + title: t("identify_booking_trends"), + description: t("identify_booking_trends_description"), + }, + { + icon: , + title: t("spot_popular_event_types"), + description: t("spot_popular_event_types_description"), + }, + ]; + + return ( +
+ + + + + + +
+ }> + {!user ? ( + <> + ) : ( + +
+ + +
+
+ + + + +
+ + + +
+
+ + +
+ + {t("looking_for_more_analytics")} + + {" "} + {t("contact_support")} + + +
+
+ )} + + + + ); +} diff --git a/apps/web/public/banners/insights.jpg b/apps/web/public/banners/insights.jpg new file mode 100644 index 0000000000000..32e4db24e5ef5 Binary files /dev/null and b/apps/web/public/banners/insights.jpg differ diff --git a/apps/web/public/banners/routing-forms.jpg b/apps/web/public/banners/routing-forms.jpg new file mode 100644 index 0000000000000..45ac4c5b5d4aa Binary files /dev/null and b/apps/web/public/banners/routing-forms.jpg differ diff --git a/apps/web/public/banners/teams.jpg b/apps/web/public/banners/teams.jpg new file mode 100644 index 0000000000000..0b4167494f66a Binary files /dev/null and b/apps/web/public/banners/teams.jpg differ diff --git a/apps/web/public/routing-form-banner-background.jpg b/apps/web/public/routing-form-banner-background.jpg deleted file mode 100644 index ba472f65fb0a3..0000000000000 Binary files a/apps/web/public/routing-form-banner-background.jpg and /dev/null differ diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index acd8a93b643bc..e0526e5a5dc89 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -111,7 +111,7 @@ "hidden_team_owner_message": "You need a pro account to use teams, you are hidden until you upgrade.", "link_expires": "p.s. It expires in {{expiresIn}} hours.", "upgrade_to_per_seat": "Upgrade to Per-Seat", - "seat_options_doesnt_support_confirmation":"Seats option doesn't support confirmation requirement", + "seat_options_doesnt_support_confirmation": "Seats option doesn't support confirmation requirement", "team_upgrade_seats_details": "Of the {{memberCount}} members in your team, {{unpaidCount}} seat(s) are unpaid. At ${{seatPrice}}/month per seat the estimated total cost of your membership is ${{totalCost}}/month.", "team_upgrade_banner_description": "Thank you for trialing our new team plan. We noticed your team \"{{teamName}}\" needs to be upgraded.", "team_upgrade_banner_action": "Upgrade here", @@ -433,7 +433,7 @@ "password_hint_min": "Minimum 7 characters long", "password_hint_admin_min": "Minimum 15 characters long", "password_hint_num": "Contain at least 1 number", - "max_limit_allowed_hint":"Must be {{limit}} or fewer characters long", + "max_limit_allowed_hint": "Must be {{limit}} or fewer characters long", "invalid_password_hint": "The password must be a minimum of {{passwordLength}} characters long containing at least one number and have a mixture of uppercase and lowercase letters", "incorrect_password": "Password is incorrect.", "incorrect_username_password": "Username or password is incorrect.", @@ -863,7 +863,7 @@ "add_new_calendar": "Add a new calendar", "set_calendar": "Set where to add new events to when you're booked.", "delete_schedule": "Delete schedule", - "delete_schedule_description":"Deleting a schedule will remove it from all event types. This action cannot be undone.", + "delete_schedule_description": "Deleting a schedule will remove it from all event types. This action cannot be undone.", "schedule_created_successfully": "{{scheduleName}} schedule created successfully", "availability_updated_successfully": "{{scheduleName}} schedule updated successfully", "schedule_deleted_successfully": "Schedule deleted successfully", @@ -1631,22 +1631,30 @@ "a_routing_form": "A Routing Form", "form_description_placeholder": "Form Description", "keep_me_connected_with_form": "Keep me connected with the form", - "fields_in_form_duplicated":"Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate.", + "fields_in_form_duplicated": "Any changes in Router and Fields of the form being duplicated, would reflect in the duplicate.", "form_deleted": "Form deleted", "delete_form": "Are you sure you want to delete this form?", "delete_form_action": "Yes, delete Form", - "delete_form_confirmation":"Anyone who you've shared the link with will no longer be able to access it.", - "delete_form_confirmation_2":"All associated responses will be deleted.", + "delete_form_confirmation": "Anyone who you've shared the link with will no longer be able to access it.", + "delete_form_confirmation_2": "All associated responses will be deleted.", "typeform_redirect_url_copied": "Typeform Redirect URL copied! You can go and set the URL in Typeform form.", "modifications_in_fields_warning": "Modifications in fields and routes of following forms will be reflected in this form.", "connected_forms": "Connected Forms", "form_modifications_warning": "Following forms would be affected when you modify fields or routes here.", "responses_collection_waiting_description": "Wait for some time for responses to be collected. You can go and submit the form yourself as well.", - "this_is_what_your_users_would_see":"This is what your users would see", + "this_is_what_your_users_would_see": "This is what your users would see", "identifies_name_field": "Identifies field by this name.", "add_1_option_per_line": "Add 1 option per line", "select_a_router": "Select a router", "add_a_new_route": "Add a new Route", + "make_informed_decisions": "Make informed decisions with Insights", + "make_informed_decisions_description": "Our Insights dashboard surfaces all activity across your team and shows you trends that enable better team scheduling and decision making.", + "view_bookings_across": "View bookings across all members", + "view_bookings_across_description": "See who’s receiving the most bookings and ensure the best distribution across your team", + "identify_booking_trends": "Identify booking trends", + "identify_booking_trends_description": "See what times of the week and what times during the day are popular for your bookers", + "spot_popular_event_types": "Spot popular event types", + "spot_popular_event_types_description": "See which of your event types are receiving the most clicks and bookings", "no_responses_yet": "No responses yet", "this_will_be_the_placeholder": "This will be the placeholder", "this_meeting_has_not_started_yet": "This meeting has not started yet", @@ -1657,8 +1665,27 @@ "verification_code": "Verification code", "can_you_try_again": "Can you try again with a different time?", "verify": "Verify", - "invalid_event_name_variables":"There is an invalid variable in your event name", + "invalid_event_name_variables": "There is an invalid variable in your event name", "select_all": "Select All", "default_conferencing_bulk_title": "Bulk update existing event types", - "default_conferencing_bulk_description": "Update the locations for the selected event types" + "default_conferencing_bulk_description": "Update the locations for the selected event types", + "looking_for_more_analytics": "Looking for more analytics?", + "add_filter": "Add filter", + "select_user": "Select User", + "select_event_type": "Select Event Type", + "select_date_range": "Select Date Range", + "popular_events": "Popular Events", + "no_event_types_found": "No event types found", + "average_event_duration": "Average Event Duration", + "most_booked_members": "Most Booked Members", + "least_booked_members": "Least Booked Members", + "events_created": "Events Created", + "events_completed": "Events Completed", + "events_cancelled": "Events Cancelled", + "events_rescheduled": "Events Rescheduled", + "from_last_period": "from last period", + "from_to_date_period": "From: {{startDate}} To: {{endDate}}", + "analytics_for_organisation": "Analytics for {{organisationName}}", + "subtitle_analytics": "This is a organisation analytics", + "event_trends": "Event Trends" } diff --git a/apps/web/public/team-banner-background.jpg b/apps/web/public/team-banner-background.jpg deleted file mode 100644 index 034fa8501aeae..0000000000000 Binary files a/apps/web/public/team-banner-background.jpg and /dev/null differ diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 771a3a866bc21..9aed99b57d1b5 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -2,5 +2,5 @@ const base = require("@calcom/config/tailwind-preset"); /** @type {import('tailwindcss').Config} */ module.exports = { ...base, - content: [...base.content], + content: [...base.content, "../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}"], }; diff --git a/packages/app-store/routing-forms/pages/forms/[...appPages].tsx b/packages/app-store/routing-forms/pages/forms/[...appPages].tsx index 0182a803877ca..3c10cbaeeee6b 100644 --- a/packages/app-store/routing-forms/pages/forms/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/forms/[...appPages].tsx @@ -99,23 +99,18 @@ export default function RoutingForms({ CTA={hasPaidPlan && } subtitle={t("routing_forms_description")}> } buttons={
- - diff --git a/packages/features/ee/teams/components/TeamsListing.tsx b/packages/features/ee/teams/components/TeamsListing.tsx index e28c5d53cfdb7..4854e42d20e60 100644 --- a/packages/features/ee/teams/components/TeamsListing.tsx +++ b/packages/features/ee/teams/components/TeamsListing.tsx @@ -71,14 +71,14 @@ export function TeamsListing() { emptyTitle="no_teams" emptyDescription="no_teams_description" features={features} - background="/team-banner-background.jpg" + background="/banners/teams.jpg" buttons={
- diff --git a/packages/features/insights/components/AverageEventDurationChart.tsx b/packages/features/insights/components/AverageEventDurationChart.tsx new file mode 100644 index 0000000000000..1fa255b3e0793 --- /dev/null +++ b/packages/features/insights/components/AverageEventDurationChart.tsx @@ -0,0 +1,38 @@ +import { Card, LineChart, Title } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useFilterContext } from "../context/provider"; +import { valueFormatter } from "../lib/valueFormatter"; + +export const AverageEventDurationChart = () => { + const { t } = useLocale(); + const { filter } = useFilterContext(); + const { dateRange, selectedUserId } = filter; + const [startDate, endDate] = dateRange; + const { selectedTeamId: teamId } = filter; + + const { data, isSuccess } = trpc.viewer.insights.averageEventDuration.useQuery({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + teamId, + userId: selectedUserId ?? undefined, + }); + + if (!isSuccess || data?.length == 0 || !startDate || !endDate || !teamId) return null; + + return ( + + {t("average_event_duration")} + + + ); +}; diff --git a/packages/features/insights/components/BookingKPICards.tsx b/packages/features/insights/components/BookingKPICards.tsx new file mode 100644 index 0000000000000..398dec102f039 --- /dev/null +++ b/packages/features/insights/components/BookingKPICards.tsx @@ -0,0 +1,62 @@ +import { Grid } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useFilterContext } from "../context/provider"; +import { KPICard } from "./KPICard"; + +export const BookingKPICards = () => { + const { t } = useLocale(); + const { filter } = useFilterContext(); + const { dateRange, selectedEventTypeId, selectedUserId } = filter; + const [startDate, endDate] = dateRange; + + const { selectedTeamId: teamId } = filter; + + const { data, isSuccess } = trpc.viewer.insights.eventsByStatus.useQuery({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + teamId, + eventTypeId: selectedEventTypeId ?? undefined, + userId: selectedUserId ?? undefined, + }); + + const categories: { + title: string; + index: "created" | "completed" | "rescheduled" | "cancelled"; + }[] = [ + { + title: t("events_created"), + index: "created", + }, + { + title: t("events_completed"), + index: "completed", + }, + { + title: t("events_rescheduled"), + index: "rescheduled", + }, + { + title: t("events_cancelled"), + index: "cancelled", + }, + ]; + + if (!isSuccess || !startDate || !endDate || !teamId || data?.empty) return null; + + return ( + + {categories.map((item) => ( + + ))} + + ); +}; diff --git a/packages/features/insights/components/BookingStatusLineChart.tsx b/packages/features/insights/components/BookingStatusLineChart.tsx new file mode 100644 index 0000000000000..4bcf46318808b --- /dev/null +++ b/packages/features/insights/components/BookingStatusLineChart.tsx @@ -0,0 +1,40 @@ +import { Card, LineChart, Title } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useFilterContext } from "../context/provider"; +import { valueFormatter } from "../lib/valueFormatter"; + +export const BookingStatusLineChart = () => { + const { t } = useLocale(); + const { filter } = useFilterContext(); + const { selectedTeamId, selectedTimeView = "week", dateRange, selectedEventTypeId } = filter; + const [startDate, endDate] = dateRange; + + if (!startDate || !endDate) return null; + + const { data: eventsTimeLine, isSuccess } = trpc.viewer.insights.eventsTimeline.useQuery({ + timeView: selectedTimeView, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + teamId: selectedTeamId || -1, + eventTypeId: selectedEventTypeId ?? undefined, + }); + + if (!isSuccess) return null; + + return ( + + {t("event_trends")} + + + ); +}; diff --git a/packages/features/insights/components/KPICard.tsx b/packages/features/insights/components/KPICard.tsx new file mode 100644 index 0000000000000..76bd954057ced --- /dev/null +++ b/packages/features/insights/components/KPICard.tsx @@ -0,0 +1,49 @@ +import { Card, Flex, Text, Metric, BadgeDelta } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Tooltip } from "@calcom/ui"; + +import { calculateDeltaType, colors, valueFormatter } from "../lib"; + +export const KPICard = ({ + title, + previousMetricData, + previousDateRange, +}: { + title: string; + value: number; + previousMetricData: { + count: number; + deltaPrevious: number; + }; + previousDateRange: { startDate: string; endDate: string }; +}) => { + const { t } = useLocale(); + return ( + + {title} + + {valueFormatter(previousMetricData.count)} + + + + + + {Number(previousMetricData.deltaPrevious).toFixed(0)}% + + + + {t("from_last_period")} + + + + + ); +}; diff --git a/packages/features/insights/components/LeastBookedTeamMembersTable.tsx b/packages/features/insights/components/LeastBookedTeamMembersTable.tsx new file mode 100644 index 0000000000000..6501f163c0c4e --- /dev/null +++ b/packages/features/insights/components/LeastBookedTeamMembersTable.tsx @@ -0,0 +1,30 @@ +import { Card, Title } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useFilterContext } from "../context/provider"; +import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; + +export const LeastBookedTeamMembersTable = () => { + const { t } = useLocale(); + const { filter } = useFilterContext(); + const { dateRange } = filter; + const [startDate, endDate] = dateRange; + const { selectedTeamId: teamId } = filter; + + const { data, isSuccess } = trpc.viewer.insights.membersWithLeastBookings.useQuery({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + teamId, + }); + + if (!isSuccess || !startDate || !endDate || !teamId) return null; + + return ( + + {t("least_booked_members")} + + + ); +}; diff --git a/packages/features/insights/components/MostBookedTeamMembersTable.tsx b/packages/features/insights/components/MostBookedTeamMembersTable.tsx new file mode 100644 index 0000000000000..3fccd13254903 --- /dev/null +++ b/packages/features/insights/components/MostBookedTeamMembersTable.tsx @@ -0,0 +1,31 @@ +import { Card, Title } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useFilterContext } from "../context/provider"; +import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; + +export const MostBookedTeamMembersTable = () => { + const { t } = useLocale(); + const { filter } = useFilterContext(); + const { dateRange, selectedEventTypeId } = filter; + const [startDate, endDate] = dateRange; + const { selectedTeamId: teamId } = filter; + + const { data, isSuccess } = trpc.viewer.insights.membersWithMostBookings.useQuery({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + teamId, + eventTypeId: selectedEventTypeId ?? undefined, + }); + + if (!isSuccess || !startDate || !endDate || !teamId) return null; + + return ( + + {t("most_booked_members")} + + + ); +}; diff --git a/packages/features/insights/components/PopularEventsTable.tsx b/packages/features/insights/components/PopularEventsTable.tsx new file mode 100644 index 0000000000000..27f257e8ebf22 --- /dev/null +++ b/packages/features/insights/components/PopularEventsTable.tsx @@ -0,0 +1,52 @@ +import { Card, Title, Table, TableBody, TableCell, TableRow, Text } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useFilterContext } from "../context/provider"; + +export const PopularEventsTable = () => { + const { t } = useLocale(); + const { filter } = useFilterContext(); + const { dateRange, selectedUserId } = filter; + const [startDate, endDate] = dateRange; + const { selectedTeamId: teamId } = filter; + + const { data, isSuccess } = trpc.viewer.insights.popularEventTypes.useQuery({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + teamId, + userId: selectedUserId ?? undefined, + }); + + if (!startDate || !endDate || !teamId) return null; + + return ( + + {t("popular_events")} + + + {isSuccess ? ( + data?.map((item) => ( + + {item.eventTypeName} + + + {item.count} + + + + )) + ) : ( + + {t("no_event_types_found")} + + 0 + + + )} + +
+
+ ); +}; diff --git a/packages/features/insights/components/TotalBookingUsersTable.tsx b/packages/features/insights/components/TotalBookingUsersTable.tsx new file mode 100644 index 0000000000000..926a0e4debe98 --- /dev/null +++ b/packages/features/insights/components/TotalBookingUsersTable.tsx @@ -0,0 +1,49 @@ +import { Table, TableBody, TableCell, TableRow, Text } from "@tremor/react"; + +import type { User } from "@calcom/prisma/client"; +import { Avatar } from "@calcom/ui"; + +export const TotalBookingUsersTable = ({ + data, +}: { + data: + | { userId: number | null; user: User; emailMd5?: string; count: number; Username?: string }[] + | undefined; +}) => { + return ( + + + <> + {data && data?.length > 0 ? ( + data?.map((item) => ( + + + +

+ {item.user.name} +

+
+ + + {item.count} + + +
+ )) + ) : ( + + No members found + + )} + +
+
+ ); +}; diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts new file mode 100644 index 0000000000000..af5e7aa673c8d --- /dev/null +++ b/packages/features/insights/components/index.ts @@ -0,0 +1,6 @@ +export { AverageEventDurationChart } from "./AverageEventDurationChart"; +export { BookingKPICards } from "./BookingKPICards"; +export { BookingStatusLineChart } from "./BookingStatusLineChart"; +export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable"; +export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable"; +export { PopularEventsTable } from "./PopularEventsTable"; diff --git a/packages/features/insights/context/FiltersProvider.tsx b/packages/features/insights/context/FiltersProvider.tsx new file mode 100644 index 0000000000000..00516c78f7cda --- /dev/null +++ b/packages/features/insights/context/FiltersProvider.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; + +import dayjs from "@calcom/dayjs"; + +import type { FilterContextType } from "./provider"; +import { FilterProvider } from "./provider"; + +export function FiltersProvider({ children }: { children: React.ReactNode }) { + // TODO: Sync insight filters with URL parameters + const [selectedTimeView, setSelectedTimeView] = + useState("week"); + const [selectedUserId, setSelectedUserId] = useState(null); + const [selectedTeamId, setSelectedTeamId] = useState(null); + const [selectedEventTypeId, setSelectedEventTypeId] = + useState(null); + const [selectedFilter, setSelectedFilter] = useState(null); + const [selectedTeamName, setSelectedTeamName] = + useState(null); + const [dateRange, setDateRange] = useState([ + dayjs().subtract(1, "month"), + dayjs(), + "t", + ]); + return ( + setSelectedFilter(filter), + setDateRange: (dateRange) => setDateRange(dateRange), + setSelectedTimeView: (selectedTimeView) => setSelectedTimeView(selectedTimeView), + setSelectedUserId: (selectedUserId) => setSelectedUserId(selectedUserId), + setSelectedTeamId: (selectedTeamId) => setSelectedTeamId(selectedTeamId), + setSelectedTeamName: (selectedTeamName) => setSelectedTeamName(selectedTeamName), + setSelectedEventTypeId: (selectedEventTypeId) => setSelectedEventTypeId(selectedEventTypeId), + }}> + {children} + + ); +} diff --git a/packages/features/insights/context/provider.ts b/packages/features/insights/context/provider.ts new file mode 100644 index 0000000000000..735f02c2ffb4a --- /dev/null +++ b/packages/features/insights/context/provider.ts @@ -0,0 +1,38 @@ +import * as React from "react"; + +import type { Dayjs } from "@calcom/dayjs"; + +export type FilterContextType = { + filter: { + dateRange: [Dayjs, Dayjs, null | string]; + selectedTimeView: "year" | "week" | "month"; + selectedFilter: Array<"user" | "event-type"> | null; + selectedTeamId: number | null; + selectedTeamName: string | null; + selectedUserId: number | null; + selectedEventTypeId: number | null; + }; + setDateRange: ([start, end, range]: [Dayjs, Dayjs, null | string]) => void; + setSelectedFilter: (filter: Array<"user" | "event-type"> | null) => void; + setSelectedTeamId: (teamId: number | null) => void; + setSelectedTeamName: (teamName: string | null) => void; + setSelectedUserId: (userId: number | null) => void; + setSelectedEventTypeId: (eventTypeId: number | null) => void; + setSelectedTimeView: (timeView: "year" | "week" | "month") => void; +}; + +export const FilterContext = React.createContext(null); + +export function useFilterContext() { + const context = React.useContext(FilterContext); + + if (!context) { + throw new Error("useFilterContext must be used within a FilterProvider"); + } + + return context; +} + +export function FilterProvider(props: { value: F; children: React.ReactNode }) { + return React.createElement(FilterContext.Provider, { value: props.value }, props.children); +} diff --git a/packages/features/insights/filters/DateSelect.tsx b/packages/features/insights/filters/DateSelect.tsx new file mode 100644 index 0000000000000..dc97c97a4436c --- /dev/null +++ b/packages/features/insights/filters/DateSelect.tsx @@ -0,0 +1,58 @@ +import { DateRangePicker } from "@tremor/react"; + +import dayjs from "@calcom/dayjs"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import { useFilterContext } from "../context/provider"; + +type RangeType = "tdy" | "w" | "t" | "m" | "y" | undefined | null; + +export const DateSelect = () => { + const { t } = useLocale(); + const { filter, setDateRange } = useFilterContext(); + const currentDate = dayjs(); + const [startDate, endDate, range] = filter.dateRange; + const startValue = startDate?.toDate() || null; + const endValue = endDate?.toDate() || null; + return ( + { + const [selected, ...rest] = datesArray; + const [start, end, range] = datesArray; + // If range has value and it's of type RangeType + + if (range && (range === "tdy" || range === "w" || range === "t" || range === "m" || range === "y")) { + setDateRange([dayjs(start), dayjs(end), range]); + return; + } else if (start && !end) { + // If only start time has value that means selected date should push to dateRange with last value null + const currentDates = filter.dateRange; + // remove last position of array + currentDates.pop(); + // push new value to array + currentDates.push(dayjs(selected)); + // if lenght > 2 then remove first value + if (currentDates.length > 2) { + currentDates.shift(); + } + + setDateRange([currentDates[0], currentDates[1], null]); + + return; + } + + // If range has value and it's of type RangeType + }} + options={undefined} + enableDropdown={true} + placeholder={t("select_date_range")} + enableYearPagination={true} + minDate={currentDate.subtract(2, "year").toDate()} + maxDate={currentDate.toDate()} + color="blue" + className="h-[42px] max-w-sm" + /> + ); +}; diff --git a/packages/features/insights/filters/EventTypeListInTeam.tsx b/packages/features/insights/filters/EventTypeListInTeam.tsx new file mode 100644 index 0000000000000..d4c77756c19e4 --- /dev/null +++ b/packages/features/insights/filters/EventTypeListInTeam.tsx @@ -0,0 +1,57 @@ +import { isArray } from "lodash"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RouterOutputs } from "@calcom/trpc"; +import { trpc } from "@calcom/trpc"; +import { Select } from "@calcom/ui"; + +import { useFilterContext } from "../context/provider"; + +type EventType = RouterOutputs["viewer"]["insights"]["eventTypeList"][number]; +type Option = { value: string; label: string }; + +const mapEventTypeToOption = (eventType: EventType): Option => ({ + value: eventType.slug, + label: eventType.title, +}); + +export const EventTypeListInTeam = () => { + const { t } = useLocale(); + const { filter, setSelectedEventTypeId } = useFilterContext(); + const { selectedTeamId, selectedEventTypeId } = filter; + const { selectedFilter } = filter; + const { data, isSuccess } = trpc.viewer.insights.eventTypeList.useQuery({ + teamId: selectedTeamId, + }); + + if (!selectedFilter?.includes("event-type")) return null; + if (!selectedTeamId) return null; + + const filterOptions = + data?.map(mapEventTypeToOption) ?? ([{ label: "No event types found", value: "" }] as Option[]); + + const selectedEventType = data?.find((item) => item.id === selectedEventTypeId); + const eventTypeValue = selectedEventType ? mapEventTypeToOption(selectedEventType) : null; + + if (!isSuccess || !data || !isArray(data)) return null; + return ( + + isSearchable={false} + isMulti={false} + options={filterOptions} + onChange={(input) => { + if (input) { + const selectedEventTypeId = data.find((item) => item.slug === input.value)?.id; + !!selectedEventTypeId && setSelectedEventTypeId(selectedEventTypeId); + } + }} + defaultValue={eventTypeValue} + className="w-52 min-w-[180px]" + placeholder={ +
+

{t("select_event_type")}

+
+ } + /> + ); +}; diff --git a/packages/features/insights/filters/FilterType.tsx b/packages/features/insights/filters/FilterType.tsx new file mode 100644 index 0000000000000..37bf04dfa39e3 --- /dev/null +++ b/packages/features/insights/filters/FilterType.tsx @@ -0,0 +1,49 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Select } from "@calcom/ui"; +import { FiFilter } from "@calcom/ui/components/icon"; + +import { useFilterContext } from "../context/provider"; + +type Option = { value: "event-type" | "user"; label: string }; + +export const FilterType = () => { + const { t } = useLocale(); + const { setSelectedFilter, setSelectedUserId, setSelectedEventTypeId } = useFilterContext(); + + const filterOptions: Option[] = [ + { + label: t("event_type"), + value: "event-type", + }, + { + label: t("user"), + value: "user", + }, + ]; + + return ( + + isMulti={false} + isSearchable={false} + options={filterOptions} + onChange={(input) => { + if (input) { + // This can multiple values, but for now we only want to have one filter active at a time + setSelectedFilter([input.value]); + if (input.value === "event-type") { + setSelectedUserId(null); + } else if (input.value === "user") { + setSelectedEventTypeId(null); + } + } + }} + className="w-32 min-w-[130px]" + placeholder={ +
+ + {t("add_filter")} +
+ } + /> + ); +}; diff --git a/packages/features/insights/filters/TeamList.tsx b/packages/features/insights/filters/TeamList.tsx new file mode 100644 index 0000000000000..ea480067ce9de --- /dev/null +++ b/packages/features/insights/filters/TeamList.tsx @@ -0,0 +1,53 @@ +import { useEffect } from "react"; + +import type { RouterOutputs } from "@calcom/trpc"; +import { trpc } from "@calcom/trpc"; +import { Select } from "@calcom/ui"; + +import { useFilterContext } from "../context/provider"; + +type Team = RouterOutputs["viewer"]["insights"]["teamListForUser"][number]; +type Option = { value: number; label: string }; + +const mapTeamToOption = (team: Team): Option => ({ + value: team.id, + label: team.name ?? "", +}); + +export const TeamList = () => { + const { filter, setSelectedTeamId, setSelectedTeamName } = useFilterContext(); + const { selectedTeamId } = filter; + const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery(); + + useEffect(() => { + if (data && data?.length > 0) { + setSelectedTeamId(data[0].id); + setSelectedTeamName(data[0].name); + } + }, [data]); + + const UserListOptions = data?.map(mapTeamToOption) || ([{ label: "Empty", value: -1 }] as Option[]); + const selectedTeam = data?.find((item) => item.id === selectedTeamId); + const teamValue = selectedTeam ? mapTeamToOption(selectedTeam) : null; + + if (!isSuccess || !selectedTeamId || data?.length === 0) return null; + + return ( + <> + + isSearchable={false} + isMulti={false} + value={teamValue} + defaultValue={selectedTeamId ? { value: data[0].id, label: data[0].name } : null} + className="h-[38px] w-[90vw] capitalize md:min-w-[100px] md:max-w-[100px]" + options={UserListOptions} + onChange={(input) => { + if (input) { + setSelectedTeamId(input.value); + setSelectedTeamName(input.label); + } + }} + /> + + ); +}; diff --git a/packages/features/insights/filters/UsersListInTeam.tsx b/packages/features/insights/filters/UsersListInTeam.tsx new file mode 100644 index 0000000000000..43bc9b6a18958 --- /dev/null +++ b/packages/features/insights/filters/UsersListInTeam.tsx @@ -0,0 +1,51 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RouterOutputs } from "@calcom/trpc"; +import { trpc } from "@calcom/trpc"; +import { Select } from "@calcom/ui"; + +import { useFilterContext } from "../context/provider"; + +type User = RouterOutputs["viewer"]["insights"]["userList"][number]; +type Option = { value: number; label: string }; + +const mapUserToOption = (user: User): Option => ({ + value: user.id, + label: user.name ?? "", +}); + +export const UserListInTeam = () => { + const { t } = useLocale(); + const { filter, setSelectedUserId } = useFilterContext(); + const { selectedFilter, selectedTeamId, selectedUserId } = filter; + const { data, isSuccess } = trpc.viewer.insights.userList.useQuery({ + teamId: selectedTeamId, + }); + + if (!selectedFilter?.includes("user")) return null; + if (!selectedTeamId) return null; + + const userListOptions = data?.map(mapUserToOption) ?? ([] as { value: number; label: string }[]); + const selectedUser = data?.find((item) => item.id === selectedUserId); + const userValue = selectedUser ? mapUserToOption(selectedUser) : null; + + if (!isSuccess || data?.length === 0) return null; + + return ( + + isSearchable={false} + className="mb-0 ml-2 h-[38px] w-40 min-w-[140px] capitalize md:min-w-[150px] md:max-w-[200px]" + defaultValue={userValue} + options={userListOptions} + onChange={(input) => { + if (input) { + setSelectedUserId(input.value); + } + }} + placeholder={ +
+

{t("select_user")}

+
+ } + /> + ); +}; diff --git a/packages/features/insights/filters/index.tsx b/packages/features/insights/filters/index.tsx new file mode 100644 index 0000000000000..6a63b5e68bf3c --- /dev/null +++ b/packages/features/insights/filters/index.tsx @@ -0,0 +1,45 @@ +import { DateSelect } from "./DateSelect"; +import { EventTypeListInTeam } from "./EventTypeListInTeam"; +import { FilterType } from "./FilterType"; +import { TeamList } from "./TeamList"; +import { UserListInTeam } from "./UsersListInTeam"; + +export const Filters = () => { + return ( +
+ + + + + + + + + + + {/* @NOTE: To be released in next iteration */} + {/* + +
+ ); +}; diff --git a/packages/features/insights/lib/calculateDeltaType.ts b/packages/features/insights/lib/calculateDeltaType.ts new file mode 100644 index 0000000000000..ef7b3bf8bc83a --- /dev/null +++ b/packages/features/insights/lib/calculateDeltaType.ts @@ -0,0 +1,9 @@ +export const calculateDeltaType = (delta: number) => { + if (delta > 0) { + return delta > 10 ? "increase" : "moderateIncrease"; + } else if (delta < 0) { + return delta < -10 ? "decrease" : "moderateDecrease"; + } else { + return "unchanged"; + } +}; diff --git a/packages/features/insights/lib/colors.ts b/packages/features/insights/lib/colors.ts new file mode 100644 index 0000000000000..3f96304f157e8 --- /dev/null +++ b/packages/features/insights/lib/colors.ts @@ -0,0 +1,9 @@ +import type { Color } from "@tremor/react"; + +export const colors: { [key: string]: Color } = { + increase: "emerald", + moderateIncrease: "emerald", + unchanged: "orange", + moderateDecrease: "rose", + decrease: "rose", +}; diff --git a/packages/features/insights/lib/index.ts b/packages/features/insights/lib/index.ts new file mode 100644 index 0000000000000..b880e893ed8d1 --- /dev/null +++ b/packages/features/insights/lib/index.ts @@ -0,0 +1,3 @@ +export { calculateDeltaType } from "./calculateDeltaType"; +export { colors } from "./colors"; +export { valueFormatter } from "./valueFormatter"; diff --git a/packages/features/insights/lib/valueFormatter.ts b/packages/features/insights/lib/valueFormatter.ts new file mode 100644 index 0000000000000..bb66aec62f132 --- /dev/null +++ b/packages/features/insights/lib/valueFormatter.ts @@ -0,0 +1 @@ +export const valueFormatter = (number: number) => `${Intl.NumberFormat().format(number).toString()}`; diff --git a/packages/features/insights/server/events.ts b/packages/features/insights/server/events.ts new file mode 100644 index 0000000000000..157b7ac8042e9 --- /dev/null +++ b/packages/features/insights/server/events.ts @@ -0,0 +1,179 @@ +import type { Dayjs } from "@calcom/dayjs"; +import dayjs from "@calcom/dayjs"; +import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; + +interface ITimeRange { + start: Dayjs; + end: Dayjs; +} + +type TimeViewType = "week" | "month" | "year" | "day"; + +class EventsInsights { + static getBookingsInTimeRange = async (timeRange: ITimeRange, where: Prisma.BookingWhereInput) => { + const { start, end } = timeRange; + + const events = await prisma.booking.count({ + where: { + ...where, + createdAt: { + gte: start.toISOString(), + lte: end.toISOString(), + }, + }, + }); + + return events; + }; + + static getCreatedEventsInTimeRange = async (timeRange: ITimeRange, where: Prisma.BookingWhereInput) => { + const result = await this.getBookingsInTimeRange(timeRange, where); + + return result; + }; + + static getCancelledEventsInTimeRange = async (timeRange: ITimeRange, where: Prisma.BookingWhereInput) => { + const result = await this.getBookingsInTimeRange(timeRange, { + ...where, + status: "CANCELLED", + }); + + return result; + }; + + static getCompletedEventsInTimeRange = async (timeRange: ITimeRange, where: Prisma.BookingWhereInput) => { + const result = await this.getBookingsInTimeRange(timeRange, { + ...where, + status: "ACCEPTED", + endTime: { + lte: dayjs().toISOString(), + }, + }); + + return result; + }; + + static getRescheduledEventsInTimeRange = async (timeRange: ITimeRange, where: Prisma.BookingWhereInput) => { + const result = await this.getBookingsInTimeRange(timeRange, { + ...where, + rescheduled: true, + }); + + return result; + }; + + static getBaseBookingForEventStatus = async (where: Prisma.BookingWhereInput) => { + const baseBookings = await prisma.booking.findMany({ + where, + select: { + id: true, + eventType: true, + }, + }); + + return baseBookings; + }; + + static getTotalRescheduledEvents = async (bookingIds: number[]) => { + return await prisma.booking.count({ + where: { + id: { + in: bookingIds, + }, + rescheduled: true, + }, + }); + }; + + static getTotalCancelledEvents = async (bookingIds: number[]) => { + return await prisma.booking.count({ + where: { + id: { + in: bookingIds, + }, + status: "CANCELLED", + }, + }); + }; + + static getTimeLine = async (timeView: TimeViewType, startDate: Dayjs, endDate: Dayjs) => { + let resultTimeLine: string[] = []; + + if (timeView) { + switch (timeView) { + case "week": + resultTimeLine = this.getWeekTimeline(startDate, endDate); + break; + case "month": + resultTimeLine = this.getMonthTimeline(startDate, endDate); + break; + case "year": + resultTimeLine = this.getYearTimeline(startDate, endDate); + break; + default: + resultTimeLine = this.getWeekTimeline(startDate, endDate); + break; + } + } + + return resultTimeLine; + }; + + static getTimeView = (timeView: TimeViewType, startDate: Dayjs, endDate: Dayjs) => { + let resultTimeView = timeView; + + if (startDate.diff(endDate, "day") > 90) { + resultTimeView = "month"; + } else if (startDate.diff(endDate, "day") > 365) { + resultTimeView = "year"; + } + + return resultTimeView; + }; + + static getWeekTimeline(startDate: Dayjs, endDate: Dayjs) { + let pivotDate = dayjs(startDate); + const dates = []; + while (pivotDate.isBefore(endDate)) { + pivotDate = pivotDate.add(7, "day"); + dates.push(pivotDate.format("YYYY-MM-DD")); + } + return dates; + } + + static getMonthTimeline(startDate: Dayjs, endDate: Dayjs) { + let pivotDate = dayjs(startDate); + const dates = []; + while (pivotDate.isBefore(endDate)) { + pivotDate = pivotDate.set("month", pivotDate.get("month") + 1); + + dates.push(pivotDate.format("YYYY-MM-DD")); + } + return dates; + } + + static getYearTimeline(startDate: Dayjs, endDate: Dayjs) { + const pivotDate = dayjs(startDate); + const dates = []; + while (pivotDate.isBefore(endDate)) { + pivotDate.set("year", pivotDate.get("year") + 1); + dates.push(pivotDate.format("YYYY-MM-DD")); + } + return dates; + } + + static getPercentage = (actualMetric: number, previousMetric: number) => { + const differenceActualVsPrevious = actualMetric - previousMetric; + if (differenceActualVsPrevious === 0) { + return 0; + } + const result = (differenceActualVsPrevious * 100) / previousMetric; + if (isNaN(result) || !isFinite(result)) { + return 0; + } + return result; + }; +} + +export { EventsInsights }; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts new file mode 100644 index 0000000000000..286bc5a5f6fcc --- /dev/null +++ b/packages/features/insights/server/trpc-router.ts @@ -0,0 +1,717 @@ +import type { Prisma } from "@prisma/client"; +import crypto from "crypto"; +import { z } from "zod"; + +import dayjs from "@calcom/dayjs"; +import { authedProcedure, isAuthed, router } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import { EventsInsights } from "./events"; + +const UserBelongsToTeamInput = z.object({ + teamId: z.coerce.number().optional(), +}); + +const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(async ({ ctx, next, rawInput }) => { + const parse = UserBelongsToTeamInput.safeParse(rawInput); + if (!parse.success) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } + + const team = await ctx.prisma.membership.findFirst({ + where: { + userId: ctx.user.id, + teamId: parse.data.teamId, + }, + }); + + if (!team) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + return next(); +}); + +const userBelongsToTeamProcedure = authedProcedure.use(userBelongsToTeamMiddleware); + +const UserSelect = { + id: true, + name: true, + email: true, + avatar: true, +}; + +const emptyResponseEventsByStatus = { + empty: true, + created: { + count: 0, + deltaPrevious: 0, + }, + completed: { + count: 0, + deltaPrevious: 0, + }, + rescheduled: { + count: 0, + deltaPrevious: 0, + }, + cancelled: { + count: 0, + deltaPrevious: 0, + }, + previousRange: { + startDate: dayjs().toISOString(), + endDate: dayjs().toISOString(), + }, +}; + +export const insightsRouter = router({ + eventsByStatus: userBelongsToTeamProcedure + .input( + z.object({ + teamId: z.coerce.number().optional().nullable(), + startDate: z.string(), + endDate: z.string(), + eventTypeId: z.coerce.number().optional(), + userId: z.coerce.number().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, startDate, endDate, eventTypeId, userId } = input; + + if (!input.teamId) { + return emptyResponseEventsByStatus; + } + + const whereConditional: Prisma.BookingWhereInput = { + eventType: { + teamId: teamId, + }, + }; + + if (eventTypeId) { + whereConditional["eventTypeId"] = eventTypeId; + } else if (userId) { + whereConditional["userId"] = userId; + } + + // Migrate to use prisma views + const baseBookings = await EventsInsights.getBaseBookingForEventStatus({ + ...whereConditional, + createdAt: { + gte: new Date(startDate), + lte: new Date(endDate), + }, + eventType: { + teamId: teamId, + }, + }); + const startTimeEndTimeDiff = dayjs(endDate).diff(dayjs(startDate), "day"); + + const baseBookingIds = baseBookings.map((b) => b.id); + + const totalRescheduled = await EventsInsights.getTotalRescheduledEvents(baseBookingIds); + + const totalCancelled = await EventsInsights.getTotalCancelledEvents(baseBookingIds); + + const lastPeriodStartDate = dayjs(startDate).subtract(startTimeEndTimeDiff, "day"); + const lastPeriodEndDate = dayjs(endDate).subtract(startTimeEndTimeDiff, "day"); + + const lastPeriodBaseBookings = await EventsInsights.getBaseBookingForEventStatus({ + ...whereConditional, + createdAt: { + gte: lastPeriodStartDate.toDate(), + lte: lastPeriodEndDate.toDate(), + }, + eventType: { + teamId: teamId, + }, + }); + + const lastPeriodBaseBookingIds = lastPeriodBaseBookings.map((b) => b.id); + + const lastPeriodTotalRescheduled = await EventsInsights.getTotalRescheduledEvents( + lastPeriodBaseBookingIds + ); + + const lastPeriodTotalCancelled = await EventsInsights.getTotalCancelledEvents(lastPeriodBaseBookingIds); + + return { + empty: false, + created: { + count: baseBookings.length, + deltaPrevious: EventsInsights.getPercentage(baseBookings.length, lastPeriodBaseBookings.length), + }, + completed: { + count: baseBookings.length - totalCancelled - totalRescheduled, + deltaPrevious: EventsInsights.getPercentage( + baseBookings.length - totalCancelled - totalRescheduled, + lastPeriodBaseBookings.length - lastPeriodTotalCancelled - lastPeriodTotalRescheduled + ), + }, + rescheduled: { + count: totalRescheduled, + deltaPrevious: EventsInsights.getPercentage(totalRescheduled, lastPeriodTotalRescheduled), + }, + cancelled: { + count: totalCancelled, + deltaPrevious: EventsInsights.getPercentage(totalCancelled, lastPeriodTotalCancelled), + }, + previousRange: { + startDate: lastPeriodStartDate.format("YYYY-MM-DD"), + endDate: lastPeriodEndDate.format("YYYY-MM-DD"), + }, + }; + }), + eventsTimeline: userBelongsToTeamProcedure + .input( + z.object({ + teamId: z.coerce.number().optional(), + startDate: z.string(), + endDate: z.string(), + eventTypeId: z.coerce.number().optional(), + userId: z.coerce.number().optional(), + timeView: z.enum(["week", "month", "year"]), + }) + ) + .query(async ({ ctx, input }) => { + const { + teamId, + startDate: startDateString, + endDate: endDateString, + eventTypeId, + userId, + timeView: inputTimeView, + } = input; + const startDate = dayjs(startDateString); + const endDate = dayjs(endDateString); + const user = ctx.user; + const timeView = inputTimeView; + + let whereConditional: Prisma.BookingWhereInput = { + eventType: { + teamId: teamId, + }, + }; + + if (userId) { + delete whereConditional.eventType; + whereConditional = { + ...whereConditional, + userId, + }; + } + if (eventTypeId && !!whereConditional) { + delete whereConditional.eventType; + delete whereConditional.userId; + whereConditional = { + ...whereConditional, + eventTypeId: eventTypeId, + }; + } + + // Get timeline data + const timeline = await EventsInsights.getTimeLine(timeView, dayjs(startDate), dayjs(endDate)); + + // iterate timeline and fetch data + if (!timeline) { + return []; + } + + const dateFormat: string = timeView === "year" ? "YYYY" : timeView === "month" ? "MMM YYYY" : "ll"; + const result = []; + + for (const date of timeline) { + const EventData = { + Month: dayjs(date).format(dateFormat), + Created: 0, + Completed: 0, + Rescheduled: 0, + Cancelled: 0, + }; + const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week"; + + const startDate = dayjs(date).startOf(startOfEndOf); + const endDate = dayjs(date).endOf(startOfEndOf); + + const promisesResult = await Promise.all([ + EventsInsights.getCreatedEventsInTimeRange( + { + start: startDate, + end: endDate, + }, + whereConditional + ), + EventsInsights.getCompletedEventsInTimeRange( + { + start: startDate, + end: endDate, + }, + whereConditional + ), + EventsInsights.getRescheduledEventsInTimeRange( + { + start: startDate, + end: endDate, + }, + whereConditional + ), + EventsInsights.getCancelledEventsInTimeRange( + { + start: startDate, + end: endDate, + }, + whereConditional + ), + ]); + EventData["Created"] = promisesResult[0]; + EventData["Completed"] = promisesResult[1]; + EventData["Rescheduled"] = promisesResult[2]; + EventData["Cancelled"] = promisesResult[3]; + result.push(EventData); + } + + return result; + }), + popularEventTypes: userBelongsToTeamProcedure + .input( + z.object({ + userId: z.coerce.number().optional(), + teamId: z.coerce.number().optional().nullable(), + startDate: z.string(), + endDate: z.string(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, startDate, endDate, userId } = input; + const user = ctx.user; + + if (!input.teamId) { + return []; + } + + const eventTypeWhere: Prisma.EventTypeWhereInput = { + teamId: teamId, + }; + + const bookingWhere: Prisma.BookingWhereInput = { + eventType: eventTypeWhere, + createdAt: { + gte: dayjs(startDate).startOf("day").toDate(), + lte: dayjs(endDate).endOf("day").toDate(), + }, + }; + + if (userId) { + bookingWhere.userId = userId; + } + + const bookingsFromTeam = await ctx.prisma.booking.groupBy({ + by: ["eventTypeId"], + where: bookingWhere, + _count: { + id: true, + }, + orderBy: { + _count: { + id: "desc", + }, + }, + take: 10, + }); + const eventTypeIds = bookingsFromTeam + .filter((booking) => typeof booking.eventTypeId === "number") + .map((booking) => booking.eventTypeId); + const eventTypesFromTeam = await ctx.prisma.eventType.findMany({ + where: { + teamId: teamId, + id: { + in: eventTypeIds as number[], + }, + }, + }); + + const eventTypeHashMap = new Map(); + eventTypesFromTeam.forEach((eventType) => { + eventTypeHashMap.set(eventType.id, eventType.title); + }); + + const result = bookingsFromTeam.map((booking) => { + return { + eventTypeId: booking.eventTypeId, + eventTypeName: eventTypeHashMap.get(booking.eventTypeId), + count: booking._count.id, + }; + }); + return result; + }), + averageEventDuration: userBelongsToTeamProcedure + .input( + z.object({ + userId: z.coerce.number().optional(), + teamId: z.coerce.number().optional().nullable(), + startDate: z.string(), + endDate: z.string(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, startDate: startDateString, endDate: endDateString, userId } = input; + + if (!teamId) { + return []; + } + + const user = ctx.user; + const startDate = dayjs(startDateString); + const endDate = dayjs(endDateString); + + const whereConditional: Prisma.BookingWhereInput = { + eventType: { + teamId: teamId, + }, + createdAt: { + gte: dayjs(startDate).startOf("day").toDate(), + lte: dayjs(endDate).endOf("day").toDate(), + }, + }; + + if (userId) { + delete whereConditional.eventType; + whereConditional["userId"] = userId; + } + + const timeView = EventsInsights.getTimeView("week", startDate, endDate); + const timeLine = await EventsInsights.getTimeLine("week", startDate, endDate); + + if (!timeLine) { + return []; + } + + const dateFormat = "ll"; + + const result = []; + + for (const date of timeLine) { + const EventData = { + Date: dayjs(date).format(dateFormat), + Average: 0, + }; + const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week"; + + const startDate = dayjs(date).startOf(startOfEndOf); + const endDate = dayjs(date).endOf(startOfEndOf); + + const bookingsInTimeRange = await ctx.prisma.booking.findMany({ + where: { + ...whereConditional, + createdAt: { + gte: startDate.toDate(), + lte: endDate.toDate(), + }, + }, + include: { + eventType: true, + }, + }); + + const avgDuration = + bookingsInTimeRange.reduce((acc, booking) => { + const duration = booking.eventType?.length || 0; + return acc + duration; + }, 0) / bookingsInTimeRange.length; + + EventData["Average"] = Number(avgDuration) || 0; + result.push(EventData); + } + + return result; + }), + membersWithMostBookings: userBelongsToTeamProcedure + .input( + z.object({ + teamId: z.coerce.number().nullable(), + startDate: z.string(), + endDate: z.string(), + eventTypeId: z.coerce.number().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, startDate, endDate, eventTypeId } = input; + if (!teamId) { + return []; + } + const user = ctx.user; + const eventTypeWhere: Prisma.EventTypeWhereInput = { + teamId: teamId, + }; + if (eventTypeId) { + eventTypeWhere["id"] = eventTypeId; + } + + const bookingsFromTeam = await ctx.prisma.booking.groupBy({ + by: ["userId"], + where: { + eventType: eventTypeWhere, + createdAt: { + gte: dayjs(startDate).startOf("day").toDate(), + lte: dayjs(endDate).endOf("day").toDate(), + }, + }, + _count: { + id: true, + }, + orderBy: { + _count: { + id: "desc", + }, + }, + take: 10, + }); + const userIds = bookingsFromTeam + .filter((booking) => typeof booking.userId === "number") + .map((booking) => booking.userId); + const usersFromTeam = await ctx.prisma.user.findMany({ + where: { + id: { + in: userIds as number[], + }, + }, + }); + + const userHashMap = new Map(); + usersFromTeam.forEach((user) => { + userHashMap.set(user.id, user); + }); + + const result = bookingsFromTeam.map((booking) => { + return { + userId: booking.userId, + user: userHashMap.get(booking.userId), + emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), + count: booking._count.id, + }; + }); + return result; + }), + membersWithLeastBookings: userBelongsToTeamProcedure + .input( + z.object({ + teamId: z.coerce.number().nullable(), + startDate: z.string(), + endDate: z.string(), + eventTypeId: z.coerce.number().optional(), + }) + ) + .query(async ({ ctx, input }) => { + const { teamId, startDate, endDate, eventTypeId } = input; + if (!teamId) { + return []; + } + + const eventTypeWhere: Prisma.EventTypeWhereInput = { + teamId: teamId, + }; + if (eventTypeId) { + eventTypeWhere["id"] = eventTypeId; + } + + const bookingsFromTeam = await ctx.prisma.booking.groupBy({ + by: ["userId"], + where: { + eventType: eventTypeWhere, + createdAt: { + gte: dayjs(startDate).startOf("day").toDate(), + lte: dayjs(endDate).endOf("day").toDate(), + }, + }, + _count: { + id: true, + }, + orderBy: { + _count: { + id: "asc", + }, + }, + take: 10, + }); + + // Users are obtained from bookings so if a user has 0 they won't be in the list + const userIds = bookingsFromTeam + .filter((booking) => typeof booking.userId === "number") + .map((booking) => booking.userId); + + const usersWithNoBookings = await ctx.prisma.user.findMany({ + select: UserSelect, + where: { + id: { + notIn: userIds as number[], + }, + teams: { + some: { + teamId: teamId, + }, + }, + }, + }); + + let usersFromTeam: Prisma.UserGetPayload<{ + select: typeof UserSelect; + }>[] = []; + if (usersWithNoBookings.length < 10) { + usersFromTeam = await ctx.prisma.user.findMany({ + where: { + id: { + in: userIds as number[], + }, + }, + select: UserSelect, + take: 10 - usersWithNoBookings.length, + }); + } + + const userHashMap = new Map(); + [...usersWithNoBookings, ...usersFromTeam].forEach((user) => { + userHashMap.set(user.id, user); + }); + + const result = bookingsFromTeam.map((booking) => { + const user = userHashMap.get(booking.userId); + return { + userId: booking.userId, + user, + emailMd5: crypto.createHash("md5").update(user.email).digest("hex"), + count: booking._count.id, + Username: user.name || "No Username found", + }; + }); + + return result; + }), + teamListForUser: authedProcedure.query(async ({ ctx }) => { + const user = ctx.user; + + // Look if user it's admin in multiple teams + const belongsToTeams = await ctx.prisma.membership.findMany({ + where: { + userId: user.id, + team: { + slug: { not: null }, + }, + OR: [ + { + role: "ADMIN", + }, + { + role: "OWNER", + }, + ], + }, + include: { + team: { + select: { + id: true, + name: true, + logo: true, + slug: true, + }, + }, + }, + }); + const result = belongsToTeams.map((membership) => membership.team); + + return result; + }), + userList: userBelongsToTeamProcedure + .input( + z.object({ + teamId: z.coerce.number().nullable(), + }) + ) + .query(async ({ ctx, input }) => { + const user = ctx.user; + + if (!input.teamId) { + return []; + } + + const membership = await ctx.prisma.membership.findFirst({ + where: { + userId: user.id, + teamId: input.teamId, + }, + include: { + user: { + select: UserSelect, + }, + }, + }); + + // If user is not admin, return himself only + if (membership && membership.role === "MEMBER") { + return [membership.user]; + } + const usersInTeam = await ctx.prisma.membership.findMany({ + where: { + teamId: input.teamId, + }, + include: { + user: { + select: UserSelect, + }, + }, + }); + return usersInTeam.map((membership) => membership.user); + }), + eventTypeList: userBelongsToTeamProcedure + .input( + z.object({ + teamId: z.coerce.number().nullable(), + }) + ) + .query(async ({ ctx, input }) => { + const user = ctx.user; + + if (!input.teamId) { + return []; + } + + const membership = await ctx.prisma.membership.findFirst({ + where: { + userId: user.id, + teamId: input.teamId, + }, + }); + + if (membership && membership.role === "MEMBER") { + const eventTypes = await ctx.prisma.eventType.findMany({ + where: { + teamId: input.teamId, + // user its listed as direct user or as part of a group + OR: [ + { + userId: user.id, + }, + { + users: { + some: { + id: user.id, + }, + }, + }, + ], + }, + }); + + return eventTypes; + } + + const eventTypes = await ctx.prisma.eventType.findMany({ + where: { + teamId: input.teamId, + }, + }); + + return eventTypes; + }), +}); diff --git a/packages/features/package.json b/packages/features/package.json index 68b679bedc6d0..f1c05ccb3340b 100644 --- a/packages/features/package.json +++ b/packages/features/package.json @@ -8,6 +8,7 @@ "dependencies": { "@calcom/dayjs": "*", "@calcom/lib": "*", + "@calcom/trpc": "*", "@calcom/ui": "*", "@lexical/react": "^0.5.0", "dompurify": "^2.4.1", diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 6c50c5c1b422f..b7684602a1bb8 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -59,6 +59,7 @@ import { FiFileText, FiZap, FiSettings, + FiBarChart, FiArrowRight, FiArrowLeft, } from "@calcom/ui/components/icon"; @@ -532,6 +533,11 @@ const navigation: NavigationItemType[] = [ href: "/workflows", icon: FiZap, }, + { + name: "Insights", + href: "/insights", + icon: FiBarChart, + }, { name: "settings", href: "/settings/my-account/profile", diff --git a/packages/features/tips/UpgradeTip.tsx b/packages/features/tips/UpgradeTip.tsx index 55be04ed37f96..ea36299dd960b 100644 --- a/packages/features/tips/UpgradeTip.tsx +++ b/packages/features/tips/UpgradeTip.tsx @@ -1,3 +1,4 @@ +import Image from "next/image"; import type { ReactNode } from "react"; import { classNames } from "@calcom/lib"; @@ -39,44 +40,27 @@ export function UpgradeTip({ if (isParentLoading || isLoading) return <>{isParentLoading}; - if (IS_SELF_HOSTED) - return ( - - ); - return ( <> -
-
-
-

{t(title)}

-

- {t(description)} -

- {buttons} -
+
+ {title} +
+

{t(title)}

+

+ {t(description)} +

+ {buttons}
+
-
- {features.map((feature) => ( -
- {feature.icon} -

{feature.title}

-

{feature.description}

-
- ))} -
+
+ {features.map((feature) => ( +
+ {feature.icon} +

{feature.title}

+

{feature.description}

+
+ ))}
); diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 047c11436c5ac..4bd768baa467b 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -16,7 +16,8 @@ "generate-schemas": "prisma generate && prisma format", "post-install": "yarn generate-schemas", "seed-app-store": "ts-node --transpile-only ./seed-app-store.ts", - "delete-app": "ts-node --transpile-only ./delete-app.ts" + "delete-app": "ts-node --transpile-only ./delete-app.ts", + "seed-analytics": "ts-node --transpile-only ./seed-analytics.ts" }, "devDependencies": { "npm-run-all": "^4.1.5" diff --git a/packages/prisma/seed-analytics.ts b/packages/prisma/seed-analytics.ts new file mode 100644 index 0000000000000..9e9b9e4b0efc0 --- /dev/null +++ b/packages/prisma/seed-analytics.ts @@ -0,0 +1,234 @@ +import type { Prisma } from "@prisma/client"; +import { BookingStatus, PrismaClient } from "@prisma/client"; +import { v4 as uuidv4 } from "uuid"; + +import dayjs from "@calcom/dayjs"; +import { hashPassword } from "@calcom/features/auth/lib/hashPassword"; + +const prisma = new PrismaClient(); +async function main() { + // First find if not then create everything + let insightsAdmin = await prisma.user.findFirst({ + where: { + email: "insights@example.com", + }, + }); + + if (!insightsAdmin) { + insightsAdmin = await prisma.user.create({ + data: { + email: "insights@example.com", + password: await hashPassword("insights"), + name: "Insights Admin", + role: "ADMIN", + username: "insights", + completedOnboarding: true, + }, + }); + } + + let insightsUser = await prisma.user.findFirst({ + where: { + email: "insightuser@example.com", + }, + }); + + if (!insightsUser) { + insightsUser = await prisma.user.create({ + data: { + email: "insightuser@example.com", + password: await hashPassword("insightsuser"), + name: "Insights User", + role: "USER", + username: "insights-user", + completedOnboarding: true, + }, + }); + } + + let insightsTeam = await prisma.team.findFirst({ + where: { + slug: "insights-team", + }, + }); + + if (!insightsTeam) { + insightsTeam = await prisma.team.create({ + data: { + name: "Insights", + slug: "insights-team", + }, + }); + await prisma.membership.createMany({ + data: [ + { + teamId: insightsTeam.id, + userId: insightsAdmin.id, + role: "OWNER", + accepted: true, + }, + { + teamId: insightsTeam.id, + userId: insightsUser.id, + role: "MEMBER", + accepted: true, + }, + ], + }); + await prisma.team.update({ + where: { + id: insightsTeam.id, + }, + data: { + members: { + connect: { + userId_teamId: { + userId: insightsAdmin.id, + teamId: insightsTeam.id, + }, + }, + }, + }, + }); + } + await prisma.team.update({ + where: { + id: insightsTeam.id, + }, + data: { + members: { + connect: { + userId_teamId: { + userId: insightsUser.id, + teamId: insightsTeam.id, + }, + }, + }, + }, + }); + + let teamEvents = await prisma.eventType.findMany({ + where: { + teamId: insightsTeam.id, + }, + }); + + if (teamEvents.length === 0) { + await prisma.eventType.createMany({ + data: [ + { + title: "Team Meeting", + slug: "team-meeting", + description: "Team Meeting", + length: 60, + teamId: insightsTeam.id, + schedulingType: "ROUND_ROBIN", + }, + { + title: "Team Lunch", + slug: "team-lunch", + description: "Team Lunch", + length: 30, + teamId: insightsTeam.id, + schedulingType: "COLLECTIVE", + }, + { + title: "Team Coffee", + slug: "team-coffee", + description: "Team Coffee", + length: 15, + teamId: insightsTeam.id, + schedulingType: "COLLECTIVE", + }, + ], + }); + } else { + teamEvents = await prisma.eventType.findMany({ + where: { + teamId: insightsTeam.id, + }, + }); + } + + const baseBooking: Prisma.BookingCreateManyInput = { + uid: "demoUID", + title: "Team Meeting", + description: "Team Meeting", + startTime: dayjs().toISOString(), + endTime: dayjs().toISOString(), + userId: insightsUser.id, + eventTypeId: teamEvents[0].id, + }; + + const shuffle = (booking: typeof baseBooking, year) => { + const startTime = dayjs(booking.startTime) + .add(Math.floor(Math.random() * 365), "day") + .add(Math.floor(Math.random() * 24), "hour") + .add(Math.floor(Math.random() * 60), "minute") + .set("y", year); + const randomEvent = teamEvents[Math.floor(Math.random() * teamEvents.length)]; + const endTime = dayjs(startTime).add(Math.floor(Math.random() * randomEvent.length), "minute"); + + booking.startTime = startTime.toISOString(); + booking.endTime = endTime.toISOString(); + booking.createdAt = startTime.subtract(1, "day").toISOString(); + + // Pick a random status + const randomStatusIndex = Math.floor(Math.random() * Object.keys(BookingStatus).length); + const statusKey = Object.keys(BookingStatus)[randomStatusIndex]; + + booking.status = BookingStatus[statusKey]; + + booking.rescheduled = Math.random() > 0.5 && Math.random() > 0.5 && Math.random() > 0.5; + + if (booking.rescheduled) { + booking.status = "CANCELLED"; + } + const randomEventTypeId = teamEvents[Math.floor(Math.random() * teamEvents.length)].id; + + booking.eventTypeId = randomEventTypeId; + booking.uid = uuidv4(); + + if (insightsUser && insightsAdmin) { + booking.userId = Math.random() > 0.5 ? insightsUser.id : insightsAdmin.id; + } + + return booking; + }; + + // Create past bookings + await prisma.booking.createMany({ + data: [...new Array(100).fill(0).map(() => shuffle({ ...baseBooking }, dayjs().get("y") - 2))], + }); + + await prisma.booking.createMany({ + data: [...new Array(100).fill(0).map(() => shuffle({ ...baseBooking }, dayjs().get("y") - 1))], + }); + + await prisma.booking.createMany({ + data: [...new Array(100).fill(0).map(() => shuffle({ ...baseBooking }, dayjs().get("y")))], + }); +} +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.user.deleteMany({ + where: { + email: { + in: ["insights@example", "insightsuser@example.com"], + }, + }, + }); + + await prisma.team.deleteMany({ + where: { + slug: "insights", + }, + }); + + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/packages/trpc/server/routers/viewer.tsx b/packages/trpc/server/routers/viewer.tsx index bf3457ea3c179..de13e9a64b776 100644 --- a/packages/trpc/server/routers/viewer.tsx +++ b/packages/trpc/server/routers/viewer.tsx @@ -23,10 +23,10 @@ import { sendCancelledEmails, sendFeedbackEmail } from "@calcom/emails"; import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword"; import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml"; +import { insightsRouter } from "@calcom/features/insights/server/trpc-router"; import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib"; import getEnabledApps from "@calcom/lib/apps/getEnabledApps"; -import { IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; -import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; +import { FULL_NAME_LENGTH_MAX_LIMIT, IS_SELF_HOSTED, WEBAPP_URL } from "@calcom/lib/constants"; import { symmetricDecrypt } from "@calcom/lib/crypto"; import getPaymentAppData from "@calcom/lib/getPaymentAppData"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; @@ -1273,6 +1273,7 @@ export const viewerRouter = mergeRouters( slots: slotsRouter, workflows: workflowsRouter, saml: ssoRouter, + insights: insightsRouter, // NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved. // After that there would just one merge call here for all the apps. appRoutingForms: app_RoutingForms, diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index be54d719d19d9..f15bfae37eee1 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -4,6 +4,7 @@ import superjson from "superjson"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { defaultAvatarSrc } from "@calcom/lib/defaultAvatarImage"; +import rateLimit from "@calcom/lib/rateLimit"; import prisma from "@calcom/prisma"; import type { Maybe } from "@trpc/server"; @@ -103,7 +104,7 @@ const perfMiddleware = t.middleware(async ({ path, type, next }) => { return result; }); -const isAuthed = t.middleware(async ({ ctx: { session, locale, ...ctx }, next }) => { +export const isAuthed = t.middleware(async ({ ctx: { session, locale, ...ctx }, next }) => { const user = await getUserFromSession({ session }); if (!user || !session) { throw new TRPCError({ code: "UNAUTHORIZED" }); @@ -135,10 +136,37 @@ const isAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => { }); }); +interface IRateLimitOptions { + intervalInMs: number; + limit: number; +} +const isRateLimitedByUserIdMiddleware = ({ intervalInMs, limit }: IRateLimitOptions) => + t.middleware(({ ctx, next }) => { + // validate user exists + if (!ctx.user) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + const { isRateLimited } = rateLimit({ intervalInMs }).check(limit, ctx.user.id.toString()); + + if (isRateLimited) { + throw new TRPCError({ code: "TOO_MANY_REQUESTS" }); + } + + return next({ + ctx: { + // infers that `user` and `session` are non-nullable to downstream procedures + session: ctx.session, + user: ctx.user, + }, + }); + }); + export const router = t.router; export const mergeRouters = t.mergeRouters; export const middleware = t.middleware; export const publicProcedure = t.procedure.use(perfMiddleware); export const authedProcedure = t.procedure.use(perfMiddleware).use(isAuthed); - +export const authedRateLimitedProcedure = ({ intervalInMs, limit }: IRateLimitOptions) => + authedProcedure.use(isRateLimitedByUserIdMiddleware({ intervalInMs, limit })); export const authedAdminProcedure = t.procedure.use(perfMiddleware).use(isAdminMiddleware); diff --git a/yarn.lock b/yarn.lock index a9aa33bfec457..570444396ea6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3534,6 +3534,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.20.13": + version: 7.21.0 + resolution: "@babel/runtime@npm:7.21.0" + dependencies: + regenerator-runtime: ^0.13.11 + checksum: 7b33e25bfa9e0e1b9e8828bb61b2d32bdd46b41b07ba7cb43319ad08efc6fda8eb89445193e67d6541814627df0ca59122c0ea795e412b99c5183a0540d338ab + languageName: node + linkType: hard + "@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.5.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4": version: 7.18.6 resolution: "@babel/runtime@npm:7.18.6" @@ -3561,15 +3570,6 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.20.13": - version: 7.21.0 - resolution: "@babel/runtime@npm:7.21.0" - dependencies: - regenerator-runtime: ^0.13.11 - checksum: 7b33e25bfa9e0e1b9e8828bb61b2d32bdd46b41b07ba7cb43319ad08efc6fda8eb89445193e67d6541814627df0ca59122c0ea795e412b99c5183a0540d338ab - languageName: node - linkType: hard - "@babel/runtime@npm:~7.5.4": version: 7.5.5 resolution: "@babel/runtime@npm:7.5.5" @@ -4411,6 +4411,7 @@ __metadata: dependencies: "@calcom/dayjs": "*" "@calcom/lib": "*" + "@calcom/trpc": "*" "@calcom/ui": "*" "@lexical/react": ^0.5.0 dompurify: ^2.4.1 @@ -4935,6 +4936,7 @@ __metadata: "@stripe/stripe-js": ^1.35.0 "@tanstack/react-query": ^4.3.9 "@testing-library/react": ^13.3.0 + "@tremor/react": ^2.0.0 "@types/accept-language-parser": 1.5.2 "@types/async": ^3.2.15 "@types/bcryptjs": ^2.4.2 @@ -6441,7 +6443,7 @@ __metadata: languageName: node linkType: hard -"@floating-ui/react-dom@npm:^1.0.0": +"@floating-ui/react-dom@npm:^1.0.0, @floating-ui/react-dom@npm:^1.3.0": version: 1.3.0 resolution: "@floating-ui/react-dom@npm:1.3.0" dependencies: @@ -6453,6 +6455,20 @@ __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 + "@formatjs/ecma402-abstract@npm:1.11.4": version: 1.11.4 resolution: "@formatjs/ecma402-abstract@npm:1.11.4" @@ -12068,6 +12084,21 @@ __metadata: languageName: node linkType: hard +"@tremor/react@npm:^2.0.0": + version: 2.0.2 + resolution: "@tremor/react@npm:2.0.2" + 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 + checksum: 4002a062a3bcdc164904187ffe53934d26edbb8d342a3f756b99c32beb0d997bf0937fdda39526d964e34cc996231b485fc68fc4a580c1d009b4a2fcb8ebdc27 + languageName: node + linkType: hard + "@trivago/prettier-plugin-sort-imports@npm:3.2.0": version: 3.2.0 resolution: "@trivago/prettier-plugin-sort-imports@npm:3.2.0" @@ -12346,6 +12377,75 @@ __metadata: languageName: node linkType: hard +"@types/d3-array@npm:^3.0.3": + version: 3.0.4 + resolution: "@types/d3-array@npm:3.0.4" + checksum: b0e398365fc1f638d48442e865e036d671c731b2b18f7a92e5172db1f60f5a38d4cd992693a29ad64b38e7ba981eb8c63a2aef95fbdcfbc4bf8926a9cb9ca978 + languageName: node + linkType: hard + +"@types/d3-color@npm:*": + version: 3.1.0 + resolution: "@types/d3-color@npm:3.1.0" + checksum: b1856f17d6366559a68eaba0164f30727e9dc5eaf1b3a6c8844354da228860240423d19fa4de65bff9da26b4ead8843eab14b1566962665412e8fd82c3810554 + languageName: node + linkType: hard + +"@types/d3-ease@npm:^3.0.0": + version: 3.0.0 + resolution: "@types/d3-ease@npm:3.0.0" + checksum: 1be7c993643b5a08332e0ee146375a3845545d8deb423db5d152e0b061524385d2345ceccf968f75f605247b940dd3f9a144335fee2e7d935cddaf187afb7095 + languageName: node + linkType: hard + +"@types/d3-interpolate@npm:^3.0.1": + version: 3.0.1 + resolution: "@types/d3-interpolate@npm:3.0.1" + dependencies: + "@types/d3-color": "*" + checksum: 29ce472968b9e6611bdf0eeedaf89e8d6066190b52ced011d16d8183b8b9f8e6dd6516ca2b85242594942896299b42f37504d44e635f8fba3090c2c58594e21b + languageName: node + linkType: hard + +"@types/d3-path@npm:*": + version: 3.0.0 + resolution: "@types/d3-path@npm:3.0.0" + checksum: af7f45ea912cddd794c03384baba856f11e1f9b57a49d05a66a61968dafaeb86e0e42394883118b9b8ccadce21a5f25b1f9a88ad05235e1dc6d24c3e34a379ff + languageName: node + linkType: hard + +"@types/d3-scale@npm:^4.0.2": + version: 4.0.3 + resolution: "@types/d3-scale@npm:4.0.3" + dependencies: + "@types/d3-time": "*" + checksum: 76684da8519ab5f2210e647f74f96ece9c6816dea4ad5d76131121703a5268cc65687a8bc9ebbf4a44039482247336d98811ecc3fbfeb7f0122fdce4bb295547 + languageName: node + linkType: hard + +"@types/d3-shape@npm:^3.1.0": + version: 3.1.1 + resolution: "@types/d3-shape@npm:3.1.1" + dependencies: + "@types/d3-path": "*" + checksum: 8f1762ecdeb4833a3802be1c65363cbc7cca753d0b836a3855fde4ba12f8e6fc142dba3c5f6d669a9e89374cc6dc414464e4f2d04e72fafd4bc64819ce30bb63 + languageName: node + linkType: hard + +"@types/d3-time@npm:*, @types/d3-time@npm:^3.0.0": + version: 3.0.0 + resolution: "@types/d3-time@npm:3.0.0" + checksum: e76adb056daccf80107f4db190ac6deb77e8774f00362bb6c76f178e67f2f217422fe502b654edbc9ac6451f6619045b9f6f5fe0db1ec5520e2ada377af7c72e + languageName: node + linkType: hard + +"@types/d3-timer@npm:^3.0.0": + version: 3.0.0 + resolution: "@types/d3-timer@npm:3.0.0" + checksum: 1ec86b3808de6ecfa93cfdf34254761069658af0cc1d9540e8353dbcba161cdf1296a0724187bd17433b2ff16563115fd20b85fc89d5e809ff28f9b1ab134b42 + languageName: node + linkType: hard + "@types/debounce@npm:^1.2.1": version: 1.2.1 resolution: "@types/debounce@npm:1.2.1" @@ -14829,6 +14929,15 @@ __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-query@npm:^4.2.2": version: 4.2.2 resolution: "aria-query@npm:4.2.2" @@ -17076,6 +17185,13 @@ __metadata: languageName: node linkType: hard +"classnames@npm:^2.2.5": + version: 2.3.2 + resolution: "classnames@npm:2.3.2" + checksum: 2c62199789618d95545c872787137262e741f9db13328e216b093eea91c85ef2bfb152c1f9e63027204e2559a006a92eb74147d46c800a9f96297ae1d9f96f4e + languageName: node + linkType: hard + "classnames@npm:^2.2.6, classnames@npm:^2.3.1": version: 2.3.1 resolution: "classnames@npm:2.3.1" @@ -18209,6 +18325,13 @@ __metadata: languageName: node linkType: hard +"css-unit-converter@npm:^1.1.1": + version: 1.1.2 + resolution: "css-unit-converter@npm:1.1.2" + checksum: 07888033346a5128f34dbe2f72884c966d24e9f29db24416dcde92860242490617ef9a178ac193a92f730834bbeea026cdc7027701d92ba9bbbe59db7a37eb2a + languageName: node + linkType: hard + "css-what@npm:^5.0.1": version: 5.1.0 resolution: "css-what@npm:5.1.0" @@ -18301,6 +18424,99 @@ __metadata: languageName: node linkType: hard +"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6": + version: 3.2.3 + resolution: "d3-array@npm:3.2.3" + dependencies: + internmap: 1 - 2 + checksum: 41d6a4989b73e0d2649a880b2f29a7e7cc059db0eba36cd29a79e0118ebdf6b78922a84cde0733cd54cb4072f3442ec44f3563902e00ea42892442d60e99f961 + languageName: node + linkType: hard + +"d3-color@npm:1 - 3": + version: 3.1.0 + resolution: "d3-color@npm:3.1.0" + checksum: 4931fbfda5d7c4b5cfa283a13c91a954f86e3b69d75ce588d06cde6c3628cebfc3af2069ccf225e982e8987c612aa7948b3932163ce15eb3c11cd7c003f3ee3b + languageName: node + linkType: hard + +"d3-ease@npm:^3.0.1": + version: 3.0.1 + resolution: "d3-ease@npm:3.0.1" + checksum: 06e2ee5326d1e3545eab4e2c0f84046a123dcd3b612e68858219aa034da1160333d9ce3da20a1d3486d98cb5c2a06f7d233eee1bc19ce42d1533458bd85dedcd + languageName: node + linkType: hard + +"d3-format@npm:1 - 3": + version: 3.1.0 + resolution: "d3-format@npm:3.1.0" + checksum: f345ec3b8ad3cab19bff5dead395bd9f5590628eb97a389b1dd89f0b204c7c4fc1d9520f13231c2c7cf14b7c9a8cf10f8ef15bde2befbab41454a569bd706ca2 + languageName: node + linkType: hard + +"d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1": + version: 3.0.1 + resolution: "d3-interpolate@npm:3.0.1" + dependencies: + d3-color: 1 - 3 + checksum: a42ba314e295e95e5365eff0f604834e67e4a3b3c7102458781c477bd67e9b24b6bb9d8e41ff5521050a3f2c7c0c4bbbb6e187fd586daa3980943095b267e78b + languageName: node + linkType: hard + +"d3-path@npm:^3.1.0": + version: 3.1.0 + resolution: "d3-path@npm:3.1.0" + checksum: 2306f1bd9191e1eac895ec13e3064f732a85f243d6e627d242a313f9777756838a2215ea11562f0c7630c7c3b16a19ec1fe0948b1c82f3317fac55882f6ee5d8 + languageName: node + linkType: hard + +"d3-scale@npm:^4.0.2": + version: 4.0.2 + resolution: "d3-scale@npm:4.0.2" + dependencies: + d3-array: 2.10.0 - 3 + d3-format: 1 - 3 + d3-interpolate: 1.2.0 - 3 + d3-time: 2.1.1 - 3 + d3-time-format: 2 - 4 + checksum: a9c770d283162c3bd11477c3d9d485d07f8db2071665f1a4ad23eec3e515e2cefbd369059ec677c9ac849877d1a765494e90e92051d4f21111aa56791c98729e + languageName: node + linkType: hard + +"d3-shape@npm:^3.1.0": + version: 3.2.0 + resolution: "d3-shape@npm:3.2.0" + dependencies: + d3-path: ^3.1.0 + checksum: de2af5fc9a93036a7b68581ca0bfc4aca2d5a328aa7ba7064c11aedd44d24f310c20c40157cb654359d4c15c3ef369f95ee53d71221017276e34172c7b719cfa + languageName: node + linkType: hard + +"d3-time-format@npm:2 - 4": + version: 4.1.0 + resolution: "d3-time-format@npm:4.1.0" + dependencies: + d3-time: 1 - 3 + checksum: 7342bce28355378152bbd4db4e275405439cabba082d9cd01946d40581140481c8328456d91740b0fe513c51ec4a467f4471ffa390c7e0e30ea30e9ec98fcdf4 + languageName: node + linkType: hard + +"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:^3.0.0": + version: 3.1.0 + resolution: "d3-time@npm:3.1.0" + dependencies: + d3-array: 2 - 3 + checksum: 613b435352a78d9f31b7f68540788186d8c331b63feca60ad21c88e9db1989fe888f97f242322ebd6365e45ec3fb206a4324cd4ca0dfffa1d9b5feb856ba00a7 + languageName: node + linkType: hard + +"d3-timer@npm:^3.0.1": + version: 3.0.1 + resolution: "d3-timer@npm:3.0.1" + checksum: 1cfddf86d7bca22f73f2c427f52dfa35c49f50d64e187eb788dcad6e927625c636aa18ae4edd44d084eb9d1f81d8ca4ec305dae7f733c15846a824575b789d73 + languageName: node + linkType: hard + "d@npm:1, d@npm:^1.0.1": version: 1.0.1 resolution: "d@npm:1.0.1" @@ -18338,7 +18554,7 @@ __metadata: languageName: node linkType: hard -"date-fns@npm:^2.29.1, date-fns@npm:^2.29.3": +"date-fns@npm:^2.28.0, date-fns@npm:^2.29.1, date-fns@npm:^2.29.3": version: 2.29.3 resolution: "date-fns@npm:2.29.3" checksum: e01cf5b62af04e05dfff921bb9c9933310ed0e1ae9a81eb8653452e64dc841acf7f6e01e1a5ae5644d0337e9a7f936175fd2cb6819dc122fdd9c5e86c56be484 @@ -18457,6 +18673,13 @@ __metadata: languageName: node linkType: hard +"decimal.js-light@npm:^2.4.1": + version: 2.5.1 + resolution: "decimal.js-light@npm:2.5.1" + checksum: f5a2c7eac1c4541c8ab8a5c8abea64fc1761cefc7794bd5f8afd57a8a78d1b51785e0c4e4f85f4895a043eaa90ddca1edc3981d1263eb6ddce60f32bf5fe66c9 + languageName: node + linkType: hard + "decimal.js@npm:^10.3.1": version: 10.4.0 resolution: "decimal.js@npm:10.4.0" @@ -18950,6 +19173,15 @@ __metadata: languageName: node linkType: hard +"dom-helpers@npm:^3.4.0": + version: 3.4.0 + resolution: "dom-helpers@npm:3.4.0" + dependencies: + "@babel/runtime": ^7.1.2 + checksum: 58d9f1c4a96daf77eddc63ae1236b826e1cddd6db66bbf39b18d7e21896d99365b376593352d52a60969d67fa4a8dbef26adc1439fa2c1b355efa37cacbaf637 + languageName: node + linkType: hard + "dom-helpers@npm:^5.0.1": version: 5.2.1 resolution: "dom-helpers@npm:5.2.1" @@ -20961,7 +21193,7 @@ __metadata: languageName: node linkType: hard -"eventemitter3@npm:4.0.7, eventemitter3@npm:^4.0.7": +"eventemitter3@npm:4.0.7, eventemitter3@npm:^4.0.1, eventemitter3@npm:^4.0.7": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" checksum: 1875311c42fcfe9c707b2712c32664a245629b42bb0a5a84439762dd0fd637fc54d078155ea83c2af9e0323c9ac13687e03cfba79b03af9f40c89b4960099374 @@ -21316,6 +21548,13 @@ __metadata: languageName: node linkType: hard +"fast-equals@npm:^4.0.3": + version: 4.0.3 + resolution: "fast-equals@npm:4.0.3" + checksum: 3d5935b757f9f2993e59b5164a7a9eeda0de149760495375cde14a4ed725186a7e6c1c0d58f7d42d2f91deb97f3fce1e0aad5591916ef0984278199a85c87c87 + languageName: node + linkType: hard + "fast-glob@npm:^2.2.6": version: 2.2.7 resolution: "fast-glob@npm:2.2.7" @@ -24158,6 +24397,13 @@ __metadata: languageName: node linkType: hard +"internmap@npm:1 - 2": + version: 2.0.3 + resolution: "internmap@npm:2.0.3" + checksum: 7ca41ec6aba8f0072fc32fa8a023450a9f44503e2d8e403583c55714b25efd6390c38a87161ec456bf42d7bc83aab62eb28f5aef34876b1ac4e60693d5e1d241 + languageName: node + linkType: hard + "interpret@npm:^2.2.0": version: 2.2.0 resolution: "interpret@npm:2.2.0" @@ -31286,6 +31532,13 @@ __metadata: languageName: node linkType: hard +"postcss-value-parser@npm:^3.3.0": + version: 3.3.1 + resolution: "postcss-value-parser@npm:3.3.1" + checksum: 62cd26e1cdbcf2dcc6bcedf3d9b409c9027bc57a367ae20d31dd99da4e206f730689471fd70a2abe866332af83f54dc1fa444c589e2381bf7f8054c46209ce16 + languageName: node + linkType: hard + "postcss-value-parser@npm:^4.0.0, postcss-value-parser@npm:^4.0.2, postcss-value-parser@npm:^4.1.0, postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" @@ -32551,7 +32804,7 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^16.13.1, react-is@npm:^16.7.0": +"react-is@npm:^16.10.2, react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" checksum: f7a19ac3496de32ca9ae12aa030f00f14a3d45374f1ceca0af707c831b2a6098ef0d6bdae51bd437b0a306d7f01d4677fcc8de7c0d331eb47ad0f46130e53c5f @@ -32565,6 +32818,13 @@ __metadata: languageName: node linkType: hard +"react-lifecycles-compat@npm:^3.0.4": + version: 3.0.4 + resolution: "react-lifecycles-compat@npm:3.0.4" + checksum: a904b0fc0a8eeb15a148c9feb7bc17cec7ef96e71188280061fc340043fd6d8ee3ff233381f0e8f95c1cf926210b2c4a31f38182c8f35ac55057e453d6df204f + languageName: node + linkType: hard + "react-live-chat-loader@npm:^2.7.3": version: 2.7.3 resolution: "react-live-chat-loader@npm:2.7.3" @@ -32771,6 +33031,18 @@ __metadata: languageName: node linkType: hard +"react-resize-detector@npm:^8.0.4": + version: 8.0.4 + resolution: "react-resize-detector@npm:8.0.4" + dependencies: + lodash: ^4.17.21 + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: f53a99cc5413844a6fc2a713b0b1094d23947897c14ab64481938632f4e77fadded52d91a6619055abf6240c7cacd6cc7dd492ea866639b302f81df680709d45 + languageName: node + linkType: hard + "react-schemaorg@npm:^2.0.0": version: 2.0.0 resolution: "react-schemaorg@npm:2.0.0" @@ -32802,6 +33074,20 @@ __metadata: languageName: node linkType: hard +"react-smooth@npm:^2.0.2": + version: 2.0.2 + resolution: "react-smooth@npm:2.0.2" + dependencies: + fast-equals: ^4.0.3 + react-transition-group: 2.9.0 + peerDependencies: + prop-types: ^15.6.0 + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: 6b0a40a17314657ac6a187a3ad01fcdaa61d498979b6a07c424e20832110c99b4394c33209a0b39b897c13d113d8cb044e7a691b4965386a11b15bbfbacfba25 + languageName: node + linkType: hard + "react-ssr-prepass@npm:^1.5.0": version: 1.5.0 resolution: "react-ssr-prepass@npm:1.5.0" @@ -32881,6 +33167,21 @@ __metadata: languageName: node linkType: hard +"react-transition-group@npm:2.9.0": + version: 2.9.0 + resolution: "react-transition-group@npm:2.9.0" + dependencies: + dom-helpers: ^3.4.0 + loose-envify: ^1.4.0 + prop-types: ^15.6.2 + react-lifecycles-compat: ^3.0.4 + peerDependencies: + react: ">=15.0.0" + react-dom: ">=15.0.0" + checksum: d8c9e50aabdc2cfc324e5cdb0ad1c6eecb02e1c0cd007b26d5b30ccf49015e900683dd489348c71fba4055858308d9ba7019e0d37d0e8d37bd46ed098788f670 + languageName: node + linkType: hard + "react-transition-group@npm:^4.3.0": version: 4.4.2 resolution: "react-transition-group@npm:4.4.2" @@ -32896,6 +33197,21 @@ __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-twemoji@npm:^0.3.0": version: 0.3.0 resolution: "react-twemoji@npm:0.3.0" @@ -33085,6 +33401,36 @@ __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.5.0 + resolution: "recharts@npm:2.5.0" + dependencies: + classnames: ^2.2.5 + eventemitter3: ^4.0.1 + lodash: ^4.17.19 + react-is: ^16.10.2 + react-resize-detector: ^8.0.4 + react-smooth: ^2.0.2 + recharts-scale: ^0.4.4 + reduce-css-calc: ^2.1.8 + victory-vendor: ^36.6.8 + peerDependencies: + prop-types: ^15.6.0 + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: a40d1788589926fa8a5844868a338aae75a2077a2bc2ac57fc5d61ad21fb62660aed6f00ae8d8a94948b9143255c1760db0d386ecb257ed8290dd7f164b5bd5f + languageName: node + linkType: hard + "redent@npm:^1.0.0": version: 1.0.0 resolution: "redent@npm:1.0.0" @@ -33119,6 +33465,16 @@ __metadata: languageName: node linkType: hard +"reduce-css-calc@npm:^2.1.8": + version: 2.1.8 + resolution: "reduce-css-calc@npm:2.1.8" + dependencies: + css-unit-converter: ^1.1.1 + postcss-value-parser: ^3.3.0 + checksum: 8fd27c06c4b443b84749a69a8b97d10e6ec7d142b625b41923a8807abb22b9e37e44df14e26cc606a802957be07bdce5e8ee2976a6952a7b438a7727007101e9 + languageName: node + linkType: hard + "redux-immutable@npm:^4.0.0": version: 4.0.0 resolution: "redux-immutable@npm:4.0.0" @@ -36141,6 +36497,13 @@ __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.8.1": version: 1.8.1 resolution: "tailwind-merge@npm:1.8.1" @@ -36148,6 +36511,13 @@ __metadata: languageName: node linkType: hard +"tailwind-merge@npm:^1.9.1": + version: 1.10.0 + resolution: "tailwind-merge@npm:1.10.0" + checksum: 80eb30d0300ca912b4b910460497a1d5fb4e697c3aedb01513e633973328684fa7ddf9bfb8550dca28176971c93bcb7ecb7eec3097c01d9c55c191563c693aab + languageName: node + linkType: hard + "tailwind-scrollbar@npm:^2.0.1": version: 2.0.1 resolution: "tailwind-scrollbar@npm:2.0.1" @@ -38446,6 +38816,28 @@ __metadata: languageName: node linkType: hard +"victory-vendor@npm:^36.6.8": + version: 36.6.8 + resolution: "victory-vendor@npm:36.6.8" + 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: 6411f7c19a776cef3919946d429293cfe33c93a6e4dcfdfa2ba1edecad3a28ed2cd6b0d117169b8917ab6a7679e2bade5e7bfc1fed3fc8b464b842f21dac5f49 + languageName: node + linkType: hard + "vite@npm:^2.9.15": version: 2.9.15 resolution: "vite@npm:2.9.15"