feat: troubleshooter with weekly view (V2) (#12280)

* Inital UI + layout setup

* use booker approach of grid

* event-select - sidebar + store work

* adds get schedule by event-type-slug

* Calendar toggle

* Load schedule from event slug

* Add busy events to calendar

* useschedule

* Store more event info than just slug

* Add date override to calendar

* Changes sizes on smaller screens

* add event title as a tooltip

* Ensure header navigation works

* Stop navigator throwing errors on inital render

* Correct br

* Event duration fixes

* Add getMoreInfo if user is authed with current request.username

* Add calendar color map wip

* Add WIP comments for coloured outlines

* Revert more info changes

* Calculate date override correctly

* Add description option

* Fix inital schedule data not being populated

* Nudge overlap over to make it clearer

* Fix disabled state

* WIP on math logic

* Event list overlapping events logic

* NIT about width

* i18n + manage calendars link

* Delete old troubleshooter

* Update packages/features/calendars/weeklyview/components/event/EventList.tsx

* Remove t-slots

* Fix i18n & install calendar action

* sm:imrovments

* NITS

* Fix types

* fix: back button

* Month prop null as we control from query param

* Add head SEO

* Fix headseo import

* Fix date override tests
This commit is contained in:
sean-brydon 2023-11-20 12:19:33 +00:00 committed by GitHub
parent 9a4c20cca4
commit bdd3b132d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1038 additions and 189 deletions

View File

@ -1,139 +1,20 @@
import dayjs from "@calcom/dayjs";
import Shell from "@calcom/features/shell/Shell";
import { Troubleshooter } from "@calcom/features/troubleshooter/Troubleshooter";
import { getLayout } from "@calcom/features/troubleshooter/layout";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { SkeletonText } from "@calcom/ui";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import { HeadSeo } from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
type User = RouterOutputs["viewer"]["me"];
export interface IBusySlot {
start: string | Date;
end: string | Date;
title?: string;
source?: string | null;
}
const AvailabilityView = ({ user }: { user: User }) => {
const { t } = useLocale();
const { date, setQuery: setSelectedDate } = useRouterQuery("date");
const selectedDate = dayjs(date);
const formattedSelectedDate = selectedDate.format("YYYY-MM-DD");
const { data, isLoading } = trpc.viewer.availability.user.useQuery(
{
username: user.username || "",
dateFrom: selectedDate.startOf("day").utc().format(),
dateTo: selectedDate.endOf("day").utc().format(),
withSource: true,
},
{
enabled: !!user.username,
}
);
const overrides =
data?.dateOverrides.reduce((acc, override) => {
if (
formattedSelectedDate !== dayjs(override.start).format("YYYY-MM-DD") &&
formattedSelectedDate !== dayjs(override.end).format("YYYY-MM-DD")
)
return acc;
acc.push({ ...override, source: "Date override" });
return acc;
}, [] as IBusySlot[]) || [];
return (
<div className="bg-default max-w-xl overflow-hidden rounded-md shadow">
<div className="px-4 py-5 sm:p-6">
{t("overview_of_day")}{" "}
<input
type="date"
className="inline h-8 border-none bg-inherit p-0"
defaultValue={formattedSelectedDate}
onChange={(e) => {
if (e.target.value) setSelectedDate(e.target.value);
}}
/>
<small className="text-muted block">{t("hover_over_bold_times_tip")}</small>
<div className="mt-4 space-y-4">
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div>
</div>
{(() => {
if (isLoading)
return (
<>
<SkeletonText className="block h-16 w-full" />
<SkeletonText className="block h-16 w-full" />
</>
);
if (data && (data.busy.length > 0 || overrides.length > 0))
return [...data.busy, ...overrides]
.sort((a: IBusySlot, b: IBusySlot) => (a.start > b.start ? -1 : 1))
.map((slot: IBusySlot) => (
<div
key={dayjs(slot.start).format("HH:mm")}
className="bg-subtle overflow-hidden rounded-md"
data-testid="troubleshooter-busy-time">
<div className="text-emphasis px-4 py-5 sm:p-6">
{t("calendar_shows_busy_between")}{" "}
<span className="text-default font-medium" title={dayjs(slot.start).format("HH:mm")}>
{dayjs(slot.start).format("HH:mm")}
</span>{" "}
{t("and")}{" "}
<span className="text-default font-medium" title={dayjs(slot.end).format("HH:mm")}>
{dayjs(slot.end).format("HH:mm")}
</span>{" "}
{t("on")} {dayjs(slot.start).format("D")}{" "}
{t(dayjs(slot.start).format("MMMM").toLowerCase())} {dayjs(slot.start).format("YYYY")}
{slot.title && ` - (${slot.title})`}
{slot.source && <small>{` - (source: ${slot.source})`}</small>}
</div>
</div>
));
return (
<div className="bg-subtle overflow-hidden rounded-md">
<div className="text-emphasis px-4 py-5 sm:p-6">{t("calendar_no_busy_slots")}</div>
</div>
);
})()}
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-md">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
</div>
</div>
</div>
</div>
</div>
);
};
export default function Troubleshoot() {
const { data, isLoading } = trpc.viewer.me.useQuery();
function TroubleshooterPage() {
const { t } = useLocale();
return (
<div>
<Shell heading={t("troubleshoot")} hideHeadingOnMobile subtitle={t("troubleshoot_description")}>
{!isLoading && data && <AvailabilityView user={data} />}
</Shell>
</div>
<>
<HeadSeo title={t("troubleshoot")} description={t("troubleshoot_availability")} />
<Troubleshooter month={null} />
</>
);
}
Troubleshoot.PageWrapper = PageWrapper;
function convertMinsToHrsMins(mins: number) {
const h = Math.floor(mins / 60);
const m = mins % 60;
const hs = h < 10 ? `0${h}` : h;
const ms = m < 10 ? `0${m}` : m;
return `${hs}:${ms}`;
}
TroubleshooterPage.getLayout = getLayout;
TroubleshooterPage.PageWrapper = PageWrapper;
export default TroubleshooterPage;

View File

@ -40,6 +40,7 @@ test.describe("Availablity tests", () => {
const date = json[0].result.data.json.schedule.availability.find((a) => !!a.date);
const troubleshooterURL = `/availability/troubleshoot?date=${dayjs(date.date).format("YYYY-MM-DD")}`;
await page.goto(troubleshooterURL);
await page.waitForLoadState("networkidle");
await expect(page.locator('[data-testid="troubleshooter-busy-time"]')).toHaveCount(1);
});
});

View File

@ -2112,8 +2112,14 @@
"overlay_my_calendar":"Overlay my calendar",
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
"troubleshooting":"Troubleshooting",
"calendars_were_checking_for_conflicts":"Calendars were checking for conflicts",
"availabilty_schedules":"Availability schedules",
"manage_calendars":"Manage calendars",
"manage_availability_schedules":"Manage availability schedules",
"lock_timezone_toggle_on_booking_page": "Lock timezone on booking page",
"description_lock_timezone_toggle_on_booking_page" : "To lock the timezone on booking page, useful for in-person events.",
"install_calendar":"Install Calendar",
"branded_subdomain": "Branded Subdomain",
"branded_subdomain_description": "Get your own branded subdomain, such as acme.cal.com",
"org_insights": "Organization-wide Insights",

View File

@ -33,6 +33,7 @@ const getCalendarsEvents = async (
const passedSelectedCalendars = selectedCalendars.filter((sc) => sc.integration === type);
if (!passedSelectedCalendars.length) return [];
/** We extract external Ids so we don't cache too much */
const selectedCalendarIds = passedSelectedCalendars.map((sc) => sc.externalId);
/** If we don't then we actually fetch external calendars (which can be very slow) */
performance.mark("eventBusyDatesStart");
@ -51,7 +52,10 @@ const getCalendarsEvents = async (
"eventBusyDatesEnd"
);
return eventBusyDates.map((a) => ({ ...a, source: `${appId}` }));
return eventBusyDates.map((a) => ({
...a,
source: `${appId}`,
}));
});
const awaitedResults = await Promise.all(results);
performance.mark("getBusyCalendarTimesEnd");

View File

@ -1,6 +1,8 @@
import { cva } from "class-variance-authority";
import dayjs from "@calcom/dayjs";
import classNames from "@calcom/lib/classNames";
import { Tooltip } from "@calcom/ui";
import type { CalendarEvent } from "../../types/events";
@ -13,7 +15,7 @@ type EventProps = {
};
const eventClasses = cva(
"group flex h-full w-full flex-col overflow-y-auto rounded-[4px] px-[6px] py-1 text-xs font-semibold leading-5 ",
"group flex h-full w-full overflow-y-auto rounded-[6px] px-[6px] text-xs font-semibold leading-5 opacity-80",
{
variants: {
status: {
@ -62,23 +64,41 @@ export function Event({
const Component = onEventClick ? "button" : "div";
return (
<Component
onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event.
className={eventClasses({
status: options?.status,
disabled,
selected,
borderColor,
})}
style={styles}>
<div className="w-full overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4">
{event.title}
</div>
{eventDuration > 30 && (
<p className="text-subtle text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
</Component>
<Tooltip content={event.title}>
<Component
onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event.
className={classNames(
eventClasses({
status: options?.status,
disabled,
selected,
borderColor,
}),
eventDuration > 30 && "flex-col py-1",
options?.className
)}
style={styles}>
<div
className={classNames(
"flex w-full gap-2 overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4",
eventDuration <= 30 && "items-center"
)}>
<span>{event.title}</span>
{eventDuration <= 30 && !event.options?.hideTime && (
<p className="text-subtle w-full whitespace-nowrap text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
</div>
{eventDuration > 30 && !event.options?.hideTime && (
<p className="text-subtle text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
{eventDuration > 45 && event.description && (
<p className="text-subtle text-left text-[10px] leading-none">{event.description}</p>
)}
</Component>
</Tooltip>
);
}

View File

@ -1,3 +1,4 @@
import { useRef } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
@ -19,6 +20,14 @@ export function EventList({ day }: Props) {
shallow
);
// Use a ref so we dont trigger a re-render
const longestRef = useRef<{
start: Date;
end: Date;
duration: number;
idx: number;
} | null>(null);
return (
<>
{events
@ -41,47 +50,59 @@ export function EventList({ day }: Props) {
const nextEvent = eventsArray[idx + 1];
const prevEvent = eventsArray[idx - 1];
// Check for overlapping events since this is sorted it should just work.
if (nextEvent) {
const nextEventStart = dayjs(nextEvent.start);
const nextEventEnd = dayjs(nextEvent.end);
// check if next event starts before this event ends
if (nextEventStart.isBefore(eventEnd)) {
// figure out which event has the longest duration
const nextEventDuration = nextEventEnd.diff(nextEventStart, "minutes");
if (nextEventDuration > eventDuration) {
if (!longestRef.current) {
longestRef.current = {
idx,
start: eventStart.toDate(),
end: eventEnd.toDate(),
duration: eventDuration,
};
} else if (
eventDuration > longestRef.current.duration &&
eventStart.isBetween(longestRef.current.start, longestRef.current.end)
) {
longestRef.current = {
idx,
start: eventStart.toDate(),
end: eventEnd.toDate(),
duration: eventDuration,
};
}
// By default longest event doesnt have any styles applied
if (longestRef.current.idx !== idx) {
if (nextEvent) {
// If we have a next event
const nextStart = dayjs(nextEvent.start);
// If the next event is inbetween the longest start and end make 65% width
if (nextStart.isBetween(longestRef.current.start, longestRef.current.end)) {
zIndex = 65;
marginLeft = "auto";
// 8 looks like a really random number but we need to take into account the bordersize on the event.
// Logically it should be 5% but this causes a bit of a overhang which we don't want.
right = 8;
right = 4;
width = width / 2;
// If not - we check to see if the next starts within 5 mins of this event - allowing us to do side by side events whenwe have
// close start times
} else if (nextStart.isBetween(eventStart.add(-5, "minutes"), eventStart.add(5, "minutes"))) {
zIndex = 65;
marginLeft = "auto";
right = 4;
width = width / 2;
}
}
} else if (prevEvent) {
const prevStart = dayjs(prevEvent.start);
if (nextEventStart.isSame(eventStart)) {
zIndex = 66;
// If the next event is inbetween the longest start and end make 65% width
marginLeft = "auto";
right = 8;
width = width / 2;
}
} else if (prevEvent) {
const prevEventStart = dayjs(prevEvent.start);
const prevEventEnd = dayjs(prevEvent.end);
// check if next event starts before this event ends
if (prevEventEnd.isAfter(eventStart)) {
// figure out which event has the longest duration
const prevEventDuration = prevEventEnd.diff(prevEventStart, "minutes");
if (prevEventDuration > eventDuration) {
if (prevStart.isBetween(longestRef.current.start, longestRef.current.end)) {
zIndex = 65;
marginLeft = "auto";
right = 8;
right = 4;
// If not - we check to see if the next starts within 5 mins of this event - allowing us to do side by side events whenwe have
// close start times (Inverse of above )
} else if (eventStart.isBetween(prevStart.add(5, "minutes"), prevStart.add(-5, "minutes"))) {
zIndex = 65;
right = 4;
width = width / 2;
if (eventDuration >= 30) {
width = 80;
}
}
}
}
@ -90,6 +111,7 @@ export function EventList({ day }: Props) {
<div
key={`${event.id}-${eventStart.toISOString()}`}
className="absolute inset-x-1 "
data-testId={event.options?.["data-test-id"]}
style={{
marginLeft,
zIndex,

View File

@ -2,8 +2,12 @@ import type dayjs from "@calcom/dayjs";
export const VeritcalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
const isRTL = () => {
const userLocale = navigator.language;
const userLanguage = new Intl.Locale(userLocale).language;
let userLanguage = "en"; // Default to 'en' if navigator is not defined
if (typeof window !== "undefined" && typeof navigator !== "undefined") {
const userLocale = navigator.language;
userLanguage = new Intl.Locale(userLocale).language;
}
return ["ar", "he", "fa", "ur"].includes(userLanguage);
};

View File

@ -32,9 +32,7 @@ export const useCalendarStore = create<CalendarStoreProps>((set) => ({
let events = state.events;
if (state.sortEvents) {
events = state.events.sort(
(a, b) => dayjs(a.start).get("milliseconds") - dayjs(b.start).get("milliseconds")
);
events = state.events.sort((a, b) => dayjs(a.start).valueOf() - dayjs(b.start).valueOf());
}
const blockingDates = mergeOverlappingDateRanges(state.blockingDates || []); // We merge overlapping dates so we don't get duplicate blocking "Cells" in the UI

View File

@ -3,12 +3,16 @@ import type { BookingStatus } from "@calcom/prisma/enums";
export interface CalendarEvent {
id: number;
title: string;
description?: string;
start: Date | string; // You can pass in a string from DB since we use dayjs for the dates.
end: Date;
source?: string;
options?: {
status?: BookingStatus;
hideTime?: boolean;
allDay?: boolean;
borderColor?: string;
className?: string;
"data-test-id"?: string;
};
}

View File

@ -0,0 +1,70 @@
import StickyBox from "react-sticky-box";
import classNames from "@calcom/lib/classNames";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { LargeCalendar } from "./components/LargeCalendar";
import { TroubleshooterHeader } from "./components/TroubleshooterHeader";
import { TroubleshooterSidebar } from "./components/TroubleshooterSidebar";
import { useInitalizeTroubleshooterStore } from "./store";
import type { TroubleshooterProps } from "./types";
const extraDaysConfig = {
desktop: 7,
tablet: 4,
};
const TroubleshooterComponent = ({ month }: TroubleshooterProps) => {
const isMobile = useMediaQuery("(max-width: 768px)");
const isTablet = useMediaQuery("(max-width: 1024px)");
const extraDays = isTablet ? extraDaysConfig.tablet : extraDaysConfig.desktop;
useInitalizeTroubleshooterStore({
month: month,
});
return (
<>
<div
className={classNames(
"text-default grid min-h-full w-full flex-col items-center overflow-clip ",
isMobile
? "[--troublehooster-meta-width:0px]"
: "[--troublehooster-meta-width:250px] lg:[--troubleshooter-meta-width:430px]"
)}>
<div
style={{
width: "100vw",
minHeight: "100vh",
height: "auto",
gridTemplateAreas: `
"meta header header"
"meta main main"
`,
gridTemplateColumns: "var(--troubleshooter-meta-width) 1fr",
gridTemplateRows: "70px auto",
}}
className={classNames(
"bg-default dark:bg-muted grid max-w-full items-start dark:[color-scheme:dark] sm:transition-[width] sm:duration-300 sm:motion-reduce:transition-none md:flex-row "
)}>
<div className={classNames("bg-default dark:bg-muted sticky top-0 z-10 [grid-area:header]")}>
<TroubleshooterHeader extraDays={extraDays} isMobile={isMobile} />
</div>
<StickyBox key="meta" className={classNames("relative z-10")}>
<div className="">
<TroubleshooterSidebar />
</div>
</StickyBox>
<div className="border-subtle sticky top-0 ml-[-1px] h-full [grid-area:main] md:border-l ">
<LargeCalendar extraDays={extraDays} />
</div>
</div>
</div>
</>
);
};
export const Troubleshooter = ({ month }: TroubleshooterProps) => {
return <TroubleshooterComponent month={month} />;
};

View File

@ -0,0 +1,38 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge, Button, Switch } from "@calcom/ui";
import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer";
function AvailabiltyItem() {
const { t } = useLocale();
return (
<TroubleshooterListItemContainer
title="Office Hours"
subtitle="Mon-Fri; 9:00 AM - 5:00 PM"
suffixSlot={
<div>
<Badge variant="green" withDot size="sm">
Connected
</Badge>
</div>
}>
<div className="flex flex-col gap-3">
<p className="text-subtle text-sm font-medium leading-none">{t("date_overrides")}</p>
<Switch label="google@calendar.com" />
</div>
</TroubleshooterListItemContainer>
);
}
export function AvailabiltySchedulesContainer() {
const { t } = useLocale();
return (
<div className="flex flex-col space-y-3">
<p className="text-sm font-medium leading-none">{t("availabilty_schedules")}</p>
<AvailabiltyItem />
<Button color="secondary" className="justify-center gap-2">
{t("manage_availabilty_schedules")}
</Button>
</div>
);
}

View File

@ -0,0 +1,121 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge, Button, Switch } from "@calcom/ui";
import { TroubleshooterListItemContainer } from "./TroubleshooterListItemContainer";
const SELECTION_COLORS = ["#f97316", "#84cc16", "#06b6d4", "#8b5cf6", "#ec4899", "#f43f5e"];
interface CalendarToggleItemProps {
title: string;
subtitle: string;
colorDot?: string;
status: "connected" | "not_found";
calendars?: {
active?: boolean;
name?: string;
}[];
}
function CalendarToggleItem(props: CalendarToggleItemProps) {
const badgeStatus = props.status === "connected" ? "green" : "orange";
const badgeText = props.status === "connected" ? "Connected" : "Not found";
return (
<TroubleshooterListItemContainer
title={props.title}
subtitle={props.subtitle}
prefixSlot={
<>
<div
className="h-4 w-4 self-center rounded-[4px]"
style={{
backgroundColor: props.colorDot,
}}
/>
</>
}
suffixSlot={
<div>
<Badge variant={badgeStatus} withDot size="sm">
{badgeText}
</Badge>
</div>
}>
<div className="[&>*]:text-emphasis flex flex-col gap-3">
{props.calendars?.map((calendar) => {
return <Switch key={calendar.name} checked={calendar.active} label={calendar.name} disabled />;
})}
</div>
</TroubleshooterListItemContainer>
);
}
function EmptyCalendarToggleItem() {
const { t } = useLocale();
return (
<TroubleshooterListItemContainer
title="Please install a calendar"
prefixSlot={
<>
<div className="h-4 w-4 self-center rounded-[4px] bg-blue-500" />
</>
}
suffixSlot={
<div>
<Badge variant="orange" withDot size="sm">
Not found
</Badge>
</div>
}>
<div className="flex flex-col gap-3">
<Button color="secondary" className="justify-center gap-2" href="/apps/categories/calendar">
{t("install_calendar")}
</Button>
</div>
</TroubleshooterListItemContainer>
);
}
export function CalendarToggleContainer() {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery();
const hasConnectedCalendars = data && data?.connectedCalendars.length > 0;
return (
<div className="flex flex-col space-y-3">
<p className="text-sm font-medium leading-none">{t("calendars_were_checking_for_conflicts")}</p>
{hasConnectedCalendars && !isLoading ? (
<>
{data.connectedCalendars.map((calendar) => {
const foundPrimary = calendar.calendars?.find((item) => item.primary);
// Will be used when getAvailbility is modified to use externalId instead of appId for source.
// const color = SELECTION_COLORS[idx] || "#000000";
// // Add calendar to color map using externalId (what we use on the backend to determine source)
// addToColorMap(foundPrimary?.externalId, color);
return (
<CalendarToggleItem
key={calendar.credentialId}
title={calendar.integration.name}
colorDot="#000000"
subtitle={foundPrimary?.name ?? "Nameless Calendar"}
status={calendar.error ? "not_found" : "connected"}
calendars={calendar.calendars?.map((item) => {
return {
active: item.isSelected,
name: item.name,
};
})}
/>
);
})}
<Button color="secondary" className="justify-center gap-2" href="/settings/my-account/calendars">
{t("manage_calendars")}
</Button>
</>
) : (
<EmptyCalendarToggleItem />
)}
</div>
);
}

View File

@ -0,0 +1,38 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui";
import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer";
function ConnectedAppsItem() {
return (
<TroubleshooterListItemHeader
title="Google Cal"
subtitle="google@calendar.com"
prefixSlot={
<>
<div className="h-4 w-4 self-center rounded-[4px] bg-blue-500" />
</>
}
suffixSlot={
<div>
<Badge variant="green" withDot size="sm">
Connected
</Badge>
</div>
}
/>
);
}
export function ConnectedAppsContainer() {
const { t } = useLocale();
return (
<div className="flex flex-col space-y-3">
<p className="text-sm font-medium leading-none">{t("other_apps")}</p>
<div className="[&>*:first-child]:rounded-t-md [&>*:last-child]:rounded-b-md [&>*:last-child]:border-b">
<ConnectedAppsItem />
<ConnectedAppsItem />
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge, Label } from "@calcom/ui";
import { useTroubleshooterStore } from "../store";
import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer";
export function EventScheduleItem() {
const { t } = useLocale();
const selectedEventType = useTroubleshooterStore((state) => state.event);
const { data: schedule } = trpc.viewer.availability.schedule.getScheduleByEventSlug.useQuery(
{
eventSlug: selectedEventType?.slug as string,
},
{
enabled: !!selectedEventType?.slug,
}
);
return (
<div>
<Label>Availability Schedule</Label>
<TroubleshooterListItemHeader
className="group rounded-md border-b"
prefixSlot={<div className="w-4 rounded-[4px] bg-black" />}
title={schedule?.name ?? "Loading"}
suffixSlot={
schedule && (
<Link href={`/availability/${schedule.id}`} className="inline-flex">
<Badge color="orange" size="sm" className="hidden hover:cursor-pointer group-hover:inline-flex">
{t("edit")}
</Badge>
</Link>
)
}
/>
</div>
);
}

View File

@ -0,0 +1,53 @@
import { useMemo, useEffect } from "react";
import { trpc } from "@calcom/trpc";
import { SelectField } from "@calcom/ui";
import { useTroubleshooterStore } from "../store";
export function EventTypeSelect() {
const { data: eventTypes, isLoading } = trpc.viewer.eventTypes.list.useQuery();
const selectedEventType = useTroubleshooterStore((state) => state.event);
const setSelectedEventType = useTroubleshooterStore((state) => state.setEvent);
// const selectedEventQueryParam = getQueryParam("eventType");
const options = useMemo(() => {
if (!eventTypes) return [];
return eventTypes.map((e) => ({
label: e.title,
value: e.slug,
id: e.id,
duration: e.length,
}));
}, [eventTypes]);
useEffect(() => {
if (!selectedEventType && eventTypes && eventTypes[0]) {
const { id, slug, length } = eventTypes[0];
setSelectedEventType({
id,
slug,
duration: length,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventTypes]);
return (
<SelectField
label="Event Type"
options={options}
isDisabled={isLoading || options.length === 0}
value={options.find((option) => option.value === selectedEventType?.slug) || options[0]}
onChange={(option) => {
if (!option) return;
setSelectedEventType({
slug: option.value,
id: option.id,
duration: option.duration,
});
}}
/>
);
}

View File

@ -0,0 +1,142 @@
import { useSession } from "next-auth/react";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { useTimePreferences } from "../../bookings/lib/timePreferences";
import { useSchedule } from "../../schedules/lib/use-schedule";
import { useTroubleshooterStore } from "../store";
export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
const { timezone } = useTimePreferences();
const selectedDate = useTroubleshooterStore((state) => state.selectedDate);
const event = useTroubleshooterStore((state) => state.event);
const calendarToColorMap = useTroubleshooterStore((state) => state.calendarToColorMap);
const { data: session } = useSession();
const startDate = selectedDate ? dayjs(selectedDate) : dayjs();
const { data: busyEvents } = trpc.viewer.availability.user.useQuery(
{
username: session?.user?.username || "",
dateFrom: startDate.startOf("day").utc().format(),
dateTo: startDate
.endOf("day")
.add(extraDays - 1, "day")
.utc()
.format(),
withSource: true,
},
{
enabled: !!session?.user?.username,
}
);
const { data: schedule } = useSchedule({
username: session?.user.username || "",
eventSlug: event?.slug,
eventId: event?.id,
timezone,
month: startDate.format("YYYY-MM"),
});
const endDate = dayjs(startDate)
.add(extraDays - 1, "day")
.toDate();
const availableSlots = useMemo(() => {
const availableTimeslots: CalendarAvailableTimeslots = {};
if (!schedule) return availableTimeslots;
if (!schedule?.slots) return availableTimeslots;
for (const day in schedule.slots) {
availableTimeslots[day] = schedule.slots[day].map((slot) => ({
start: dayjs(slot.time).toDate(),
end: dayjs(slot.time)
.add(event?.duration ?? 30, "minutes")
.toDate(),
}));
}
return availableTimeslots;
}, [schedule, event]);
const events = useMemo(() => {
if (!busyEvents?.busy) return [];
// TODO: Add buffer times in here as well just requires a bit of logic for fetching event type and then adding buffer time
// start: dayjs(startTime)
// .subtract((eventType?.beforeEventBuffer || 0) + (afterEventBuffer || 0), "minute")
// .toDate(),
// end: dayjs(endTime)
// .add((eventType?.afterEventBuffer || 0) + (beforeEventBuffer || 0), "minute")
// .toDate(),
const calendarEvents = busyEvents?.busy.map((event, idx) => {
return {
id: idx,
title: event.title ?? `Busy`,
start: new Date(event.start),
end: new Date(event.end),
options: {
borderColor:
event.source && calendarToColorMap[event.source] ? calendarToColorMap[event.source] : "black",
status: BookingStatus.ACCEPTED,
"data-test-id": "troubleshooter-busy-event",
},
};
});
if (busyEvents.dateOverrides) {
busyEvents.dateOverrides.forEach((dateOverride) => {
const dateOverrideStart = dayjs(dateOverride.start);
const dateOverrideEnd = dayjs(dateOverride.end);
if (!dateOverrideStart.isSame(dateOverrideEnd)) {
return;
}
const dayOfWeekNum = dateOverrideStart.day();
const workingHoursForDay = busyEvents.workingHours.find((workingHours) =>
workingHours.days.includes(dayOfWeekNum)
);
if (!workingHoursForDay) return;
calendarEvents.push({
id: calendarEvents.length,
title: "Date Override",
start: dateOverrideStart.add(workingHoursForDay.startTime, "minutes").toDate(),
end: dateOverrideEnd.add(workingHoursForDay.endTime, "minutes").toDate(),
options: {
borderColor: "black",
status: BookingStatus.ACCEPTED,
"data-test-id": "troubleshooter-busy-time",
},
});
});
}
return calendarEvents;
}, [busyEvents, calendarToColorMap]);
return (
<div className="h-full [--calendar-dates-sticky-offset:66px]">
<Calendar
sortEvents
startHour={0}
endHour={23}
events={events}
availableTimeslots={availableSlots}
startDate={startDate.toDate()}
endDate={endDate}
gridCellsPerHour={60 / (event?.duration || 15)}
hoverEventDuration={30}
hideHeader
/>
</div>
);
};

View File

@ -0,0 +1,80 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, ButtonGroup } from "@calcom/ui";
import { useTroubleshooterStore } from "../store";
export function TroubleshooterHeader({ extraDays, isMobile }: { extraDays: number; isMobile: boolean }) {
const { t, i18n } = useLocale();
const selectedDateString = useTroubleshooterStore((state) => state.selectedDate);
const setSelectedDate = useTroubleshooterStore((state) => state.setSelectedDate);
const addToSelectedDate = useTroubleshooterStore((state) => state.addToSelectedDate);
const selectedDate = selectedDateString ? dayjs(selectedDateString) : dayjs();
const today = dayjs();
const selectedDateMin3DaysDifference = useMemo(() => {
const diff = today.diff(selectedDate, "days");
return diff > 3 || diff < -3;
}, [today, selectedDate]);
if (isMobile) return null;
const endDate = selectedDate.add(extraDays - 1, "days");
const isSameMonth = () => {
return selectedDate.format("MMM") === endDate.format("MMM");
};
const isSameYear = () => {
return selectedDate.format("YYYY") === endDate.format("YYYY");
};
const formattedMonth = new Intl.DateTimeFormat(i18n.language, { month: "short" });
const FormattedSelectedDateRange = () => {
return (
<h3 className="min-w-[150px] text-base font-semibold leading-4">
{formattedMonth.format(selectedDate.toDate())} {selectedDate.format("D")}
{!isSameYear() && <span className="text-subtle">, {selectedDate.format("YYYY")} </span>}-{" "}
{!isSameMonth() && formattedMonth.format(endDate.toDate())} {endDate.format("D")},{" "}
<span className="text-subtle">
{isSameYear() ? selectedDate.format("YYYY") : endDate.format("YYYY")}
</span>
</h3>
);
};
return (
<div className="border-default relative z-10 flex border-b px-5 py-4 ltr:border-l rtl:border-r">
<div className="flex items-center gap-5 rtl:flex-grow">
<FormattedSelectedDateRange />
<ButtonGroup>
<Button
className="group rtl:ml-1 rtl:rotate-180"
variant="icon"
color="minimal"
StartIcon={ChevronLeft}
aria-label="Previous Day"
onClick={() => addToSelectedDate(-extraDays)}
/>
<Button
className="group rtl:mr-1 rtl:rotate-180"
variant="icon"
color="minimal"
StartIcon={ChevronRight}
aria-label="Next Day"
onClick={() => addToSelectedDate(extraDays)}
/>
{selectedDateMin3DaysDifference && (
<Button
className="capitalize ltr:ml-2 rtl:mr-2"
color="secondary"
onClick={() => setSelectedDate(today.format("YYYY-MM-DD"))}>
{t("today")}
</Button>
)}
</ButtonGroup>
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import type { PropsWithChildren } from "react";
import classNames from "@calcom/lib/classNames";
interface TroubleshooterListItemContainerProps {
title: string;
subtitle?: string;
suffixSlot?: React.ReactNode;
prefixSlot?: React.ReactNode;
className?: string;
}
export function TroubleshooterListItemHeader({
prefixSlot,
title,
subtitle,
suffixSlot,
className,
}: TroubleshooterListItemContainerProps) {
return (
<div className={classNames("border-subtle flex max-w-full gap-3 border border-b-0 px-4 py-2", className)}>
{prefixSlot}
<div className="flex h-full max-w-full flex-1 flex-col flex-nowrap truncate text-sm leading-4">
<p className="font-medium">{title}</p>
{subtitle && <p className="font-normal">{subtitle}</p>}
</div>
{suffixSlot}
</div>
);
}
export function TroubleshooterListItemContainer({
children,
...rest
}: PropsWithChildren<TroubleshooterListItemContainerProps>) {
return (
<div className="[&>*:first-child]:rounded-t-md ">
<TroubleshooterListItemHeader {...rest} />
<div className="border-subtle flex flex-col space-y-3 rounded-b-md border p-4">{children}</div>
</div>
);
}

View File

@ -0,0 +1,39 @@
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Skeleton } from "@calcom/ui";
import { CalendarToggleContainer } from "./CalendarToggleContainer";
import { EventScheduleItem } from "./EventScheduleItem";
import { EventTypeSelect } from "./EventTypeSelect";
const BackButtonInSidebar = ({ name }: { name: string }) => {
return (
<Link
href="/availability"
className="hover:bg-subtle group-hover:text-default text-emphasis group flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2">
<ArrowLeft className="h-4 w-4 stroke-[2px] ltr:mr-[10px] rtl:ml-[10px] rtl:rotate-180 md:mt-0" />
<Skeleton
title={name}
as="p"
className="max-w-36 min-h-4 truncate text-xl font-semibold"
loadingClassName="ms-3">
{name}
</Skeleton>
</Link>
);
};
export const TroubleshooterSidebar = () => {
const { t } = useLocale();
return (
<div className="relative z-10 hidden w-full flex-col gap-6 py-6 pl-4 pr-6 sm:flex md:pl-0">
<BackButtonInSidebar name={t("troubleshooter")} />
<EventTypeSelect />
<EventScheduleItem />
<CalendarToggleContainer />
</div>
);
};

View File

@ -0,0 +1,23 @@
import type { ComponentProps } from "react";
import React, { Suspense } from "react";
import Shell from "@calcom/features/shell/Shell";
import { ErrorBoundary } from "@calcom/ui";
import { Loader } from "@calcom/ui/components/icon";
export default function TroubleshooterLayout({
children,
...rest
}: { children: React.ReactNode } & ComponentProps<typeof Shell>) {
return (
<Shell withoutSeo={true} flexChildrenContainer hideHeadingOnMobile {...rest} SidebarContainer={<></>}>
<div className="flex flex-1 [&>*]:flex-1">
<ErrorBoundary>
<Suspense fallback={<Loader />}>{children}</Suspense>
</ErrorBoundary>
</div>
</Shell>
);
}
export const getLayout = (page: React.ReactElement) => <TroubleshooterLayout>{page}</TroubleshooterLayout>;

View File

@ -0,0 +1,110 @@
import { useEffect } from "react";
import { create } from "zustand";
import dayjs from "@calcom/dayjs";
import { updateQueryParam, getQueryParam, removeQueryParam } from "../bookings/Booker/utils/query-param";
/**
* Arguments passed into store initializer, containing
* the event data.
*/
type StoreInitializeType = {
month: string | null;
};
type EventType = {
id: number;
slug: string;
duration: number;
};
export type TroubleshooterStore = {
event: EventType | null;
setEvent: (eventSlug: EventType) => void;
month: string | null;
setMonth: (month: string | null) => void;
selectedDate: string | null;
setSelectedDate: (date: string | null) => void;
addToSelectedDate: (days: number) => void;
initialize: (data: StoreInitializeType) => void;
calendarToColorMap: Record<string, string>;
addToCalendarToColorMap: (calendarId: string | undefined, color: string) => void;
};
/**
* The booker store contains the data of the component's
* current state. This data can be reused within child components
* by importing this hook.
*
* See comments in interface above for more information on it's specific values.
*/
export const useTroubleshooterStore = create<TroubleshooterStore>((set, get) => ({
selectedDate: getQueryParam("date") || null,
setSelectedDate: (selectedDate: string | null) => {
// unset selected date
if (!selectedDate) {
removeQueryParam("date");
return;
}
const currentSelection = dayjs(get().selectedDate);
const newSelection = dayjs(selectedDate);
set({ selectedDate });
updateQueryParam("date", selectedDate ?? "");
// Setting month make sure small calendar in fullscreen layouts also updates.
if (newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
},
addToSelectedDate: (days: number) => {
const selectedDate = get().selectedDate;
const currentSelection = selectedDate ? dayjs(get().selectedDate) : dayjs();
const newSelection = currentSelection.add(days, "day");
const newSelectionFormatted = newSelection.format("YYYY-MM-DD");
if (newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
set({ selectedDate: newSelectionFormatted });
updateQueryParam("date", newSelectionFormatted);
},
event: null,
setEvent: (event: EventType) => {
set({ event });
updateQueryParam("eventType", event.slug ?? "");
},
month: getQueryParam("month") || getQueryParam("date") || dayjs().format("YYYY-MM"),
setMonth: (month: string | null) => {
set({ month });
updateQueryParam("month", month ?? "");
get().setSelectedDate(null);
},
initialize: ({ month }: StoreInitializeType) => {
if (month) {
set({ month });
updateQueryParam("month", month);
}
//removeQueryParam("layout");
},
calendarToColorMap: {},
addToCalendarToColorMap: (calendarId: string | undefined, color: string) => {
if (!calendarId) return;
const calendarToColorMap = get().calendarToColorMap;
calendarToColorMap[calendarId] = color;
set({ calendarToColorMap });
},
}));
export const useInitalizeTroubleshooterStore = ({ month }: StoreInitializeType) => {
const initializeStore = useTroubleshooterStore((state) => state.initialize);
useEffect(() => {
initializeStore({
month,
});
}, [initializeStore, month]);
};

View File

@ -0,0 +1,13 @@
export interface TroubleshooterProps {
/**
* If month is NOT set as a prop on the component, we expect a query parameter
* called `month` to be present on the url. If that is missing, the component will
* default to the current month.
* @note In case you're using a client side router, please pass the value in as a prop,
* since the component will leverage window.location, which might not have the query param yet.
* @format YYYY-MM.
* @optional
*/
month: string | null;
selectedDate?: Date;
}

View File

@ -4,6 +4,7 @@ import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZScheduleDuplicateSchema } from "./duplicate.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetByEventSlugInputSchema } from "./getScheduleByEventTypeSlug.schema";
import { ZGetByUserIdInputSchema } from "./getScheduleByUserId.schema";
import { ZUpdateInputSchema } from "./update.schema";
@ -14,6 +15,7 @@ type ScheduleRouterHandlerCache = {
update?: typeof import("./update.handler").updateHandler;
duplicate?: typeof import("./duplicate.handler").duplicateHandler;
getScheduleByUserId?: typeof import("./getScheduleByUserId.handler").getScheduleByUserIdHandler;
getScheduleByEventSlug?: typeof import("./getScheduleByEventTypeSlug.handler").getScheduleByEventSlugHandler;
};
const UNSTABLE_HANDLER_CACHE: ScheduleRouterHandlerCache = {};
@ -118,4 +120,21 @@ export const scheduleRouter = router({
input,
});
}),
getScheduleByEventSlug: authedProcedure.input(ZGetByEventSlugInputSchema).query(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug) {
UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug = await import(
"./getScheduleByEventTypeSlug.handler"
).then((mod) => mod.getScheduleByEventSlugHandler);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug({
ctx,
input,
});
}),
});

View File

@ -0,0 +1,69 @@
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "../../../../trpc";
import { getHandler } from "./get.handler";
import type { TGetByEventSlugInputSchema } from "./getScheduleByEventTypeSlug.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
input: TGetByEventSlugInputSchema;
};
const EMPTY_SCHEDULE = [[], [], [], [], [], [], []];
export const getScheduleByEventSlugHandler = async ({ ctx, input }: GetOptions) => {
const foundScheduleForSlug = await ctx.prisma.eventType.findFirst({
where: {
slug: input.eventSlug,
userId: ctx.user.id,
},
select: {
scheduleId: true,
},
});
try {
// This looks kinda weird that we throw straight in the catch - its so that we can return a default schedule if the user has not completed onboarding @shiraz will loveme for this
if (!foundScheduleForSlug?.scheduleId) {
const foundUserDefaultId = await ctx.prisma.user.findUnique({
where: {
id: ctx.user.id,
},
select: {
defaultScheduleId: true,
},
});
if (foundUserDefaultId?.defaultScheduleId) {
return await getHandler({
ctx,
input: {
scheduleId: foundUserDefaultId?.defaultScheduleId,
},
});
}
throw new Error("NOT_FOUND");
}
return await getHandler({
ctx,
input: {
scheduleId: foundScheduleForSlug?.scheduleId,
},
});
} catch (e) {
console.log(e);
return {
id: -1,
name: "No schedules found",
availability: EMPTY_SCHEDULE,
dateOverrides: [],
timeZone: ctx.user.timeZone || "Europe/London",
workingHours: [],
isDefault: true,
};
}
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetByEventSlugInputSchema = z.object({
eventSlug: z.string(),
});
export type TGetByEventSlugInputSchema = z.infer<typeof ZGetByEventSlugInputSchema>;

View File

@ -1,12 +1,15 @@
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import type { TrpcSessionUser } from "../../../trpc";
import type { TUserInputSchema } from "./user.schema";
type UserOptions = {
ctx: Record<string, unknown>;
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TUserInputSchema;
};
export const userHandler = async ({ input }: UserOptions) => {
return getUserAvailability(input);
return getUserAvailability(input, undefined);
};