Follow up insights - user metrics (#8016)

* 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

* fix types for select-react

* Removed submodules

* Delete website

* Update yarn.lock

* Code organization and type fixes

* trpc fixes

* Builds are now passing

* Relocates server code

* Add url state in insights

* Update FiltersProvider.tsx

* Cleanup

* Update embed-iframe.ts

* Update FilterType.tsx

* Update seed-app-store.config.json

* Update index.tsx

* Renamed seeder

* Update FiltersProvider.tsx

* Fix for query params

* no wrap on lg screen

* Fix shadow borders from tremor components, fix title font

* Add ring-gray to match filters

* add cursor pointer

* copy improvements

* blue to black

* fixed date select focus

* text right for table

* Adds missing translation strings

* Fix url state for filter type

* more layout improvements

* more padding improvements

* more UI fixes (padding, sizes) requested by ciaran

* Apply suggestions from code review

* WIP user without team metrics

* yarn

* Adds loading state for graphs and tables

* fix average render when no data

* Multiple fixes in insights

* Fix most and least booked members querys

* More fix for clear filters

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
alannnc
2023-04-04 13:58:19 +02:00
committed by GitHub
co-authored by Peer Richelsen Peer Richelsen zomars
parent bbf50dbc53
commit 30b72b687e
23 changed files with 2949 additions and 360 deletions
+6 -11
View File
@@ -8,7 +8,6 @@ import {
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";
@@ -20,15 +19,11 @@ import { FiRefreshCcw, FiUserPlus, FiUsers } from "@calcom/ui/components/icon";
const Heading = () => {
const { t } = useLocale();
const {
filter: { selectedTeamName },
} = useFilterContext();
return (
<div className="min-w-52">
<h3 className="font-cal max-w-28 sm:max-w-72 md:max-w-80 hidden truncate text-xl font-semibold tracking-wide text-black md:block xl:max-w-full">
{t("analytics_for_organisation", {
organisationName: selectedTeamName,
})}
<div className="min-w-52 hidden md:block">
<h3 className="font-cal max-w-28 sm:max-w-72 md:max-w-80 truncate text-xl font-semibold tracking-wide text-black xl:max-w-full">
{t("analytics_for_organisation")}
</h3>
<p className="hidden text-sm text-gray-500 md:block">{t("subtitle_analytics")}</p>
</div>
@@ -80,7 +75,7 @@ export default function InsightsPage() {
<></>
) : (
<FiltersProvider>
<div className="mb-4 mt-0 ml-auto flex w-full flex-wrap justify-between md:-mt-8">
<div className="mb-8 mt-0 ml-auto flex w-full flex-wrap justify-between md:-mt-8">
<Heading />
<Filters />
</div>
@@ -99,7 +94,7 @@ export default function InsightsPage() {
<LeastBookedTeamMembersTable />
</div>
<small className="block text-center text-gray-600">
{t("looking_for_more_analytics")}
{t("looking_for_more_insights")}
<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...">
@@ -404,7 +404,7 @@
"add_time_availability": "Add new time slot",
"add_an_extra_layer_of_security": "Add an extra layer of security to your account in case your password is stolen.",
"2fa": "Two-Factor Authentication",
"2fa_disabled":"Two-Factor authentication can only be enabled for email and password authentication",
"2fa_disabled": "Two-Factor authentication can only be enabled for email and password authentication",
"enable_2fa": "Enable two-factor authentication",
"disable_2fa": "Disable two-factor authentication",
"disable_2fa_recommendation": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
@@ -1673,7 +1673,7 @@
"default_conferencing_bulk_title": "Bulk update existing event types",
"requires_at_least_one_schedule": "You are required to have at least one schedule",
"default_conferencing_bulk_description": "Update the locations for the selected event types",
"looking_for_more_analytics": "Looking for more analytics?",
"looking_for_more_insights": "Looking for more Insights?",
"add_filter": "Add filter",
"select_user": "Select User",
"select_event_type": "Select Event Type",
@@ -1689,11 +1689,12 @@
"events_rescheduled": "Events Rescheduled",
"from_last_period": "from last period",
"from_to_date_period": "From: {{startDate}} To: {{endDate}}",
"redirect_url_warning": "Adding a redirect will disable the success page. Make sure to mention \"Booking Confirmed\" on your custom success page.",
"analytics_for_organisation": "Insights for {{organisationName}}",
"analytics_for_organisation": "Insights",
"subtitle_analytics": "Learn more about your team's activity",
"redirect_url_warning": "Adding a redirect will disable the success page. Make sure to mention \"Booking Confirmed\" on your custom success page.",
"event_trends": "Event Trends",
"clear_filters": "Clear Filters",
"insights": "Insights",
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
"email_invite_team": "{{email}} has been invited"
}
@@ -6,34 +6,45 @@ import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { valueFormatter } from "../lib/valueFormatter";
import { CardInsights } from "./Card";
import { LoadingInsight } from "./LoadingInsights";
export const AverageEventDurationChart = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedUserId } = filter;
const { dateRange, selectedMemberUserId } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { selectedTeamId: teamId, selectedUserId } = filter;
const { data, isSuccess } = trpc.viewer.insights.averageEventDuration.useQuery({
const { data, isSuccess, isLoading } = trpc.viewer.insights.averageEventDuration.useQuery({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
teamId: teamId ?? undefined,
memberUserId: selectedMemberUserId ?? undefined,
userId: selectedUserId ?? undefined,
});
if (!isSuccess || data?.length == 0 || !startDate || !endDate || !teamId) return null;
if (isLoading) return <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
const isNoData = (data && data.length === 0) || data.every((item) => item["Average"] === 0);
return (
<CardInsights>
<Title>{t("average_event_duration")}</Title>
<LineChart
className="mt-4 h-80"
data={data}
index="Date"
categories={["Average"]}
colors={["blue"]}
valueFormatter={valueFormatter}
/>
{isNoData && (
<div className="flex h-60 text-center">
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
</div>
)}
{data && data.length > 0 && !isNoData && (
<LineChart
className="mt-4 h-80"
data={data}
index="Date"
categories={["Average"]}
colors={["blue"]}
valueFormatter={valueFormatter}
/>
)}
</CardInsights>
);
};
@@ -2,23 +2,26 @@ import { Grid } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { KPICard } from "./KPICard";
export const BookingKPICards = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedEventTypeId, selectedUserId } = filter;
const { dateRange, selectedEventTypeId, selectedUserId, selectedMemberUserId } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess } = trpc.viewer.insights.eventsByStatus.useQuery({
const { data, isSuccess, isLoading } = trpc.viewer.insights.eventsByStatus.useQuery({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
memberUserId: selectedMemberUserId ?? undefined,
userId: selectedUserId ?? undefined,
});
@@ -44,18 +47,41 @@ export const BookingKPICards = () => {
},
];
if (!isSuccess || !startDate || !endDate || !teamId || data?.empty) return null;
if (isLoading) {
return <LoadingKPICards categories={categories} />;
}
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) 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>
</>
);
};
const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
const { categories } = props;
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}
/>
<CardInsights key={item.title}>
<SkeletonContainer className="flex w-full flex-col">
<SkeletonText className="mt-2 h-4 w-32" />
<SkeletonText className="mt-2 h-6 w-16" />
<SkeletonText className="mt-4 h-6 w-44" />
</SkeletonContainer>
</CardInsights>
))}
</Grid>
);
@@ -6,23 +6,37 @@ import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { valueFormatter } from "../lib/valueFormatter";
import { CardInsights } from "./Card";
import { LoadingInsight } from "./LoadingInsights";
export const BookingStatusLineChart = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { selectedTeamId, selectedTimeView = "week", dateRange, selectedEventTypeId } = filter;
const {
selectedTeamId,
selectedUserId,
selectedTimeView = "week",
dateRange,
selectedEventTypeId,
} = filter;
const [startDate, endDate] = dateRange;
if (!startDate || !endDate) return null;
const { data: eventsTimeLine, isSuccess } = trpc.viewer.insights.eventsTimeline.useQuery({
const {
data: eventsTimeLine,
isSuccess,
isLoading,
} = trpc.viewer.insights.eventsTimeline.useQuery({
timeView: selectedTimeView,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId: selectedTeamId || -1,
teamId: selectedTeamId ?? undefined,
eventTypeId: selectedEventTypeId ?? undefined,
userId: selectedUserId ?? undefined,
});
if (isLoading) return <LoadingInsight />;
if (!isSuccess) return null;
return (
@@ -1,5 +1,7 @@
import { Card } from "@tremor/react";
import { classNames } from "@calcom/lib";
interface ICardProps {
children: React.ReactNode;
className?: string;
@@ -9,7 +11,7 @@ export const CardInsights = (props: ICardProps) => {
const { children, className = "", ...rest } = props;
return (
<Card className={`shadow-none ring-gray-300 ${className}`} {...rest}>
<Card className={classNames("shadow-none ring-gray-200", className)} {...rest}>
{children}
</Card>
);
@@ -5,6 +5,7 @@ import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { LoadingInsight } from "./LoadingInsights";
import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
export const LeastBookedTeamMembersTable = () => {
@@ -13,13 +14,15 @@ export const LeastBookedTeamMembersTable = () => {
const { dateRange, selectedEventTypeId, selectedTeamId: teamId } = filter;
const [startDate, endDate] = dateRange;
const { data, isSuccess } = trpc.viewer.insights.membersWithLeastBookings.useQuery({
const { data, isSuccess, isLoading } = trpc.viewer.insights.membersWithLeastBookings.useQuery({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
});
if (isLoading) return <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
return (
@@ -0,0 +1,26 @@
import { classNames } from "@calcom/lib";
import { SkeletonText } from "@calcom/ui";
import { CardInsights } from "./Card";
export const LoadingInsight = () => {
return (
<CardInsights>
<SkeletonText className="w-32" />
<div className="m-auto flex h-80 flex-col items-center justify-center">
<svg
className={classNames("mx-4 h-8 w-8 animate-spin", "text-black dark:text-white")}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
</CardInsights>
);
};
@@ -5,6 +5,7 @@ import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { LoadingInsight } from "./LoadingInsights";
import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
export const MostBookedTeamMembersTable = () => {
@@ -14,13 +15,15 @@ export const MostBookedTeamMembersTable = () => {
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess } = trpc.viewer.insights.membersWithMostBookings.useQuery({
const { data, isSuccess, isLoading } = trpc.viewer.insights.membersWithMostBookings.useQuery({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
});
if (isLoading) return <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || !teamId) return null;
return (
@@ -5,22 +5,26 @@ import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { CardInsights } from "./Card";
import { LoadingInsight } from "./LoadingInsights";
export const PopularEventsTable = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { dateRange, selectedUserId } = filter;
const { dateRange, selectedMemberUserId, selectedUserId } = filter;
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess } = trpc.viewer.insights.popularEventTypes.useQuery({
const { data, isSuccess, isLoading } = trpc.viewer.insights.popularEventTypes.useQuery({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
teamId: teamId ?? undefined,
userId: selectedUserId ?? undefined,
memberUserId: selectedMemberUserId ?? undefined,
});
if (!isSuccess || !startDate || !endDate || !teamId || data?.length === 0) return null;
if (isLoading) return <LoadingInsight />;
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
return (
<CardInsights>
@@ -30,7 +34,7 @@ export const PopularEventsTable = () => {
{data.map((item) => (
<TableRow key={item.eventTypeId}>
<TableCell>{item.eventTypeName}</TableCell>
<TableCell>
<TableCell className="text-right">
<Text>
<strong>{item.count}</strong>
</Text>
@@ -39,6 +43,11 @@ export const PopularEventsTable = () => {
))}
</TableBody>
</Table>
{data.length === 0 && (
<div className="flex h-60 text-center">
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
</div>
)}
</CardInsights>
);
};
@@ -30,7 +30,7 @@ export const TotalBookingUsersTable = ({
<strong>{item.user.name}</strong>
</p>
</TableCell>
<TableCell>
<TableCell className="text-right">
<Text>
<strong>{item.count}</strong>
</Text>
@@ -10,17 +10,24 @@ import { FilterProvider } from "./provider";
export function FiltersProvider({ children }: { children: React.ReactNode }) {
// useRouter to get initial values from query params
const router = useRouter();
const { startTime, endTime, teamId, userId, eventTypeId, filter } = router.query;
const { startTime, endTime, teamId, userId, eventTypeId, filter, memberUserId } = router.query;
const querySchema = z.object({
startTime: z.string().optional(),
endTime: z.string().optional(),
teamId: z.coerce.number().optional(),
userId: z.coerce.number().optional(),
memberUserId: z.coerce.number().optional(),
eventTypeId: z.coerce.number().optional(),
filter: z.enum(["event-type", "user"]).optional(),
});
let startTimeParsed, endTimeParsed, teamIdParsed, userIdParsed, eventTypeIdParsed, filterParsed;
let startTimeParsed,
endTimeParsed,
teamIdParsed: number | undefined,
userIdParsed,
eventTypeIdParsed,
filterParsed,
memberUserIdParsed;
const safe = querySchema.safeParse({
startTime,
@@ -29,6 +36,7 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
userId,
eventTypeId,
filter,
memberUserId,
});
if (!safe.success) {
@@ -40,6 +48,7 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
userIdParsed = safe.data.userId;
eventTypeIdParsed = safe.data.eventTypeId;
filterParsed = safe.data.filter;
memberUserIdParsed = safe.data.memberUserId;
}
// TODO: Sync insight filters with URL parameters
@@ -48,6 +57,9 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
const [selectedUserId, setSelectedUserId] = useState<FilterContextType["filter"]["selectedUserId"]>(
userIdParsed || null
);
const [selectedMemberUserId, setSelectedMemberUserId] = useState<
FilterContextType["filter"]["selectedMemberUserId"]
>(memberUserIdParsed || null);
const [selectedTeamId, setSelectedTeamId] = useState<FilterContextType["filter"]["selectedTeamId"]>(
teamIdParsed || null
);
@@ -64,21 +76,24 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
endTimeParsed ? dayjs(endTimeParsed) : dayjs(),
"t",
]);
return (
<FilterProvider
value={{
filter: {
dateRange,
selectedTimeView,
selectedUserId,
selectedMemberUserId,
selectedTeamId,
selectedUserId,
selectedTeamName,
selectedEventTypeId,
selectedFilter,
},
setSelectedFilter: (filter) => {
setSelectedFilter(filter);
const userId = filter?.[0] === "user" ? selectedUserId : undefined;
const userId =
filter?.[0] === "user" ? selectedMemberUserId : selectedUserId ? selectedUserId : undefined;
const eventTypeId = filter?.[0] === "event-type" ? selectedEventTypeId : undefined;
router.push({
query: {
@@ -100,24 +115,38 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
});
},
setSelectedTimeView: (selectedTimeView) => setSelectedTimeView(selectedTimeView),
setSelectedUserId: (selectedUserId) => {
setSelectedUserId(selectedUserId);
setSelectedMemberUserId: (selectedMemberUserId) => {
setSelectedMemberUserId(selectedMemberUserId);
router.push({
query: {
...router.query,
userId: selectedUserId,
memberUserId: selectedMemberUserId,
},
});
},
setSelectedTeamId: (selectedTeamId) => {
setSelectedTeamId(selectedTeamId);
router.push({
setSelectedUserId(null);
const { teamId, eventTypeId, memberUserId, ...rest } = router.query;
router.replace({
query: {
...router.query,
...rest,
teamId: selectedTeamId,
},
});
},
setSelectedUserId: (selectedUserId) => {
setSelectedUserId(selectedUserId);
setSelectedTeamId(null);
setSelectedTeamName(null);
const { teamId, eventTypeId, memberUserId, ...rest } = router.query;
router.replace({
query: {
...rest,
userId: selectedUserId,
},
});
},
setSelectedTeamName: (selectedTeamName) => setSelectedTeamName(selectedTeamName),
setSelectedEventTypeId: (selectedEventTypeId) => {
setSelectedEventTypeId(selectedEventTypeId);
@@ -128,6 +157,30 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
},
});
},
clearFilters: () => {
setSelectedTeamName(null);
setSelectedEventTypeId(null);
setSelectedMemberUserId(null);
setSelectedFilter(null);
const { teamId, userId, ...rest } = router.query;
const query: { teamId?: number; userId?: number } = {};
const parsedTeamId = Number(Array.isArray(teamId) ? teamId[0] : teamId);
const parsedUserId = Number(Array.isArray(userId) ? userId[0] : userId);
if ((teamId && !userId) || (userId && teamId)) {
query.teamId = parsedTeamId;
setSelectedTeamId(parsedTeamId);
setSelectedUserId(null);
} else if (userId && !teamId) {
query.userId = parsedUserId;
setSelectedUserId(parsedUserId);
setSelectedTeamId(null);
}
router.push({
query,
});
},
}}>
{children}
</FilterProvider>
@@ -10,6 +10,7 @@ export type FilterContextType = {
selectedTeamId: number | null;
selectedTeamName: string | null;
selectedUserId: number | null;
selectedMemberUserId: number | null;
selectedEventTypeId: number | null;
};
setDateRange: ([start, end, range]: [Dayjs, Dayjs, null | string]) => void;
@@ -17,8 +18,10 @@ export type FilterContextType = {
setSelectedTeamId: (teamId: number | null) => void;
setSelectedTeamName: (teamName: string | null) => void;
setSelectedUserId: (userId: number | null) => void;
setSelectedMemberUserId: (userId: number | null) => void;
setSelectedEventTypeId: (eventTypeId: number | null) => void;
setSelectedTimeView: (timeView: "year" | "week" | "month") => void;
clearFilters: () => void;
};
export const FilterContext = React.createContext<FilterContextType | null>(null);
@@ -1,7 +1,28 @@
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
box-shadow: none;
max-width: 288px;
}
.tremor-DateRangePicker-calendarButton, .tremor-DateRangePicker-dropdownButton {
@apply dark:bg-darkgray-100 dark:border-darkgray-300 border-gray-300 bg-white text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0 focus-within:ring-gray-800 hover:border-gray-400 dark:focus-within:ring-darkgray-900
}
.tremor-Card-root{
@apply p-5
}
.tremor-TableCell-root{
@apply pl-0
}
.recharts-responsive-container{
@apply -mx-4
}
.tremor-Card-root > p {
@apply text-base font-semibold text-black mb-2
}
.tremor-Legend-legendItem{
@apply ml-2
}
@@ -15,17 +15,18 @@ const mapEventTypeToOption = (eventType: EventType): Option => ({
label: eventType.title,
});
export const EventTypeListInTeam = () => {
export const EventTypeList = () => {
const { t } = useLocale();
const { filter, setSelectedEventTypeId } = useFilterContext();
const { selectedTeamId, selectedEventTypeId } = filter;
const { selectedTeamId, selectedEventTypeId, selectedUserId } = filter;
const { selectedFilter } = filter;
const { data, isSuccess } = trpc.viewer.insights.eventTypeList.useQuery({
teamId: selectedTeamId,
teamId: selectedTeamId ?? undefined,
userId: selectedUserId ?? undefined,
});
if (!selectedFilter?.includes("event-type")) return null;
if (!selectedTeamId) return null;
if (!selectedTeamId && !selectedUserId) return null;
const filterOptions =
data?.map(mapEventTypeToOption) ?? ([{ label: "No event types found", value: "" }] as Option[]);
@@ -46,6 +47,7 @@ export const EventTypeListInTeam = () => {
}
}}
defaultValue={eventTypeValue}
value={eventTypeValue}
className="w-52 min-w-[180px]"
placeholder={
<div className="flex flex-row">
@@ -8,10 +8,10 @@ type Option = { value: "event-type" | "user"; label: string };
export const FilterType = () => {
const { t } = useLocale();
const { setSelectedFilter, setSelectedUserId, setSelectedEventTypeId, filter } = useFilterContext();
const { selectedFilter } = filter;
const { setSelectedFilter, filter } = useFilterContext();
const { selectedFilter, selectedUserId } = filter;
const filterOptions: Option[] = [
let filterOptions: Option[] = [
{
label: t("event_type"),
value: "event-type",
@@ -22,9 +22,14 @@ export const FilterType = () => {
},
];
if (selectedUserId) {
// remove user option from filterOptions
filterOptions = filterOptions.filter((option) => option.value !== "user");
}
const filterValue = selectedFilter
? filterOptions.find((option) => option.value === selectedFilter[0])
: undefined;
: null;
return (
<Select<Option>
@@ -7,30 +7,42 @@ import { Select } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type Team = RouterOutputs["viewer"]["insights"]["teamListForUser"][number];
type Option = { value: number; label: string };
type Option = { value: number; label: string | null; userId?: number };
const mapTeamToOption = (team: Team): Option => ({
value: team.id,
label: team.name ?? "",
userId: team.userId,
});
export const TeamList = () => {
const { filter, setSelectedTeamId, setSelectedTeamName } = useFilterContext();
const { selectedTeamId } = filter;
export const TeamAndSelfList = () => {
const { filter, setSelectedTeamId, setSelectedTeamName, setSelectedUserId } = useFilterContext();
const { selectedTeamId, selectedUserId } = filter;
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery();
useEffect(() => {
if (data && data?.length > 0) {
setSelectedTeamId(data[0].id);
setSelectedTeamName(data[0].name);
// We have a team?
if (data[0].id) {
setSelectedTeamId(data[0].id);
setSelectedTeamName(data[0].name);
} else if (data[0].userId) {
// default to user
setSelectedUserId(data[0].userId);
}
}
}, [data]);
const UserListOptions = data?.map(mapTeamToOption) || ([{ label: "Empty", value: -1 }] as Option[]);
const selectedTeam = data?.find((item) => item.id === selectedTeamId);
const selectedTeam = data?.find((item) => {
if (!!selectedUserId && !selectedTeamId) {
return item.userId === selectedUserId;
}
return item.id === selectedTeamId;
});
const teamValue = selectedTeam ? mapTeamToOption(selectedTeam) : null;
if (!isSuccess || !selectedTeamId || data?.length === 0) return null;
if (!isSuccess || data?.length === 0) return null;
return (
<>
@@ -39,10 +51,12 @@ export const TeamList = () => {
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]"
className="h-[38px] w-[90vw] min-w-[160px] max-w-[100px]"
options={UserListOptions}
onChange={(input) => {
if (input) {
if (!!input?.userId) {
setSelectedUserId(input.userId);
} else if (input && input.value) {
setSelectedTeamId(input.value);
setSelectedTeamName(input.label);
}
@@ -15,8 +15,8 @@ const mapUserToOption = (user: User): Option => ({
export const UserListInTeam = () => {
const { t } = useLocale();
const { filter, setSelectedUserId } = useFilterContext();
const { selectedFilter, selectedTeamId, selectedUserId } = filter;
const { filter, setSelectedMemberUserId } = useFilterContext();
const { selectedFilter, selectedTeamId, selectedMemberUserId } = filter;
const { data, isSuccess } = trpc.viewer.insights.userList.useQuery({
teamId: selectedTeamId,
});
@@ -25,20 +25,21 @@ export const UserListInTeam = () => {
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;
const selectedTeamUser = data?.find((item) => item.id === selectedMemberUserId);
const userValue = selectedTeamUser ? mapUserToOption(selectedTeamUser) : 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]"
isSearchable={true}
className="mb-0 h-[38px] w-40 min-w-[140px] capitalize md:min-w-[150px] md:max-w-[200px]"
defaultValue={userValue}
value={userValue}
options={userListOptions}
onChange={(input) => {
if (input) {
setSelectedUserId(input.value);
setSelectedMemberUserId(input.value);
}
}}
placeholder={
+9 -12
View File
@@ -1,17 +1,16 @@
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Tooltip } from "@calcom/ui";
import { FiX } from "@calcom/ui/components/icon";
import { DateSelect } from "./DateSelect";
import { EventTypeListInTeam } from "./EventTypeListInTeam";
import { EventTypeList } from "./EventTypeList";
import { FilterType } from "./FilterType";
import { TeamList } from "./TeamList";
import { TeamAndSelfList } from "./TeamAndSelfList";
import { UserListInTeam } from "./UsersListInTeam";
const ClearFilters = () => {
const { t } = useLocale();
const { filter, setSelectedUserId, setSelectedFilter, setSelectedEventTypeId } = useFilterContext();
const { filter, clearFilters } = useFilterContext();
const { selectedFilter } = filter;
if (!selectedFilter || selectedFilter?.length < 1) return null;
@@ -23,14 +22,12 @@ const ClearFilters = () => {
color="secondary"
target="_blank"
rel="noreferrer"
StartIcon={FiX}
className="h-[38px]"
onClick={() => {
setSelectedFilter(null);
setSelectedUserId(null);
setSelectedEventTypeId(null);
}}
/>
clearFilters();
}}>
{t("clear_filters")}
</Button>
</Tooltip>
);
};
@@ -38,13 +35,13 @@ const ClearFilters = () => {
export const Filters = () => {
return (
<div className="mt-2 flex flex-col flex-wrap gap-2 md:flex-row lg:flex-nowrap">
<TeamList />
<TeamAndSelfList />
<FilterType />
<UserListInTeam />
<EventTypeListInTeam />
<EventTypeList />
<DateSelect />
+7 -4
View File
@@ -132,12 +132,15 @@ class EventsInsights {
return resultTimeView;
};
static getWeekTimeline(startDate: Dayjs, endDate: Dayjs) {
static getWeekTimeline(startDate: Dayjs, endDate: Dayjs): string[] {
const now = dayjs();
const endOfDay = now.endOf("day");
let pivotDate = dayjs(startDate);
const dates = [];
while (pivotDate.isBefore(endDate)) {
pivotDate = pivotDate.add(7, "day");
const dates: string[] = [];
while (pivotDate.isBefore(endDate) && pivotDate.isBefore(endOfDay)) {
const weekEndDate = pivotDate.add(7, "day").isBefore(endOfDay) ? pivotDate.add(7, "day") : endOfDay;
dates.push(pivotDate.format("YYYY-MM-DD"));
pivotDate = weekEndDate.add(1, "day");
}
return dates;
}
+460 -155
View File
@@ -10,7 +10,7 @@ import { TRPCError } from "@trpc/server";
import { EventsInsights } from "./events";
const UserBelongsToTeamInput = z.object({
teamId: z.coerce.number().optional(),
teamId: z.coerce.number().optional().nullable(),
});
const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(async ({ ctx, next, rawInput }) => {
@@ -19,14 +19,22 @@ const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(async ({ ctx, next, r
throw new TRPCError({ code: "BAD_REQUEST" });
}
const team = await ctx.prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: parse.data.teamId,
},
// If teamId is provided, check if user belongs to team
// If teamId is not provided, check if user belongs to any team
const membershipWhereConditional: Prisma.MembershipWhereInput = {
userId: ctx.user.id,
};
if (parse.data.teamId) {
membershipWhereConditional["teamId"] = parse.data.teamId;
}
const membership = await ctx.prisma.membership.findFirst({
where: membershipWhereConditional,
});
if (!team) {
if (!membership) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
@@ -74,28 +82,59 @@ export const insightsRouter = router({
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
memberUserId: z.coerce.number().optional(),
userId: z.coerce.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, userId } = input;
const { teamId, startDate, endDate, eventTypeId, memberUserId, userId } = input;
if (!input.teamId) {
return emptyResponseEventsByStatus;
if (userId && userId !== ctx.user.id) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
const whereConditional: Prisma.BookingWhereInput = {
eventType: {
teamId: teamId,
},
};
let whereConditional: Prisma.BookingWhereInput = {};
if (eventTypeId) {
whereConditional["eventTypeId"] = eventTypeId;
} else if (userId) {
}
if (memberUserId) {
whereConditional["userId"] = memberUserId;
}
if (userId) {
whereConditional["eventType"] = {
teamId: null,
};
whereConditional["userId"] = userId;
}
if (teamId) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId: teamId,
},
select: {
userId: true,
},
});
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
whereConditional = {
...whereConditional,
OR: [
{
eventType: {
teamId: teamId,
},
},
{
userId: {
in: userIdsFromTeam,
},
},
],
};
}
// Migrate to use prisma views
const baseBookings = await EventsInsights.getBaseBookingForEventStatus({
...whereConditional,
@@ -103,10 +142,8 @@ export const insightsRouter = router({
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);
@@ -136,8 +173,7 @@ export const insightsRouter = router({
);
const lastPeriodTotalCancelled = await EventsInsights.getTotalCancelledEvents(lastPeriodBaseBookingIds);
return {
const result = {
empty: false,
created: {
count: baseBookings.length,
@@ -163,16 +199,26 @@ export const insightsRouter = router({
endDate: lastPeriodEndDate.format("YYYY-MM-DD"),
},
};
if (
result.created.count === 0 &&
result.completed.count === 0 &&
result.rescheduled.count === 0 &&
result.cancelled.count === 0
) {
return emptyResponseEventsByStatus;
}
return result;
}),
eventsTimeline: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().optional(),
teamId: z.coerce.number().optional().nullable(),
startDate: z.string(),
endDate: z.string(),
eventTypeId: z.coerce.number().optional(),
userId: z.coerce.number().optional(),
memberUserId: z.coerce.number().optional(),
timeView: z.enum(["week", "month", "year"]),
userId: z.coerce.number().optional(),
})
)
.query(async ({ ctx, input }) => {
@@ -181,36 +227,75 @@ export const insightsRouter = router({
startDate: startDateString,
endDate: endDateString,
eventTypeId,
userId,
memberUserId,
timeView: inputTimeView,
userId: selfUserId,
} = input;
const startDate = dayjs(startDateString);
const endDate = dayjs(endDateString);
const user = ctx.user;
if (selfUserId && user?.id !== selfUserId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (!teamId && !selfUserId) {
return [];
}
const timeView = inputTimeView;
let whereConditional: Prisma.BookingWhereInput = {
eventType: {
teamId: teamId,
},
};
let whereConditional: Prisma.BookingWhereInput = {};
if (teamId) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId,
},
select: {
userId: true,
},
});
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
if (userId) {
delete whereConditional.eventType;
whereConditional = {
...whereConditional,
userId,
OR: [
{
eventType: {
teamId,
},
},
{
userId: {
in: userIdsFromTeams,
},
},
],
};
}
if (eventTypeId && !!whereConditional) {
delete whereConditional.eventType;
delete whereConditional.userId;
if (memberUserId) {
whereConditional = {
...whereConditional,
userId: memberUserId,
};
}
if (eventTypeId && !!whereConditional) {
whereConditional = {
eventTypeId: eventTypeId,
};
}
if (selfUserId && !!whereConditional) {
// In this delete we are deleting the teamId filter
whereConditional["userId"] = selfUserId;
whereConditional["eventType"] = {
teamId: null,
};
}
// Get timeline data
const timeline = await EventsInsights.getTimeLine(timeView, dayjs(startDate), dayjs(endDate));
@@ -277,37 +362,73 @@ export const insightsRouter = router({
popularEventTypes: userBelongsToTeamProcedure
.input(
z.object({
userId: z.coerce.number().optional(),
memberUserId: z.coerce.number().optional(),
teamId: z.coerce.number().optional().nullable(),
startDate: z.string(),
endDate: z.string(),
userId: z.coerce.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, userId } = input;
const { teamId, startDate, endDate, memberUserId, userId } = input;
const user = ctx.user;
if (!input.teamId) {
if (userId && user?.id !== userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (!teamId && !userId) {
return [];
}
const eventTypeWhere: Prisma.EventTypeWhereInput = {
teamId: teamId,
};
const bookingWhere: Prisma.BookingWhereInput = {
eventType: eventTypeWhere,
let bookingWhere: Prisma.BookingWhereInput = {
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
};
if (teamId) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId,
},
select: {
userId: true,
},
});
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
bookingWhere = {
...bookingWhere,
OR: [
{
eventType: {
teamId,
},
},
{
userId: {
in: userIdsFromTeams,
},
},
],
};
}
if (userId) {
bookingWhere.userId = userId;
// Don't take bookings from any team
bookingWhere.eventType = {
teamId: null,
};
}
const bookingsFromTeam = await ctx.prisma.booking.groupBy({
if (memberUserId) {
bookingWhere.userId = memberUserId;
}
const bookingsFromSelected = await ctx.prisma.booking.groupBy({
by: ["eventTypeId"],
where: bookingWhere,
_count: {
@@ -320,66 +441,158 @@ export const insightsRouter = router({
},
take: 10,
});
const eventTypeIds = bookingsFromTeam
const eventTypeIds = bookingsFromSelected
.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 eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
id: {
in: eventTypeIds as number[],
},
};
const eventTypesFrom = await ctx.prisma.eventType.findMany({
select: {
id: true,
title: true,
teamId: true,
userId: true,
slug: true,
users: {
select: {
username: true,
},
},
team: {
select: {
slug: true,
},
},
},
where: eventTypeWhereConditional,
});
const eventTypeHashMap = new Map();
eventTypesFromTeam.forEach((eventType) => {
eventTypeHashMap.set(eventType.id, eventType.title);
const eventTypeHashMap: Map<
number,
Prisma.EventTypeGetPayload<{
select: {
id: true;
title: true;
teamId: true;
userId: true;
slug: true;
users: {
select: {
username: true;
};
};
team: {
select: {
slug: true;
};
};
};
}>
> = new Map();
eventTypesFrom.forEach((eventType) => {
eventTypeHashMap.set(eventType.id, eventType);
});
const result = bookingsFromTeam.map((booking) => {
const result = bookingsFromSelected.map((booking) => {
const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0);
if (!eventTypeSelected) {
return {};
}
let eventSlug = "";
if (eventTypeSelected.userId) {
eventSlug = `${eventTypeSelected?.slug}/${eventTypeSelected?.users[0]?.username}`;
}
if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
}
return {
eventTypeId: booking.eventTypeId,
eventTypeName: eventTypeHashMap.get(booking.eventTypeId),
eventTypeName: eventSlug,
count: booking._count.id,
};
});
return result;
}),
averageEventDuration: userBelongsToTeamProcedure
.input(
z.object({
userId: z.coerce.number().optional(),
memberUserId: z.coerce.number().optional(),
teamId: z.coerce.number().optional().nullable(),
startDate: z.string(),
endDate: z.string(),
userId: z.coerce.number().optional(),
})
)
.query(async ({ ctx, input }) => {
const { teamId, startDate: startDateString, endDate: endDateString, userId } = input;
const { teamId, startDate: startDateString, endDate: endDateString, memberUserId, userId } = input;
if (!teamId) {
if (userId && ctx.user?.id !== userId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (!teamId && !userId) {
return [];
}
const user = ctx.user;
const startDate = dayjs(startDateString);
const endDate = dayjs(endDateString);
const whereConditional: Prisma.BookingWhereInput = {
eventType: {
teamId: teamId,
},
let whereConditional: Prisma.BookingWhereInput = {
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
};
if (userId) {
delete whereConditional.eventType;
whereConditional["userId"] = userId;
}
if (teamId) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId,
},
select: {
userId: true,
},
});
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
whereConditional = {
...whereConditional,
OR: [
{
eventType: {
teamId,
},
},
{
userId: {
in: userIdsFromTeams,
},
},
],
};
}
if (memberUserId) {
whereConditional = {
userId: memberUserId,
eventType: {
teamId,
},
};
}
const timeView = EventsInsights.getTimeView("week", startDate, endDate);
const timeLine = await EventsInsights.getTimeLine("week", startDate, endDate);
@@ -448,15 +661,42 @@ export const insightsRouter = router({
eventTypeWhere["id"] = eventTypeId;
}
const bookingWhere: Prisma.BookingWhereInput = {
eventType: eventTypeWhere,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
};
if (teamId) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId,
},
select: {
userId: true,
},
});
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
delete bookingWhere.eventType;
bookingWhere["OR"] = [
{
eventType: {
teamId,
},
},
{
userId: {
in: userIdsFromTeams,
},
},
];
}
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(),
},
},
where: bookingWhere,
_count: {
id: true,
},
@@ -467,9 +707,13 @@ export const insightsRouter = router({
},
take: 10,
});
const userIds = bookingsFromTeam
.filter((booking) => typeof booking.userId === "number")
.map((booking) => booking.userId);
if (userIds.length === 0) {
return [];
}
const usersFromTeam = await ctx.prisma.user.findMany({
where: {
id: {
@@ -487,10 +731,11 @@ export const insightsRouter = router({
return {
userId: booking.userId,
user: userHashMap.get(booking.userId),
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
emailMd5: crypto.createHash("md5").update(user?.email).digest("hex"),
count: booking._count.id,
};
});
return result;
}),
membersWithLeastBookings: userBelongsToTeamProcedure
@@ -507,7 +752,7 @@ export const insightsRouter = router({
if (!teamId) {
return [];
}
const user = ctx.user;
const eventTypeWhere: Prisma.EventTypeWhereInput = {
teamId: teamId,
};
@@ -515,15 +760,42 @@ export const insightsRouter = router({
eventTypeWhere["id"] = eventTypeId;
}
const bookingWhere: Prisma.BookingWhereInput = {
eventType: eventTypeWhere,
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
};
if (teamId) {
const usersFromTeam = await ctx.prisma.membership.findMany({
where: {
teamId,
},
select: {
userId: true,
},
});
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
delete bookingWhere.eventType;
bookingWhere["OR"] = [
{
eventType: {
teamId,
},
},
{
userId: {
in: userIdsFromTeams,
},
},
];
}
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(),
},
},
where: bookingWhere,
_count: {
id: true,
},
@@ -535,53 +807,31 @@ export const insightsRouter = router({
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,
if (userIds.length === 0) {
return [];
}
const usersFromTeam = await ctx.prisma.user.findMany({
where: {
id: {
notIn: userIds as number[],
},
teams: {
some: {
teamId: teamId,
},
in: userIds as number[],
},
},
});
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) => {
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"),
user: userHashMap.get(booking.userId),
emailMd5: crypto.createHash("md5").update(user?.email).digest("hex"),
count: booking._count.id,
Username: user.name || "No Username found",
};
});
@@ -590,6 +840,18 @@ export const insightsRouter = router({
teamListForUser: authedProcedure.query(async ({ ctx }) => {
const user = ctx.user;
// Fetch user data
const userData = await ctx.prisma.user.findUnique({
where: {
id: user.id,
},
select: {
id: true,
name: true,
avatar: true,
},
});
// Look if user it's admin in multiple teams
const belongsToTeams = await ctx.prisma.membership.findMany({
where: {
@@ -617,8 +879,25 @@ export const insightsRouter = router({
},
},
});
const result = belongsToTeams.map((membership) => membership.team);
const result: {
id: number;
slug: string | null;
name: string | null;
logo: string | null;
userId?: number;
}[] = belongsToTeams.map((membership) => {
return { ...membership.team };
});
if (userData && userData.id) {
result.push({
id: 0,
slug: "",
userId: userData.id,
name: userData.name,
logo: userData.avatar,
});
}
return result;
}),
userList: userBelongsToTeamProcedure
@@ -665,58 +944,84 @@ export const insightsRouter = router({
eventTypeList: userBelongsToTeamProcedure
.input(
z.object({
teamId: z.coerce.number().nullable(),
teamId: z.coerce.number().optional().nullable(),
userId: z.coerce.number().optional().nullable(),
})
)
.query(async ({ ctx, input }) => {
const user = ctx.user;
const { prisma, user } = ctx;
const { teamId, userId } = input;
if (!input.teamId) {
if (!teamId && !userId) {
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 membershipWhereConditional: Prisma.MembershipWhereInput = {};
if (teamId) {
membershipWhereConditional["teamId"] = teamId;
membershipWhereConditional["userId"] = user.id;
}
if (userId) {
membershipWhereConditional["userId"] = userId;
}
const eventTypes = await ctx.prisma.eventType.findMany({
select: {
id: true,
slug: true,
teamId: true,
title: true,
},
where: {
teamId: input.teamId,
},
// I'm not using unique here since when userId comes from input we should look for every
// event type that user owns
const membership = await prisma.membership.findFirst({
where: membershipWhereConditional,
});
return eventTypes;
if (!membership) {
throw new Error("User is not part of a team");
}
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {};
if (teamId) {
eventTypeWhereConditional["teamId"] = teamId;
}
if (userId) {
eventTypeWhereConditional["userId"] = userId;
}
let eventTypeResult: Prisma.EventTypeGetPayload<{
select: {
id: true;
slug: true;
teamId: true;
title: true;
};
}>[] = [];
switch (membership?.role) {
case "MEMBER":
eventTypeWhereConditional["OR"] = {
userId: user.id,
users: { some: { id: user.id } },
// @TODO this is not working as expected
// hosts: { some: { id: user.id } },
};
eventTypeResult = await prisma.eventType.findMany({
select: {
id: true,
slug: true,
teamId: true,
title: true,
},
where: eventTypeWhereConditional,
});
break;
default:
eventTypeResult = await prisma.eventType.findMany({
select: {
id: true,
slug: true,
teamId: true,
title: true,
},
where: eventTypeWhereConditional,
});
break;
}
return eventTypeResult;
}),
});
+234 -74
View File
@@ -5,6 +5,65 @@ import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
const shuffle = (
booking: any,
year: number,
eventTypesToPick: Prisma.EventTypeGetPayload<{
select: {
id: true;
userId: true;
title: true;
length: true;
description: true;
};
}>[],
usersIdsToPick?: number[]
) => {
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 = eventTypesToPick[Math.floor(Math.random() * eventTypesToPick.length)];
const endTime = dayjs(startTime).add(Math.floor(Math.random() * randomEvent.length), "minute");
booking.title = randomEvent.title;
booking.description = randomEvent.description;
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 = randomEvent.id;
booking.eventTypeId = randomEventTypeId;
booking.uid = uuidv4();
if (usersIdsToPick && usersIdsToPick.length > 0) {
booking.userId = Math.random() > 0.5 ? usersIdsToPick[0] : usersIdsToPick[1];
} else {
booking.userId = randomEvent.userId;
}
if (booking.userId === undefined || booking.userId === null) {
console.log({ randomEvent, usersIdsToPick });
console.log("This should not happen");
}
return booking;
};
const prisma = new PrismaClient();
async function main() {
// First find if not then create everything
@@ -18,25 +77,30 @@ async function main() {
insightsAdmin = await prisma.user.create({
data: {
email: "insights@example.com",
password: await hashPassword("insights"),
password: await hashPassword("insightsinsightsinsights...!"),
name: "Insights Admin",
role: "ADMIN",
username: "insights",
username: "insights-admin",
completedOnboarding: true,
},
});
if (!insightsAdmin) {
// Throw error
throw new Error("Insights Admin not created");
}
}
let insightsUser = await prisma.user.findFirst({
where: {
email: "insightuser@example.com",
email: "insightsuser@example.com",
},
});
if (!insightsUser) {
insightsUser = await prisma.user.create({
data: {
email: "insightuser@example.com",
email: "insightsuser@example.com",
password: await hashPassword("insightsuser"),
name: "Insights User",
role: "USER",
@@ -44,6 +108,10 @@ async function main() {
completedOnboarding: true,
},
});
if (!insightsUser) {
// Throw error
throw new Error("Insights Admin not created");
}
}
let insightsTeam = await prisma.team.findFirst({
@@ -75,37 +143,33 @@ async function main() {
},
],
});
await prisma.team.update({
where: {
id: insightsTeam.id,
},
data: {
members: {
connect: {
userId_teamId: {
userId: insightsAdmin.id,
teamId: insightsTeam.id,
if (!insightsTeam) {
insightsTeam = await prisma.team.create({
data: {
name: "Insights",
slug: "insights-team",
},
});
}
if (insightsAdmin && insightsTeam) {
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: {
@@ -113,6 +177,112 @@ async function main() {
},
});
const userSingleEventsAdmin = await prisma.eventType.findMany({
where: {
userId: insightsUser.id,
},
});
const userSingleEvents = await prisma.eventType.findMany({
where: {
userId: insightsUser.id,
},
});
if (userSingleEventsAdmin.length === 0 && userSingleEvents.length === 0) {
await prisma.eventType.create({
data: {
title: "Single Event Admin",
slug: "single-event-admin",
description: "Single Event Admin",
length: 60,
userId: insightsAdmin.id,
users: {
connect: {
id: insightsAdmin.id,
},
},
},
});
await prisma.eventType.create({
data: {
title: "Single Event",
slug: "single-event",
description: "Single Event",
length: 60,
userId: insightsUser.id,
users: {
connect: {
id: insightsUser.id,
},
},
},
});
const singleEventTypesAdmin = await prisma.eventType.findFirst({
select: {
id: true,
userId: true,
title: true,
length: true,
description: true,
},
where: {
userId: insightsAdmin.id,
},
});
const singleEventTypes = await prisma.eventType.findFirst({
select: {
id: true,
userId: true,
length: true,
title: true,
description: true,
},
where: {
userId: insightsUser.id,
},
});
if (!singleEventTypesAdmin || !singleEventTypes) {
throw new Error("Could not create single event types");
}
const singleEvents = [singleEventTypesAdmin, singleEventTypes];
// create bookings random for single event type
const baseBookingSingle: Prisma.BookingCreateManyInput = {
uid: "demoUIDSingle",
title: "Single Event",
description: "Single Event",
startTime: dayjs().toISOString(),
endTime: dayjs().toISOString(),
// This gets overriden in shuffle
userId: insightsUser.id,
};
// Validate if they are null of undefined throw error
if (!insightsAdmin || !insightsUser || !insightsAdmin.id || !insightsUser.id) {
throw new Error("Could not find user");
}
await prisma.booking.createMany({
data: [
...new Array(100)
.fill(0)
.map(() => shuffle({ ...baseBookingSingle }, dayjs().get("y") - 1, singleEvents)),
],
});
await prisma.booking.createMany({
data: [
...new Array(100)
.fill(0)
.map(() => shuffle({ ...baseBookingSingle }, dayjs().get("y") - 0, singleEvents)),
],
});
}
if (teamEvents.length === 0) {
await prisma.eventType.createMany({
data: [
@@ -142,7 +312,6 @@ async function main() {
},
],
});
} else {
teamEvents = await prisma.eventType.findMany({
where: {
teamId: insightsTeam.id,
@@ -152,61 +321,52 @@ async function main() {
const baseBooking: Prisma.BookingCreateManyInput = {
uid: "demoUID",
title: "Team Meeting",
description: "Team Meeting",
title: "Team Meeting should be changed in shuffle",
description: "Team Meeting Should be changed in shuffle",
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
// Create past bookings -2y, -1y, -0y
await prisma.booking.createMany({
data: [...new Array(100).fill(0).map(() => shuffle({ ...baseBooking }, dayjs().get("y") - 2))],
data: [
...new Array(100)
.fill(0)
.map(() =>
shuffle({ ...baseBooking }, dayjs().get("y") - 2, teamEvents, [
insightsAdmin?.id ?? 0,
insightsUser?.id ?? 0,
])
),
],
});
await prisma.booking.createMany({
data: [...new Array(100).fill(0).map(() => shuffle({ ...baseBooking }, dayjs().get("y") - 1))],
data: [
...new Array(100)
.fill(0)
.map(() =>
shuffle({ ...baseBooking }, dayjs().get("y") - 1, teamEvents, [
insightsAdmin?.id ?? 0,
insightsUser?.id ?? 0,
])
),
],
});
await prisma.booking.createMany({
data: [...new Array(100).fill(0).map(() => shuffle({ ...baseBooking }, dayjs().get("y")))],
data: [
...new Array(100)
.fill(0)
.map(() =>
shuffle({ ...baseBooking }, dayjs().get("y"), teamEvents, [
insightsAdmin?.id ?? 0,
insightsUser?.id ?? 0,
])
),
],
});
}
main()
+1966 -31
View File
File diff suppressed because it is too large Load Diff