diff --git a/apps/storybook/.storybook/main.js b/apps/storybook/.storybook/main.js index d2d013bcb7..02ebe324b2 100644 --- a/apps/storybook/.storybook/main.js +++ b/apps/storybook/.storybook/main.js @@ -4,6 +4,7 @@ module.exports = { stories: [ "../intro.stories.mdx", "../../../packages/ui/components/**/*.stories.mdx", + "../../../packages/features/**/*.stories.mdx", "../../../packages/ui/components/**/*.stories.@(js|jsx|ts|tsx)", ], addons: [ @@ -50,6 +51,21 @@ module.exports = { vm: false, zlib: false, }; + + config.module.rules.push({ + test: /\.css$/, + use: [ + "style-loader", + { + loader: "css-loader", + options: { + modules: true, // Enable modules to help you using className + }, + }, + ], + include: path.resolve(__dirname, "../src"), + }); + return config; }, }; diff --git a/apps/storybook/.storybook/preview.jsx b/apps/storybook/.storybook/preview.jsx index ecc3cf53a2..5d8d9bdd0b 100644 --- a/apps/storybook/.storybook/preview.jsx +++ b/apps/storybook/.storybook/preview.jsx @@ -15,7 +15,11 @@ export const parameters = { }, }; -addDecorator((storyFn) => {storyFn()}); +addDecorator((storyFn) => ( + +
{storyFn()}
+
+)); window.getEmbedNamespace = () => { const url = new URL(document.URL); diff --git a/apps/storybook/styles/globals.css b/apps/storybook/styles/globals.css index 9cf51c189d..e375a7ba75 100644 --- a/apps/storybook/styles/globals.css +++ b/apps/storybook/styles/globals.css @@ -2,4 +2,4 @@ @tailwind components; @tailwind utilities; -@import "../../../packages/ui/styles/shared-globals.css" \ No newline at end of file +@import "../../../packages/ui/styles/shared-globals.css" diff --git a/apps/storybook/styles/storybook-styles.css b/apps/storybook/styles/storybook-styles.css index 896dfa6379..cddd25836c 100644 --- a/apps/storybook/styles/storybook-styles.css +++ b/apps/storybook/styles/storybook-styles.css @@ -1,3 +1,5 @@ +@import url("../../../packages/features/calendars/weeklyview/styles/styles.css"); + .sbdocs { font-family: 'Inter var' !important; padding: 0!important; @@ -36,7 +38,7 @@ /** Docs table **/ .custom-args-wrapper{ - max-height: 600px; + max-height: 400px; overflow-y: scroll; overflow-x: hidden; margin-bottom: 1rem; diff --git a/packages/dayjs/index.ts b/packages/dayjs/index.ts index 0f576d4103..77907ffa61 100644 --- a/packages/dayjs/index.ts +++ b/packages/dayjs/index.ts @@ -30,6 +30,7 @@ import customParseFormat from "dayjs/plugin/customParseFormat"; import isBetween from "dayjs/plugin/isBetween"; import isToday from "dayjs/plugin/isToday"; import localizedFormat from "dayjs/plugin/localizedFormat"; +import minmax from "dayjs/plugin/minMax"; import relativeTime from "dayjs/plugin/relativeTime"; import timeZone from "dayjs/plugin/timezone"; import toArray from "dayjs/plugin/toArray"; @@ -44,6 +45,7 @@ dayjs.extend(relativeTime); dayjs.extend(timeZone); dayjs.extend(toArray); dayjs.extend(utc); +dayjs.extend(minmax); export type Dayjs = dayjs.Dayjs; diff --git a/packages/features/calendars/weeklyview/_storybookData.ts b/packages/features/calendars/weeklyview/_storybookData.ts new file mode 100644 index 0000000000..9ef919c32a --- /dev/null +++ b/packages/features/calendars/weeklyview/_storybookData.ts @@ -0,0 +1,79 @@ +import dayjs from "@calcom/dayjs"; +import { TimeRange } from "@calcom/types/schedule"; + +import { CalendarEvent } from "./types/events"; + +const startDate = dayjs().set("hour", 11).set("minute", 0); + +export const events: CalendarEvent[] = [ + { + id: 1, + title: "Event 1", + start: startDate.add(10, "minutes").toDate(), + end: startDate.add(45, "minutes").toDate(), + allDay: false, + source: "Booking", + status: "ACCEPTED", + }, + { + id: 2, + title: "Event 2", + start: startDate.add(1, "day").toDate(), + end: startDate.add(1, "day").add(30, "minutes").toDate(), + allDay: false, + source: "Booking", + status: "ACCEPTED", + }, + { + id: 2, + title: "Event 3", + start: startDate.add(2, "day").toDate(), + end: startDate.add(2, "day").add(60, "minutes").toDate(), + allDay: false, + source: "Booking", + status: "ACCEPTED", + }, + { + id: 3, + title: "Event 4", + start: startDate.add(3, "day").toDate(), + end: startDate.add(3, "day").add(2, "hour").add(30, "minutes").toDate(), + allDay: false, + source: "Booking", + status: "ACCEPTED", + }, + { + id: 5, + title: "Event 4 Overlap", + start: startDate.add(3, "day").add(30, "minutes").toDate(), + end: startDate.add(3, "day").add(2, "hour").add(45, "minutes").toDate(), + allDay: false, + source: "Booking", + status: "ACCEPTED", + }, + { + id: 4, + title: "Event 1 Overlap", + start: startDate.toDate(), + end: startDate.add(30, "minutes").toDate(), + allDay: false, + source: "Booking", + status: "ACCEPTED", + }, + { + id: 6, + title: "Event 1 Overlap Two", + start: startDate.toDate(), + end: startDate.add(30, "minutes").toDate(), + allDay: false, + source: "Booking", + status: "ACCEPTED", + }, +]; + +export const blockingDates: TimeRange[] = [ + { + start: startDate.add(1, "day").hour(10).toDate(), + end: startDate.add(1, "day").hour(13).toDate(), + }, +]; diff --git a/packages/features/calendars/weeklyview/components/Calendar.tsx b/packages/features/calendars/weeklyview/components/Calendar.tsx new file mode 100644 index 0000000000..22fe626942 --- /dev/null +++ b/packages/features/calendars/weeklyview/components/Calendar.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useMemo, useRef } from "react"; + +import { useCalendarStore } from "../state/store"; +import "../styles/styles.css"; +import { CalendarComponentProps } from "../types/state"; +import { getDaysBetweenDates, getHoursToDisplay } from "../utils"; +import { DateValues } from "./DateValues"; +import { BlockedList } from "./blocking/BlockedList"; +import { EmptyCell } from "./event/Empty"; +import { EventList } from "./event/EventList"; +import { SchedulerColumns } from "./grid"; +import { SchedulerHeading } from "./heading/SchedulerHeading"; +import { HorizontalLines } from "./horizontalLines"; +import { VeritcalLines } from "./verticalLines"; + +export function Calendar(props: CalendarComponentProps) { + const container = useRef(null); + const containerNav = useRef(null); + const containerOffset = useRef(null); + const initalState = useCalendarStore((state) => state.initState); + + const startDate = useCalendarStore((state) => state.startDate); + const endDate = useCalendarStore((state) => state.endDate); + const startHour = useCalendarStore((state) => state.startHour || 0); + const endHour = useCalendarStore((state) => state.endHour || 23); + const usersCellsStopsPerHour = useCalendarStore((state) => state.gridCellsPerHour || 4); + + const days = useMemo(() => getDaysBetweenDates(startDate, endDate), [startDate, endDate]); + + const hours = useMemo(() => getHoursToDisplay(startHour || 0, endHour || 23), [startHour, endHour]); + + const numberOfGridStopsPerDay = hours.length * usersCellsStopsPerHour; + + // Initalise State on inital mount + useEffect(() => { + initalState(props); + }, [props, initalState]); + + return ( + +
+ +
+
+ + {/* TODO: Implement this at a later date. */} + {/* */} +
+
+
+ + + + {/* Empty Cells */} + + <> + {[...Array(days.length)].map((_, i) => ( +
  • + {/* While startDate < endDate: */} + {[...Array(numberOfGridStopsPerDay)].map((_, j) => { + const key = `${i}-${j}`; + return ( + + ); + })} +
  • + ))} + +
    + + + {/*Loop over events per day */} + {days.map((day, i) => { + return ( +
  • + + +
  • + ); + })} +
    +
    +
    +
    +
    +
    + + ); +} + +/** @todo Will be removed once we have mobile support */ +const MobileNotSupported = ({ children }: { children: React.ReactNode }) => { + return ( + <> +
    +

    Mobile not supported yet

    +

    Please use a desktop browser to view this page

    +
    +
    {children}
    + + ); +}; diff --git a/packages/features/calendars/weeklyview/components/DateValues/index.tsx b/packages/features/calendars/weeklyview/components/DateValues/index.tsx new file mode 100644 index 0000000000..4c7e5b0990 --- /dev/null +++ b/packages/features/calendars/weeklyview/components/DateValues/index.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +import dayjs from "@calcom/dayjs"; +import { classNames } from "@calcom/lib"; + +type Props = { + days: dayjs.Dayjs[]; + containerNavRef: React.RefObject; +}; + +export function DateValues({ days, containerNavRef }: Props) { + return ( +
    +
    + {days.map((day) => { + const isToday = dayjs().isSame(day, "day"); + return ( + + ); + })} +
    +
    +
    + {days.map((day) => { + const isToday = dayjs().isSame(day, "day"); + return ( +
    + + {day.format("ddd")}{" "} + + {day.format("DD")} + + +
    + ); + })} +
    +
    + ); +} diff --git a/packages/features/calendars/weeklyview/components/blocking/BlockedList.tsx b/packages/features/calendars/weeklyview/components/blocking/BlockedList.tsx new file mode 100644 index 0000000000..b14cb571f3 --- /dev/null +++ b/packages/features/calendars/weeklyview/components/blocking/BlockedList.tsx @@ -0,0 +1,129 @@ +import { useMemo } from "react"; +import shallow from "zustand/shallow"; + +import dayjs from "@calcom/dayjs"; + +import { useCalendarStore } from "../../state/store"; +import { BlockedTimeCell } from "./BlockedTimeCell"; + +type Props = { + day: dayjs.Dayjs; + containerRef: React.RefObject; +}; + +function roundX(x: number, roundBy: number) { + return Math.round(x / roundBy) * roundBy; +} + +type BlockedDayProps = { + startHour: number; + endHour: number; + day: dayjs.Dayjs; +}; + +function BlockedBeforeToday({ day, startHour, endHour }: BlockedDayProps) { + return ( + <> + {day.isBefore(dayjs(), "day") && ( +
    + +
    + )} + + ); +} +function BlockedToday({ + day, + startHour, + gridCellsPerHour, + endHour, +}: BlockedDayProps & { gridCellsPerHour: number }) { + const dayStart = useMemo(() => day.startOf("day").hour(startHour), [day, startHour]); + const dayEnd = useMemo(() => day.startOf("day").hour(endHour), [day, endHour]); + const dayEndInMinutes = useMemo(() => dayEnd.diff(dayStart, "minutes"), [dayEnd, dayStart]); + let nowComparedToDayStart = useMemo(() => dayjs().diff(dayStart, "minutes"), [dayStart]); + + if (nowComparedToDayStart > dayEndInMinutes) nowComparedToDayStart = dayEndInMinutes; + + return ( + <> + {day.isToday() && ( +
    + +
    + )} + + ); +} + +export function BlockedList({ day }: Props) { + const { startHour, blockingDates, endHour, gridCellsPerHour } = useCalendarStore( + (state) => ({ + startHour: state.startHour || 0, + endHour: state.endHour || 23, + blockingDates: state.blockingDates, + gridCellsPerHour: state.gridCellsPerHour || 4, + }), + shallow + ); + + return ( + <> + + + {blockingDates && + blockingDates.map((event, i) => { + const dayStart = day.startOf("day").hour(startHour); + const blockingStart = dayjs(event.start); + const eventEnd = dayjs(event.end); + + const eventStart = dayStart.isAfter(blockingStart) ? dayStart : blockingStart; + + if (!eventStart.isSame(day, "day")) { + return null; + } + + if (eventStart.isBefore(dayjs())) { + if (eventEnd.isBefore(dayjs())) { + return null; + } + } + + const eventDuration = eventEnd.diff(eventStart, "minutes"); + + const eventStartHour = eventStart.hour(); + const eventStartDiff = (eventStartHour - (startHour || 0)) * 60; + + return ( +
    + +
    + ); + })} + + ); +} diff --git a/packages/features/calendars/weeklyview/components/blocking/BlockedTimeCell.tsx b/packages/features/calendars/weeklyview/components/blocking/BlockedTimeCell.tsx new file mode 100644 index 0000000000..62bd3b903b --- /dev/null +++ b/packages/features/calendars/weeklyview/components/blocking/BlockedTimeCell.tsx @@ -0,0 +1,15 @@ +import { classNames } from "@calcom/lib"; + +export function BlockedTimeCell() { + return ( +
    + ); +} diff --git a/packages/features/calendars/weeklyview/components/calendar.stories.mdx b/packages/features/calendars/weeklyview/components/calendar.stories.mdx new file mode 100644 index 0000000000..2ba17e64fd --- /dev/null +++ b/packages/features/calendars/weeklyview/components/calendar.stories.mdx @@ -0,0 +1,69 @@ +import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs"; + +import { + Examples, + Example, + Note, + Title, + CustomArgsTable, + VariantsTable, + VariantRow, +} from "@calcom/storybook/components"; + +import { events, blockingDates } from "../_storybookData"; +import "../styles/styles.css"; +import { CalendarEvent } from "../types/events"; +import { Calendar } from "./Calendar"; + + + + + +## Props + +The Args Table below shows you a breakdown of what props can be passed into the Calendar component. All props should have a desciption to make it self explanitory to see what is going on. + +<CustomArgsTable of={Calendar} /> + +## Example + +There will be a few examples of how to use the Calendar component to show different usecases. + +export const Template = (args) => <Calendar {...args} />; + +<Canvas> + <Story + name="Customising Start Hour and EndHour" + argTypes={{ + startHour: { + control: { type: "number", min: 0, max: 23, step: 1 }, + }, + endHour: { + control: { type: "number", min: 0, max: 23, step: 1 }, + }, + }} + args={{ + sortEvents: true, + startHour: 8, + endHour: 20, + events: events, + hoverEventDuration: 0, + blockingDates: blockingDates, + }}> + {Template.bind({})} + </Story> +</Canvas> + +<Canvas> + <Story name="Onclick Handlers"> + <Calendar + startHour={8} + endHour={17} + events={events} + onEventClick={(e) => alert(e.title)} + onEmptyCellClick={(date) => alert(date.toString())} + sortEvents + hoverEventDuration={30} + /> + </Story> +</Canvas> diff --git a/packages/features/calendars/weeklyview/components/currentTime/index.tsx b/packages/features/calendars/weeklyview/components/currentTime/index.tsx new file mode 100644 index 0000000000..962efe299c --- /dev/null +++ b/packages/features/calendars/weeklyview/components/currentTime/index.tsx @@ -0,0 +1,43 @@ +import React, { useEffect, useState } from "react"; + +import dayjs from "@calcom/dayjs"; + +import { useCalendarStore } from "../../state/store"; + +type Props = { + containerNavRef: React.RefObject<HTMLDivElement>; + containerRef: React.RefObject<HTMLDivElement>; + containerOffsetRef: React.RefObject<HTMLDivElement>; +}; + +export function CurrentTime({ containerOffsetRef }: Props) { + const [currentTimePos, setCurrentTimePos] = useState<number>(0); + const { startHour, endHour } = useCalendarStore((state) => ({ + startHour: state.startHour || 0, + endHour: state.endHour || 23, + })); + + useEffect(() => { + // Set the container scroll position based on the current time. + let currentMinute = new Date().getHours() * 60; + currentMinute = currentMinute + new Date().getMinutes(); + + if (containerOffsetRef.current) { + const totalHours = endHour - startHour; + const currentTimePos = currentMinute; + setCurrentTimePos(currentTimePos); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [startHour, endHour]); + + return ( + <div + className="absolute z-40 ml-3 flex h-1 items-center justify-center border-gray-800" + aria-hidden="true" + style={{ top: `calc(${currentTimePos}*var(--one-minute-height))`, zIndex: 100 }}> + {dayjs().format("HH:mm")} + <div className="ml-1 h-3 w-1 bg-gray-800" /> + <div className="h-1 w-full bg-gray-800" /> + </div> + ); +} diff --git a/packages/features/calendars/weeklyview/components/event/Empty.tsx b/packages/features/calendars/weeklyview/components/event/Empty.tsx new file mode 100644 index 0000000000..bc101293da --- /dev/null +++ b/packages/features/calendars/weeklyview/components/event/Empty.tsx @@ -0,0 +1,43 @@ +import shallow from "zustand/shallow"; + +import { useCalendarStore } from "../../state/store"; +import { gridCellToDateTime, GridCellToDateProps } from "../../utils"; + +export function EmptyCell(props: GridCellToDateProps) { + const { onEmptyCellClick, hoverEventDuration } = useCalendarStore( + (state) => ({ + onEmptyCellClick: state.onEmptyCellClick, + hoverEventDuration: state.hoverEventDuration, + }), + shallow + ); + + const cellToDate = gridCellToDateTime({ + day: props.day, + gridCellIdx: props.gridCellIdx, + totalGridCells: props.totalGridCells, + selectionLength: props.selectionLength, + startHour: props.startHour, + }); + + return ( + <div + className="group w-full" + style={{ height: "1.75rem", overflow: "visible" }} + onClick={() => onEmptyCellClick && onEmptyCellClick(cellToDate.toDate())}> + {hoverEventDuration !== 0 && ( + <div + className="opacity-4 absolute inset-x-1 hidden rounded-[4px] border-[1px] border-gray-900 bg-gray-100 + py-1 + px-[6px] text-xs font-semibold leading-5 text-gray-900 hover:bg-gray-200 group-hover:block group-hover:cursor-pointer" + style={{ + height: `calc(${hoverEventDuration}*var(--one-minute-height))`, + zIndex: 49, + width: "90%", + }}> + <div className="overflow-ellipsis leading-4">{cellToDate.format("HH:mm")}</div> + </div> + )} + </div> + ); +} diff --git a/packages/features/calendars/weeklyview/components/event/Event.tsx b/packages/features/calendars/weeklyview/components/event/Event.tsx new file mode 100644 index 0000000000..b4f3f22cea --- /dev/null +++ b/packages/features/calendars/weeklyview/components/event/Event.tsx @@ -0,0 +1,49 @@ +import dayjs from "@calcom/dayjs"; +import { classNames } from "@calcom/lib"; + +import { CalendarEvent } from "../../types/events"; + +type EventProps = { + event: CalendarEvent; + currentlySelectedEventId?: number; + eventDuration: number; + onEventClick?: (event: CalendarEvent) => void; + disabled?: boolean; +}; + +export function Event({ + event, + currentlySelectedEventId, + eventDuration, + disabled, + onEventClick, +}: EventProps) { + const selected = currentlySelectedEventId === event.id; + + const Component = onEventClick ? "button" : "div"; + + return ( + <Component + onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event. + className={classNames( + "group flex h-full w-full flex-col overflow-y-auto rounded-[4px] py-1 px-[6px] text-xs font-semibold leading-5 ", + event.status === "ACCEPTED" && + !selected && + "border-[1px] border-gray-900 bg-gray-100 text-gray-900 hover:bg-gray-200", + event.status === "PENDING" && + !selected && + "border-[1px] border-dashed border-gray-900 bg-white text-gray-900", + selected && "border-[1px] border-transparent bg-gray-900 text-white", + disabled ? "hover:cursor-default" : "hover:cursor-pointer" + )}> + <div className="w-full overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4"> + {event.title} + </div> + {eventDuration >= 30 && ( + <p className="text-left text-[10px] leading-none text-gray-500"> + {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")} + </p> + )} + </Component> + ); +} diff --git a/packages/features/calendars/weeklyview/components/event/EventList.tsx b/packages/features/calendars/weeklyview/components/event/EventList.tsx new file mode 100644 index 0000000000..e08fd8f0dd --- /dev/null +++ b/packages/features/calendars/weeklyview/components/event/EventList.tsx @@ -0,0 +1,107 @@ +import shallow from "zustand/shallow"; + +import dayjs from "@calcom/dayjs"; + +import { useCalendarStore } from "../../state/store"; +import { Event } from "./Event"; + +type Props = { + day: dayjs.Dayjs; +}; + +export function EventList({ day }: Props) { + const { startHour, events, eventOnClick } = useCalendarStore( + (state) => ({ + startHour: state.startHour, + events: state.events, + eventOnClick: state.onEventClick, + }), + shallow + ); + + return ( + <> + {events + .filter((event) => { + return dayjs(event.start).isSame(day, "day") && !event.allDay; // Filter all events that are not allDay and that are on the current day + }) + .map((event, idx, eventsArray) => { + let width = 90; + let marginLeft: string | number = 0; + let right = 0; + let zIndex = 61; + + const eventStart = dayjs(event.start); + const eventEnd = dayjs(event.end); + + const eventDuration = eventEnd.diff(eventStart, "minutes"); + + const eventStartHour = eventStart.hour(); + const eventStartDiff = (eventStartHour - (startHour || 0)) * 60 + eventStart.minute(); + 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) { + 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; + width = width / 2; + } + } + + if (nextEventStart.isSame(eventStart)) { + zIndex = 66; + + 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) { + zIndex = 65; + marginLeft = "auto"; + right = 8; + width = width / 2; + if (eventDuration >= 30) { + width = 80; + } + } + } + } + + return ( + <div + key={`${event.id}-${eventStart.toISOString()}`} + className="absolute inset-x-1 " + style={{ + marginLeft, + zIndex, + right: `calc(${right}% - 1px)`, + width: `${width}%`, + top: `calc(${eventStartDiff}*var(--one-minute-height))`, + height: `calc(${eventDuration}*var(--one-minute-height))`, + }}> + <Event event={event} eventDuration={eventDuration} onEventClick={eventOnClick} /> + </div> + ); + })} + </> + ); +} diff --git a/packages/features/calendars/weeklyview/components/event/index.tsx b/packages/features/calendars/weeklyview/components/event/index.tsx new file mode 100644 index 0000000000..dbbeacce51 --- /dev/null +++ b/packages/features/calendars/weeklyview/components/event/index.tsx @@ -0,0 +1 @@ +export { Event } from "./Event"; diff --git a/packages/features/calendars/weeklyview/components/grid/index.tsx b/packages/features/calendars/weeklyview/components/grid/index.tsx new file mode 100644 index 0000000000..9130015be4 --- /dev/null +++ b/packages/features/calendars/weeklyview/components/grid/index.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +type Props = { + offsetHeight: number | undefined; + gridStopsPerDay: number; + children: React.ReactNode; + zIndex?: number; +}; + +export function SchedulerColumns({ offsetHeight, gridStopsPerDay, children, zIndex }: Props) { + return ( + <ol + className="scheduler-grid-row-template col-start-1 col-end-2 row-start-1 grid auto-cols-auto sm:pr-8" + style={{ marginTop: offsetHeight || "var(--gridDefaultSize)", zIndex }} + data-gridstopsperday={gridStopsPerDay}> + {children} + </ol> + ); +} diff --git a/packages/features/calendars/weeklyview/components/heading/SchedulerHeading.tsx b/packages/features/calendars/weeklyview/components/heading/SchedulerHeading.tsx new file mode 100644 index 0000000000..295182374e --- /dev/null +++ b/packages/features/calendars/weeklyview/components/heading/SchedulerHeading.tsx @@ -0,0 +1,53 @@ +import dayjs from "@calcom/dayjs"; +import { Icon, Button, ButtonGroup } from "@calcom/ui"; + +import { useCalendarStore } from "../../state/store"; + +export function SchedulerHeading() { + const { startDate, endDate, handleDateChange } = useCalendarStore((state) => ({ + startDate: dayjs(state.startDate), + endDate: dayjs(state.endDate), + handleDateChange: state.handleDateChange, + })); + + return ( + <header className="flex flex-none flex-col justify-between py-4 sm:flex-row sm:items-center"> + <h1 className="text-xl font-semibold text-gray-900"> + {startDate.format("MMM DD")}-{endDate.format("DD")} + <span className="text-gray-500">,{startDate.format("YYYY")}</span> + </h1> + <div className="flex items-center space-x-2"> + {/* TODO: Renable when we have daily/mobile support */} + {/* <ToggleGroup + options={[ + { label: "Daily", value: "day", disabled: false }, + { label: "Weekly", value: "week", disabled: isSm }, + ]} + defaultValue={view === "day" ? "day" : "week"} + /> */} + + <ButtonGroup combined> + {/* TODO: i18n label with correct view */} + <Button + StartIcon={Icon.FiChevronLeft} + size="icon" + color="secondary" + aria-label="Previous Week" + onClick={() => { + handleDateChange("DECREMENT"); + }} + /> + <Button + StartIcon={Icon.FiChevronRight} + size="icon" + color="secondary" + aria-label="Next Week" + onClick={() => { + handleDateChange("INCREMENT"); + }} + /> + </ButtonGroup> + </div> + </header> + ); +} diff --git a/packages/features/calendars/weeklyview/components/heading/index.tsx b/packages/features/calendars/weeklyview/components/heading/index.tsx new file mode 100644 index 0000000000..61b6ca0e24 --- /dev/null +++ b/packages/features/calendars/weeklyview/components/heading/index.tsx @@ -0,0 +1 @@ +export { SchedulerHeading } from "./SchedulerHeading"; diff --git a/packages/features/calendars/weeklyview/components/horizontalLines/index.tsx b/packages/features/calendars/weeklyview/components/horizontalLines/index.tsx new file mode 100644 index 0000000000..5c4dfc5a80 --- /dev/null +++ b/packages/features/calendars/weeklyview/components/horizontalLines/index.tsx @@ -0,0 +1,39 @@ +import { useId } from "react"; + +import dayjs from "@calcom/dayjs"; + +export const HorizontalLines = ({ + hours, + numberOfGridStopsPerCell, + containerOffsetRef, +}: { + hours: dayjs.Dayjs[]; + numberOfGridStopsPerCell: number; + containerOffsetRef: React.RefObject<HTMLDivElement>; +}) => { + const finalHour = hours[hours.length - 1].add(1, "hour").format("h A"); + const id = useId(); + return ( + <div + className=" col-start-1 col-end-2 row-start-1 grid divide-y divide-gray-300" + style={{ + gridTemplateRows: `repeat(${hours.length}, minmax(${1.75 * numberOfGridStopsPerCell}rem,1fr)`, + }}> + <div className="row-end-1 h-7 " ref={containerOffsetRef} /> + {hours.map((hour) => ( + <> + <div key={`${id}-${hour.get("hour")}`}> + <div className="sticky left-0 z-20 -mt-2.5 -ml-14 w-14 pr-2 text-right text-xs leading-5 text-gray-400"> + {hour.format("h A")} + </div> + </div> + </> + ))} + <div key={`${id}-${finalHour}`}> + <div className="sticky left-0 z-20 -mt-2.5 -ml-14 w-14 pr-2 text-right text-xs leading-5 text-gray-400"> + {finalHour} + </div> + </div> + </div> + ); +}; diff --git a/packages/features/calendars/weeklyview/components/index.tsx b/packages/features/calendars/weeklyview/components/index.tsx new file mode 100644 index 0000000000..c61d9908b9 --- /dev/null +++ b/packages/features/calendars/weeklyview/components/index.tsx @@ -0,0 +1 @@ +export { Calendar } from "./Calendar"; diff --git a/packages/features/calendars/weeklyview/components/verticalLines/index.tsx b/packages/features/calendars/weeklyview/components/verticalLines/index.tsx new file mode 100644 index 0000000000..4911959265 --- /dev/null +++ b/packages/features/calendars/weeklyview/components/verticalLines/index.tsx @@ -0,0 +1,19 @@ +import dayjs from "@calcom/dayjs"; + +export const VeritcalLines = ({ days }: { days: dayjs.Dayjs[] }) => { + return ( + <div + className="col-start-1 col-end-2 row-start-1 grid auto-cols-auto grid-rows-1 divide-x divide-gray-300 + sm:pr-8"> + {days.map((_, i) => ( + <div + key={`Key_vertical_${i}`} + className="row-span-full" + style={{ + gridColumnStart: i + 1, + }} + /> + ))} + </div> + ); +}; diff --git a/packages/features/calendars/weeklyview/index.tsx b/packages/features/calendars/weeklyview/index.tsx new file mode 100644 index 0000000000..9148e04fef --- /dev/null +++ b/packages/features/calendars/weeklyview/index.tsx @@ -0,0 +1 @@ +export { Calendar } from "./components/Calendar"; diff --git a/packages/features/calendars/weeklyview/state/store.ts b/packages/features/calendars/weeklyview/state/store.ts new file mode 100644 index 0000000000..09315f6d2f --- /dev/null +++ b/packages/features/calendars/weeklyview/state/store.ts @@ -0,0 +1,81 @@ +import create from "zustand"; + +import dayjs from "@calcom/dayjs"; + +import { + CalendarComponentProps, + CalendarPublicActions, + CalendarState, + CalendarStoreProps, +} from "../types/state"; +import { mergeOverlappingDateRanges, weekdayDates } from "../utils"; + +const defaultState: CalendarComponentProps = { + view: "week", + startDate: weekdayDates(0, new Date()).startDate, + endDate: weekdayDates(0, new Date()).endDate, + events: [], + startHour: 0, + endHour: 23, + gridCellsPerHour: 4, +}; + +export const useCalendarStore = create<CalendarStoreProps>((set) => ({ + ...defaultState, + setView: (view: CalendarComponentProps["view"]) => set({ view }), + setStartDate: (startDate: CalendarComponentProps["startDate"]) => set({ startDate }), + setEndDate: (endDate: CalendarComponentProps["endDate"]) => set({ endDate }), + setEvents: (events: CalendarComponentProps["events"]) => set({ events }), + // This looks a bit odd but init state only overrides the public props + actions as we don't want to override our internal state + initState: (state: CalendarState & CalendarPublicActions) => { + // Handle sorting of events if required + let events = state.events; + + if (state.sortEvents) { + events = state.events.sort( + (a, b) => dayjs(a.start).get("milliseconds") - dayjs(b.start).get("milliseconds") + ); + } + const blockingDates = mergeOverlappingDateRanges(state.blockingDates || []); // We merge overlapping dates so we don't get duplicate blocking "Cells" in the UI + + set({ + ...state, + blockingDates, + events, + }); + }, + setSelectedEvent: (event) => set({ selectedEvent: event }), + handleDateChange: (payload) => + set((state) => { + const { startDate, endDate } = state; + if (payload === "INCREMENT") { + const newStartDate = dayjs(startDate).add(1, state.view).toDate(); + const newEndDate = dayjs(endDate).add(1, state.view).toDate(); + + // Do nothing if + if ( + (state.minDate && newStartDate < state.minDate) || + (state.maxDate && newEndDate > state.maxDate) + ) { + return { + startDate, + endDate, + }; + } + + // We call this callback if we have it -> Allows you to change your state outside of the component + state.onDateChange && state.onDateChange(newStartDate, newEndDate); + return { + startDate: newStartDate, + endDate: newEndDate, + }; + } + const newStartDate = dayjs(startDate).subtract(1, state.view).toDate(); + const newEndDate = dayjs(endDate).subtract(1, state.view).toDate(); + state.onDateChange && state.onDateChange(newStartDate, newEndDate); + return { + startDate: newStartDate, + endDate: newEndDate, + }; + }), +})); diff --git a/packages/features/calendars/weeklyview/styles/styles.css b/packages/features/calendars/weeklyview/styles/styles.css new file mode 100644 index 0000000000..1c4de29ae3 --- /dev/null +++ b/packages/features/calendars/weeklyview/styles/styles.css @@ -0,0 +1,17 @@ +.scheduler-wrapper { + --gridDefaultSize: 1.75rem; + --hoursInDay: 24; + --minuteInHour:60; + --gridMobileSize: 100vw; +} + +.scheduler-grid-row-template{ + grid-template-rows: var(--gridMobileSize) repeat(attr(data-gridstoperperday number), var(--gridMobileSize)) auto; +} + +@media (min-width: 640px) { + .scheduler-grid-row-template { + grid-template-rows: var(--gridDefaultSize) repeat(attr(data-gridstoperperday number), var(--gridDefaultSize)) auto; + } +} + diff --git a/packages/features/calendars/weeklyview/types/events.ts b/packages/features/calendars/weeklyview/types/events.ts new file mode 100644 index 0000000000..4ed3b919c5 --- /dev/null +++ b/packages/features/calendars/weeklyview/types/events.ts @@ -0,0 +1,11 @@ +import { BookingStatus } from "@calcom/prisma/client"; + +export interface CalendarEvent { + id: number; + title: string; + start: Date | string; // You can pass in a string from DB since we use dayjs for the dates. + end: Date; + allDay?: boolean; + source?: string; + status?: BookingStatus; +} diff --git a/packages/features/calendars/weeklyview/types/state.ts b/packages/features/calendars/weeklyview/types/state.ts new file mode 100644 index 0000000000..5384c3c178 --- /dev/null +++ b/packages/features/calendars/weeklyview/types/state.ts @@ -0,0 +1,111 @@ +import { TimeRange } from "@calcom/types/schedule"; + +import { CalendarEvent } from "./events"; + +export type View = "month" | "week" | "day"; +export type Hours = + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15 + | 16 + | 17 + | 18 + | 19 + | 20 + | 21 + | 22 + | 23; + +// These will be on eventHandlers - e.g. do more actions on view change if required +export type CalendarPublicActions = { + onViewChange?: (view: View) => void; + onEventClick?: (event: CalendarEvent) => void; + onEventContextMenu?: (event: CalendarEvent) => void; + onEmptyCellClick?: (date: Date) => void; + onDateChange?: (startDate: Date, endDate?: Date) => void; +}; + +// We have private actions here that we want to be available in state but not as component props. +export type CalendarPrivateActions = { + /** initState is used to init the state from public props -> Doesn't override internal state */ + initState: (state: CalendarState & CalendarPublicActions) => void; + setView: (view: CalendarComponentProps["view"]) => void; + setStartDate: (startDate: CalendarComponentProps["startDate"]) => void; + setEndDate: (endDate: CalendarComponentProps["endDate"]) => void; + setEvents: (events: CalendarComponentProps["events"]) => void; + selectedEvent?: CalendarEvent; + setSelectedEvent: (event: CalendarEvent) => void; + handleDateChange: (payload: "INCREMENT" | "DECREMENT") => void; +}; + +export type CalendarState = { + /** @NotImplemented This in future will change the view to be daily/weekly/monthly DAY/WEEK are supported currently however WEEK is the most adv.*/ + view?: View; + startDate: Date; + /** By default we just dynamically create endDate from the viewType */ + endDate: Date; + /** + * Please enter events already SORTED. This is required to setup tab index correctly. + * @Note Ideally you should pass in a sorted array from the DB however, pass the prop `sortEvents` if this is not possible and we will sort this for you.. + */ + events: CalendarEvent[]; + /** Any time ranges passed in here will display as blocked on the users calendar. Note: Anything < than the current date automatically gets blocked. */ + blockingDates?: TimeRange[]; + /** Loading will only expect events to be loading. */ + loading?: boolean; + /** Disables all actions on Events*/ + eventsDisabled?: boolean; + /** If you don't want the date to be scrollable past a certian date */ + minDate?: Date; + /** If you don't want the date to be scrollable past a certain date */ + maxDate?: Date; + /** + * Defined the time your calendar will start at + * @default 0 + */ + startHour?: Hours; + /** + * Defined the time your calendar will end at + * @default 23 + */ + endHour?: Hours; + /** Toggle the ability to scroll to currentTime */ + scrollToCurrentTime?: boolean; + /** Toggle the ability show the current time on the calendar + * @NotImplemented + */ + showCurrentTimeLine?: boolean; + /** + * This indicates the number of grid stops that are available per hour. 4 -> Grid set to 15 minutes. + * @NotImplemented + * @default 4 + */ + gridCellsPerHour?: number; + /** + * Sets the duration on the hover event. In minutes. + * @Note set to 0 to disable any hover actions. + */ + hoverEventDuration?: number; + /** + * If passed in we will sort the events internally. + * @Note It is recommended to sort the events before passing them into the scheduler - e.g. On DB level. + */ + sortEvents?: boolean; +}; + +export type CalendarComponentProps = CalendarPublicActions & CalendarState; + +export type CalendarStoreProps = CalendarComponentProps & CalendarPrivateActions; diff --git a/packages/features/calendars/weeklyview/utils/index.ts b/packages/features/calendars/weeklyview/utils/index.ts new file mode 100644 index 0000000000..d22732934c --- /dev/null +++ b/packages/features/calendars/weeklyview/utils/index.ts @@ -0,0 +1,92 @@ +import dayjs from "@calcom/dayjs"; +import { TimeRange } from "@calcom/types/schedule"; + +// By default starts on Sunday (Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday) +export function weekdayDates(weekStart = 0, startDate: Date, length = 6) { + const tmpStartDate = startDate; + while (tmpStartDate.getDay() !== weekStart) { + tmpStartDate.setDate(tmpStartDate.getDate() - 1); + } + return { + startDate: tmpStartDate, + endDate: new Date(tmpStartDate.getTime() + length * 24 * 60 * 60 * 1000), + }; +} +export type GridCellToDateProps = { + day: Date; + gridCellIdx: number; + totalGridCells: number; + selectionLength: number; + startHour: number; +}; + +export function gridCellToDateTime({ + day, + gridCellIdx, + totalGridCells, + selectionLength, + startHour, +}: GridCellToDateProps) { + // endHour - startHour = selectionLength + const minutesInSelection = (selectionLength + 1) * 60; + const minutesPerCell = minutesInSelection / totalGridCells; + const minutesIntoSelection = minutesPerCell * gridCellIdx; + + // Add startHour since we use StartOfDay for day props. This could be improved by changing the getDaysBetweenDates function + // To handle the startHour+endHour + const cellDateTime = dayjs(day).add(minutesIntoSelection, "minutes").add(startHour, "hours"); + return cellDateTime; +} + +export function getDaysBetweenDates(dateFrom: Date, dateTo: Date) { + const dates = []; // this is as dayjs date + let startDate = dayjs(dateFrom).utc().hour(0).minute(0).second(0).millisecond(0); + dates.push(startDate); + const endDate = dayjs(dateTo).utc().hour(0).minute(0).second(0).millisecond(0); + while (startDate.isBefore(endDate)) { + dates.push(startDate.add(1, "day")); + startDate = startDate.add(1, "day"); + } + return dates; +} + +export function getHoursToDisplay(startHour: number, endHour: number) { + const dates = []; // this is as dayjs date + let startDate = dayjs("1970-01-01").utc().hour(startHour); + dates.push(startDate); + const endDate = dayjs("1970-01-01").utc().hour(endHour); + while (startDate.isBefore(endDate)) { + dates.push(startDate.add(1, "hour")); + startDate = startDate.add(1, "hour"); + } + return dates; +} + +export function mergeOverlappingDateRanges(dateRanges: TimeRange[]) { + //Sort the date ranges by start date + dateRanges.sort((a, b) => { + return a.start.getTime() - b.start.getTime(); + }); + //Create a new array to hold the merged date ranges + const mergedDateRanges = []; + //Loop through the date ranges + for (let i = 0; i < dateRanges.length; i++) { + //If the merged date ranges array is empty, add the first date range + if (mergedDateRanges.length === 0) { + mergedDateRanges.push(dateRanges[i]); + } else { + //Get the last date range in the merged date ranges array + const lastMergedDateRange = mergedDateRanges[mergedDateRanges.length - 1]; + //Get the current date range + const currentDateRange = dateRanges[i]; + //If the last merged date range overlaps with the current date range, merge them + if (lastMergedDateRange.end.getTime() >= currentDateRange.start.getTime()) { + lastMergedDateRange.end = currentDateRange.end; + } else { + //Otherwise, add the current date range to the merged date ranges array + mergedDateRanges.push(currentDateRange); + } + } + } + return mergedDateRanges; +} diff --git a/packages/features/package.json b/packages/features/package.json index fb464f2f36..1f18f0ca9e 100644 --- a/packages/features/package.json +++ b/packages/features/package.json @@ -8,6 +8,10 @@ "dependencies": { "@lexical/react": "^0.5.0", "dompurify": "^2.4.1", - "lexical": "^0.5.0" + "lexical": "^0.5.0", + "zustand": "^4.1.4", + "@calcom/ui": "*", + "@calcom/lib": "*", + "@calcom/dayjs": "*" } } diff --git a/packages/ui/v2/core/form/ToggleGroup.tsx b/packages/ui/v2/core/form/ToggleGroup.tsx index 779ec4bb6c..3801301430 100644 --- a/packages/ui/v2/core/form/ToggleGroup.tsx +++ b/packages/ui/v2/core/form/ToggleGroup.tsx @@ -6,7 +6,7 @@ import { classNames } from "@calcom/lib"; export const ToggleGroupItem = () => <div>hi</div>; interface ToggleGroupProps extends Omit<RadixToggleGroup.ToggleGroupSingleProps, "type"> { - options: { value: string; label: string }[]; + options: { value: string; label: string; disabled?: boolean }[]; isFullWidth?: boolean; } @@ -37,10 +37,14 @@ export const ToggleGroup = ({ options, onValueChange, isFullWidth, ...props }: T /> {options.map((option) => ( <RadixToggleGroup.Item + disabled={option.disabled} key={option.value} value={option.value} className={classNames( - "relative rounded-[4px] px-3 py-1 text-sm dark:text-neutral-200 [&[aria-checked='false']]:hover:font-medium", + "relative rounded-[4px] px-3 py-1 text-sm", + option.disabled + ? "text-gray-400 hover:cursor-not-allowed dark:text-neutral-100" + : "dark:text-neutral-200 [&[aria-checked='false']]:hover:font-medium", isFullWidth && "w-full" )} ref={(node) => {