feat: Improving Booking Visibility at Month-End (#22770)

* remove first weeks and add last

* fix added last week

* prefetch availability of next month

* don't switch month

* On hover show month

* only show new UI in monthly view

* show month tooldtip only when needed

* show month on first day of month

* remove isFirstDayOfNextMonth

* fix prefetching next month

* fix datePicker tests

* preventMonthSwitching in monthly view

* add tests

* code clean up

* code clean up

* code clena up for ooo days

* push first day of month

* remove bg color for the month badge

* fix text colour

* remove not needed

* use object param

* revert: use object param

* use object param

* fix DatePicker tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
Carina Wollendorfer
2025-07-30 16:20:16 +00:00
committed by GitHub
co-authored by CarinaWolli Sean Brydon Eunjae Lee
parent 63df3d9c14
commit 7fef683abf
8 changed files with 278 additions and 41 deletions
@@ -74,7 +74,10 @@ export const DatePicker = ({
scrollToTimeSlots?: () => void;
}) => {
const { i18n } = useLocale();
const [month, selectedDate] = useBookerStore((state) => [state.month, state.selectedDate], shallow);
const [month, selectedDate, layout] = useBookerStore(
(state) => [state.month, state.selectedDate, state.layout],
shallow
);
const [setSelectedDate, setMonth, setDayCount] = useBookerStore(
(state) => [state.setSelectedDate, state.setMonth, state.setDayCount],
@@ -83,7 +86,7 @@ export const DatePicker = ({
const onMonthChange = (date: Dayjs) => {
setMonth(date.format("YYYY-MM"));
setSelectedDate(date.format("YYYY-MM-DD"));
setSelectedDate({ date: date.format("YYYY-MM-DD") });
setDayCount(null); // Whenever the month is changed, we nullify getting X days
};
@@ -98,6 +101,9 @@ export const DatePicker = ({
});
moveToNextMonthOnNoAvailability();
// Determine if this is a compact sidebar view based on layout
const isCompact = layout !== "month_view";
const periodData: PeriodData = {
...{
periodType: "UNLIMITED",
@@ -126,7 +132,11 @@ export const DatePicker = ({
className={classNames?.datePickerContainer}
isLoading={isLoading}
onChange={(date: Dayjs | null, omitUpdatingParams?: boolean) => {
setSelectedDate(date === null ? date : date.format("YYYY-MM-DD"), omitUpdatingParams);
setSelectedDate({
date: date === null ? date : date.format("YYYY-MM-DD"),
omitUpdatingParams,
preventMonthSwitching: !isCompact, // Prevent month switching when in monthly view
});
}}
onMonthChange={onMonthChange}
includedDates={nonEmptyScheduleDays}
@@ -137,6 +147,7 @@ export const DatePicker = ({
slots={slots}
scrollToTimeSlots={scrollToTimeSlots}
periodData={periodData}
isCompact={isCompact}
/>
);
};
@@ -130,7 +130,7 @@ export function Header({
<Button
className="capitalize ltr:ml-2 rtl:mr-2"
color="secondary"
onClick={() => setSelectedDate(today.format("YYYY-MM-DD"))}>
onClick={() => setSelectedDate({ date: today.format("YYYY-MM-DD") })}>
{t("today")}
</Button>
)}
+9 -4
View File
@@ -83,7 +83,11 @@ export type BookerStore = {
* Date selected by user (exact day). Format is YYYY-MM-DD.
*/
selectedDate: string | null;
setSelectedDate: (date: string | null, omitUpdatingParams?: boolean) => void;
setSelectedDate: (params: {
date: string | null;
omitUpdatingParams?: boolean;
preventMonthSwitching?: boolean;
}) => void;
addToSelectedDate: (days: number) => void;
/**
* Multiple Selected Dates and Times
@@ -192,7 +196,7 @@ export const useBookerStore = createWithEqualityFn<BookerStore>((set, get) => ({
return set({ layout });
},
selectedDate: getQueryParam("date") || null,
setSelectedDate: (selectedDate: string | null, omitUpdatingParams = false) => {
setSelectedDate: ({ date: selectedDate, omitUpdatingParams = false, preventMonthSwitching = false }) => {
// unset selected date
if (!selectedDate) {
removeQueryParam("date");
@@ -207,7 +211,8 @@ export const useBookerStore = createWithEqualityFn<BookerStore>((set, get) => ({
}
// Setting month make sure small calendar in fullscreen layouts also updates.
if (newSelection.month() !== currentSelection.month()) {
// preventMonthSwitching is true in monthly view
if (!preventMonthSwitching && newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
if (!omitUpdatingParams && (!get().isPlatform || get().allowUpdatingUrlParams)) {
updateQueryParam("month", newSelection.format("YYYY-MM"));
@@ -264,7 +269,7 @@ export const useBookerStore = createWithEqualityFn<BookerStore>((set, get) => ({
if (!get().isPlatform || get().allowUpdatingUrlParams) {
updateQueryParam("month", month ?? "");
}
get().setSelectedDate(null);
get().setSelectedDate({ date: null });
},
dayCount: BOOKER_NUMBER_OF_DAYS_TO_LOAD > 0 ? BOOKER_NUMBER_OF_DAYS_TO_LOAD : null,
setDayCount: (dayCount: number | null) => {
+89 -13
View File
@@ -14,6 +14,7 @@ import type { PeriodData } from "@calcom/types/Event";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { Tooltip } from "@calcom/ui/components/tooltip";
import NoAvailabilityDialog from "./NoAvailabilityDialog";
@@ -56,6 +57,8 @@ export type DatePickerProps = {
}[]
>;
periodData?: PeriodData;
// Whether this is a compact sidebar view or main monthly view
isCompact?: boolean;
};
const Day = ({
@@ -65,6 +68,8 @@ const Day = ({
away,
emoji,
customClassName,
showMonthTooltip,
isFirstDayOfNextMonth,
...props
}: JSX.IntrinsicElements["button"] & {
active: boolean;
@@ -75,12 +80,14 @@ const Day = ({
dayContainer?: string;
dayActive?: string;
};
showMonthTooltip?: boolean;
isFirstDayOfNextMonth?: boolean;
}) => {
const { t } = useLocale();
const enabledDateButtonEmbedStyles = useEmbedStyles("enabledDateButton");
const disabledDateButtonEmbedStyles = useEmbedStyles("disabledDateButton");
return (
const buttonContent = (
<button
type="button"
style={disabled ? { ...disabledDateButtonEmbedStyles } : { ...enabledDateButtonEmbedStyles }}
@@ -113,6 +120,33 @@ const Day = ({
)}
</button>
);
const content = showMonthTooltip ? (
<Tooltip content={date.format("MMMM")}>{buttonContent}</Tooltip>
) : (
buttonContent
);
return (
<>
{isFirstDayOfNextMonth && (
<div
className={classNames(
"absolute top-0 z-10 mx-auto w-fit rounded-full font-semibold uppercase tracking-wide",
active ? "text-white" : "text-default",
disabled && "bg-emphasis"
)}
style={{
fontSize: "10px",
lineHeight: "13px",
padding: disabled ? "0 3px" : "3px 3px 3px 4px",
}}>
{date.format("MMM")}
</div>
)}
{content}
</>
);
};
const Days = ({
@@ -129,6 +163,7 @@ const Days = ({
customClassName,
isBookingInPast,
periodData,
isCompact,
...props
}: Omit<DatePickerProps, "locale" | "className" | "weekStart"> & {
DayComponent?: React.FC<React.ComponentProps<typeof Day>>;
@@ -143,20 +178,48 @@ const Days = ({
scrollToTimeSlots?: () => void;
isBookingInPast: boolean;
periodData: PeriodData;
isCompact?: boolean;
}) => {
// Create placeholder elements for empty days in first week
const weekdayOfFirst = browsingDate.date(1).day();
const includedDates = getAvailableDatesInMonth({
browsingDate: browsingDate.toDate(),
minDate,
includedDates: props.includedDates,
});
const days: (Dayjs | null)[] = Array((weekdayOfFirst - weekStart + 7) % 7).fill(null);
for (let day = 1, dayCount = daysInMonth(browsingDate); day <= dayCount; day++) {
const date = browsingDate.set("date", day);
days.push(date);
const today = dayjs();
const firstDayOfMonth = browsingDate.startOf("month");
const isSecondWeekOver = today.isAfter(firstDayOfMonth.add(2, "week"));
let days: (Dayjs | null)[] = [];
const getPadding = (day: number) => (browsingDate.set("date", day).day() - weekStart + 7) % 7;
const totalDays = daysInMonth(browsingDate);
// Only apply end-of-month logic for main monthly view (not compact sidebar)
if (isSecondWeekOver && !isCompact) {
const startDay = 8;
const pad = getPadding(startDay);
days = Array(pad).fill(null);
for (let day = startDay; day <= totalDays; day++) {
days.push(browsingDate.set("date", day));
}
const remainingInRow = days.length % 7;
const extraDays = (remainingInRow > 0 ? 7 - remainingInRow : 0) + 7;
const nextMonth = browsingDate.add(1, "month");
// Add days starting from day 1 of next month
for (let i = 0; i < extraDays; i++) {
days.push(nextMonth.set("date", 1 + i));
}
} else {
// Traditional calendar grid logic for compact sidebar or early in month
const pad = getPadding(1);
days = Array(pad).fill(null);
for (let day = 1; day <= totalDays; day++) {
days.push(browsingDate.set("date", day));
}
}
const [selectedDatesAndTimes] = useBookerStore((state) => [state.selectedDatesAndTimes], shallow);
@@ -188,20 +251,29 @@ const Days = ({
const daysToRenderForTheMonth = days.map((day) => {
if (!day) return { day: null, disabled: true };
const dateKey = yyyymmdd(day);
const oooInfo = slots && slots?.[dateKey] ? slots?.[dateKey]?.find((slot) => slot.away) : null;
const daySlots = slots?.[dateKey] || [];
const oooInfo = daySlots.find((slot) => slot.away) || null;
const isNextMonth = day.month() !== browsingDate.month();
const isFirstDayOfNextMonth = isSecondWeekOver && !isCompact && isNextMonth && day.date() === 1;
const included = includedDates?.includes(dateKey);
const excluded = excludedDates.includes(dateKey);
const isOOOAllDay = !!(slots && slots[dateKey] && slots[dateKey].every((slot) => slot.away));
const hasAvailableSlots = daySlots.some((slot) => !slot.away);
const isOOOAllDay = daySlots.length > 0 && daySlots.every((slot) => slot.away);
const away = isOOOAllDay;
const disabled = away ? !oooInfo?.toUser : !included || excluded;
const disabled = away ? !oooInfo?.toUser : isNextMonth ? !hasAvailableSlots : !included || excluded;
return {
day: day,
day,
disabled,
away,
emoji: oooInfo?.emoji,
isFirstDayOfNextMonth,
};
});
@@ -239,7 +311,7 @@ const Days = ({
return (
<>
{daysToRenderForTheMonth.map(({ day, disabled, away, emoji }, idx) => (
{daysToRenderForTheMonth.map(({ day, disabled, away, emoji, isFirstDayOfNextMonth }, idx) => (
<div key={day === null ? `e-${idx}` : `day-${day.format()}`} className="relative w-full pt-[100%]">
{day === null ? (
<div key={`e-${idx}`} />
@@ -265,6 +337,8 @@ const Days = ({
active={isActive(day)}
away={away}
emoji={emoji}
showMonthTooltip={isSecondWeekOver && !isCompact}
isFirstDayOfNextMonth={isFirstDayOfNextMonth}
/>
)}
</div>
@@ -297,6 +371,7 @@ const DatePicker = ({
periodDays: null,
periodType: "UNLIMITED",
},
isCompact,
...passThroughProps
}: DatePickerProps &
Partial<React.ComponentProps<typeof Days>> & {
@@ -406,6 +481,7 @@ const DatePicker = ({
includedDates={includedDates}
isBookingInPast={isBookingInPast}
periodData={periodData}
isCompact={isCompact}
/>
</div>
</div>
@@ -1,4 +1,7 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { render } from "@testing-library/react";
import React from "react";
import { vi } from "vitest";
import dayjs from "@calcom/dayjs";
import { PeriodType } from "@calcom/prisma/enums";
@@ -13,18 +16,20 @@ describe("Tests for DatePicker Component", () => {
test("Should render correctly with default date", async () => {
const testDate = dayjs("2024-02-20");
const { getByTestId } = render(
<DatePicker
onChange={noop}
browsingDate={testDate}
locale="en"
periodData={{
periodType: PeriodType.UNLIMITED,
periodDays: null,
periodCountCalendarDays: false,
periodStartDate: null,
periodEndDate: null,
}}
/>
<TooltipProvider>
<DatePicker
onChange={noop}
browsingDate={testDate}
locale="en"
periodData={{
periodType: PeriodType.UNLIMITED,
periodDays: null,
periodCountCalendarDays: false,
periodStartDate: null,
periodEndDate: null,
}}
/>
</TooltipProvider>
);
const selectedMonthLabel = getByTestId("selected-month-label");
@@ -35,7 +40,9 @@ describe("Tests for DatePicker Component", () => {
const testDate = dayjs("2024-02-20");
const minDate = dayjs("2025-02-10");
const { getByTestId } = render(
<DatePicker onChange={noop} browsingDate={testDate} minDate={minDate.toDate()} locale="en" />
<TooltipProvider>
<DatePicker onChange={noop} browsingDate={testDate} minDate={minDate.toDate()} locale="en" />
</TooltipProvider>
);
const selectedMonthLabel = getByTestId("selected-month-label");
@@ -46,10 +53,141 @@ describe("Tests for DatePicker Component", () => {
const testDate = dayjs("2025-03-20");
const minDate = dayjs("2025-02-10");
const { getByTestId } = render(
<DatePicker onChange={noop} browsingDate={testDate} minDate={minDate} locale="en" />
<TooltipProvider>
<DatePicker onChange={noop} browsingDate={testDate} minDate={minDate.toDate()} locale="en" />
</TooltipProvider>
);
const selectedMonthLabel = getByTestId("selected-month-label");
await expect(selectedMonthLabel).toHaveAttribute("dateTime", testDate.format("YYYY-MM"));
});
describe("End-of-Month UI Improvements", () => {
const createMockSlots = (dates: string[]) => {
const slots: Record<string, { time: string; userIds?: number[] }[]> = {};
dates.forEach((date) => {
slots[date] = [{ time: `${date}T10:00:00` }];
});
return slots;
};
test("Should show traditional calendar view before second week of month", async () => {
// Set test date to early in month (January 10th, 2024)
const earlyMonthDate = dayjs("2024-01-10");
// Mock current date to also be early in month so isSecondWeekOver is false
vi.useFakeTimers();
vi.setSystemTime(earlyMonthDate.toDate());
const slots = createMockSlots([
"2024-01-15", // Available date in current month
"2024-01-20",
]);
const { getAllByTestId } = render(
<TooltipProvider>
<DatePicker
onChange={noop}
browsingDate={earlyMonthDate}
locale="en"
slots={slots}
isCompact={false}
periodData={{
periodType: PeriodType.UNLIMITED,
periodDays: null,
periodCountCalendarDays: false,
periodStartDate: null,
periodEndDate: null,
}}
/>
</TooltipProvider>
);
const dayElements = getAllByTestId("day");
// Should show full month starting from day 1
const firstAvailableDay = dayElements.find((day) => day.textContent && day.textContent.trim() !== "");
expect(firstAvailableDay?.textContent).toBe("1");
vi.useRealTimers();
});
test("Should show end-of-month view after second week (monthly view)", async () => {
// Mock current date to ensure we're after second week
const mockDate = dayjs("2024-01-20");
vi.useFakeTimers();
vi.setSystemTime(mockDate.toDate());
const lateMonthDate = dayjs("2024-01-20");
const slots = createMockSlots([
"2024-01-25", // Available in current month
"2024-02-01", // Available in next month
"2024-02-05",
]);
const { getAllByTestId, queryByText } = render(
<TooltipProvider>
<DatePicker
onChange={noop}
browsingDate={lateMonthDate}
locale="en"
slots={slots}
isCompact={false}
periodData={{
periodType: PeriodType.UNLIMITED,
periodDays: null,
periodCountCalendarDays: false,
periodStartDate: null,
periodEndDate: null,
}}
/>
</TooltipProvider>
);
const dayElements = getAllByTestId("day");
const firstAvailableDay = dayElements.find((day) => day.textContent && day.textContent.trim() !== "");
// Should show days from day 8 onwards of current month (the main change in end-of-month view)
expect(firstAvailableDay?.textContent).toBe("8");
// Should show next month days (February days when browsing January)
// In end-of-month view, the first day of next month gets a month label
const febLabel = queryByText("Feb");
expect(febLabel).toBeTruthy();
vi.useRealTimers();
});
test("Should show traditional view when compact=true (not monthly view) even after second week", async () => {
const lateMonthDate = dayjs("2024-01-20");
const slots = createMockSlots(["2024-01-25", "2024-02-01"]);
const { getAllByTestId } = render(
<TooltipProvider>
<DatePicker
onChange={noop}
browsingDate={lateMonthDate}
locale="en"
slots={slots}
isCompact={true} // This should force traditional view
periodData={{
periodType: PeriodType.UNLIMITED,
periodDays: null,
periodCountCalendarDays: false,
periodStartDate: null,
periodEndDate: null,
}}
/>
</TooltipProvider>
);
const dayElements = getAllByTestId("day");
// Should show day 1 even in compact mode after second week
const firstDayOfMonth = dayElements.find((day) => day.textContent === "1");
expect(firstDayOfMonth).toBeTruthy();
});
});
});
+2 -2
View File
@@ -359,11 +359,11 @@ const EmailEmbed = ({
<DatePicker
isLoading={schedule.isPending}
onChange={(date: Dayjs | null) => {
setSelectedDate(date === null ? date : date.format("YYYY-MM-DD"));
setSelectedDate({ date: date === null ? date : date.format("YYYY-MM-DD") });
}}
onMonthChange={(date: Dayjs) => {
setMonth(date.format("YYYY-MM"));
setSelectedDate(date.format("YYYY-MM-DD"));
setSelectedDate({ date: date.format("YYYY-MM-DD") });
}}
includedDates={nonEmptyScheduleDays}
locale={i18n.language}
@@ -206,7 +206,10 @@ export const BookerPlatformWrapper = (
!!bookerLayout.extraDays &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.extraDays, "day").month()) ||
(bookerLayout.layout === BookerLayouts.COLUMN_VIEW &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month());
dayjs(date).month() !== dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month()) ||
(bookerLayout.layout === BookerLayouts.MONTH_VIEW &&
(!dayjs(date).isValid() || dayjs().isSame(dayjs(month), "month")) &&
dayjs().isAfter(dayjs(month).startOf("month").add(2, "week")));
const monthCount =
((bookerLayout.layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") ||
@@ -413,7 +416,7 @@ export const BookerPlatformWrapper = (
[props.selectedDate]
);
useEffect(() => {
setSelectedDate(selectedDateProp, true);
setSelectedDate({ date: selectedDateProp, omitUpdatingParams: true });
}, [selectedDateProp]);
useEffect(() => {
@@ -421,7 +424,7 @@ export const BookerPlatformWrapper = (
return () => {
slots.handleRemoveSlot();
setBookerState("loading");
setSelectedDate(null);
setSelectedDate({ date: null });
setSelectedTimeslot(null);
setSelectedDuration(null);
setOrg(null);
@@ -77,6 +77,7 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
const [bookerState, _] = useBookerStore((state) => [state.state, state.setState], shallow);
const [dayCount] = useBookerStore((state) => [state.dayCount, state.setDayCount], shallow);
const [month] = useBookerStore((state) => [state.month, state.setMonth], shallow);
const { data: session } = useSession();
const routerQuery = useRouterQuery();
@@ -126,7 +127,10 @@ export const BookerWebWrapper = (props: BookerWebWrapperAtomProps) => {
!!bookerLayout.extraDays &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.extraDays, "day").month()) ||
(bookerLayout.layout === BookerLayouts.COLUMN_VIEW &&
dayjs(date).month() !== dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month());
dayjs(date).month() !== dayjs(date).add(bookerLayout.columnViewExtraDays.current, "day").month()) ||
(bookerLayout.layout === BookerLayouts.MONTH_VIEW &&
(!dayjs(date).isValid() || dayjs().isSame(dayjs(month), "month")) &&
dayjs().isAfter(dayjs(month).startOf("month").add(2, "week")));
const monthCount =
((bookerLayout.layout !== BookerLayouts.WEEK_VIEW && bookerState === "selecting_time") ||