From bdd3b132d441a226cb0a0c477cc86fe34e1c3272 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Mon, 20 Nov 2023 12:19:33 +0000 Subject: [PATCH] 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 --- apps/web/pages/availability/troubleshoot.tsx | 141 ++--------------- apps/web/playwright/availability.e2e.ts | 1 + apps/web/public/static/locales/en/common.json | 6 + packages/core/getCalendarsEvents.ts | 6 +- .../weeklyview/components/event/Event.tsx | 58 ++++--- .../weeklyview/components/event/EventList.tsx | 86 +++++++---- .../components/verticalLines/index.tsx | 8 +- .../calendars/weeklyview/state/store.ts | 4 +- .../calendars/weeklyview/types/events.ts | 4 + .../troubleshooter/Troubleshooter.tsx | 70 +++++++++ .../AvailabilitySchedulesContainer.tsx | 38 +++++ .../components/CalendarToggleContainer.tsx | 121 +++++++++++++++ .../components/ConnectedAppsContainer.tsx | 38 +++++ .../components/EventScheduleItem.tsx | 42 ++++++ .../components/EventTypeSelect.tsx | 53 +++++++ .../components/LargeCalendar.tsx | 142 ++++++++++++++++++ .../components/TroubleshooterHeader.tsx | 80 ++++++++++ .../TroubleshooterListItemContainer.tsx | 42 ++++++ .../components/TroubleshooterSidebar.tsx | 39 +++++ packages/features/troubleshooter/layout.tsx | 23 +++ packages/features/troubleshooter/store.ts | 110 ++++++++++++++ packages/features/troubleshooter/types.ts | 13 ++ .../viewer/availability/schedule/_router.tsx | 19 +++ .../getScheduleByEventTypeSlug.handler.ts | 69 +++++++++ .../getScheduleByEventTypeSlug.schema.ts | 7 + .../viewer/availability/user.handler.ts | 7 +- 26 files changed, 1038 insertions(+), 189 deletions(-) create mode 100644 packages/features/troubleshooter/Troubleshooter.tsx create mode 100644 packages/features/troubleshooter/components/AvailabilitySchedulesContainer.tsx create mode 100644 packages/features/troubleshooter/components/CalendarToggleContainer.tsx create mode 100644 packages/features/troubleshooter/components/ConnectedAppsContainer.tsx create mode 100644 packages/features/troubleshooter/components/EventScheduleItem.tsx create mode 100644 packages/features/troubleshooter/components/EventTypeSelect.tsx create mode 100644 packages/features/troubleshooter/components/LargeCalendar.tsx create mode 100644 packages/features/troubleshooter/components/TroubleshooterHeader.tsx create mode 100644 packages/features/troubleshooter/components/TroubleshooterListItemContainer.tsx create mode 100644 packages/features/troubleshooter/components/TroubleshooterSidebar.tsx create mode 100644 packages/features/troubleshooter/layout.tsx create mode 100644 packages/features/troubleshooter/store.ts create mode 100644 packages/features/troubleshooter/types.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.handler.ts create mode 100644 packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.schema.ts diff --git a/apps/web/pages/availability/troubleshoot.tsx b/apps/web/pages/availability/troubleshoot.tsx index e3bfd24f0e..e37b598d68 100644 --- a/apps/web/pages/availability/troubleshoot.tsx +++ b/apps/web/pages/availability/troubleshoot.tsx @@ -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 ( -
-
- {t("overview_of_day")}{" "} - { - if (e.target.value) setSelectedDate(e.target.value); - }} - /> - {t("hover_over_bold_times_tip")} -
-
-
- {t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)} -
-
- {(() => { - if (isLoading) - return ( - <> - - - - ); - - 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) => ( -
-
- {t("calendar_shows_busy_between")}{" "} - - {dayjs(slot.start).format("HH:mm")} - {" "} - {t("and")}{" "} - - {dayjs(slot.end).format("HH:mm")} - {" "} - {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 && {` - (source: ${slot.source})`}} -
-
- )); - return ( -
-
{t("calendar_no_busy_slots")}
-
- ); - })()} - -
-
- {t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)} -
-
-
-
-
- ); -}; - -export default function Troubleshoot() { - const { data, isLoading } = trpc.viewer.me.useQuery(); +function TroubleshooterPage() { const { t } = useLocale(); return ( -
- - {!isLoading && data && } - -
+ <> + + + ); } -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; diff --git a/apps/web/playwright/availability.e2e.ts b/apps/web/playwright/availability.e2e.ts index d065e3dbb5..bd07557603 100644 --- a/apps/web/playwright/availability.e2e.ts +++ b/apps/web/playwright/availability.e2e.ts @@ -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); }); }); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 69906ee67e..c3a505de55 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 we’re 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", diff --git a/packages/core/getCalendarsEvents.ts b/packages/core/getCalendarsEvents.ts index 8ee9d44fb0..f8700ddcf9 100644 --- a/packages/core/getCalendarsEvents.ts +++ b/packages/core/getCalendarsEvents.ts @@ -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"); diff --git a/packages/features/calendars/weeklyview/components/event/Event.tsx b/packages/features/calendars/weeklyview/components/event/Event.tsx index c997e9a16d..8cb854ae09 100644 --- a/packages/features/calendars/weeklyview/components/event/Event.tsx +++ b/packages/features/calendars/weeklyview/components/event/Event.tsx @@ -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 ( - onEventClick?.(event)} // Note this is not the button event. It is the calendar event. - className={eventClasses({ - status: options?.status, - disabled, - selected, - borderColor, - })} - style={styles}> -
- {event.title} -
- {eventDuration > 30 && ( -

- {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")} -

- )} -
+ + 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}> +
+ {event.title} + {eventDuration <= 30 && !event.options?.hideTime && ( +

+ {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")} +

+ )} +
+ {eventDuration > 30 && !event.options?.hideTime && ( +

+ {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")} +

+ )} + {eventDuration > 45 && event.description && ( +

{event.description}

+ )} +
+
); } diff --git a/packages/features/calendars/weeklyview/components/event/EventList.tsx b/packages/features/calendars/weeklyview/components/event/EventList.tsx index 57cbb47e8c..ed6af0e763 100644 --- a/packages/features/calendars/weeklyview/components/event/EventList.tsx +++ b/packages/features/calendars/weeklyview/components/event/EventList.tsx @@ -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) {
{ 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); }; diff --git a/packages/features/calendars/weeklyview/state/store.ts b/packages/features/calendars/weeklyview/state/store.ts index 9424eec9b8..af1b52358d 100644 --- a/packages/features/calendars/weeklyview/state/store.ts +++ b/packages/features/calendars/weeklyview/state/store.ts @@ -32,9 +32,7 @@ export const useCalendarStore = create((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 diff --git a/packages/features/calendars/weeklyview/types/events.ts b/packages/features/calendars/weeklyview/types/events.ts index bade4cf0c4..493750ea8f 100644 --- a/packages/features/calendars/weeklyview/types/events.ts +++ b/packages/features/calendars/weeklyview/types/events.ts @@ -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; }; } diff --git a/packages/features/troubleshooter/Troubleshooter.tsx b/packages/features/troubleshooter/Troubleshooter.tsx new file mode 100644 index 0000000000..ad9457fdb1 --- /dev/null +++ b/packages/features/troubleshooter/Troubleshooter.tsx @@ -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 ( + <> +
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+ + ); +}; + +export const Troubleshooter = ({ month }: TroubleshooterProps) => { + return ; +}; diff --git a/packages/features/troubleshooter/components/AvailabilitySchedulesContainer.tsx b/packages/features/troubleshooter/components/AvailabilitySchedulesContainer.tsx new file mode 100644 index 0000000000..ee3196b731 --- /dev/null +++ b/packages/features/troubleshooter/components/AvailabilitySchedulesContainer.tsx @@ -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 ( + + + Connected + +
+ }> +
+

{t("date_overrides")}

+ +
+ + ); +} + +export function AvailabiltySchedulesContainer() { + const { t } = useLocale(); + return ( +
+

{t("availabilty_schedules")}

+ + +
+ ); +} diff --git a/packages/features/troubleshooter/components/CalendarToggleContainer.tsx b/packages/features/troubleshooter/components/CalendarToggleContainer.tsx new file mode 100644 index 0000000000..4fceb7bf81 --- /dev/null +++ b/packages/features/troubleshooter/components/CalendarToggleContainer.tsx @@ -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 ( + +
+ + } + suffixSlot={ +
+ + {badgeText} + +
+ }> +
+ {props.calendars?.map((calendar) => { + return ; + })} +
+ + ); +} + +function EmptyCalendarToggleItem() { + const { t } = useLocale(); + + return ( + +
+ + } + suffixSlot={ +
+ + Not found + +
+ }> +
+ +
+ + ); +} + +export function CalendarToggleContainer() { + const { t } = useLocale(); + const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(); + + const hasConnectedCalendars = data && data?.connectedCalendars.length > 0; + + return ( +
+

{t("calendars_were_checking_for_conflicts")}

+ {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 ( + { + return { + active: item.isSelected, + name: item.name, + }; + })} + /> + ); + })} + + + ) : ( + + )} +
+ ); +} diff --git a/packages/features/troubleshooter/components/ConnectedAppsContainer.tsx b/packages/features/troubleshooter/components/ConnectedAppsContainer.tsx new file mode 100644 index 0000000000..d25c70ada1 --- /dev/null +++ b/packages/features/troubleshooter/components/ConnectedAppsContainer.tsx @@ -0,0 +1,38 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Badge } from "@calcom/ui"; + +import { TroubleshooterListItemHeader } from "./TroubleshooterListItemContainer"; + +function ConnectedAppsItem() { + return ( + +
+ + } + suffixSlot={ +
+ + Connected + +
+ } + /> + ); +} + +export function ConnectedAppsContainer() { + const { t } = useLocale(); + return ( +
+

{t("other_apps")}

+
+ + +
+
+ ); +} diff --git a/packages/features/troubleshooter/components/EventScheduleItem.tsx b/packages/features/troubleshooter/components/EventScheduleItem.tsx new file mode 100644 index 0000000000..5cd47b2ccb --- /dev/null +++ b/packages/features/troubleshooter/components/EventScheduleItem.tsx @@ -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 ( +
+ + } + title={schedule?.name ?? "Loading"} + suffixSlot={ + schedule && ( + + + {t("edit")} + + + ) + } + /> +
+ ); +} diff --git a/packages/features/troubleshooter/components/EventTypeSelect.tsx b/packages/features/troubleshooter/components/EventTypeSelect.tsx new file mode 100644 index 0000000000..dac68067b7 --- /dev/null +++ b/packages/features/troubleshooter/components/EventTypeSelect.tsx @@ -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 ( + option.value === selectedEventType?.slug) || options[0]} + onChange={(option) => { + if (!option) return; + setSelectedEventType({ + slug: option.value, + id: option.id, + duration: option.duration, + }); + }} + /> + ); +} diff --git a/packages/features/troubleshooter/components/LargeCalendar.tsx b/packages/features/troubleshooter/components/LargeCalendar.tsx new file mode 100644 index 0000000000..7884e38e18 --- /dev/null +++ b/packages/features/troubleshooter/components/LargeCalendar.tsx @@ -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 ( +
+ +
+ ); +}; diff --git a/packages/features/troubleshooter/components/TroubleshooterHeader.tsx b/packages/features/troubleshooter/components/TroubleshooterHeader.tsx new file mode 100644 index 0000000000..90e6318184 --- /dev/null +++ b/packages/features/troubleshooter/components/TroubleshooterHeader.tsx @@ -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 ( +

+ {formattedMonth.format(selectedDate.toDate())} {selectedDate.format("D")} + {!isSameYear() && , {selectedDate.format("YYYY")} }-{" "} + {!isSameMonth() && formattedMonth.format(endDate.toDate())} {endDate.format("D")},{" "} + + {isSameYear() ? selectedDate.format("YYYY") : endDate.format("YYYY")} + +

+ ); + }; + + return ( +
+
+ + + + )} + +
+
+ ); +} diff --git a/packages/features/troubleshooter/components/TroubleshooterListItemContainer.tsx b/packages/features/troubleshooter/components/TroubleshooterListItemContainer.tsx new file mode 100644 index 0000000000..0d5bb956a3 --- /dev/null +++ b/packages/features/troubleshooter/components/TroubleshooterListItemContainer.tsx @@ -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 ( +
+ {prefixSlot} +
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {suffixSlot} +
+ ); +} + +export function TroubleshooterListItemContainer({ + children, + ...rest +}: PropsWithChildren) { + return ( +
+ +
{children}
+
+ ); +} diff --git a/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx b/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx new file mode 100644 index 0000000000..101656db27 --- /dev/null +++ b/packages/features/troubleshooter/components/TroubleshooterSidebar.tsx @@ -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 ( + + + + {name} + + + ); +}; + +export const TroubleshooterSidebar = () => { + const { t } = useLocale(); + + return ( +
+ + + + +
+ ); +}; diff --git a/packages/features/troubleshooter/layout.tsx b/packages/features/troubleshooter/layout.tsx new file mode 100644 index 0000000000..fdcc968cd4 --- /dev/null +++ b/packages/features/troubleshooter/layout.tsx @@ -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) { + return ( + }> +
+ + }>{children} + +
+
+ ); +} + +export const getLayout = (page: React.ReactElement) => {page}; diff --git a/packages/features/troubleshooter/store.ts b/packages/features/troubleshooter/store.ts new file mode 100644 index 0000000000..4d5daa2601 --- /dev/null +++ b/packages/features/troubleshooter/store.ts @@ -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; + 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((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]); +}; diff --git a/packages/features/troubleshooter/types.ts b/packages/features/troubleshooter/types.ts new file mode 100644 index 0000000000..a1029e077b --- /dev/null +++ b/packages/features/troubleshooter/types.ts @@ -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; +} diff --git a/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx b/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx index b886f81a64..f6e6fde73b 100644 --- a/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx +++ b/packages/trpc/server/routers/viewer/availability/schedule/_router.tsx @@ -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, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.handler.ts b/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.handler.ts new file mode 100644 index 0000000000..50457eff3a --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.handler.ts @@ -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; + 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, + }; + } +}; diff --git a/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.schema.ts b/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.schema.ts new file mode 100644 index 0000000000..b65f1b5e37 --- /dev/null +++ b/packages/trpc/server/routers/viewer/availability/schedule/getScheduleByEventTypeSlug.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetByEventSlugInputSchema = z.object({ + eventSlug: z.string(), +}); + +export type TGetByEventSlugInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/availability/user.handler.ts b/packages/trpc/server/routers/viewer/availability/user.handler.ts index d45c2d85c0..decfe5ca66 100644 --- a/packages/trpc/server/routers/viewer/availability/user.handler.ts +++ b/packages/trpc/server/routers/viewer/availability/user.handler.ts @@ -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; + ctx: { + user: NonNullable; + }; input: TUserInputSchema; }; export const userHandler = async ({ input }: UserOptions) => { - return getUserAvailability(input); + return getUserAvailability(input, undefined); };