Skip to content

Commit f00c14d

Browse files
feat: implement booking calendar view with weekly layout (calcom#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>
1 parent 8d4dd02 commit f00c14d

26 files changed

Lines changed: 420 additions & 119 deletions

File tree

apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { redirect } from "next/navigation";
66
import { z } from "zod";
77

88
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
9+
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
910
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
11+
import { prisma } from "@calcom/prisma";
1012
import { MembershipRole } from "@calcom/prisma/enums";
1113

1214
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -52,12 +54,18 @@ const Page = async ({ params }: PageProps) => {
5254
canReadOthersBookings = teamIdsWithPermission.length > 0;
5355
}
5456

57+
const featuresRepository = new FeaturesRepository(prisma);
58+
const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
59+
"booking-calendar-view"
60+
);
61+
5562
return (
5663
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
5764
<BookingsList
5865
status={parsed.data.status}
5966
userId={session?.user?.id}
6067
permissions={{ canReadOthersBookings }}
68+
isCalendarViewEnabled={isCalendarViewEnabled}
6169
/>
6270
</ShellMainAppDir>
6371
);
Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,96 @@
11
"use client";
22

33
import type { Table as ReactTable } from "@tanstack/react-table";
4+
import { createParser, useQueryState } from "nuqs";
5+
import { useMemo, useCallback } from "react";
46

5-
import { DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
6-
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
7+
import dayjs from "@calcom/dayjs";
8+
import {
9+
DataTableFilters,
10+
DataTableSegment,
11+
useDataTable,
12+
useFilterValue,
13+
ZDateRangeFilterValue,
14+
ColumnFilterType,
15+
} from "@calcom/features/data-table";
16+
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
717

818
import type { RowData, BookingListingStatus } from "../types";
19+
import { BookingsCalendarView } from "./BookingsCalendarView";
920

1021
type BookingsCalendarViewProps = {
1122
status: BookingListingStatus;
1223
table: ReactTable<RowData>;
1324
};
1425

26+
const COLUMN_IDS_TO_HIDE = ["dateRange"];
27+
28+
const weekStartParser = createParser({
29+
parse: (value: string) => {
30+
const parsed = dayjs(value);
31+
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
32+
},
33+
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
34+
});
35+
1536
export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
37+
const { rows } = table.getRowModel();
38+
const { updateFilter } = useDataTable();
39+
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
40+
41+
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
42+
"weekStart",
43+
weekStartParser.withDefault(dayjs().startOf("week"))
44+
);
45+
46+
const bookings = useMemo(() => {
47+
return rows
48+
.filter((row) => row.original.type === "data")
49+
.map((row) => (row.original.type === "data" ? row.original.booking : null))
50+
.filter((booking): booking is NonNullable<typeof booking> => booking !== null);
51+
}, [rows]);
52+
53+
const handleWeekStartChange = useCallback(
54+
(newWeekStart: dayjs.Dayjs) => {
55+
setCurrentWeekStart(newWeekStart);
56+
57+
const startDate = newWeekStart.toDate();
58+
const endDate = newWeekStart.add(6, "day").toDate();
59+
60+
if (!dateRange) {
61+
return;
62+
}
63+
64+
const rangeStart = dateRange.startDate ? new Date(dateRange.startDate) : null;
65+
const rangeEnd = dateRange.endDate ? new Date(dateRange.endDate) : null;
66+
67+
const needsStartUpdate = !rangeStart || startDate < rangeStart;
68+
const needsEndUpdate = !rangeEnd || endDate > rangeEnd;
69+
70+
if (!needsStartUpdate && !needsEndUpdate) {
71+
return;
72+
}
73+
74+
const newStartDate = needsStartUpdate ? startDate : rangeStart;
75+
const newEndDate = needsEndUpdate ? endDate : rangeEnd;
76+
77+
updateFilter("dateRange", {
78+
type: ColumnFilterType.DATE_RANGE,
79+
data: {
80+
startDate: newStartDate.toISOString(),
81+
endDate: newEndDate.toISOString(),
82+
preset: CUSTOM_PRESET.value,
83+
},
84+
});
85+
},
86+
[dateRange, updateFilter, setCurrentWeekStart]
87+
);
88+
1689
return (
1790
<>
1891
<div className="mb-4 flex items-center justify-between">
1992
<div className="flex items-center gap-2">
20-
<DataTableFilters.FilterBar table={table} />
93+
<DataTableFilters.FilterBar table={table} columnIdsToHide={COLUMN_IDS_TO_HIDE} />
2194
</div>
2295

2396
<div className="flex items-center gap-2">
@@ -26,9 +99,11 @@ export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
2699
<DataTableSegment.Select />
27100
</div>
28101
</div>
29-
<div className="flex items-center justify-center pt-2 xl:pt-0">
30-
<EmptyScreen Icon="calendar" headline="Calendar view" description="Calendar view is coming soon." />
31-
</div>
102+
<BookingsCalendarView
103+
bookings={bookings}
104+
currentWeekStart={currentWeekStart}
105+
onWeekStartChange={handleWeekStartChange}
106+
/>
32107
</>
33108
);
34109
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"use client";
2+
3+
import { useMemo, useEffect } from "react";
4+
5+
import dayjs from "@calcom/dayjs";
6+
import { useTimePreferences } from "@calcom/features/bookings/lib";
7+
import { Calendar } from "@calcom/features/calendars/weeklyview";
8+
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
9+
import { useLocale } from "@calcom/lib/hooks/useLocale";
10+
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
11+
import { Button } from "@calcom/ui/components/button";
12+
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
13+
import { Icon } from "@calcom/ui/components/icon";
14+
15+
import type { BookingOutput } from "../types";
16+
17+
type BookingsCalendarViewProps = {
18+
bookings: BookingOutput[];
19+
currentWeekStart: dayjs.Dayjs;
20+
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
21+
};
22+
23+
export function BookingsCalendarView({
24+
bookings,
25+
currentWeekStart,
26+
onWeekStartChange,
27+
}: BookingsCalendarViewProps) {
28+
const { t } = useLocale();
29+
const { timezone } = useTimePreferences();
30+
const { resolvedTheme, forcedTheme } = useGetTheme();
31+
32+
const goToPreviousWeek = () => {
33+
onWeekStartChange(currentWeekStart.subtract(1, "week"));
34+
};
35+
36+
const goToNextWeek = () => {
37+
onWeekStartChange(currentWeekStart.add(1, "week"));
38+
};
39+
40+
const goToToday = () => {
41+
onWeekStartChange(dayjs().startOf("week"));
42+
};
43+
44+
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
45+
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
46+
47+
useEffect(() => {
48+
onWeekStartChange(currentWeekStart);
49+
}, []);
50+
51+
const events = useMemo<CalendarEvent[]>(() => {
52+
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
53+
54+
return bookings
55+
.filter((booking) => {
56+
const bookingStart = dayjs(booking.startTime);
57+
return (
58+
(bookingStart.isAfter(currentWeekStart) || bookingStart.isSame(currentWeekStart)) &&
59+
bookingStart.isBefore(currentWeekStart.add(7, "day"))
60+
);
61+
})
62+
.sort((a, b) => {
63+
const startDiff = new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
64+
if (startDiff !== 0) return startDiff;
65+
return new Date(a.endTime).getTime() - new Date(b.endTime).getTime();
66+
})
67+
.map((booking, idx) => {
68+
// Parse eventTypeColor and extract the appropriate color based on theme
69+
const eventTypeColor =
70+
booking.eventType?.eventTypeColor &&
71+
booking.eventType.eventTypeColor[hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"];
72+
73+
return {
74+
id: idx,
75+
title: booking.title,
76+
start: new Date(booking.startTime),
77+
end: new Date(booking.endTime),
78+
options: {
79+
status: booking.status,
80+
...(eventTypeColor && { color: eventTypeColor }),
81+
},
82+
};
83+
});
84+
}, [bookings, currentWeekStart, resolvedTheme, forcedTheme]);
85+
86+
const weekStart = currentWeekStart;
87+
const weekEnd = currentWeekStart.add(6, "day");
88+
const startMonth = weekStart.format("MMM");
89+
const endMonth = weekEnd.format("MMM");
90+
const year = weekEnd.format("YYYY");
91+
92+
const weekRange =
93+
startMonth === endMonth ? (
94+
<>
95+
<span className="text-emphasis">{`${startMonth} ${weekStart.format("D")} - ${weekEnd.format(
96+
"D"
97+
)}`}</span>
98+
<span className="text-muted">, {year}</span>
99+
</>
100+
) : (
101+
<>
102+
<span className="text-emphasis">{`${weekStart.format("MMM D")} - ${weekEnd.format("MMM D")}`}</span>
103+
<span className="text-muted">, {year}</span>
104+
</>
105+
);
106+
107+
return (
108+
<div className="border-subtle flex h-[calc(100vh-260px)] min-h-[600px] flex-col rounded-2xl border">
109+
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
110+
<div className="flex items-center gap-2">
111+
<h2 className="text-xl font-semibold">{weekRange}</h2>
112+
</div>
113+
<div className="flex items-center gap-2">
114+
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
115+
{t("today")}
116+
</Button>
117+
<ButtonGroup combined>
118+
<Button color="secondary" onClick={goToPreviousWeek}>
119+
<span className="sr-only">{t("view_previous_week")}</span>
120+
<Icon name="chevron-left" className="h-4 w-4" />
121+
</Button>
122+
<Button color="secondary" onClick={goToNextWeek}>
123+
<span className="sr-only">{t("view_next_week")}</span>
124+
<Icon name="chevron-right" className="h-4 w-4" />
125+
</Button>
126+
</ButtonGroup>
127+
</div>
128+
</div>
129+
130+
<div className="flex-1 overflow-y-auto overflow-x-hidden rounded-2xl">
131+
<Calendar
132+
timezone={timezone}
133+
sortEvents
134+
startHour={0}
135+
endHour={23}
136+
events={events}
137+
startDate={startDate}
138+
endDate={endDate}
139+
gridCellsPerHour={4}
140+
hoverEventDuration={0}
141+
showBackgroundPattern={false}
142+
showBorder={false}
143+
borderColor="subtle"
144+
onEventClick={(_event) => {}}
145+
hideHeader
146+
/>
147+
</div>
148+
</div>
149+
);
150+
}

apps/web/modules/bookings/views/bookings-view.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ type BookingsProps = {
4040
permissions: {
4141
canReadOthersBookings: boolean;
4242
};
43+
isCalendarViewEnabled: boolean;
4344
};
4445

4546
function useSystemSegments(userId?: number) {
@@ -89,8 +90,10 @@ const viewParser = createParser({
8990
serialize: (value: "list" | "calendar") => value,
9091
});
9192

92-
function BookingsContent({ status, permissions }: BookingsProps) {
93-
const [view] = useQueryState("view", viewParser.withDefault("list"));
93+
function BookingsContent({ status, permissions, isCalendarViewEnabled }: BookingsProps) {
94+
const [_view] = useQueryState("view", viewParser.withDefault("list"));
95+
// Force view to be "list" if calendar view is disabled
96+
const view = isCalendarViewEnabled ? _view : "list";
9497
const { t } = useLocale();
9598
const user = useMeQuery().data;
9699
const searchParams = useSearchParams();

apps/web/modules/insights/insights-view.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,7 @@ function InsightsPageContent() {
8686
className="flex flex-wrap items-center gap-2"
8787
data-testid={`insights-filters-${isAll}-${teamId}-${userId}`}>
8888
<OrgTeamsFilter />
89-
<DataTableFilters.AddFilterButton table={table} hideWhenFilterApplied />
90-
<DataTableFilters.ActiveFilters table={table} />
91-
<DataTableFilters.AddFilterButton table={table} variant="sm" showWhenFilterApplied />
89+
<DataTableFilters.FilterBar table={table} />
9290
<DataTableFilters.ClearFiltersButton exclude={["startTime", "createdAt"]} />
9391
<div className="grow" />
9492
<Download />

apps/web/public/static/locales/en/common.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3903,6 +3903,8 @@
39033903
"onboarding_plan_team_badge": "$15/user/mo",
39043904
"onboarding_plan_organization_badge": "$37/user/mo",
39053905
"onboarding_plan_organization_description": "Robust scheduling for larger teams looking to have more control, privacy, and security.",
3906+
"view_previous_week": "View previous week",
3907+
"view_next_week": "View next week",
39063908
"invite_teammates": "Invite Teammates",
39073909
"organization_brand": "Organization Brand",
39083910
"organization_details": "Organization Details",

packages/features/calendar-view/LargeCalendar.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ export const LargeCalendar = ({
6565
start: new Date(booking.start),
6666
end: new Date(booking.end),
6767
options: {
68-
borderColor: "black",
6968
status: booking.status.toUpperCase() as BookingStatus,
7069
"data-test-id": "troubleshooter-busy-event",
7170
className: "border-[1.5px]",

packages/features/calendars/weeklyview/_storybookData.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const events: CalendarEvent[] = [
1313
end: startDate.add(45, "minutes").toDate(),
1414
options: {
1515
allDay: false,
16-
borderColor: "#ff0000",
16+
color: "#ff0000",
1717
status: "ACCEPTED",
1818
},
1919
source: "Booking",
@@ -37,7 +37,7 @@ export const events: CalendarEvent[] = [
3737
source: "Booking",
3838
options: {
3939
status: "PENDING",
40-
borderColor: "#ff0000",
40+
color: "#ff0000",
4141
allDay: false,
4242
},
4343
},

0 commit comments

Comments
 (0)