* init page

* init insights frontend

* nit

* nit

* nit

* merge

* fixed icons

* i18n, needs features

* Init insights trpc

* Using trpc on client

* Added events timeline

* Seed analytics script

* connect ui with trpc

* Added and fixed event time lines

* WIP popular days and avg time duration event type

* added new metric graphs

* improved upgrade tip

* always show upgrade screen

* upgrade tremor.so and select inputs for page

* Remove log

* Move everything to components and add context

* Fix select types using calcom ui one

* Adding translations

* Add missing translations

* Add more translations

* min fix

* Fixes for date select

* Prefer early return and mobile design fixes

* Fix style for mobile

* Fix data with userId filter from popular events

* add user id to average time duration

* Removed submodules

* Delete website

* Update yarn.lock

* Code organization and type fixes

* trpc fixes

* Builds are now passing

* Relocates server code

* Cleanup

* Medium size screen fixes

* Added TODO

---------

Co-authored-by: Alan <alannnc@gmail.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Peer Richelsen
2023-03-23 22:10:01 +00:00
committed by GitHub
co-authored by Alan zomars
parent 9ab196c30e
commit cfa720a523
43 changed files with 2536 additions and 72 deletions
+5
View File
@@ -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}}",
},
+1
View File
@@ -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",
+116
View File
@@ -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 (
<div className="min-w-52">
<p className="text-lg font-semibold">
{t("analytics_for_organisation", {
organisationName: selectedTeamName,
})}
</p>
<p>{t("subtitle_analytics")}</p>
</div>
);
};
export default function InsightsPage() {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <FiUsers className="h-5 w-5 text-red-500" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <FiRefreshCcw className="h-5 w-5 text-blue-500" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <FiUserPlus className="h-5 w-5 text-green-500" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<div>
<Shell>
<UpgradeTip
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/banners/insights.jpg"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="secondary" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!user ? (
<></>
) : (
<FiltersProvider>
<div className="mb-4 ml-auto flex w-full flex-wrap justify-between">
<Heading />
<Filters />
</div>
<div className="mb-4 space-y-6">
<BookingKPICards />
<BookingStatusLineChart />
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<small className="block text-center text-gray-600">
{t("looking_for_more_analytics")}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
)}
</UpgradeTip>
</Shell>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

+36 -9
View File
@@ -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 whos 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"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

+1 -1
View File
@@ -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}"],
};
@@ -99,23 +99,18 @@ export default function RoutingForms({
CTA={hasPaidPlan && <NewFormButton />}
subtitle={t("routing_forms_description")}>
<UpgradeTip
dark
title={t("teams_plan_required")}
description={t("routing_forms_are_a_great_way")}
features={features}
background="/routing-form-banner-background.jpg"
background="/banners/routing-forms.jpg"
isParentLoading={isLoading && <SkeletonLoaderTeamList />}
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="secondary" href={`${WEBAPP_URL}/settings/teams/new`}>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("upgrade")}
</Button>
<Button
color="minimal"
className="!bg-transparent text-white opacity-50 hover:opacity-100"
href="https://go.cal.com/teams-video"
target="_blank">
<Button color="minimal" href="https://go.cal.com/teams-video" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
@@ -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={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="secondary" href="https://go.cal.com/teams-video" target="_blank">
<Button color="minimal" href="https://go.cal.com/teams-video" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
@@ -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 (
<Card>
<Title>{t("average_event_duration")}</Title>
<LineChart
className="mt-4 h-80"
data={data}
index="Date"
categories={["Average"]}
colors={["blue"]}
valueFormatter={valueFormatter}
/>
</Card>
);
};
@@ -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 (
<Grid numColsSm={2} numColsLg={4} className="gap-x-6 gap-y-6">
{categories.map((item) => (
<KPICard
key={item.title}
title={item.title}
value={data[item.index].count}
previousMetricData={data[item.index]}
previousDateRange={data.previousRange}
/>
))}
</Grid>
);
};
@@ -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 (
<Card>
<Title>{t("event_trends")}</Title>
<LineChart
className="mt-4 h-80"
data={eventsTimeLine ?? []}
categories={["Created", "Completed", "Rescheduled", "Cancelled"]}
index="Month"
colors={["gray", "green", "blue", "red"]}
valueFormatter={valueFormatter}
/>
</Card>
);
};
@@ -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 (
<Card key={title}>
<Text>{title}</Text>
<Flex className="items-baseline justify-start space-x-3 truncate">
<Metric>{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>
<Tooltip
content={t("from_to_date_period", {
startDate: previousDateRange.startDate,
endDate: previousDateRange.endDate,
})}>
<small className="relative top-px text-xs text-gray-600">{t("from_last_period")}</small>
</Tooltip>
</Flex>
</Flex>
</Card>
);
};
@@ -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 (
<Card>
<Title>{t("least_booked_members")}</Title>
<TotalBookingUsersTable data={data} />
</Card>
);
};
@@ -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 (
<Card>
<Title>{t("most_booked_members")}</Title>
<TotalBookingUsersTable data={data} />
</Card>
);
};
@@ -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 (
<Card>
<Title>{t("popular_events")}</Title>
<Table className="mt-5">
<TableBody>
{isSuccess ? (
data?.map((item) => (
<TableRow key={item.eventTypeId}>
<TableCell>{item.eventTypeName}</TableCell>
<TableCell>
<Text>
<strong>{item.count}</strong>
</Text>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell>{t("no_event_types_found")}</TableCell>
<TableCell>
<strong>0</strong>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
);
};
@@ -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 (
<Table>
<TableBody>
<>
{data && data?.length > 0 ? (
data?.map((item) => (
<TableRow key={item.userId}>
<TableCell className="flex flex-row">
<Avatar
alt={item.user.name || ""}
size="sm"
imageSrc={item.user.avatar}
title={item.user.name || ""}
className="m-2"
gravatarFallbackMd5={item.emailMd5}
/>
<p className="my-auto mx-0">
<strong>{item.user.name}</strong>
</p>
</TableCell>
<TableCell>
<Text>
<strong>{item.count}</strong>
</Text>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell>No members found</TableCell>
</TableRow>
)}
</>
</TableBody>
</Table>
);
};
@@ -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";
@@ -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<FilterContextType["filter"]["selectedTimeView"]>("week");
const [selectedUserId, setSelectedUserId] = useState<FilterContextType["filter"]["selectedUserId"]>(null);
const [selectedTeamId, setSelectedTeamId] = useState<FilterContextType["filter"]["selectedTeamId"]>(null);
const [selectedEventTypeId, setSelectedEventTypeId] =
useState<FilterContextType["filter"]["selectedEventTypeId"]>(null);
const [selectedFilter, setSelectedFilter] = useState<FilterContextType["filter"]["selectedFilter"]>(null);
const [selectedTeamName, setSelectedTeamName] =
useState<FilterContextType["filter"]["selectedTeamName"]>(null);
const [dateRange, setDateRange] = useState<FilterContextType["filter"]["dateRange"]>([
dayjs().subtract(1, "month"),
dayjs(),
"t",
]);
return (
<FilterProvider
value={{
filter: {
dateRange,
selectedTimeView,
selectedUserId,
selectedTeamId,
selectedTeamName,
selectedEventTypeId,
selectedFilter,
},
setSelectedFilter: (filter) => 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}
</FilterProvider>
);
}
@@ -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<FilterContextType | null>(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<F extends FilterContextType>(props: { value: F; children: React.ReactNode }) {
return React.createElement(FilterContext.Provider, { value: props.value }, props.children);
}
@@ -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 (
<DateRangePicker
value={[startValue, endValue, range]}
defaultValue={[startValue, endValue, range]}
onValueChange={(datesArray) => {
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"
/>
);
};
@@ -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 (
<Select<Option>
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={
<div className="flex flex-row">
<p>{t("select_event_type")}</p>
</div>
}
/>
);
};
@@ -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 (
<Select<Option>
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={
<div className="flex flex-row text-gray-900">
<FiFilter className="m-auto text-gray-900" />
{t("add_filter")}
</div>
}
/>
);
};
@@ -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 (
<>
<Select<Option>
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);
}
}}
/>
</>
);
};
@@ -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 (
<Select<Option>
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={
<div className="flex flex-row">
<p>{t("select_user")}</p>
</div>
}
/>
);
};
@@ -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 (
<div className="mt-2 flex flex-col flex-wrap gap-2 md:flex-row md:flex-nowrap">
<TeamList />
<FilterType />
<UserListInTeam />
<EventTypeListInTeam />
<DateSelect />
{/* @NOTE: To be released in next iteration */}
{/* <ButtonGroup combined containerProps={{ className: "hidden lg:flex mr-2" }}>
<Tooltip content={t("settings")}>
<Button
variant="icon"
color="secondary"
target="_blank"
rel="noreferrer"
StartIcon={FiSettings}
className="h-[38px]"
/>
</Tooltip>
<Tooltip content={t("download_csv")>
<Button
variant="icon"
color="secondary"
target="_blank"
rel="noreferrer"
StartIcon={FiDownload}
className="h-[38px]"
/>
</Tooltip>
</ButtonGroup> */}
</div>
);
};
@@ -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";
}
};
+9
View File
@@ -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",
};
+3
View File
@@ -0,0 +1,3 @@
export { calculateDeltaType } from "./calculateDeltaType";
export { colors } from "./colors";
export { valueFormatter } from "./valueFormatter";
@@ -0,0 +1 @@
export const valueFormatter = (number: number) => `${Intl.NumberFormat().format(number).toString()}`;
+179
View File
@@ -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 };
@@ -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;
}),
});
+1
View File
@@ -8,6 +8,7 @@
"dependencies": {
"@calcom/dayjs": "*",
"@calcom/lib": "*",
"@calcom/trpc": "*",
"@calcom/ui": "*",
"@lexical/react": "^0.5.0",
"dompurify": "^2.4.1",
+6
View File
@@ -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",
+18 -34
View File
@@ -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 (
<EmptyScreen
Icon={FiUsers}
headline={emptyTitle ? t(emptyTitle) : t(title)}
description={emptyDescription ? t(emptyDescription) : t(description)}
buttonRaw={buttons}
/>
);
return (
<>
<div className="-mt-10 rtl:ml-4 sm:mt-0 md:rtl:ml-0 lg:-mt-6">
<div
className="flex w-full justify-between overflow-hidden rounded-lg pt-4 pb-10 md:min-h-[295px] md:pt-10"
style={{
background: `url(${background})`,
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
}}>
<div className="mt-3 px-8 sm:px-14">
<h1 className={classNames("font-cal text-3xl", dark && "text-white")}>{t(title)}</h1>
<p className={classNames("my-4 max-w-sm", dark ? "text-white" : "text-gray-600")}>
{t(description)}
</p>
{buttons}
</div>
<div className="relative flex justify-between w-full pb-10 min-h-[295px] items-center rounded-lg overflow-hidden">
<Image alt={title} src={background} className="absolute object-cover min-h-[295px] w-full rounded-lg" height={295} width={1118} quality={100} />
<div className="relative px-8 mt-4 sm:px-14">
<h1 className={classNames("font-cal text-3xl", dark && "text-white")}>{t(title)}</h1>
<p className={classNames("mt-4 mb-8 max-w-sm", dark ? "text-white" : "text-gray-700")}>
{t(description)}
</p>
{buttons}
</div>
</div>
<div className="mt-4 grid-cols-3 md:grid md:gap-4">
{features.map((feature) => (
<div key={feature.title} className="mb-4 min-h-[180px] w-full rounded-md bg-gray-50 p-8 md:mb-0">
{feature.icon}
<h2 className="font-cal mt-4 text-lg">{feature.title}</h2>
<p className="text-gray-700">{feature.description}</p>
</div>
))}
</div>
<div className="grid-cols-3 mt-4 md:grid md:gap-4">
{features.map((feature) => (
<div key={feature.title} className="mb-4 min-h-[180px] w-full rounded-md bg-gray-50 p-8 md:mb-0">
{feature.icon}
<h2 className="mt-4 text-lg font-cal">{feature.title}</h2>
<p className="text-gray-700">{feature.description}</p>
</div>
))}
</div>
</>
);
+2 -1
View File
@@ -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"
+234
View File
@@ -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);
});
+3 -2
View File
@@ -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,
+30 -2
View File
@@ -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);
+405 -13
View File
@@ -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"