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:
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user