feat: team availability - vertical selector and align times (#17502)

* - aligns timezones offset by minutes and add minutes to the label in the teams availability view
- added a vertical guidebar to view alligned schedules

* fixed type-check issues
This commit is contained in:
Vincent Lam
2024-11-06 10:37:21 +00:00
committed by GitHub
parent 21aaff01fa
commit 92fafa7344
4 changed files with 149 additions and 19 deletions
@@ -1,6 +1,6 @@
import { keepPreviousData } from "@tanstack/react-query";
import { getCoreRowModel, useReactTable, getFilteredRowModel } from "@tanstack/react-table";
import type { ColumnDef } from "@tanstack/react-table";
import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dayjs from "@calcom/dayjs";
@@ -15,6 +15,7 @@ import { Button, ButtonGroup, DataTable, DataTableToolbar, UserAvatar } from "@c
import { UpgradeTip } from "../../tips/UpgradeTip";
import { createTimezoneBuddyStore, TBContext } from "../store";
import { AvailabilityEditSheet } from "./AvailabilityEditSheet";
import { CellHighlightContainer } from "./CellHighlightContainer";
import { TimeDial } from "./TimeDial";
export interface SliderUser {
@@ -64,6 +65,10 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null;
const [editSheetOpen, setEditSheetOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<SliderUser | null>(null);
const tbStore = createTimezoneBuddyStore({
browsingDate: browsingDate.toDate(),
});
const { data, isPending, fetchNextPage, isFetching } = trpc.viewer.availability.listTeam.useInfiniteQuery(
{
limit: 10,
@@ -205,7 +210,7 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null;
browsingDate: browsingDate.toDate(),
})}>
<>
<div className="relative -mx-2 w-[calc(100%+16px)] overflow-x-scroll px-2 lg:-mx-6 lg:w-[calc(100%+48px)] lg:px-6">
<CellHighlightContainer>
<DataTable
table={table}
tableContainerRef={tableContainerRef}
@@ -221,7 +226,7 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null;
<DataTableToolbar.SearchBar table={table} searchKey="member" />
</DataTableToolbar.Root>
</DataTable>
</div>
</CellHighlightContainer>
{selectedUser && editSheetOpen ? (
<AvailabilityEditSheet
open={editSheetOpen}
@@ -0,0 +1,66 @@
"use client";
import { LazyMotion, domAnimation, m } from "framer-motion";
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useStore } from "zustand";
import { DAY_CELL_WIDTH } from "../constants";
import { TBContext } from "../store";
export function CellHighlightContainer({ children }: { children: React.ReactNode }) {
const store = useContext(TBContext);
if (!store) throw new Error("Missing TBContext.Provider in the tree");
const [isAnimating, setIsAnimating] = useState(false);
const componentContainerRef = useRef<HTMLDivElement>(null);
const { x, y, height, isHover, updateDimensions, setContainerRef } = useStore(store, (state) => state);
useEffect(() => {
let timeout: NodeJS.Timeout | null = null;
if (isHover) {
setIsAnimating(true);
} else {
timeout = setTimeout(() => setIsAnimating(false), 1000);
}
return () => {
timeout && clearTimeout(timeout);
};
}, [isHover]);
useLayoutEffect(() => {
const handleUpdate = () => {
updateDimensions();
};
const resizeObserver = new ResizeObserver(() => handleUpdate());
const currentContainerRef = componentContainerRef.current;
setContainerRef(componentContainerRef);
if (currentContainerRef) {
resizeObserver.observe(currentContainerRef);
}
return () => {
if (currentContainerRef) {
resizeObserver.unobserve(currentContainerRef);
}
};
}, [componentContainerRef, setContainerRef, updateDimensions]);
return (
<LazyMotion features={domAnimation}>
<div
className="relative -mx-2 w-[calc(100%+16px)] overflow-x-scroll px-2 lg:-mx-6 lg:w-[calc(100%+48px)] lg:px-6"
ref={componentContainerRef}>
{children}
<m.div
className="border-subtle opcaity-0 pointer-events-none absolute left-0 top-0 h-full rounded-lg border-2"
animate={{ x: [null, x], opacity: isAnimating || isHover ? 1 : 0.1 }}
style={{ y, height, width: DAY_CELL_WIDTH }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
</div>
</LazyMotion>
);
}
@@ -1,3 +1,4 @@
import type { MouseEventHandler } from "react";
import { useContext } from "react";
import { useStore } from "zustand";
@@ -86,7 +87,10 @@ function isCurrentHourInRange({
export function TimeDial({ timezone, dateRanges }: TimeDialProps) {
const store = useContext(TBContext);
if (!store) throw new Error("Missing TBContext.Provider in the tree");
const browsingDate = useStore(store, (s) => s.browsingDate);
const { browsingDate, emitCellPosition } = useStore(store, ({ browsingDate, emitCellPosition }) => ({
browsingDate,
emitCellPosition,
}));
const usersTimezoneDate = dayjs(browsingDate).tz(timezone);
@@ -102,16 +106,27 @@ export function TimeDial({ timezone, dateRanges }: TimeDialProps) {
hours.filter((i) => i >= 24).map((i) => i % 24),
];
let minuteOffsetApplied = false;
const handleMouseEnter: MouseEventHandler<HTMLDivElement> = (e) => {
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const x = rect.left; // x position within the element
const y = rect.top; // y position within the element
emitCellPosition(x);
};
return (
<>
<div className="flex items-end justify-center overflow-auto text-sm">
<div data-time-dial className="flex items-end justify-center overflow-auto text-sm">
{days.map((day, i) => {
if (!day.length) return null;
const dateWithDaySet = usersTimezoneDate.add(i - 1, "day");
return (
<div key={i} className={classNames("border-subtle overflow-hidden rounded-lg border-2")}>
<div
key={i}
className={classNames(
"border-subtle overflow-hidden rounded-lg border-2",
i !== 0 && "ml-[-4px]" // border-2 adds 4px to the width, and offsets the alignment
)}>
<div className="flex flex-none">
{day.map((h) => {
const hours = Math.floor(h); // Whole number part
@@ -154,11 +169,14 @@ export function TimeDial({ timezone, dateRanges }: TimeDialProps) {
}
}
const minuteOffsetStyles: { marginLeft?: string } = {};
if (hours !== 0 && !minuteOffsetApplied) {
minuteOffsetApplied = true;
minuteOffsetStyles.marginLeft = `${DAY_CELL_WIDTH * (offset % 1)}px`;
}
const TimeLabel = () => (
<>
{hourSet.format("H")}
{minutes !== 0 && (
<span className="align-text-top text-[.5rem]">{hourSet.format("mm")}</span>
)}
</>
);
return (
<div
@@ -169,19 +187,20 @@ export function TimeDial({ timezone, dateRanges }: TimeDialProps) {
isInRange && !rangeOverlap ? "bg-success" : "",
hours ? "" : "bg-subtle font-medium"
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => emitCellPosition(-1)}
style={{
...minuteOffsetStyles,
width: `${DAY_CELL_WIDTH}px`,
backgroundImage: rangeGradients.backgroundGradient,
}}>
{hours ? (
<div title={hourSet.format("DD/MM HH:mm")}>
<div className="flex flex-col text-center text-xs leading-3">
<div className="flex flex-col text-center text-xs font-bold leading-3 ">
{rangeGradients.textGradient ? (
<>
{/* light mode */}
<span className={classNames("text-1xl font-bold dark:hidden")}>
{hourSet.format("H")}
<span className={classNames("font-bold dark:hidden")}>
<TimeLabel />
</span>
{/* dark mode */}
<span
@@ -189,14 +208,16 @@ export function TimeDial({ timezone, dateRanges }: TimeDialProps) {
backgroundImage: rangeGradients.darkTextGradient,
}}
className={classNames(
"text-1xl hidden font-bold dark:block",
"hidden dark:block",
rangeOverlap ? "bg-clip-text text-transparent" : ""
)}>
{hourSet.format("H")}
<TimeLabel />
</span>
</>
) : (
<span className="text-1xl font-bold">{hourSet.format("H")}</span>
<span>
<TimeLabel />
</span>
)}
</div>
</div>
+38
View File
@@ -13,12 +13,20 @@ export interface Timezone {
export interface TimezoneBuddyProps {
browsingDate: Date;
timeMode?: "12h" | "24h";
containerRef?: React.RefObject<HTMLElement>;
x: number;
height: number;
y: number;
isHover?: boolean;
}
type TimezoneBuddyState = TimezoneBuddyProps & {
addToDate: (amount: number) => void;
subtractFromDate: (amount: number) => void;
setBrowseDate: (date: Date) => void;
setContainerRef: (ref: React.RefObject<HTMLElement>) => void;
emitCellPosition: (x: number) => void;
updateDimensions: () => void;
};
export type TimezoneBuddyStore = ReturnType<typeof createTimezoneBuddyStore>;
@@ -32,6 +40,9 @@ export const createTimezoneBuddyStore = (initProps?: Partial<TimezoneBuddyProps>
const DEFAULT_PROPS: TimezoneBuddyProps = {
timeMode: "24h",
browsingDate: new Date(),
x: 0,
y: 0,
height: 0,
};
return createStore<TimezoneBuddyState>()((set, get) => ({
@@ -50,6 +61,33 @@ export const createTimezoneBuddyStore = (initProps?: Partial<TimezoneBuddyProps>
setBrowseDate: (date: Date) => {
set({ browsingDate: date });
},
setContainerRef: (ref: React.RefObject<HTMLElement>) => {
set({ containerRef: ref });
},
emitCellPosition: (x: number) => {
const container = get().containerRef?.current;
if (x < 0) {
// If x is less than 0, we are outside the container
set({ isHover: false });
} else if (container) {
const containerRect = container.getBoundingClientRect();
set({ x: x - containerRect.left, isHover: true });
}
},
updateDimensions: () => {
const container = get().containerRef?.current;
let x = get().x;
if (container) {
const containerRect = container.getBoundingClientRect();
const timeDials = container.querySelectorAll("[data-time-dial]>div");
const height =
timeDials[timeDials.length - 1]?.getBoundingClientRect().bottom -
timeDials[0]?.getBoundingClientRect().top;
const y = timeDials[0]?.getBoundingClientRect().top - containerRect.top;
x = x ? x : timeDials[0]?.getBoundingClientRect().left - containerRect.left;
set({ height, y, x });
}
},
}));
};