From 12e07f20d44d6b547642b1b5055203d8a0a73def Mon Sep 17 00:00:00 2001 From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Date: Mon, 15 Dec 2025 22:09:10 +0530 Subject: [PATCH] feat: Add Holidays feature to block availability on public holidays (#25561) * feat: add holidays feature for automatic availability blocking- Add UserHolidaySettings model for storing user preferences- Generate static holiday data for 20 countries using date-holidays- Create HolidayService for runtime holiday queries- Add TRPC router with endpoints for country selection and holiday toggles- Create Holidays tab UI in Availability page with conflict warnings- Integrate holiday blocking into getUserAvailability calculation- Show holiday indicator on blocked dates in booker page- Add warning in OOO modal when dates overlap with holidays * feat: add optimizations, tests, and code quality improvements - Add memoization/caching to HolidayService for better performance - Optimize checkConflicts DB query with OR conditions for specific dates - Add HolidayService unit tests (10 tests) - Add error handling with Alert component for failed queries - Memoize HolidayListItem component to prevent unnecessary re-renders - Extract magic numbers into constants.ts - Use TRPCError consistently in handlers - Add missing i18n keys for error messages - Update handlers to follow Cal.com patterns (default exports, minimal comments) - Add regeneration instructions in constants * refactor: replace static JSON with Google Calendar API integration - Add GoogleHolidayService to fetch holidays from Google Calendar public calendars - Add HolidayCache Prisma model for caching API responses - Add GOOGLE_CALENDAR_API_KEY and HOLIDAY_CACHE_DAYS env variables - Support 38 countries via Google Calendar holiday calendars - Update HolidayService methods to async with database caching - Update all TRPC handlers for async holiday methods - Fix UI to display holiday dates correctly - Remove static holidays.json and generate script * use we instead of calcom in i18n message * address cubics comments * move holidays from availability to ooo * public holidays filter for holidays * follow i18n _one and _other pattern * remove holiday feature flag * revert lint command code change * revert lint command code change 2.0 * revert lint command code change 3.0 * bye bye my christmas emoji :crying-emoji * remove comments * refactor(holidays): add repository pattern, split services, and add tests - Create HolidayRepository for database operations - Split GoogleHolidayService into GoogleCalendarClient and HolidayCacheService - Add dependency injection to HolidayService and HolidayCacheService - Update TRPC handlers to use HolidayRepository - Add tests for HolidayRepository and calculateHolidayBlockedDates - Update calendar IDs to use official holiday format (244 countries + religions) * fix: address PR review feedback - Remove unused date-holidays package - Add pluralization for and_more_holidays_with_conflicts translation - DRY: spread GOOGLE_RELIGIOUS_HOLIDAY_CALENDARS into GOOGLE_HOLIDAY_CALENDARS - Add select to userHolidaySettings query in getUserAvailability - Optimize checkConflicts with pre-computed timestamps * refactor(holidays): apply proxy pattern and move logic to service - Rename HolidayCacheService to HolidayServiceCachingProxy (proxy pattern) - Remove HOLIDAY_CACHE_DAYS env var, use constant directly - Add isSupportedCountry() method to HolidayService - Add getUserSettings() and updateSettings() to HolidayService - Move toggleHoliday logic from handler to service - Move checkConflicts logic from handler to service - Add findBookingsInDateRanges() to HolidayRepository - Simplify all handlers to just call service methods * feat(bookings): add backend validation to prevent booking on holidays Adds explicit holiday conflict validation during booking creation to handle the race condition where a host enables a holiday after a guest selects a date but before they submit the booking. Changes: - Add checkHolidayConflict validation with HolidayRepository integration - Integrate ensureNoHolidayConflict in RegularBookingService - Add BookingOnHoliday error code with proper HTTP 400 response - Handle holiday error display in BookEventForm with name interpolation - Hide trace ID for expected validation errors - Add unit tests for holiday conflict validation * fix failing type check * fix: address PR review comments for holiday feature - Change error code from BAD_REQUEST to INTERNAL_SERVER_ERROR in toggleHoliday handler (errors are internal failures, not bad input) - Refactor ensureNoHolidayConflict to use Promise.all for parallel user checking instead of sequential loop * refactor: use Promise.all with logging in loop for holiday check - Check all users in parallel using Promise.all - Log conflicts inside the loop as they are detected - Wait for all checks to complete before throwing error * fix(holidays): use host timezone for holiday conflict checks The holiday feature was checking booking dates against holidays using server/local timezone instead of the host's timezone. This caused bookings near midnight boundaries to incorrectly pass or fail the holiday check. Example: A booking at Dec 24th 8PM UTC (which is Dec 25th in IST) was not being blocked for an Indian host with Christmas as a holiday. Changes: - Add .utc() to holiday date formatting for consistency - Fetch host's timezone in checkHolidayConflict - Convert booking time to host's timezone before comparison - Add findUserSettingsWithTimezone to HolidayRepository - Update error message to clarify it's the host's local time - Add timezone edge case tests * fix(holidays): align holiday blocking with OOO pattern for consistent timezone handling - Simplify calculateHolidayBlockedDates to match OOO pattern using dayjs.utc() - Fix date range query to use full day bounds (startOfDay/endOfDay) so holidays stored at midnight UTC are correctly found during booking validation - Remove separate checkHolidayConflict booking-time validation - holidays now block through oooExcludedDateRanges like OOO does - Remove getHolidayOnDate from HolidayService (no longer needed) - Remove findUserSettingsWithTimezone from HolidayRepository (no longer needed) - Clean up related tests Holiday blocking now works exactly like OOO: 1. Holidays are added to datesOutOfOffice in calculateHolidayBlockedDates 2. buildDateRanges processes them via processOOO with .tz(timeZone, true) 3. oooExcludedDateRanges excludes those dates from availability 4. ensureAvailableUsers uses oooExcludedDateRanges to block bookings This ensures consistent timezone handling where Dec 25th blocks all hours of Dec 25th in the host's timezone, regardless of booker's timezone. * update test * update .env.example file --- .env.example | 8 + .../my-account/out-of-office-view.tsx | 10 +- apps/web/pages/api/trpc/holidays/[trpc].ts | 4 + apps/web/public/static/locales/en/common.json | 20 ++ .../lib/calculateHolidayBlockedDates.test.ts | 337 ++++++++++++++++++ .../availability/lib/getUserAvailability.ts | 79 ++++ .../Booker/components/OutOfOfficeInSlots.tsx | 53 ++- .../pages/settings/delegationCredential.tsx | 2 +- .../ee/teams/components/EditMemberSheet.tsx | 4 +- .../components/tabs/ai/AIEventController.tsx | 10 +- .../assignment/EventTeamAssignmentTab.tsx | 12 +- .../CreateOrEditOutOfOfficeModal.tsx | 45 ++- .../settings/outOfOffice/HolidaysView.tsx | 312 ++++++++++++++++ .../outOfOffice/OutOfOfficeToggleGroup.tsx | 2 + packages/lib/holidays/GoogleCalendarClient.ts | 96 +++++ packages/lib/holidays/HolidayService.test.ts | 128 +++++++ packages/lib/holidays/HolidayService.ts | 266 ++++++++++++++ .../holidays/HolidayServiceCachingProxy.ts | 116 ++++++ packages/lib/holidays/constants.ts | 260 ++++++++++++++ packages/lib/holidays/index.ts | 6 + packages/lib/holidays/types.ts | 15 + .../repository/HolidayRepository.test.ts | 326 +++++++++++++++++ .../server/repository/HolidayRepository.ts | 217 +++++++++++ .../migration.sql | 17 + .../migration.sql | 24 ++ packages/prisma/schema.prisma | 30 ++ packages/trpc/react/shared.ts | 1 + .../trpc/server/routers/viewer/_router.tsx | 2 + .../viewer/admin/toggleFeatureFlag.handler.ts | 1 + .../server/routers/viewer/holidays/_router.ts | 39 ++ .../viewer/holidays/checkConflicts.handler.ts | 20 ++ .../viewer/holidays/checkConflicts.schema.ts | 9 + .../holidays/getSupportedCountries.handler.ts | 8 + .../holidays/getUserSettings.handler.ts | 18 + .../viewer/holidays/getUserSettings.schema.ts | 6 + .../viewer/holidays/toggleHoliday.handler.ts | 35 ++ .../viewer/holidays/toggleHoliday.schema.ts | 9 + .../viewer/holidays/updateSettings.handler.ts | 32 ++ .../viewer/holidays/updateSettings.schema.ts | 10 + packages/types/environment.d.ts | 4 + turbo.json | 1 + 41 files changed, 2559 insertions(+), 35 deletions(-) create mode 100644 apps/web/pages/api/trpc/holidays/[trpc].ts create mode 100644 packages/features/availability/lib/calculateHolidayBlockedDates.test.ts create mode 100644 packages/features/settings/outOfOffice/HolidaysView.tsx create mode 100644 packages/lib/holidays/GoogleCalendarClient.ts create mode 100644 packages/lib/holidays/HolidayService.test.ts create mode 100644 packages/lib/holidays/HolidayService.ts create mode 100644 packages/lib/holidays/HolidayServiceCachingProxy.ts create mode 100644 packages/lib/holidays/constants.ts create mode 100644 packages/lib/holidays/index.ts create mode 100644 packages/lib/holidays/types.ts create mode 100644 packages/lib/server/repository/HolidayRepository.test.ts create mode 100644 packages/lib/server/repository/HolidayRepository.ts create mode 100644 packages/prisma/migrations/20251202181340_add_user_holiday_settings/migration.sql create mode 100644 packages/prisma/migrations/20251203000000_add_holiday_cache/migration.sql create mode 100644 packages/trpc/server/routers/viewer/holidays/_router.ts create mode 100644 packages/trpc/server/routers/viewer/holidays/checkConflicts.handler.ts create mode 100644 packages/trpc/server/routers/viewer/holidays/checkConflicts.schema.ts create mode 100644 packages/trpc/server/routers/viewer/holidays/getSupportedCountries.handler.ts create mode 100644 packages/trpc/server/routers/viewer/holidays/getUserSettings.handler.ts create mode 100644 packages/trpc/server/routers/viewer/holidays/getUserSettings.schema.ts create mode 100644 packages/trpc/server/routers/viewer/holidays/toggleHoliday.handler.ts create mode 100644 packages/trpc/server/routers/viewer/holidays/toggleHoliday.schema.ts create mode 100644 packages/trpc/server/routers/viewer/holidays/updateSettings.handler.ts create mode 100644 packages/trpc/server/routers/viewer/holidays/updateSettings.schema.ts diff --git a/.env.example b/.env.example index 9a65204948..33f70bb046 100644 --- a/.env.example +++ b/.env.example @@ -127,6 +127,14 @@ NEXT_PUBLIC_HELPSCOUT_KEY= NEXT_PUBLIC_FRESHCHAT_TOKEN= NEXT_PUBLIC_FRESHCHAT_HOST= +# For holiday feature: +# Step-by-step: Get a Google Calendar API Key +# 1. Go to Google Cloud Console: https://console.cloud.google.com/ +# 2. Select or Create a Project +# 3. Enable Google Calendar API (APIs & Services → Library , Search for Google Calendar API) +# 4. Create the API Key (APIs & Services → Credentials) +GOOGLE_CALENDAR_API_KEY= + # Google OAuth credentials # To enable Login with Google you need to: # 1. Set `GOOGLE_API_CREDENTIALS` below diff --git a/apps/web/modules/settings/my-account/out-of-office-view.tsx b/apps/web/modules/settings/my-account/out-of-office-view.tsx index 223d96e841..b1410fc464 100644 --- a/apps/web/modules/settings/my-account/out-of-office-view.tsx +++ b/apps/web/modules/settings/my-account/out-of-office-view.tsx @@ -4,9 +4,11 @@ import { useEffect, useState } from "react"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; -import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList"; import { CreateOrEditOutOfOfficeEntryModal } from "@calcom/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal"; import type { BookingRedirectForm } from "@calcom/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal"; +import { HolidaysView } from "@calcom/features/settings/outOfOffice/HolidaysView"; +import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList"; +import { OutOfOfficeTab } from "@calcom/features/settings/outOfOffice/OutOfOfficeToggleGroup"; export default function OutOfOfficeView() { const [openModal, setOpenModal] = useState(false); @@ -15,6 +17,7 @@ export default function OutOfOfficeView() { const params = useCompatSearchParams(); const openModalOnStart = !!params?.get("om"); + const selectedTab = params?.get("type") ?? OutOfOfficeTab.MINE; useEffect(() => { if (openModalOnStart) { @@ -37,6 +40,11 @@ export default function OutOfOfficeView() { setCurrentlyEditingOutOfOfficeEntry(null); }; + // Show HolidaysView when holidays tab is selected + if (selectedTab === OutOfOfficeTab.HOLIDAYS) { + return ; + } + return ( <> ({ + getHolidayService: vi.fn(() => ({ + getHolidayDatesInRange: vi.fn(), + })), +})); + +import { getHolidayService } from "@calcom/lib/holidays"; + +import { UserAvailabilityService } from "./getUserAvailability"; + +const mockDependencies = { + oooRepo: {} as never, + bookingRepo: {} as never, + redisClient: {} as never, +}; + +describe("UserAvailabilityService.calculateHolidayBlockedDates", () => { + let service: UserAvailabilityService; + let mockHolidayService: { getHolidayDatesInRange: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new UserAvailabilityService(mockDependencies); + mockHolidayService = { + getHolidayDatesInRange: vi.fn(), + }; + vi.mocked(getHolidayService).mockReturnValue(mockHolidayService as never); + }); + + it("should return empty object when user has no holiday settings", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue(null); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-01-01"), + new Date("2025-01-31"), + [{ days: [1, 2, 3, 4, 5] }] // Monday to Friday + ); + + expect(prismaMock.userHolidaySettings.findUnique).toHaveBeenCalledWith({ + where: { userId: 123 }, + select: { + countryCode: true, + disabledIds: true, + }, + }); + expect(result).toEqual({}); + }); + + it("should return empty object when user has no country selected", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: null, + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-01-01"), + new Date("2025-01-31"), + [{ days: [1, 2, 3, 4, 5] }] + ); + + expect(result).toEqual({}); + }); + + it("should return empty object when no holidays in date range", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([]); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-02-01"), + new Date("2025-02-28"), + [{ days: [1, 2, 3, 4, 5] }] + ); + + // Dates are expanded to full day range (startOfDay to endOfDay in UTC) + // to ensure holidays stored at midnight UTC are found + expect(mockHolidayService.getHolidayDatesInRange).toHaveBeenCalledWith( + "US", + [], + expect.any(Date), + expect.any(Date) + ); + // Verify the date range covers the full days + const [, , startDate, endDate] = mockHolidayService.getHolidayDatesInRange.mock.calls[0]; + expect(startDate.toISOString()).toBe("2025-02-01T00:00:00.000Z"); + expect(endDate.toISOString()).toBe("2025-02-28T23:59:59.999Z"); + expect(result).toEqual({}); + }); + + it("should return holiday data for dates that match user's working days", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Wednesday, January 1, 2025 - New Year's Day + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([ + { + date: "2025-01-01", + holiday: { + id: "new_years_day_2025", + name: "New Year's Day", + date: "2025-01-01", + year: 2025, + }, + }, + ]); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-01-01"), + new Date("2025-01-31"), + [{ days: [1, 2, 3, 4, 5] }] // Monday(1) to Friday(5) - includes Wednesday(3) + ); + + expect(result).toEqual({ + "2025-01-01": { + fromUser: null, + toUser: null, + reason: "New Year's Day", + emoji: "📆", + }, + }); + }); + + it("should skip holidays that fall on non-working days", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Saturday, December 25, 2027 - Christmas Day + const christmasDate = "2027-12-25"; + const christmasDayOfWeek = dayjs(christmasDate).day(); // Should be 6 (Saturday) + + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([ + { + date: christmasDate, + holiday: { + id: "christmas_2027", + name: "Christmas Day", + date: christmasDate, + year: 2027, + }, + }, + ]); + + // User only works Monday to Friday (days 1-5), not Saturday (6) or Sunday (0) + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2027-12-01"), + new Date("2027-12-31"), + [{ days: [1, 2, 3, 4, 5] }] + ); + + // Christmas 2027 is on Saturday (day 6), should be skipped + expect(christmasDayOfWeek).toBe(6); + expect(result).toEqual({}); + }); + + it("should handle multiple holidays correctly", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([ + { + date: "2025-01-01", // Wednesday + holiday: { + id: "new_years_day_2025", + name: "New Year's Day", + date: "2025-01-01", + year: 2025, + }, + }, + { + date: "2025-01-20", // Monday - MLK Day + holiday: { + id: "mlk_day_2025", + name: "Martin Luther King Jr. Day", + date: "2025-01-20", + year: 2025, + }, + }, + ]); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-01-01"), + new Date("2025-01-31"), + [{ days: [1, 2, 3, 4, 5] }] + ); + + expect(result).toEqual({ + "2025-01-01": { + fromUser: null, + toUser: null, + reason: "New Year's Day", + emoji: "📆", + }, + "2025-01-20": { + fromUser: null, + toUser: null, + reason: "Martin Luther King Jr. Day", + emoji: "📆", + }, + }); + }); + + it("should handle availability with multiple schedules", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + // Thursday, July 4, 2024 - Independence Day + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([ + { + date: "2024-07-04", + holiday: { + id: "independence_day_2024", + name: "Independence Day", + date: "2024-07-04", + year: 2024, + }, + }, + ]); + + // User has multiple availability schedules, some include Thursday (4) + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2024-07-01"), + new Date("2024-07-31"), + [ + { days: [1, 3] }, // Monday, Wednesday only + { days: [2, 4] }, // Tuesday, Thursday - includes the holiday + ] + ); + + expect(result).toEqual({ + "2024-07-04": { + fromUser: null, + toUser: null, + reason: "Independence Day", + emoji: "📆", + }, + }); + }); + + it("should respect disabled holidays", async () => { + prismaMock.userHolidaySettings.findUnique.mockResolvedValue({ + id: 1, + userId: 123, + countryCode: "US", + disabledIds: ["christmas_2025"], + createdAt: new Date(), + updatedAt: new Date(), + }); + + // The holiday service should receive the disabledIds and filter them out + mockHolidayService.getHolidayDatesInRange.mockResolvedValue([ + { + date: "2025-01-01", + holiday: { + id: "new_years_day_2025", + name: "New Year's Day", + date: "2025-01-01", + year: 2025, + }, + }, + // Christmas is NOT returned because it's in disabledIds + ]); + + const result = await service.calculateHolidayBlockedDates( + 123, + new Date("2025-01-01"), + new Date("2025-12-31"), + [{ days: [1, 2, 3, 4, 5] }] + ); + + // Verify disabledIds were passed to the service + expect(mockHolidayService.getHolidayDatesInRange).toHaveBeenCalledWith( + "US", + ["christmas_2025"], + expect.any(Date), + expect.any(Date) + ); + + // Only New Year's Day should be blocked + expect(result).toEqual({ + "2025-01-01": { + fromUser: null, + toUser: null, + reason: "New Year's Day", + emoji: "📆", + }, + }); + }); +}); + diff --git a/packages/features/availability/lib/getUserAvailability.ts b/packages/features/availability/lib/getUserAvailability.ts index 49713e6f30..9722865cd4 100644 --- a/packages/features/availability/lib/getUserAvailability.ts +++ b/packages/features/availability/lib/getUserAvailability.ts @@ -18,6 +18,7 @@ import { buildDateRanges, subtract } from "@calcom/features/schedules/lib/date-r import { getWorkingHours } from "@calcom/lib/availability"; import { stringToDayjsZod } from "@calcom/lib/dayjs"; import { ErrorCode } from "@calcom/lib/errorCodes"; +import { getHolidayService } from "@calcom/lib/holidays"; import { HttpError } from "@calcom/lib/http-error"; import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits"; import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits"; @@ -25,6 +26,7 @@ import { getPeriodStartDatesBetween as getPeriodStartDatesBetweenUtil } from "@c import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { withReporting } from "@calcom/lib/sentryWrapper"; +import prisma from "@calcom/prisma"; import type { Booking, Prisma, @@ -558,6 +560,19 @@ export class UserAvailabilityService { const datesOutOfOffice: IOutOfOfficeData = this.calculateOutOfOfficeRanges(outOfOfficeDays, availability); + const holidayBlockedDates = await this.calculateHolidayBlockedDates( + user.id, + dateFrom.toDate(), + dateTo.toDate(), + availability + ); + + for (const [date, holidayData] of Object.entries(holidayBlockedDates)) { + if (!datesOutOfOffice[date]) { + datesOutOfOffice[date] = holidayData; + } + } + const { dateRanges, oooExcludedDateRanges } = buildDateRanges({ dateFrom, dateTo, @@ -687,4 +702,68 @@ export class UserAvailabilityService { } getUsersAvailability = withReporting(this._getUsersAvailability.bind(this), "getUsersAvailability"); + + async calculateHolidayBlockedDates( + userId: number, + startDate: Date, + endDate: Date, + availability: GetUserAvailabilityParamsDTO["availability"] + ): Promise { + const holidaySettings = await prisma.userHolidaySettings.findUnique({ + where: { userId }, + select: { + countryCode: true, + disabledIds: true, + }, + }); + + if (!holidaySettings || !holidaySettings.countryCode) { + return {}; + } + + // Holidays are stored as midnight UTC (e.g., 2025-12-25T00:00:00Z). + // When checking availability for a specific booking slot (e.g., 10:00-11:00), + // we need to expand the date range to include the full day so holidays are found. + // Otherwise, a booking at 10:00 wouldn't find a holiday stored at 00:00. + const startOfDay = dayjs(startDate).utc().startOf("day").toDate(); + const endOfDay = dayjs(endDate).utc().endOf("day").toDate(); + + const holidayService = getHolidayService(); + const holidayDates = await holidayService.getHolidayDatesInRange( + holidaySettings.countryCode, + holidaySettings.disabledIds, + startOfDay, + endOfDay + ); + + if (holidayDates.length === 0) { + return {}; + } + + // Match OOO pattern: get working days from availability + const flattenDays = Array.from(new Set(availability.flatMap((a) => ("days" in a ? a.days : [])))).sort( + (a, b) => a - b + ); + + const result: IOutOfOfficeData = {}; + + for (const { date, holiday } of holidayDates) { + // Match OOO pattern: use dayjs.utc() to parse date string and get day of week + const dayOfWeek = dayjs.utc(date).day(); + + if (!flattenDays.includes(dayOfWeek)) { + continue; + } + + // Match OOO pattern: key by the date string (already in YYYY-MM-DD UTC format) + result[date] = { + fromUser: null, + toUser: null, + reason: holiday.name, + emoji: "📆", + }; + } + + return result; + } } diff --git a/packages/features/bookings/Booker/components/OutOfOfficeInSlots.tsx b/packages/features/bookings/Booker/components/OutOfOfficeInSlots.tsx index bd0536e9a3..31406c096c 100644 --- a/packages/features/bookings/Booker/components/OutOfOfficeInSlots.tsx +++ b/packages/features/bookings/Booker/components/OutOfOfficeInSlots.tsx @@ -25,6 +25,7 @@ export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => { fromUser, toUser, emoji = "🏝️", + reason, borderDashed = true, date, className, @@ -35,7 +36,12 @@ export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => { const router = useRouter(); - if (!fromUser) return null; + // Check if this is a holiday (no fromUser but has reason) + const isHoliday = !fromUser && reason; + + // For regular OOO, require fromUser (toUser is optional for redirect) + // For holidays, we just need the reason + if (!isHoliday && !fromUser) return null; return (
{ {emoji}
-

- {t("ooo_user_is_ooo", { displayName: fromUser.displayName })} -

+ {isHoliday ? ( + <> +

{reason}

+

{t("holiday_no_availability")}

+ + ) : ( + <> +

+ {t("ooo_user_is_ooo", { displayName: fromUser?.displayName })} +

- {notes && showNotePublicly && ( -

- {notes} -

- )} + {notes && showNotePublicly && ( +

+ {notes} +

+ )} - {fromUser?.displayName && toUser?.displayName && ( -

- username]} - /> -

+ {fromUser?.displayName && toUser?.displayName && ( +

+ username]} + /> +

+ )} + )}
- {toUser?.id && ( + {!isHoliday && toUser?.id && (
) : affectedMembers?.length ? ( <> -
    +
      {affectedMembers.slice(0, 5).map((m) => (
    • {m.name ? `${m.name} (${m.email})` : m.email} diff --git a/packages/features/ee/teams/components/EditMemberSheet.tsx b/packages/features/ee/teams/components/EditMemberSheet.tsx index 8611d8d9f3..8a3e10e1e0 100644 --- a/packages/features/ee/teams/components/EditMemberSheet.tsx +++ b/packages/features/ee/teams/components/EditMemberSheet.tsx @@ -219,8 +219,8 @@ export function EditMemberSheet({ - -
      + +

      {t("profile")}

      diff --git a/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx b/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx index 36f5f5b604..5e087a23aa 100644 --- a/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx +++ b/packages/features/eventtypes/components/tabs/ai/AIEventController.tsx @@ -4,21 +4,21 @@ import React, { useState } from "react"; import { useFormContext, Controller } from "react-hook-form"; import { z } from "zod"; -import PhoneInput from "@calcom/features/components/phone-input"; import { getTemplateFieldsSchema } from "@calcom/features/calAIPhone/getTemplateFieldsSchema"; import { templateFieldsMap } from "@calcom/features/calAIPhone/template-fields-map"; import type { TemplateType } from "@calcom/features/calAIPhone/zod-utils"; +import PhoneInput from "@calcom/features/components/phone-input"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; import type { EventTypeSetup, FormValues } from "@calcom/features/eventtypes/lib/types"; import { ComponentForField } from "@calcom/features/form-builder/FormBuilderField"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { Label } from "@calcom/ui/components/form"; -import { Divider } from "@calcom/ui/components/divider"; -import { TextField } from "@calcom/ui/components/form"; -import { Button } from "@calcom/ui/components/button"; import classNames from "@calcom/ui/classNames"; +import { Button } from "@calcom/ui/components/button"; +import { Divider } from "@calcom/ui/components/divider"; import { EmptyScreen } from "@calcom/ui/components/empty-screen"; +import { Label } from "@calcom/ui/components/form"; +import { TextField } from "@calcom/ui/components/form"; import { SettingsToggle } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { showToast } from "@calcom/ui/components/toast"; diff --git a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx index 70b393c773..54021d3e03 100644 --- a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx +++ b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -96,7 +96,7 @@ const ChildrenEventTypesList = ({ } & Omit>, "onChange" | "value">) => { const { t } = useLocale(); return ( -
      +

      @@ -475,7 +475,7 @@ const RoundRobinHosts = ({

      @@ -830,7 +830,7 @@ export const EventTeamAssignmentTab = ({

      {t("assignment_description")} @@ -865,7 +865,7 @@ export const EventTeamAssignmentTab = ({

      -

      +

      {t("rr_distribution_method_description")}

      diff --git a/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx b/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx index 2f13c9226c..2e63292860 100644 --- a/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx +++ b/packages/features/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import dayjs from "@calcom/dayjs"; @@ -11,6 +11,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import classNames from "@calcom/ui/classNames"; +import { Alert } from "@calcom/ui/components/alert"; import { UpgradeTeamsBadge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; @@ -162,9 +163,28 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ const watchedTeamUserId = watch("toTeamUserId"); const watchForUserId = watch("forUserId"); + const watchedDateRange = watch("dateRange"); const watchedNotes = watch("notes"); const hasValidNotes = Boolean(watchedNotes?.trim()); + // Fetch user's holiday settings to show warning if OOO dates overlap with holidays + const { data: holidaySettings } = trpc.viewer.holidays.getUserSettings.useQuery({}); + + // Check if selected dates overlap with any enabled holidays + const overlappingHolidays = useMemo(() => { + if (!holidaySettings?.countryCode || !watchedDateRange?.startDate || !watchedDateRange?.endDate) { + return []; + } + + // Filter holidays that are enabled and fall within the date range + const startStr = dayjs(watchedDateRange.startDate).format("YYYY-MM-DD"); + const endStr = dayjs(watchedDateRange.endDate).format("YYYY-MM-DD"); + + return (holidaySettings.holidays || []) + .filter((h) => h.enabled && h.date >= startStr && h.date <= endStr) + .map((h) => ({ date: h.date, holiday: { id: h.id, name: h.name } })); + }, [holidaySettings, watchedDateRange]); + const createOrEditOutOfOfficeEntry = trpc.viewer.ooo.outOfOfficeCreateOrUpdate.useMutation({ onSuccess: () => { showToast( @@ -310,6 +330,29 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ )} />
      + + {/* Holiday overlap warning */} + {overlappingHolidays.length > 0 && ( + h.holiday.name) + .join(", "), + }) + } + /> + )}
      {/* Reason Select */} diff --git a/packages/features/settings/outOfOffice/HolidaysView.tsx b/packages/features/settings/outOfOffice/HolidaysView.tsx new file mode 100644 index 0000000000..026affbab6 --- /dev/null +++ b/packages/features/settings/outOfOffice/HolidaysView.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { memo, useMemo, useCallback } from "react"; + +import dayjs from "@calcom/dayjs"; +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; +import { Alert } from "@calcom/ui/components/alert"; +import { Button } from "@calcom/ui/components/button"; +import { EmptyScreen } from "@calcom/ui/components/empty-screen"; +import { Label, Select } from "@calcom/ui/components/form"; +import { Switch } from "@calcom/ui/components/form"; +import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton"; +import { showToast } from "@calcom/ui/components/toast"; + +import { OutOfOfficeToggleGroup } from "./OutOfOfficeToggleGroup"; + +function HolidaysCTA() { + const { t } = useLocale(); + return ( +
      + + +
      + ); +} + +type HolidayWithStatus = RouterOutputs["viewer"]["holidays"]["getUserSettings"]["holidays"][number]; +type Country = { code: string; name: string }; + +const CountrySelector = memo(function CountrySelector({ + countries, + value, + onChange, + isLoading, +}: { + countries: Country[]; + value: string; + onChange: (value: string) => void; + isLoading: boolean; +}) { + const { t } = useLocale(); + + const options = useMemo( + () => [ + { value: "", label: t("no_holidays") }, + ...countries.map((country) => ({ + value: country.code, + label: country.name, + })), + ], + [countries, t] + ); + + const selectedOption = useMemo(() => { + if (!value) return { value: "", label: t("select_country") }; + const country = countries.find((c) => c.code === value); + return { value, label: country?.name || value }; + }, [value, countries, t]); + + if (isLoading) { + return ; + } + + return ( +