feat: implement booking calendar view with weekly layout (#24563)

* feat: implement booking calendar view with weekly layout

- Create reusable WeekCalendarView component that displays bookings in a weekly calendar format
- Replace EmptyScreen in BookingsCalendar with the new calendar view
- Calendar view includes:
  - Week navigation with Today, Previous, and Next buttons
  - 7-day week view with time slots from 12 AM to 11 PM
  - Bookings displayed as colored blocks positioned by time
  - Support for event type colors and status-based colors
  - Responsive design that fills the viewport
  - Hover tooltips showing booking details
- Filters remain functional at the top of the view

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: use existing Calendar component from weeklyview

- Replace custom calendar implementation with the existing Calendar component
- Use parseEventTypeColor to properly handle event type colors
- Simplify implementation by leveraging existing calendar infrastructure
- Maintain week navigation and filtering functionality

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix imports

* fix: replace isSameOrAfter with isAfter || isSame

- isSameOrAfter method does not exist in dayjs
- Use combination of isAfter and isSame instead

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* remove useBookerTime dependency from weekly calendar view

* modify date range filters

* initial callback

* sort events

* clean up FilterBar

* add showBackgroundPattern

* update styles

* update style

* update styles

* fix type error

* fix error

* update styles

* update styles

* update event colors

* rename component

* persist weekStart on the url

* use FilterBar

* apply feedback

* extract BorderColor type

* use client

* clean up

* adjust styles

* color-code events

* rename borderColor to color

* restore class name

* add feature flag

* update class name

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2025-10-31 11:13:41 +00:00
committed by GitHub
co-authored by Devin AI
parent 8d4dd0241a
commit f00c14d0c1
26 changed files with 421 additions and 120 deletions
@@ -6,7 +6,9 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -52,12 +54,18 @@ const Page = async ({ params }: PageProps) => {
canReadOthersBookings = teamIdsWithPermission.length > 0;
}
const featuresRepository = new FeaturesRepository(prisma);
const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"booking-calendar-view"
);
return (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<BookingsList
status={parsed.data.status}
userId={session?.user?.id}
permissions={{ canReadOthersBookings }}
isCalendarViewEnabled={isCalendarViewEnabled}
/>
</ShellMainAppDir>
);
@@ -1,23 +1,96 @@
"use client";
import type { Table as ReactTable } from "@tanstack/react-table";
import { createParser, useQueryState } from "nuqs";
import { useMemo, useCallback } from "react";
import { DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import dayjs from "@calcom/dayjs";
import {
DataTableFilters,
DataTableSegment,
useDataTable,
useFilterValue,
ZDateRangeFilterValue,
ColumnFilterType,
} from "@calcom/features/data-table";
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
import type { RowData, BookingListingStatus } from "../types";
import { BookingsCalendarView } from "./BookingsCalendarView";
type BookingsCalendarViewProps = {
status: BookingListingStatus;
table: ReactTable<RowData>;
};
const COLUMN_IDS_TO_HIDE = ["dateRange"];
const weekStartParser = createParser({
parse: (value: string) => {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
},
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
});
export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
const { rows } = table.getRowModel();
const { updateFilter } = useDataTable();
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
"weekStart",
weekStartParser.withDefault(dayjs().startOf("week"))
);
const bookings = useMemo(() => {
return rows
.filter((row) => row.original.type === "data")
.map((row) => (row.original.type === "data" ? row.original.booking : null))
.filter((booking): booking is NonNullable<typeof booking> => booking !== null);
}, [rows]);
const handleWeekStartChange = useCallback(
(newWeekStart: dayjs.Dayjs) => {
setCurrentWeekStart(newWeekStart);
const startDate = newWeekStart.toDate();
const endDate = newWeekStart.add(6, "day").toDate();
if (!dateRange) {
return;
}
const rangeStart = dateRange.startDate ? new Date(dateRange.startDate) : null;
const rangeEnd = dateRange.endDate ? new Date(dateRange.endDate) : null;
const needsStartUpdate = !rangeStart || startDate < rangeStart;
const needsEndUpdate = !rangeEnd || endDate > rangeEnd;
if (!needsStartUpdate && !needsEndUpdate) {
return;
}
const newStartDate = needsStartUpdate ? startDate : rangeStart;
const newEndDate = needsEndUpdate ? endDate : rangeEnd;
updateFilter("dateRange", {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: newStartDate.toISOString(),
endDate: newEndDate.toISOString(),
preset: CUSTOM_PRESET.value,
},
});
},
[dateRange, updateFilter, setCurrentWeekStart]
);
return (
<>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<DataTableFilters.FilterBar table={table} />
<DataTableFilters.FilterBar table={table} columnIdsToHide={COLUMN_IDS_TO_HIDE} />
</div>
<div className="flex items-center gap-2">
@@ -26,9 +99,11 @@ export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
<DataTableSegment.Select />
</div>
</div>
<div className="flex items-center justify-center pt-2 xl:pt-0">
<EmptyScreen Icon="calendar" headline="Calendar view" description="Calendar view is coming soon." />
</div>
<BookingsCalendarView
bookings={bookings}
currentWeekStart={currentWeekStart}
onWeekStartChange={handleWeekStartChange}
/>
</>
);
}
@@ -0,0 +1,150 @@
"use client";
import { useMemo, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { Icon } from "@calcom/ui/components/icon";
import type { BookingOutput } from "../types";
type BookingsCalendarViewProps = {
bookings: BookingOutput[];
currentWeekStart: dayjs.Dayjs;
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
};
export function BookingsCalendarView({
bookings,
currentWeekStart,
onWeekStartChange,
}: BookingsCalendarViewProps) {
const { t } = useLocale();
const { timezone } = useTimePreferences();
const { resolvedTheme, forcedTheme } = useGetTheme();
const goToPreviousWeek = () => {
onWeekStartChange(currentWeekStart.subtract(1, "week"));
};
const goToNextWeek = () => {
onWeekStartChange(currentWeekStart.add(1, "week"));
};
const goToToday = () => {
onWeekStartChange(dayjs().startOf("week"));
};
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
useEffect(() => {
onWeekStartChange(currentWeekStart);
}, []);
const events = useMemo<CalendarEvent[]>(() => {
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
return bookings
.filter((booking) => {
const bookingStart = dayjs(booking.startTime);
return (
(bookingStart.isAfter(currentWeekStart) || bookingStart.isSame(currentWeekStart)) &&
bookingStart.isBefore(currentWeekStart.add(7, "day"))
);
})
.sort((a, b) => {
const startDiff = new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
if (startDiff !== 0) return startDiff;
return new Date(a.endTime).getTime() - new Date(b.endTime).getTime();
})
.map((booking, idx) => {
// Parse eventTypeColor and extract the appropriate color based on theme
const eventTypeColor =
booking.eventType?.eventTypeColor &&
booking.eventType.eventTypeColor[hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"];
return {
id: idx,
title: booking.title,
start: new Date(booking.startTime),
end: new Date(booking.endTime),
options: {
status: booking.status,
...(eventTypeColor && { color: eventTypeColor }),
},
};
});
}, [bookings, currentWeekStart, resolvedTheme, forcedTheme]);
const weekStart = currentWeekStart;
const weekEnd = currentWeekStart.add(6, "day");
const startMonth = weekStart.format("MMM");
const endMonth = weekEnd.format("MMM");
const year = weekEnd.format("YYYY");
const weekRange =
startMonth === endMonth ? (
<>
<span className="text-emphasis">{`${startMonth} ${weekStart.format("D")} - ${weekEnd.format(
"D"
)}`}</span>
<span className="text-muted">, {year}</span>
</>
) : (
<>
<span className="text-emphasis">{`${weekStart.format("MMM D")} - ${weekEnd.format("MMM D")}`}</span>
<span className="text-muted">, {year}</span>
</>
);
return (
<div className="border-subtle flex h-[calc(100vh-260px)] min-h-[600px] flex-col rounded-2xl border">
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">{weekRange}</h2>
</div>
<div className="flex items-center gap-2">
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
{t("today")}
</Button>
<ButtonGroup combined>
<Button color="secondary" onClick={goToPreviousWeek}>
<span className="sr-only">{t("view_previous_week")}</span>
<Icon name="chevron-left" className="h-4 w-4" />
</Button>
<Button color="secondary" onClick={goToNextWeek}>
<span className="sr-only">{t("view_next_week")}</span>
<Icon name="chevron-right" className="h-4 w-4" />
</Button>
</ButtonGroup>
</div>
</div>
<div className="flex-1 overflow-y-auto overflow-x-hidden rounded-2xl">
<Calendar
timezone={timezone}
sortEvents
startHour={0}
endHour={23}
events={events}
startDate={startDate}
endDate={endDate}
gridCellsPerHour={4}
hoverEventDuration={0}
showBackgroundPattern={false}
showBorder={false}
borderColor="subtle"
onEventClick={(_event) => {}}
hideHeader
/>
</div>
</div>
);
}
@@ -40,6 +40,7 @@ type BookingsProps = {
permissions: {
canReadOthersBookings: boolean;
};
isCalendarViewEnabled: boolean;
};
function useSystemSegments(userId?: number) {
@@ -89,8 +90,10 @@ const viewParser = createParser({
serialize: (value: "list" | "calendar") => value,
});
function BookingsContent({ status, permissions }: BookingsProps) {
const [view] = useQueryState("view", viewParser.withDefault("list"));
function BookingsContent({ status, permissions, isCalendarViewEnabled }: BookingsProps) {
const [_view] = useQueryState("view", viewParser.withDefault("list"));
// Force view to be "list" if calendar view is disabled
const view = isCalendarViewEnabled ? _view : "list";
const { t } = useLocale();
const user = useMeQuery().data;
const searchParams = useSearchParams();
+1 -3
View File
@@ -86,9 +86,7 @@ function InsightsPageContent() {
className="flex flex-wrap items-center gap-2"
data-testid={`insights-filters-${isAll}-${teamId}-${userId}`}>
<OrgTeamsFilter />
<DataTableFilters.AddFilterButton table={table} hideWhenFilterApplied />
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.AddFilterButton table={table} variant="sm" showWhenFilterApplied />
<DataTableFilters.FilterBar table={table} />
<DataTableFilters.ClearFiltersButton exclude={["startTime", "createdAt"]} />
<div className="grow" />
<Download />
@@ -3903,6 +3903,8 @@
"onboarding_plan_team_badge": "$15/user/mo",
"onboarding_plan_organization_badge": "$37/user/mo",
"onboarding_plan_organization_description": "Robust scheduling for larger teams looking to have more control, privacy, and security.",
"view_previous_week": "View previous week",
"view_next_week": "View next week",
"invite_teammates": "Invite Teammates",
"organization_brand": "Organization Brand",
"organization_details": "Organization Details",
@@ -65,7 +65,6 @@ export const LargeCalendar = ({
start: new Date(booking.start),
end: new Date(booking.end),
options: {
borderColor: "black",
status: booking.status.toUpperCase() as BookingStatus,
"data-test-id": "troubleshooter-busy-event",
className: "border-[1.5px]",
@@ -13,7 +13,7 @@ export const events: CalendarEvent[] = [
end: startDate.add(45, "minutes").toDate(),
options: {
allDay: false,
borderColor: "#ff0000",
color: "#ff0000",
status: "ACCEPTED",
},
source: "Booking",
@@ -37,7 +37,7 @@ export const events: CalendarEvent[] = [
source: "Booking",
options: {
status: "PENDING",
borderColor: "#ff0000",
color: "#ff0000",
allDay: false,
},
},
@@ -31,6 +31,9 @@ export function Calendar(props: CalendarComponentProps) {
const availableTimeslots = useCalendarStore((state) => state.availableTimeslots);
const hideHeader = useCalendarStore((state) => state.hideHeader);
const timezone = useCalendarStore((state) => state.timezone);
const showBackgroundPattern = useCalendarStore((state) => state.showBackgroundPattern);
const showBorder = useCalendarStore((state) => state.showBorder ?? true);
const borderColor = useCalendarStore((state) => state.borderColor ?? "default");
const days = useMemo(() => getDaysBetweenDates(startDate, endDate), [startDate, endDate]);
@@ -64,23 +67,41 @@ export function Calendar(props: CalendarComponentProps) {
<div
style={{ width: "165%" }}
className="flex h-full max-w-full flex-none flex-col sm:max-w-none md:max-w-full">
<DateValues containerNavRef={containerNav} days={days} />
<DateValues
containerNavRef={containerNav}
days={days}
showBorder={showBorder}
borderColor={borderColor}
/>
<div className="relative flex flex-auto">
<CurrentTime />
<div className="bg-default dark:bg-muted ring-muted border-default sticky left-0 z-10 w-14 flex-none border-l border-r ring-1" />
<CurrentTime timezone={timezone} />
<div
className={classNames(
"bg-default dark:bg-muted ring-muted sticky left-0 z-10 w-16 flex-none ring-1",
showBorder &&
(borderColor === "subtle"
? "border-subtle border-l border-r"
: "border-default border-l border-r")
)}
/>
<div
className="grid flex-auto grid-cols-1 grid-rows-1 [--disabled-gradient-background:#F8F9FB] [--disabled-gradient-foreground:#E6E7EB] dark:[--disabled-gradient-background:#262626] dark:[--disabled-gradient-foreground:#393939]"
style={{
backgroundColor: "var(--disabled-gradient-background)",
background:
"repeating-linear-gradient(-45deg, var(--disabled-gradient-background), var(--disabled-gradient-background) 2.5px, var(--disabled-gradient-foreground) 2.5px, var(--disabled-gradient-foreground) 5px)",
}}>
style={
showBackgroundPattern === false
? undefined
: {
backgroundColor: "var(--disabled-gradient-background)",
background:
"repeating-linear-gradient(-45deg, var(--disabled-gradient-background), var(--disabled-gradient-background) 2.5px, var(--disabled-gradient-foreground) 2.5px, var(--disabled-gradient-foreground) 5px)",
}
}>
<HorizontalLines
hours={hours}
numberOfGridStopsPerCell={usersCellsStopsPerHour}
containerOffsetRef={containerOffset}
borderColor={borderColor}
/>
<VerticalLines days={days} />
<VerticalLines days={days} borderColor={borderColor} />
<SchedulerColumns
offsetHeight={containerOffset.current?.offsetHeight}
@@ -113,6 +134,7 @@ export function Calendar(props: CalendarComponentProps) {
{availableTimeslots ? (
<AvailableCellsForDay
key={days[i].toISOString()}
timezone={timezone}
day={days[i]}
startHour={startHour}
availableSlots={availableTimeslots}
@@ -4,12 +4,16 @@ import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import type { BorderColor } from "../../types/common";
type Props = {
showBorder: boolean;
borderColor: BorderColor;
days: dayjs.Dayjs[];
containerNavRef: React.RefObject<HTMLDivElement>;
};
export function DateValues({ days, containerNavRef }: Props) {
export function DateValues({ showBorder, borderColor, days, containerNavRef }: Props) {
const { i18n } = useLocale();
const formatDate = (date: dayjs.Dayjs): string => {
return new Intl.DateTimeFormat(i18n.language, { weekday: "short" }).format(date.toDate());
@@ -17,8 +21,12 @@ export function DateValues({ days, containerNavRef }: Props) {
return (
<div
ref={containerNavRef}
className="bg-default dark:bg-muted border-b-subtle rtl:border-r-default sticky top-[var(--calendar-dates-sticky-offset,0px)] z-[80] flex-none border-b border-r sm:pr-8">
<div className="text-subtle flex text-sm leading-6 sm:hidden" data-dayslength={days.length}>
className={classNames(
"bg-default dark:bg-default sticky top-[var(--calendar-dates-sticky-offset,0px)] z-[80] flex-none border-b sm:pr-8",
borderColor === "subtle" ? "border-b-subtle" : "border-b-default",
showBorder && (borderColor === "subtle" ? "border-r-subtle border-r" : "border-r-default border-r")
)}>
<div className="text-subtle flex leading-6 sm:hidden" data-dayslength={days.length}>
{days.map((day) => {
const isToday = dayjs().isSame(day, "day");
return (
@@ -29,8 +37,8 @@ export function DateValues({ days, containerNavRef }: Props) {
{day.format("dd")}{" "}
<span
className={classNames(
"text-emphasis mt-1 flex h-8 w-8 items-center justify-center font-semibold",
isToday && "bg-inverted text-inverted rounded-full"
"text-emphasis mt-1 flex h-8 w-8 items-center justify-center font-medium",
isToday && "bg-inverted text-inverted rounded-sm"
)}>
{day.format("D")}
</span>
@@ -38,8 +46,14 @@ export function DateValues({ days, containerNavRef }: Props) {
);
})}
</div>
<div className="text-subtle -mr-px hidden auto-cols-fr text-sm leading-6 sm:flex ">
<div className="border-default col-end-1 w-14 ltr:border-l" />
<div className="text-subtle -mr-px hidden auto-cols-fr leading-6 sm:flex">
<div
className={classNames(
"col-end-1 w-16",
showBorder &&
(borderColor === "subtle" ? "border-l-subtle border-l" : "border-l-default border-l")
)}
/>
{days.map((day) => {
const isToday = dayjs().isSame(day, "day");
return (
@@ -47,14 +61,14 @@ export function DateValues({ days, containerNavRef }: Props) {
key={day.toString()}
className={classNames(
"flex flex-1 items-center justify-center py-3 text-xs font-medium uppercase",
isToday && "font-bold"
isToday && "text-default"
)}>
<span>
{formatDate(day)}{" "}
<span
className={classNames(
"items-center justify-center p-1",
isToday && "bg-brand-default text-brand rounded-full"
isToday && "bg-brand-default text-brand rounded-md"
)}>
{day.format("DD")}
</span>
@@ -1,8 +1,8 @@
import { useEffect, useState, useRef } from "react";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { useBookerTime } from "../../../../bookings/Booker/components/hooks/useBookerTime";
import { useCalendarStore } from "../../state/store";
function calculateMinutesFromStart(startHour: number, currentHour: number, currentMinute: number) {
@@ -11,7 +11,8 @@ function calculateMinutesFromStart(startHour: number, currentHour: number, curre
return currentMinuteOfDay - startMinute;
}
export function CurrentTime() {
export function CurrentTime({ timezone }: { timezone: string }) {
const { timeFormat } = useTimePreferences();
const currentTimeRef = useRef<HTMLDivElement>(null);
const [scrolledIntoView, setScrolledIntoView] = useState(false);
const [currentTimePos, setCurrentTimePos] = useState<number | null>(null);
@@ -19,7 +20,6 @@ export function CurrentTime() {
startHour: state.startHour || 0,
endHour: state.endHour || 23,
}));
const { timeFormat, timezone } = useBookerTime();
useEffect(() => {
// Set the container scroll position based on the current time.
@@ -39,11 +39,10 @@ export function CurrentTime() {
if (!currentTimeRef.current || scrolledIntoView) return;
// Within a small timeout so element has time to render.
setTimeout(() => {
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Doesn't seem to cause any issue. Put it under condition if needed
// Doesn't seem to cause any issue. Put it under condition if needed
currentTimeRef?.current?.scrollIntoView({ block: "center" });
setScrolledIntoView(true);
}, 100);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [startHour, endHour, scrolledIntoView, timezone]);
return (
@@ -55,7 +54,7 @@ export function CurrentTime() {
top: `calc(${currentTimePos}*var(--one-minute-height) + var(--calendar-offset-top))`,
zIndex: 70,
}}>
<div className="w-14 pr-2 text-right">{dayjs().tz(timezone).format(timeFormat)}</div>
<div className="w-16 pr-2 text-right">{dayjs().tz(timezone).format(timeFormat)}</div>
<div className="bg-inverted h-3 w-px" />
<div className="bg-inverted h-px w-screen" />
</div>
@@ -3,10 +3,10 @@ import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import classNames from "@calcom/ui/classNames";
import { OutOfOfficeInSlots } from "../../../../bookings/Booker/components/OutOfOfficeInSlots";
import { useBookerTime } from "../../../../bookings/Booker/components/hooks/useBookerTime";
import { useCalendarStore } from "../../state/store";
import type { CalendarAvailableTimeslots } from "../../types/state";
import type { GridCellToDateProps } from "../../utils";
@@ -33,13 +33,13 @@ export function EmptyCell(props: EmptyCellProps) {
}
type AvailableCellProps = {
timezone: string;
availableSlots: CalendarAvailableTimeslots;
day: GridCellToDateProps["day"];
startHour: GridCellToDateProps["startHour"];
};
export function AvailableCellsForDay({ availableSlots, day, startHour }: AvailableCellProps) {
const { timezone } = useBookerTime();
export function AvailableCellsForDay({ timezone, availableSlots, day, startHour }: AvailableCellProps) {
const date = dayjs(day);
const dateFormatted = date.format("YYYY-MM-DD");
const slotsForToday = availableSlots && availableSlots[dateFormatted];
@@ -136,7 +136,7 @@ type CellProps = {
};
function Cell({ isDisabled, topOffsetMinutes, timeSlot }: CellProps) {
const { timeFormat } = useBookerTime();
const { timeFormat } = useTimePreferences();
const { onEmptyCellClick, hoverEventDuration } = useCalendarStore(
(state) => ({
@@ -163,7 +163,7 @@ function Cell({ isDisabled, topOffsetMinutes, timeSlot }: CellProps) {
top: topOffsetMinutes ? `calc(${topOffsetMinutes}*var(--one-minute-height))` : undefined,
}}
onClick={() => {
onEmptyCellClick && onEmptyCellClick(timeSlot.toDate());
onEmptyCellClick?.(timeSlot.toDate());
}}>
{!isDisabled && hoverEventDuration !== 0 && (
<div
@@ -1,8 +1,8 @@
import { cva } from "class-variance-authority";
import dayjs from "@calcom/dayjs";
import { Tooltip } from "@calcom/ui/components/tooltip";
import classNames from "@calcom/ui/classNames";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { CalendarEvent } from "../../types/events";
@@ -15,15 +15,15 @@ type EventProps = {
};
const eventClasses = cva(
"group flex h-full w-full overflow-y-auto rounded-[6px] px-[6px] text-xs font-semibold leading-5 opacity-80",
"group flex h-full w-full overflow-x-hidden overflow-y-auto rounded-[6px] px-[6px] text-xs leading-5 opacity-80 border-default font-medium",
{
variants: {
status: {
ACCEPTED: "bg-subtle hover:bg-emphasis text-emphasis border-[1px] border-gray-900",
PENDING: "bg-default text-emphasis border-[1px] border-dashed border-gray-900",
REJECTED: "",
CANCELLED: "",
AWAITING_HOST: "",
ACCEPTED: "bg-subtle hover:bg-emphasis text-emphasis border-[1px] border-default",
PENDING: "bg-muted text-emphasis border-[1px] border-dashed border-default",
REJECTED: "bg-muted border-[1px] text-subtle line-through",
CANCELLED: "bg-muted border-[1px] text-subtle line-through",
AWAITING_HOST: "bg-muted text-emphasis border-[1px] border-dashed border-default",
},
disabled: {
true: "hover:cursor-default",
@@ -33,14 +33,6 @@ const eventClasses = cva(
true: "bg-inverted text-inverted border-[1px] border-transparent",
false: "",
},
borderColor: {
ACCEPTED: "border-gray-900",
PENDING: "border-gray-900",
REJECTED: "border-gray-900",
CANCELLED: "border-gray-900",
AWAITING_HOST: "",
custom: "",
},
},
}
);
@@ -55,14 +47,6 @@ export function Event({
const selected = currentlySelectedEventId === event.id;
const { options } = event;
const borderColor = options?.borderColor ? "custom" : options?.status;
const styles = options?.borderColor
? {
borderColor: options?.borderColor,
}
: {};
const Component = onEventClick ? "button" : "div";
return (
@@ -74,32 +58,36 @@ export function Event({
status: options?.status,
disabled,
selected,
borderColor,
}),
eventDuration > 30 && "flex-col py-1",
options?.className
)}>
{options?.color && (
<div
className="-ml-1.5 mr-1.5 h-full w-[3px] shrink-0"
style={{ backgroundColor: options.color }}></div>
)}
style={styles}>
<div
className={classNames(
"flex w-full gap-2 overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4",
eventDuration <= 30 && "items-center"
)}>
<span>{event.title}</span>
{eventDuration <= 30 && !event.options?.hideTime && (
<p className="text-subtle w-full whitespace-nowrap text-left text-[10px] leading-none">
<div className={classNames("flex", eventDuration > 30 && "flex-col py-1")}>
<div
className={classNames(
"flex w-full gap-2 overflow-hidden overflow-ellipsis whitespace-nowrap text-left leading-4",
eventDuration <= 30 && "items-center"
)}>
<span>{event.title}</span>
{eventDuration <= 30 && !event.options?.hideTime && (
<p className="text-subtle mt-1 w-full whitespace-nowrap text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
</div>
{eventDuration > 30 && !event.options?.hideTime && (
<p className="text-subtle mt-1 text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
{eventDuration > 45 && event.description && (
<p className="text-subtle mt-1 text-left text-[10px] leading-none">{event.description}</p>
)}
</div>
{eventDuration > 30 && !event.options?.hideTime && (
<p className="text-subtle text-left text-[10px] leading-none">
{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}
</p>
)}
{eventDuration > 45 && event.description && (
<p className="text-subtle text-left text-[10px] leading-none">{event.description}</p>
)}
</Component>
</Tooltip>
);
@@ -2,15 +2,19 @@ import { useId } from "react";
import type dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import classNames from "@calcom/ui/classNames";
import type { BorderColor } from "../../types/common";
export const HorizontalLines = ({
hours,
numberOfGridStopsPerCell,
containerOffsetRef,
borderColor,
}: {
hours: dayjs.Dayjs[];
numberOfGridStopsPerCell: number;
containerOffsetRef: React.RefObject<HTMLDivElement>;
borderColor: BorderColor;
}) => {
const { timeFormat } = useTimePreferences();
// We need to force the minute to zero, because otherwise in ex GMT+5.5, it would show :30 minute times (but at the positino of :00)
@@ -19,7 +23,10 @@ export const HorizontalLines = ({
return (
<div
className=" divide-default pointer-events-none relative z-[60] col-start-1 col-end-2 row-start-1 grid divide-y"
className={classNames(
"pointer-events-none relative z-[60] col-start-1 col-end-2 row-start-1 grid divide-y",
borderColor === "subtle" ? "divide-subtle" : "divide-default"
)}
style={{
gridTemplateRows: `repeat(${hours.length}, minmax(var(--gridDefaultSize),1fr)`,
}}>
@@ -1,6 +1,9 @@
import type dayjs from "@calcom/dayjs";
import classNames from "@calcom/ui/classNames";
export const VerticalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
import type { BorderColor } from "../../types/common";
export const VerticalLines = ({ days, borderColor }: { days: dayjs.Dayjs[]; borderColor: BorderColor }) => {
const isRTL = () => {
let userLanguage = "en"; // Default to 'en' if navigator is not defined
@@ -15,8 +18,10 @@ export const VerticalLines = ({ days }: { days: dayjs.Dayjs[] }) => {
return (
<div
className="divide-default pointer-events-none relative z-[60] col-start-1 col-end-2 row-start-1 grid
auto-cols-auto grid-rows-1 divide-x sm:pr-8"
className={classNames(
"pointer-events-none relative z-[60] col-start-1 col-end-2 row-start-1 grid auto-cols-auto grid-rows-1 divide-x sm:pr-8",
borderColor === "subtle" ? "divide-subtle" : "divide-default"
)}
dir={direction}
style={{
direction: direction,
@@ -20,6 +20,9 @@ const defaultState: CalendarComponentProps = {
endHour: 23,
gridCellsPerHour: 4,
timezone: CURRENT_TIMEZONE,
showBackgroundPattern: true,
showBorder: true,
borderColor: "default",
};
export const useCalendarStore = create<CalendarStoreProps>((set) => ({
@@ -0,0 +1 @@
export type BorderColor = "default" | "subtle";
@@ -11,7 +11,7 @@ export interface CalendarEvent {
status?: BookingStatus;
hideTime?: boolean;
allDay?: boolean;
borderColor?: string;
color?: string;
className?: string;
"data-test-id"?: string;
};
@@ -1,6 +1,7 @@
import type { IFromUser, IToUser } from "@calcom/features/availability/lib/getUserAvailability";
import type { TimeRange } from "@calcom/types/schedule";
import type { BorderColor } from "./common";
import type { CalendarEvent } from "./events";
export type View = "month" | "week" | "day";
@@ -131,6 +132,21 @@ export type CalendarState = {
* Timezone to use for displaying times in the calendar
*/
timezone: string;
/**
* Show the background pattern for unavailable areas
* @default true
*/
showBackgroundPattern?: boolean;
/**
* Show borders on the leftmost and rightmost sides of the calendar container
* @default true
*/
showBorder?: boolean;
/**
* Border color for calendar cells
* @default "default"
*/
borderColor?: BorderColor;
};
export type CalendarComponentProps = CalendarPublicActions & CalendarState & { isPending?: boolean };
@@ -1,8 +1,6 @@
"use client";
import { type Table } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import { Fragment } from "react";
import { useDataTable, useFilterableColumns } from "../../hooks";
import { ColumnFilterType } from "../../lib/types";
@@ -12,9 +10,10 @@ import { FilterPopover } from "./FilterPopover";
// Add the new ActiveFilters component
interface ActiveFiltersProps<TData> {
table: Table<TData>;
columnIdsToHide?: string[];
}
export function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
export function ActiveFilters<TData>({ table, columnIdsToHide }: ActiveFiltersProps<TData>) {
const { activeFilters } = useDataTable();
const filterableColumns = useFilterableColumns(table);
@@ -22,7 +21,12 @@ export function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
<>
{activeFilters.map((filter) => {
const column = filterableColumns.find((col) => col.id === filter.f);
if (!column) return null;
if (!column) {
return null;
}
if (columnIdsToHide?.includes(column.id)) {
return null;
}
if (column.type === ColumnFilterType.DATE_RANGE) {
return (
@@ -1,7 +1,6 @@
"use client";
import { type Table } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import startCase from "lodash/startCase";
import { forwardRef, useState } from "react";
@@ -17,17 +16,10 @@ import { useDataTable, useFilterableColumns } from "../../hooks";
export interface AddFilterButtonProps<TData> {
table: Table<TData>;
variant?: "base" | "sm";
hideWhenFilterApplied?: boolean;
showWhenFilterApplied?: boolean;
}
function AddFilterButtonComponent<TData>(
{
table,
variant = "base",
hideWhenFilterApplied = false,
showWhenFilterApplied = false,
}: AddFilterButtonProps<TData>,
{ table, variant = "base" }: AddFilterButtonProps<TData>,
ref: React.Ref<HTMLButtonElement>
) {
const { t } = useLocale();
@@ -39,14 +31,6 @@ function AddFilterButtonComponent<TData>(
(column) => !activeFilters?.some((filter) => filter.f === column.id)
);
if (hideWhenFilterApplied && activeFilters?.length > 0) {
return null;
}
if (showWhenFilterApplied && activeFilters?.length === 0) {
return null;
}
if (variant === "sm" && availableColumns.length === 0) {
return null;
}
@@ -1,18 +1,30 @@
import { type Table } from "@tanstack/react-table";
"use client";
import { type Table } from "@tanstack/react-table";
import { useMemo } from "react";
import { useDataTable } from "../../hooks";
import { ActiveFilters } from "./ActiveFilters";
import { AddFilterButton } from "./AddFilterButton";
interface FilterBarProps<TData> {
table: Table<TData>;
columnIdsToHide?: string[];
}
export function FilterBar<TData>({ table }: FilterBarProps<TData>) {
export function FilterBar<TData>({ table, columnIdsToHide }: FilterBarProps<TData>) {
const { activeFilters } = useDataTable();
const displayedFilterCount = useMemo(() => {
return (activeFilters ?? []).filter((filter) =>
columnIdsToHide ? !columnIdsToHide.includes(filter.f) : true
).length;
}, [activeFilters, columnIdsToHide]);
return (
<>
<AddFilterButton table={table} hideWhenFilterApplied />
<ActiveFilters table={table} />
<AddFilterButton table={table} variant="sm" showWhenFilterApplied />
{displayedFilterCount === 0 && <AddFilterButton table={table} />}
<ActiveFilters table={table} columnIdsToHide={columnIdsToHide} />
{displayedFilterCount > 0 && <AddFilterButton table={table} variant="sm" />}
</>
);
}
+1
View File
@@ -30,6 +30,7 @@ export type AppFlags = {
"calendar-subscription-sync": boolean;
"onboarding-v3": boolean;
"booker-botid": boolean;
"booking-calendar-view": boolean;
};
export type TeamFeatures = Record<keyof AppFlags, boolean>;
+1
View File
@@ -29,6 +29,7 @@ const initialData: AppFlags = {
"calendar-subscription-sync": false,
"onboarding-v3": false,
"booker-botid": false,
"booking-calendar-view": false,
};
if (process.env.NEXT_PUBLIC_IS_E2E) {
@@ -68,8 +68,8 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
start: new Date(event.start),
end: new Date(event.end),
options: {
borderColor:
event.source && calendarToColorMap[event.source] ? calendarToColorMap[event.source] : "black",
color:
event.source && calendarToColorMap[event.source] ? calendarToColorMap[event.source] : undefined,
status: BookingStatus.ACCEPTED,
"data-test-id": "troubleshooter-busy-event",
},
@@ -99,7 +99,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
start: dateOverrideStart.add(workingHoursForDay.startTime, "minutes").toDate(),
end: dateOverrideEnd.add(workingHoursForDay.endTime, "minutes").toDate(),
options: {
borderColor: "black",
color: "black",
status: BookingStatus.ACCEPTED,
"data-test-id": "troubleshooter-busy-time",
},
@@ -0,0 +1,9 @@
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'booking-calendar-view',
false,
'Enable booking calendar view for all users',
'EXPERIMENT'
) ON CONFLICT (slug) DO NOTHING;