Skip to content
This repository has been archived by the owner on Jan 23, 2024. It is now read-only.

Commit

Permalink
feat: troubleshooter with weekly view (V2) (calcom#12280)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
sean-brydon authored Nov 20, 2023
1 parent 9a4c20c commit bdd3b13
Show file tree
Hide file tree
Showing 26 changed files with 1,037 additions and 188 deletions.
141 changes: 11 additions & 130 deletions apps/web/pages/availability/troubleshoot.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => {
function TroubleshooterPage() {
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();
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;
1 change: 1 addition & 0 deletions apps/web/playwright/availability.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Expand Down
6 changes: 6 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/core/getCalendarsEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
Expand Down
58 changes: 39 additions & 19 deletions packages/features/calendars/weeklyview/components/event/Event.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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: {
Expand Down Expand Up @@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useRef } from "react";
import { shallow } from "zustand/shallow";

import dayjs from "@calcom/dayjs";
Expand All @@ -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
Expand All @@ -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";
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";
// 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;
}
}
} 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;
}
}
}
}
Expand All @@ -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,
Expand Down
Loading

0 comments on commit bdd3b13

Please sign in to comment.