From f00c14d0c11680a53826cdc94f496526ca6a427e Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 31 Oct 2025 12:13:41 +0100 Subject: [PATCH] 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 * 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 * 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 * 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> --- .../(main-nav)/bookings/[status]/page.tsx | 8 + .../bookings/components/BookingsCalendar.tsx | 87 +++++++++- .../components/BookingsCalendarView.tsx | 150 ++++++++++++++++++ .../modules/bookings/views/bookings-view.tsx | 7 +- apps/web/modules/insights/insights-view.tsx | 4 +- apps/web/public/static/locales/en/common.json | 2 + .../features/calendar-view/LargeCalendar.tsx | 1 - .../calendars/weeklyview/_storybookData.ts | 4 +- .../weeklyview/components/Calendar.tsx | 40 +++-- .../components/DateValues/index.tsx | 32 ++-- .../components/currentTime/index.tsx | 11 +- .../weeklyview/components/event/Empty.tsx | 10 +- .../weeklyview/components/event/Event.tsx | 72 ++++----- .../components/horizontalLines/index.tsx | 11 +- .../components/verticalLines/index.tsx | 11 +- .../calendars/weeklyview/state/store.ts | 3 + .../calendars/weeklyview/types/common.ts | 1 + .../calendars/weeklyview/types/events.ts | 2 +- .../calendars/weeklyview/types/state.ts | 16 ++ .../components/filters/ActiveFilters.tsx | 12 +- .../components/filters/AddFilterButton.tsx | 18 +-- .../components/filters/FilterBar.tsx | 22 ++- packages/features/flags/config.ts | 1 + packages/features/flags/hooks/index.ts | 1 + .../components/LargeCalendar.tsx | 6 +- .../migration.sql | 9 ++ 26 files changed, 421 insertions(+), 120 deletions(-) create mode 100644 apps/web/modules/bookings/components/BookingsCalendarView.tsx create mode 100644 packages/features/calendars/weeklyview/types/common.ts create mode 100644 packages/prisma/migrations/20251029103123_add_booking_calendar_view_feature_flag/migration.sql diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx index dfb17af3ee..f749bfe150 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx @@ -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 ( ); diff --git a/apps/web/modules/bookings/components/BookingsCalendar.tsx b/apps/web/modules/bookings/components/BookingsCalendar.tsx index eb1cf6b6c5..8c7c68bb57 100644 --- a/apps/web/modules/bookings/components/BookingsCalendar.tsx +++ b/apps/web/modules/bookings/components/BookingsCalendar.tsx @@ -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; }; +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 => 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 ( <>
- +
@@ -26,9 +99,11 @@ export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
-
- -
+ ); } diff --git a/apps/web/modules/bookings/components/BookingsCalendarView.tsx b/apps/web/modules/bookings/components/BookingsCalendarView.tsx new file mode 100644 index 0000000000..6a24a62f72 --- /dev/null +++ b/apps/web/modules/bookings/components/BookingsCalendarView.tsx @@ -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(() => { + 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 ? ( + <> + {`${startMonth} ${weekStart.format("D")} - ${weekEnd.format( + "D" + )}`} + , {year} + + ) : ( + <> + {`${weekStart.format("MMM D")} - ${weekEnd.format("MMM D")}`} + , {year} + + ); + + return ( +
+
+
+

{weekRange}

+
+
+ + + + + +
+
+ +
+ {}} + hideHeader + /> +
+
+ ); +} diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx index 88b9206868..77f7e50f06 100644 --- a/apps/web/modules/bookings/views/bookings-view.tsx +++ b/apps/web/modules/bookings/views/bookings-view.tsx @@ -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(); diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index bd02a21bdd..5c2003fe82 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -86,9 +86,7 @@ function InsightsPageContent() { className="flex flex-wrap items-center gap-2" data-testid={`insights-filters-${isAll}-${teamId}-${userId}`}> - - - +
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 597fdceb0a..fc9b3fd001 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/calendar-view/LargeCalendar.tsx b/packages/features/calendar-view/LargeCalendar.tsx index 53b8acf257..538ab9df22 100644 --- a/packages/features/calendar-view/LargeCalendar.tsx +++ b/packages/features/calendar-view/LargeCalendar.tsx @@ -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]", diff --git a/packages/features/calendars/weeklyview/_storybookData.ts b/packages/features/calendars/weeklyview/_storybookData.ts index 13c816d6aa..35c6bbc752 100644 --- a/packages/features/calendars/weeklyview/_storybookData.ts +++ b/packages/features/calendars/weeklyview/_storybookData.ts @@ -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, }, }, diff --git a/packages/features/calendars/weeklyview/components/Calendar.tsx b/packages/features/calendars/weeklyview/components/Calendar.tsx index ef8844f0dd..5206411241 100644 --- a/packages/features/calendars/weeklyview/components/Calendar.tsx +++ b/packages/features/calendars/weeklyview/components/Calendar.tsx @@ -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) {
- +
- -
+ +
+ 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)", + } + }> - + ; }; -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 (
-
+ 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") + )}> +
{days.map((day) => { const isToday = dayjs().isSame(day, "day"); return ( @@ -29,8 +37,8 @@ export function DateValues({ days, containerNavRef }: Props) { {day.format("dd")}{" "} {day.format("D")} @@ -38,8 +46,14 @@ export function DateValues({ days, containerNavRef }: Props) { ); })}
-
-
+
+
{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" )}> {formatDate(day)}{" "} {day.format("DD")} diff --git a/packages/features/calendars/weeklyview/components/currentTime/index.tsx b/packages/features/calendars/weeklyview/components/currentTime/index.tsx index 56c94b7873..f8e1d78ba8 100644 --- a/packages/features/calendars/weeklyview/components/currentTime/index.tsx +++ b/packages/features/calendars/weeklyview/components/currentTime/index.tsx @@ -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(null); const [scrolledIntoView, setScrolledIntoView] = useState(false); const [currentTimePos, setCurrentTimePos] = useState(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, }}> -
{dayjs().tz(timezone).format(timeFormat)}
+
{dayjs().tz(timezone).format(timeFormat)}
diff --git a/packages/features/calendars/weeklyview/components/event/Empty.tsx b/packages/features/calendars/weeklyview/components/event/Empty.tsx index 913f09980c..6ec98b2cad 100644 --- a/packages/features/calendars/weeklyview/components/event/Empty.tsx +++ b/packages/features/calendars/weeklyview/components/event/Empty.tsx @@ -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 && (
30 && "flex-col py-1", options?.className + )}> + {options?.color && ( +
)} - style={styles}> -
- {event.title} - {eventDuration <= 30 && !event.options?.hideTime && ( -

+

30 && "flex-col py-1")}> +
+ {event.title} + {eventDuration <= 30 && !event.options?.hideTime && ( +

+ {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")} +

+ )} +
+ {eventDuration > 30 && !event.options?.hideTime && ( +

{dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")}

)} + {eventDuration > 45 && event.description && ( +

{event.description}

+ )}
- {eventDuration > 30 && !event.options?.hideTime && ( -

- {dayjs(event.start).format("HH:mm")} - {dayjs(event.end).format("HH:mm")} -

- )} - {eventDuration > 45 && event.description && ( -

{event.description}

- )} ); diff --git a/packages/features/calendars/weeklyview/components/horizontalLines/index.tsx b/packages/features/calendars/weeklyview/components/horizontalLines/index.tsx index b73c40be70..852eac14ce 100644 --- a/packages/features/calendars/weeklyview/components/horizontalLines/index.tsx +++ b/packages/features/calendars/weeklyview/components/horizontalLines/index.tsx @@ -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; + 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 (
diff --git a/packages/features/calendars/weeklyview/components/verticalLines/index.tsx b/packages/features/calendars/weeklyview/components/verticalLines/index.tsx index dfc1f9560a..b021bdb0f4 100644 --- a/packages/features/calendars/weeklyview/components/verticalLines/index.tsx +++ b/packages/features/calendars/weeklyview/components/verticalLines/index.tsx @@ -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 (
((set) => ({ diff --git a/packages/features/calendars/weeklyview/types/common.ts b/packages/features/calendars/weeklyview/types/common.ts new file mode 100644 index 0000000000..1b447070e7 --- /dev/null +++ b/packages/features/calendars/weeklyview/types/common.ts @@ -0,0 +1 @@ +export type BorderColor = "default" | "subtle"; diff --git a/packages/features/calendars/weeklyview/types/events.ts b/packages/features/calendars/weeklyview/types/events.ts index 493750ea8f..5803d0a46d 100644 --- a/packages/features/calendars/weeklyview/types/events.ts +++ b/packages/features/calendars/weeklyview/types/events.ts @@ -11,7 +11,7 @@ export interface CalendarEvent { status?: BookingStatus; hideTime?: boolean; allDay?: boolean; - borderColor?: string; + color?: string; className?: string; "data-test-id"?: string; }; diff --git a/packages/features/calendars/weeklyview/types/state.ts b/packages/features/calendars/weeklyview/types/state.ts index a9fc46301b..00ebb33fd1 100644 --- a/packages/features/calendars/weeklyview/types/state.ts +++ b/packages/features/calendars/weeklyview/types/state.ts @@ -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 }; diff --git a/packages/features/data-table/components/filters/ActiveFilters.tsx b/packages/features/data-table/components/filters/ActiveFilters.tsx index 23cd495931..7e69e5031e 100644 --- a/packages/features/data-table/components/filters/ActiveFilters.tsx +++ b/packages/features/data-table/components/filters/ActiveFilters.tsx @@ -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 { table: Table; + columnIdsToHide?: string[]; } -export function ActiveFilters({ table }: ActiveFiltersProps) { +export function ActiveFilters({ table, columnIdsToHide }: ActiveFiltersProps) { const { activeFilters } = useDataTable(); const filterableColumns = useFilterableColumns(table); @@ -22,7 +21,12 @@ export function ActiveFilters({ table }: ActiveFiltersProps) { <> {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 ( diff --git a/packages/features/data-table/components/filters/AddFilterButton.tsx b/packages/features/data-table/components/filters/AddFilterButton.tsx index 71c182f15f..d7399c63c8 100644 --- a/packages/features/data-table/components/filters/AddFilterButton.tsx +++ b/packages/features/data-table/components/filters/AddFilterButton.tsx @@ -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 { table: Table; variant?: "base" | "sm"; - hideWhenFilterApplied?: boolean; - showWhenFilterApplied?: boolean; } function AddFilterButtonComponent( - { - table, - variant = "base", - hideWhenFilterApplied = false, - showWhenFilterApplied = false, - }: AddFilterButtonProps, + { table, variant = "base" }: AddFilterButtonProps, ref: React.Ref ) { const { t } = useLocale(); @@ -39,14 +31,6 @@ function AddFilterButtonComponent( (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; } diff --git a/packages/features/data-table/components/filters/FilterBar.tsx b/packages/features/data-table/components/filters/FilterBar.tsx index df2161141e..362759c433 100644 --- a/packages/features/data-table/components/filters/FilterBar.tsx +++ b/packages/features/data-table/components/filters/FilterBar.tsx @@ -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 { table: Table; + columnIdsToHide?: string[]; } -export function FilterBar({ table }: FilterBarProps) { +export function FilterBar({ table, columnIdsToHide }: FilterBarProps) { + const { activeFilters } = useDataTable(); + const displayedFilterCount = useMemo(() => { + return (activeFilters ?? []).filter((filter) => + columnIdsToHide ? !columnIdsToHide.includes(filter.f) : true + ).length; + }, [activeFilters, columnIdsToHide]); + return ( <> - - - + {displayedFilterCount === 0 && } + + {displayedFilterCount > 0 && } ); } diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 79582d7ac5..8002437a86 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -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; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index 05ce9e61ab..cc5c20b96e 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -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) { diff --git a/packages/features/troubleshooter/components/LargeCalendar.tsx b/packages/features/troubleshooter/components/LargeCalendar.tsx index 6ffd5ea060..de2f725a71 100644 --- a/packages/features/troubleshooter/components/LargeCalendar.tsx +++ b/packages/features/troubleshooter/components/LargeCalendar.tsx @@ -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", }, diff --git a/packages/prisma/migrations/20251029103123_add_booking_calendar_view_feature_flag/migration.sql b/packages/prisma/migrations/20251029103123_add_booking_calendar_view_feature_flag/migration.sql new file mode 100644 index 0000000000..2deec6873e --- /dev/null +++ b/packages/prisma/migrations/20251029103123_add_booking_calendar_view_feature_flag/migration.sql @@ -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;