2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

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

View File

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

View File

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

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