Calendar Weekly Scheduler (#5653)

* storybook v2 init

* Merge config into storybook vite build

* Remove path

* Storybook config tweaks

* Added styles and settings for storybook v2, and started working on button documentation and examples.

* Badges + flex wrap on mobile

* Breadcrumbs+button+avatar

* Checkbox

* Input + moving files around

* WIP table

* WIP table grid

* Replaced imports for new components.

* Added first steps for varianttable.

* Small alignment fix.

* Custom Args Table - With scrollbar

* Adding table to components that need it + darkmode

* Add intro

* Fix types

* Remove V1 storybook and replace with V2

* Fix badge type error

* Fixed storybook dependencies

* Added cover image to storybook

* Remove vita from ts config, we dont use vite.

* Fixed button import.

* Explained postcss pseudo plugin.

* Fixed badge import.

* Add Avatar Stories

* ButtonGroup Stories

* Fixed imports

* Add checkbox stories

* Inital state plannning

* Inital state combined with passed in props

* Start of UI work

* Able to change dates?

* Add dynamic hour props

* Get grid system setup correctly

* Show events on grid

* Weird sizing issue but events placed correctly gridstart

* CAL styled calendar event component

* availability WIP ish

* Blocking days! + Block days < today

* Kinda working time line

* Rename grid stop + formatting

* Handle sorting events if required.

* Add util for getting startDate bassed on weekday

* Remove event stories for now

* Implement gridstops per hour to be dyamic

* New CSS Grid + offsetbased positoning

* Fix weird Z-Index issues on hover

* Implement blocklist again with new format

* Side by side events working - styling needs work

* New design of overlap

* Overlapping? Working :O

* Cleanup

* WIP hover state

* Werid border issue

* fix translate issue

* Kinda working with overflow

* Fix overflow

* Progressive date blocking

* Cleanup

* Fix double render of blocked list

* WIP mobile implementaiton

* Trying to fix CSS

* Extract CSS to styles.css to allow media queries

* Improve documentation - allow args to be changed in storybook

* Fix hover showing even if disabled

* WIP cols auto approach

* Merge blocking dates

* Fix zindex

* Fix hover position

* Fix Z-Index issues on hover and blocking events

* Re add onclick handler

* Fix overlapping blocking dates

* Fix scaling for datevalues columns

* Date values closer to DS

* Blocked List Tidy up

* Storybook + file tidy up

* Little tidy up

* Fix offsets

* Remove event hover

* Fix random bg-red-500

* Fix import

* FIx blocking cells appearing above start Date

* Fix truncation

* Fix border overlap

* Overlap a little nicer

* Condtional 80% sizing

* Nitpicks

* Fix today height and top breaking

* Add text left to time stamp

* Support string dates

* Add shalow to reduce re-renders

* Rename to Calendar instead of scheduler

* Fix 3 overlapping events

* Fix merge type error

* Fix destructuring

* NITS

* Move to features package

Co-authored-by: Jeroen Reumkens <hello@jeroenreumkens.nl>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon 2022-12-14 13:36:10 +00:00 committed by GitHub
parent 2078f12a85
commit 8fd5d6b5b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1211 additions and 6 deletions

View File

@ -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;
},
};

View File

@ -15,7 +15,11 @@ export const parameters = {
},
};
addDecorator((storyFn) => <I18nextProvider i18n={i18n}>{storyFn()}</I18nextProvider>);
addDecorator((storyFn) => (
<I18nextProvider i18n={i18n}>
<div style={{ margin: "2rem" }}>{storyFn()}</div>
</I18nextProvider>
));
window.getEmbedNamespace = () => {
const url = new URL(document.URL);

View File

@ -2,4 +2,4 @@
@tailwind components;
@tailwind utilities;
@import "../../../packages/ui/styles/shared-globals.css"
@import "../../../packages/ui/styles/shared-globals.css"

View File

@ -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;

View File

@ -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;

View File

@ -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(),
},
];

View File

@ -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<HTMLDivElement | null>(null);
const containerNav = useRef<HTMLDivElement | null>(null);
const containerOffset = useRef<HTMLDivElement | null>(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 (
<MobileNotSupported>
<div
className="scheduler-wrapper flex h-full w-full flex-col overflow-y-scroll"
style={
{ "--one-minute-height": `calc(1.75rem/(60/${usersCellsStopsPerHour}))` } as React.CSSProperties // This can't live in the css file because it's a dynamic value and css variable gets super
}>
<SchedulerHeading />
<div ref={container} className="relative isolate flex flex-auto flex-col bg-white">
<div
style={{ width: "165%" }}
className="flex max-w-full flex-none flex-col sm:max-w-none md:max-w-full">
<DateValues containerNavRef={containerNav} days={days} />
{/* TODO: Implement this at a later date. */}
{/* <CurrentTime
containerNavRef={containerNav}
containerOffsetRef={containerOffset}
containerRef={container}
/> */}
<div className="flex flex-auto">
<div className="sticky left-0 z-10 w-14 flex-none bg-white ring-1 ring-gray-100" />
<div className="grid flex-auto grid-cols-1 grid-rows-1 ">
<HorizontalLines
hours={hours}
numberOfGridStopsPerCell={usersCellsStopsPerHour}
containerOffsetRef={containerOffset}
/>
<VeritcalLines days={days} />
{/* Empty Cells */}
<SchedulerColumns
zIndex={50}
offsetHeight={containerOffset.current?.offsetHeight}
gridStopsPerDay={numberOfGridStopsPerDay}>
<>
{[...Array(days.length)].map((_, i) => (
<li
key={i}
style={{
gridRow: `2 / span ${numberOfGridStopsPerDay}`,
position: "relative",
}}>
{/* While startDate < endDate: */}
{[...Array(numberOfGridStopsPerDay)].map((_, j) => {
const key = `${i}-${j}`;
return (
<EmptyCell
key={key}
day={days[i].toDate()}
gridCellIdx={j}
totalGridCells={numberOfGridStopsPerDay}
selectionLength={endHour - startHour}
startHour={startHour}
/>
);
})}
</li>
))}
</>
</SchedulerColumns>
<SchedulerColumns
offsetHeight={containerOffset.current?.offsetHeight}
gridStopsPerDay={numberOfGridStopsPerDay}>
{/*Loop over events per day */}
{days.map((day, i) => {
return (
<li key={day.toISOString()} className="relative" style={{ gridColumnStart: i + 1 }}>
<EventList day={day} />
<BlockedList day={day} containerRef={container} />
</li>
);
})}
</SchedulerColumns>
</div>
</div>
</div>
</div>
</div>
</MobileNotSupported>
);
}
/** @todo Will be removed once we have mobile support */
const MobileNotSupported = ({ children }: { children: React.ReactNode }) => {
return (
<>
<div className="flex h-full flex-col items-center justify-center sm:hidden">
<h1 className="text-2xl font-bold">Mobile not supported yet </h1>
<p className="text-gray-500">Please use a desktop browser to view this page</p>
</div>
<div className="hidden sm:block">{children}</div>
</>
);
};

View File

@ -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<HTMLDivElement>;
};
export function DateValues({ days, containerNavRef }: Props) {
return (
<div
ref={containerNavRef}
className="sticky top-0 z-30 flex-none border-b border-b-gray-300 bg-white sm:pr-8">
<div className="flex text-sm leading-6 text-gray-500 sm:hidden" data-dayslength={days.length}>
{days.map((day) => {
const isToday = dayjs().isSame(day, "day");
return (
<button
key={day.toString()}
type="button"
className="flex flex-1 flex-col items-center pt-2 pb-3">
{day.format("dd")}{" "}
<span
className={classNames(
"mt-1 flex h-8 w-8 items-center justify-center font-semibold text-gray-900",
isToday && "rounded-full bg-gray-900 text-white"
)}>
{day.format("D")}
</span>
</button>
);
})}
</div>
<div className="-mr-px hidden auto-cols-fr text-sm leading-6 text-gray-500 sm:flex ">
<div className="col-end-1 w-14" />
{days.map((day) => {
const isToday = dayjs().isSame(day, "day");
return (
<div
key={day.toString()}
className={classNames("flex flex-1 items-center justify-center py-3", isToday && "font-bold")}>
<span>
{day.format("ddd")}{" "}
<span
className={classNames(
"items-center justify-center p-1",
isToday && "rounded-full bg-gray-900 text-white"
)}>
{day.format("DD")}
</span>
</span>
</div>
);
})}
</div>
</div>
);
}

View File

@ -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<HTMLDivElement>;
};
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") && (
<div
key={day.format("YYYY-MM-DD")}
className="absolute z-40 w-full"
style={{
top: `var(--one-minute-height)`,
zIndex: 60,
height: `calc(${(endHour + 1 - startHour) * 60} * var(--one-minute-height))`, // Add 1 to endHour to include the last hour that we add to display the last vertical line
}}>
<BlockedTimeCell />
</div>
)}
</>
);
}
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() && (
<div
key={day.format("YYYY-MM-DD")}
className="absolute z-40 w-full"
style={{
top: `var(--one-minute-height)`, // Still need this as this var takes into consideration the offset of the "AllDayEvents" bar
zIndex: 60,
height: `calc(${roundX(
nowComparedToDayStart,
60 / gridCellsPerHour
)} * var(--one-minute-height) - 2px)`, // We minus the border width to make it 🧹
}}>
<BlockedTimeCell />
</div>
)}
</>
);
}
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 (
<>
<BlockedBeforeToday day={day} startHour={startHour} endHour={endHour} />
<BlockedToday gridCellsPerHour={gridCellsPerHour} day={day} startHour={startHour} endHour={endHour} />
{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 (
<div
key={`${eventStart.toISOString()}-${i}`}
className="absolute w-full"
style={{
zIndex: 60,
top: `calc(${eventStartDiff}*var(--one-minute-height))`,
height: `calc(${eventDuration}*var(--one-minute-height))`,
}}>
<BlockedTimeCell />
</div>
);
})}
</>
);
}

View File

@ -0,0 +1,15 @@
import { classNames } from "@calcom/lib";
export function BlockedTimeCell() {
return (
<div
className={classNames("group absolute inset-0 flex h-full flex-col hover:cursor-not-allowed")}
style={{
backgroundColor: "#D1D5DB",
opacity: 0.2,
background:
"repeating-linear-gradient( -45deg, #E5E7EB, #E5E7EB 4.5px, #D1D5DB 4.5px, #D1D5DB 22.5px )",
}}
/>
);
}

View File

@ -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";
<Meta title="UI/Calendar" component={Calendar} />
<Title title="Calendar" suffix="Brief" subtitle="Version 2.0 — Last Update: 22 Aug 2022" />
## 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>

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
})}
</>
);
}

View File

@ -0,0 +1 @@
export { Event } from "./Event";

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -0,0 +1 @@
export { SchedulerHeading } from "./SchedulerHeading";

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export { Calendar } from "./Calendar";

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export { Calendar } from "./components/Calendar";

View File

@ -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,
};
}),
}));

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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": "*"
}
}

View File

@ -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) => {