first commit
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
|
||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import type { Schedule as ScheduleType, TimeRange, WorkingHours } from "@calcom/types/schedule";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
Label,
|
||||
Sheet,
|
||||
SheetBody,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
showToast,
|
||||
TimezoneSelect,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { SliderUser } from "./AvailabilitySliderTable";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedUser?: SliderUser | null;
|
||||
}
|
||||
|
||||
type AvailabilityFormValues = {
|
||||
name: string;
|
||||
schedule: ScheduleType;
|
||||
dateOverrides: { ranges: TimeRange[] }[];
|
||||
timeZone: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
const useSettings = () => {
|
||||
const { data } = useMeQuery();
|
||||
return {
|
||||
userTimeFormat: data?.timeFormat ?? 12,
|
||||
};
|
||||
};
|
||||
|
||||
const DateOverride = ({ workingHours, disabled }: { workingHours: WorkingHours[]; disabled?: boolean }) => {
|
||||
const { userTimeFormat } = useSettings();
|
||||
|
||||
const { append, replace, fields } = useFieldArray<AvailabilityFormValues, "dateOverrides">({
|
||||
name: "dateOverrides",
|
||||
});
|
||||
const excludedDates = fields.map((field) => dayjs(field.ranges[0].start).utc().format("YYYY-MM-DD"));
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="">
|
||||
<Label>{t("date_overrides")}</Label>
|
||||
<div className="space-y-2">
|
||||
<DateOverrideList
|
||||
excludedDates={excludedDates}
|
||||
replace={replace}
|
||||
fields={fields}
|
||||
hour12={Boolean(userTimeFormat === 12)}
|
||||
workingHours={workingHours}
|
||||
userTimeFormat={userTimeFormat}
|
||||
/>
|
||||
<DateOverrideInputDialog
|
||||
userTimeFormat={userTimeFormat}
|
||||
workingHours={workingHours}
|
||||
excludedDates={excludedDates}
|
||||
onChange={(ranges) => ranges.forEach((range) => append({ ranges: [range] }))}
|
||||
Trigger={
|
||||
<Button color="secondary" StartIcon="plus" data-testid="add-override" disabled={disabled}>
|
||||
{t("add_an_override")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function AvailabilityEditSheet(props: Props) {
|
||||
// This sheet will not be rendered without a selected user
|
||||
const userId = props.selectedUser?.id as number;
|
||||
const { data, isPending } = trpc.viewer.availability.schedule.getScheduleByUserId.useQuery({
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
// TODO: reimplement Skeletons for this page in here
|
||||
if (isPending) return null;
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
// We wait for the schedule to be loaded before rendering the form since `defaultValues`
|
||||
// cannot be redeclared after first render. And using `values` will trigger a form reset
|
||||
// when revalidating.
|
||||
return <AvailabilityEditSheetForm {...props} data={data} isPending={isPending} />;
|
||||
}
|
||||
|
||||
type Data = RouterOutputs["viewer"]["availability"]["schedule"]["getScheduleByUserId"];
|
||||
export function AvailabilityEditSheetForm(props: Props & { data: Data; isPending: boolean }) {
|
||||
// This sheet will not be rendered without a selected user
|
||||
const userId = props.selectedUser?.id as number;
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: hasEditPermission, isPending: loadingPermissions } =
|
||||
trpc.viewer.teams.hasEditPermissionForUser.useQuery({
|
||||
memberId: userId,
|
||||
});
|
||||
|
||||
const { data, isPending } = props;
|
||||
|
||||
const updateMutation = trpc.viewer.availability.schedule.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.viewer.availability.listTeam.invalidate();
|
||||
showToast(t("success"), "success");
|
||||
props.onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<AvailabilityFormValues>({
|
||||
defaultValues: {
|
||||
...data,
|
||||
timeZone: data.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
schedule: data.availability || [],
|
||||
},
|
||||
});
|
||||
|
||||
const watchTimezone = form.watch("timeZone");
|
||||
|
||||
return (
|
||||
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<Form
|
||||
form={form}
|
||||
id="availability-form"
|
||||
handleSubmit={async ({ dateOverrides, ...values }) => {
|
||||
// Just blocking on a UI side -> Backend will also do the validation
|
||||
if (!hasEditPermission) return;
|
||||
updateMutation.mutate({
|
||||
scheduleId: data.id,
|
||||
dateOverrides: dateOverrides.flatMap((override) => override.ranges),
|
||||
...values,
|
||||
});
|
||||
}}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{t("edit_users_availability", {
|
||||
username: props.selectedUser?.username ?? "Nameless user",
|
||||
})}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
{!data.hasDefaultSchedule && !isPending && hasEditPermission && (
|
||||
<div className="my-2">
|
||||
<Alert severity="warning" title={t("view_only_edit_availability_not_onboarded")} />
|
||||
</div>
|
||||
)}
|
||||
{!hasEditPermission && !loadingPermissions && (
|
||||
<div className="my-2">
|
||||
<Alert severity="warning" title={t("view_only_edit_availability")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SheetBody className="mt-4 flex flex-col space-y-4">
|
||||
<div>
|
||||
<Label className="text-emphasis">
|
||||
<>{t("timezone")}</>
|
||||
</Label>
|
||||
<TimezoneSelect
|
||||
id="timezone"
|
||||
isDisabled={!hasEditPermission || !data.hasDefaultSchedule}
|
||||
value={watchTimezone ?? "Europe/London"}
|
||||
data-testid="timezone-select"
|
||||
onChange={(event) => {
|
||||
if (event) form.setValue("timeZone", event.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Label className="text-emphasis">{t("members_default_schedule")}</Label>
|
||||
{/* Remove padding from schedule without touching the component */}
|
||||
<div className="[&>*:first-child]:!p-0">
|
||||
<Schedule
|
||||
control={form.control}
|
||||
name="schedule"
|
||||
weekStart={0}
|
||||
disabled={!hasEditPermission || !data.hasDefaultSchedule}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{data.workingHours && (
|
||||
<DateOverride
|
||||
workingHours={data.workingHours}
|
||||
disabled={!hasEditPermission || !data.hasDefaultSchedule}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetBody>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button color="secondary" className="w-full justify-center">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</SheetClose>
|
||||
<Button
|
||||
disabled={!hasEditPermission || !data.hasDefaultSchedule}
|
||||
className="w-full justify-center"
|
||||
type="submit"
|
||||
loading={updateMutation.isPending}
|
||||
form="availability-form">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Form>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import type { DateRange } from "@calcom/lib/date-ranges";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
import { Button, ButtonGroup, DataTable, UserAvatar } from "@calcom/ui";
|
||||
|
||||
import { UpgradeTip } from "../../tips/UpgradeTip";
|
||||
import { createTimezoneBuddyStore, TBContext } from "../store";
|
||||
import { AvailabilityEditSheet } from "./AvailabilityEditSheet";
|
||||
import { TimeDial } from "./TimeDial";
|
||||
|
||||
export interface SliderUser {
|
||||
id: number;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
organizationId: number;
|
||||
avatarUrl: string | null;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
role: MembershipRole;
|
||||
defaultScheduleId: number | null;
|
||||
dateRanges: DateRange[];
|
||||
profile: UserProfile;
|
||||
}
|
||||
|
||||
function UpgradeTeamTip() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<UpgradeTip
|
||||
plan="team"
|
||||
title={t("calcom_is_better_with_team", { appName: APP_NAME }) as string}
|
||||
description="add_your_team_members"
|
||||
background="/tips/teams"
|
||||
features={[]}
|
||||
buttons={
|
||||
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<ButtonGroup>
|
||||
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
|
||||
{t("create_team")}
|
||||
</Button>
|
||||
<Button color="minimal" href="https://bls.media/cal#teams" target="_blank">
|
||||
{t("learn_more")}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
}>
|
||||
<></>
|
||||
</UpgradeTip>
|
||||
);
|
||||
}
|
||||
|
||||
export function AvailabilitySliderTable(props: { userTimeFormat: number | null }) {
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [browsingDate, setBrowsingDate] = useState(dayjs());
|
||||
const [editSheetOpen, setEditSheetOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<SliderUser | null>(null);
|
||||
|
||||
const { data, isPending, fetchNextPage, isFetching } = trpc.viewer.availability.listTeam.useInfiniteQuery(
|
||||
{
|
||||
limit: 10,
|
||||
loggedInUsersTz: dayjs.tz.guess() || "Europe/London",
|
||||
startDate: browsingDate.startOf("day").toISOString(),
|
||||
endDate: browsingDate.endOf("day").toISOString(),
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
);
|
||||
|
||||
const memorisedColumns = useMemo(() => {
|
||||
const cols: ColumnDef<SliderUser>[] = [
|
||||
{
|
||||
id: "member",
|
||||
accessorFn: (data) => data.email,
|
||||
header: "Member",
|
||||
cell: ({ row }) => {
|
||||
const { username, email, timeZone, name, avatarUrl, profile } = row.original;
|
||||
return (
|
||||
<div className="max-w-64 flex flex-shrink-0 items-center gap-2 overflow-hidden">
|
||||
<UserAvatar
|
||||
size="sm"
|
||||
user={{
|
||||
username,
|
||||
name,
|
||||
avatarUrl,
|
||||
profile,
|
||||
}}
|
||||
/>
|
||||
<div className="">
|
||||
<div className="text-emphasis max-w-64 truncate text-sm font-medium" title={email}>
|
||||
{username || "No username"}
|
||||
</div>
|
||||
<div className="text-subtle text-xs leading-none">{timeZone}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "timezone",
|
||||
accessorFn: (data) => data.timeZone,
|
||||
header: "Timezone",
|
||||
cell: ({ row }) => {
|
||||
const { timeZone } = row.original;
|
||||
const timeRaw = dayjs().tz(timeZone);
|
||||
const time = timeRaw.format("HH:mm");
|
||||
const utcOffsetInMinutes = timeRaw.utcOffset();
|
||||
const hours = Math.abs(Math.floor(utcOffsetInMinutes / 60));
|
||||
const minutes = Math.abs(utcOffsetInMinutes % 60);
|
||||
const offsetFormatted = `${utcOffsetInMinutes < 0 ? "-" : "+"}${hours
|
||||
.toString()
|
||||
.padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-default text-sm font-medium">{time}</span>
|
||||
<span className="text-subtle text-xs leading-none">GMT {offsetFormatted}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slider",
|
||||
header: () => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<ButtonGroup containerProps={{ className: "space-x-0" }}>
|
||||
<Button
|
||||
color="minimal"
|
||||
variant="icon"
|
||||
StartIcon="chevron-left"
|
||||
onClick={() => setBrowsingDate(browsingDate.subtract(1, "day"))}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setBrowsingDate(browsingDate.add(1, "day"))}
|
||||
color="minimal"
|
||||
StartIcon="chevron-right"
|
||||
variant="icon"
|
||||
/>
|
||||
</ButtonGroup>
|
||||
<span>{browsingDate.format("LL")}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const { timeZone, dateRanges } = row.original;
|
||||
// return <pre>{JSON.stringify(dateRanges, null, 2)}</pre>;
|
||||
return <TimeDial timezone={timeZone} dateRanges={dateRanges} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return cols;
|
||||
}, [browsingDate]);
|
||||
|
||||
//we must flatten the array of arrays from the useInfiniteQuery hook
|
||||
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as SliderUser[];
|
||||
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
|
||||
const totalFetched = flatData.length;
|
||||
|
||||
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
|
||||
const fetchMoreOnBottomReached = useCallback(
|
||||
(containerRefElement?: HTMLDivElement | null) => {
|
||||
if (containerRefElement) {
|
||||
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
|
||||
//once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any
|
||||
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMoreOnBottomReached(tableContainerRef.current);
|
||||
}, [fetchMoreOnBottomReached]);
|
||||
|
||||
// This means they are not apart of any teams so we show the upgrade tip
|
||||
if (!flatData.length) return <UpgradeTeamTip />;
|
||||
|
||||
return (
|
||||
<TBContext.Provider
|
||||
value={createTimezoneBuddyStore({
|
||||
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">
|
||||
<DataTable
|
||||
variant="compact"
|
||||
searchKey="member"
|
||||
tableContainerRef={tableContainerRef}
|
||||
columns={memorisedColumns}
|
||||
onRowMouseclick={(row) => {
|
||||
setEditSheetOpen(true);
|
||||
setSelectedUser(row.original);
|
||||
}}
|
||||
data={flatData}
|
||||
isPending={isPending}
|
||||
// tableOverlay={<HoverOverview />}
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
|
||||
/>
|
||||
</div>
|
||||
{selectedUser && editSheetOpen ? (
|
||||
<AvailabilityEditSheet
|
||||
open={editSheetOpen}
|
||||
onOpenChange={(e) => {
|
||||
setEditSheetOpen(e);
|
||||
setSelectedUser(null); // We need to clear the user here or else the sheet will not re-render when opening a new user
|
||||
}}
|
||||
selectedUser={selectedUser}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
</TBContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState, useRef, useEffect, useMemo } from "react";
|
||||
|
||||
import { DAY_CELL_WIDTH } from "../constants";
|
||||
|
||||
function rounded(x: number, dayCellWidth: number) {
|
||||
let n = Math.round(x / dayCellWidth);
|
||||
n = Math.max(0, n);
|
||||
n = Math.min(24, n);
|
||||
return n * dayCellWidth;
|
||||
}
|
||||
function useElementBounding<T extends HTMLDivElement>(ref: React.RefObject<T>): DOMRect | null {
|
||||
const [boundingRect, setBoundingRect] = useState<DOMRect | null>(null);
|
||||
const observer = useRef<ResizeObserver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const target = ref.current;
|
||||
|
||||
function updateBoundingRect() {
|
||||
if (target) {
|
||||
const rect = target.getBoundingClientRect();
|
||||
setBoundingRect(rect);
|
||||
}
|
||||
}
|
||||
|
||||
updateBoundingRect();
|
||||
|
||||
observer.current = new ResizeObserver(updateBoundingRect);
|
||||
observer.current.observe(target as Element);
|
||||
|
||||
return () => {
|
||||
if (observer.current) {
|
||||
observer.current.disconnect();
|
||||
observer.current = null;
|
||||
}
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return boundingRect;
|
||||
}
|
||||
|
||||
function useMouse() {
|
||||
const [x, setX] = useState(0);
|
||||
const [y, setY] = useState(0);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
setX(event.clientX);
|
||||
setY(event.clientY);
|
||||
}
|
||||
|
||||
function handleMouseDown() {
|
||||
setIsPressed(true);
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
setIsPressed(false);
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { x, y, isPressed };
|
||||
}
|
||||
|
||||
export function HoverOverview() {
|
||||
const top = useRef(0);
|
||||
const bottom = useRef(0);
|
||||
const edgeStart = useRef(0);
|
||||
const edgeEnd = useRef(0);
|
||||
const [leftEdge, setLeftEdge] = useState(0);
|
||||
const [rightEdge, setRightEdge] = useState(0);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
const { x, y, isPressed: pressed } = useMouse();
|
||||
const el = useRef<HTMLDivElement | null>(null);
|
||||
const box = useElementBounding(el);
|
||||
|
||||
useEffect(() => {
|
||||
function updateEdge() {
|
||||
const roundedX = rounded(x - (box?.left || 0), DAY_CELL_WIDTH);
|
||||
edgeStart.current = roundedX;
|
||||
edgeEnd.current = roundedX;
|
||||
setLeftEdge(roundedX);
|
||||
}
|
||||
|
||||
if (pressed) {
|
||||
updateEdge();
|
||||
}
|
||||
}, [pressed, x, box]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pressed) {
|
||||
const roundedX = rounded(x - (box?.left || 0), DAY_CELL_WIDTH);
|
||||
edgeEnd.current = roundedX;
|
||||
setRightEdge(roundedX);
|
||||
}
|
||||
}, [pressed, x, box]);
|
||||
|
||||
useEffect(() => {
|
||||
setWidth(Math.abs(edgeStart.current - edgeEnd.current));
|
||||
}, [edgeStart, edgeEnd]);
|
||||
|
||||
const position = useMemo(
|
||||
() => ({
|
||||
left: `${leftEdge}px`,
|
||||
top: `${top}px`,
|
||||
bottom: `${bottom}px`,
|
||||
width: `${width}px`,
|
||||
}),
|
||||
[leftEdge, top, bottom, width]
|
||||
);
|
||||
|
||||
const leftWhiteout = useMemo(
|
||||
() => ({
|
||||
left: "0",
|
||||
top: `${top}px`,
|
||||
bottom: `${bottom}px`,
|
||||
width: `${leftEdge}px`,
|
||||
}),
|
||||
[leftEdge, top, bottom]
|
||||
);
|
||||
|
||||
const rightWhiteout = useMemo(
|
||||
() => ({
|
||||
right: "0",
|
||||
top: `${top}px`,
|
||||
bottom: `${bottom}px`,
|
||||
left: `${rightEdge}px`,
|
||||
}),
|
||||
[rightEdge, top, bottom]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={el} className="absoulte inset-0 w-full">
|
||||
<div className="bg-default/80 absolute" style={leftWhiteout} />
|
||||
<div className="bg-default/80 absolute" style={rightWhiteout} />
|
||||
<div className="border-emphasis border border-dashed" style={position} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
calcom/packages/features/timezone-buddy/components/TimeDial.tsx
Normal file
219
calcom/packages/features/timezone-buddy/components/TimeDial.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useContext } from "react";
|
||||
import { useStore } from "zustand";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { DateRange } from "@calcom/lib/date-ranges";
|
||||
|
||||
import { DAY_CELL_WIDTH } from "../constants";
|
||||
import { TBContext } from "../store";
|
||||
|
||||
interface TimeDialProps {
|
||||
timezone: string;
|
||||
dateRanges?: DateRange[];
|
||||
}
|
||||
|
||||
function isMidnight(h: number) {
|
||||
return h <= 5 || h >= 22;
|
||||
}
|
||||
|
||||
function isCurrentHourInRange({
|
||||
dateRanges,
|
||||
cellDate,
|
||||
offset,
|
||||
}: {
|
||||
dateRanges?: DateRange[];
|
||||
cellDate: Dayjs;
|
||||
offset: number;
|
||||
}): {
|
||||
rangeOverlap?: number;
|
||||
isInRange: boolean;
|
||||
} {
|
||||
if (!dateRanges)
|
||||
return {
|
||||
isInRange: false,
|
||||
};
|
||||
const currentHour = cellDate.hour();
|
||||
|
||||
let rangeOverlap = 0;
|
||||
|
||||
// yes or no answer whether it's in range.
|
||||
const isFullyInRange = dateRanges.some((time) => {
|
||||
if (!time) null;
|
||||
|
||||
const startHour = dayjs(time.start).subtract(offset, "hour");
|
||||
const endHour = dayjs(time.end).subtract(offset, "hour");
|
||||
|
||||
// If not same day number then we don't care
|
||||
|
||||
if (startHour.day() !== cellDate.day()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// this is a weird way of doing this
|
||||
const newDate = dayjs(time.start).set("hour", currentHour);
|
||||
|
||||
const diffStart = newDate.diff(startHour, "minutes");
|
||||
if (Math.abs(diffStart) < 60 && diffStart != 0) {
|
||||
rangeOverlap =
|
||||
diffStart < 0
|
||||
? -(Math.floor(startHour.minute() / 15) * 25)
|
||||
: Math.floor(startHour.minute() / 15) * 25;
|
||||
}
|
||||
|
||||
const diffEnd = newDate.diff(endHour, "minutes");
|
||||
if (Math.abs(diffEnd) < 60 && diffEnd != 0) {
|
||||
rangeOverlap =
|
||||
diffEnd < 0 ? -(Math.floor(endHour.minute() / 15) * 25) : Math.floor(endHour.minute() / 15) * 25;
|
||||
}
|
||||
|
||||
return newDate.isBetween(startHour, endHour, undefined, "[)"); // smiley faces or something
|
||||
});
|
||||
// most common situation, bail early.
|
||||
if (isFullyInRange) {
|
||||
return {
|
||||
isInRange: isFullyInRange,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isInRange: !!rangeOverlap,
|
||||
rangeOverlap, // value from -75 to 75 to indicate range overlap
|
||||
};
|
||||
}
|
||||
|
||||
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 usersTimezoneDate = dayjs(browsingDate).tz(timezone);
|
||||
|
||||
const nowDate = dayjs(browsingDate);
|
||||
|
||||
const offset = (nowDate.utcOffset() - usersTimezoneDate.utcOffset()) / 60;
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i - offset + 1);
|
||||
|
||||
const days = [
|
||||
hours.filter((i) => i < 0).map((i) => (i + 24) % 24),
|
||||
hours.filter((i) => i >= 0 && i < 24),
|
||||
hours.filter((i) => i >= 24).map((i) => i % 24),
|
||||
];
|
||||
|
||||
let minuteOffsetApplied = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div 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 className="flex flex-none">
|
||||
{day.map((h) => {
|
||||
const hours = Math.floor(h); // Whole number part
|
||||
const fractionalHours = h - hours; // Decimal part
|
||||
|
||||
// Convert the fractional hours to minutes
|
||||
const minutes = fractionalHours * 60;
|
||||
const hourSet = dateWithDaySet.set("hour", h).set("minute", minutes);
|
||||
|
||||
const { isInRange, rangeOverlap = 0 } = isCurrentHourInRange({
|
||||
dateRanges,
|
||||
offset,
|
||||
cellDate: hourSet,
|
||||
});
|
||||
|
||||
const rangeGradients: {
|
||||
backgroundGradient?: string;
|
||||
textGradient?: string;
|
||||
darkTextGradient?: string;
|
||||
} = {};
|
||||
|
||||
if (isInRange && rangeOverlap) {
|
||||
if (rangeOverlap < 0) {
|
||||
const gradientValue = Math.abs(rangeOverlap);
|
||||
rangeGradients.backgroundGradient = `linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0) ${gradientValue}%, var(--cal-bg-success) ${gradientValue}%)`;
|
||||
|
||||
rangeGradients.textGradient = `linear-gradient(90deg, rgba(0,212,255,1) 0%, rgba(2,0,36,1) 100%, rgba(9,108,121,1) 100%)`;
|
||||
|
||||
rangeGradients.darkTextGradient = `linear-gradient(90deg, var(--cal-text-emphasis, #111827) ${
|
||||
gradientValue === 50 ? "50%" : `${Math.round(gradientValue / 100) * 100}%`
|
||||
}, var(--cal-text-inverted, white) 0%, var(--cal-text-inverted, white) 0%)`;
|
||||
} else {
|
||||
rangeGradients.backgroundGradient = `linear-gradient(90deg, var(--cal-bg-success) ${rangeOverlap}%, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 0%)`;
|
||||
|
||||
rangeGradients.textGradient = `linear-gradient(90deg, rgba(0,212,255,1) 0%, rgba(2,0,36,1) 50%, rgba(9,108,121,1) 100%)`;
|
||||
|
||||
rangeGradients.darkTextGradient = `linear-gradient(90deg, var(--cal-text-inverted, white) ${
|
||||
rangeOverlap === 50 ? "50%" : `${Math.round(rangeOverlap / 100) * 100}%`
|
||||
}, var(--cal-text-emphasis, #111827) 0%, var(--cal-text-emphasis, #111827) 0%)`;
|
||||
}
|
||||
}
|
||||
|
||||
const minuteOffsetStyles: { marginLeft?: string } = {};
|
||||
if (hours !== 0 && !minuteOffsetApplied) {
|
||||
minuteOffsetApplied = true;
|
||||
minuteOffsetStyles.marginLeft = `${DAY_CELL_WIDTH * (offset % 1)}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
className={classNames(
|
||||
"flex h-8 flex-col items-center justify-center",
|
||||
isInRange ? "text-emphasis" : "",
|
||||
isInRange && !rangeOverlap ? "bg-success" : "",
|
||||
hours ? "" : "bg-subtle font-medium"
|
||||
)}
|
||||
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">
|
||||
{rangeGradients.textGradient ? (
|
||||
<>
|
||||
{/* light mode */}
|
||||
<span className={classNames("text-1xl font-bold dark:hidden")}>
|
||||
{hourSet.format("H")}
|
||||
</span>
|
||||
{/* dark mode */}
|
||||
<span
|
||||
style={{
|
||||
backgroundImage: rangeGradients.darkTextGradient,
|
||||
}}
|
||||
className={classNames(
|
||||
"text-1xl hidden font-bold dark:block",
|
||||
rangeOverlap ? "bg-clip-text text-transparent" : ""
|
||||
)}>
|
||||
{hourSet.format("H")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-1xl font-bold">{hourSet.format("H")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col text-center text-xs leading-3">
|
||||
<span>{hourSet.format("MMM")}</span>
|
||||
<span>{hourSet.format("DD")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user