From 5b9dd09f0de113a199790ad5416d3c2a1b1ea73a Mon Sep 17 00:00:00 2001 From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Date: Tue, 16 Dec 2025 06:10:04 +0530 Subject: [PATCH] feat(holidays): improve UI/UX and add contextual emojis (#25895) * refactor(holidays): improve code quality and address PR review feedback - Move HolidaysView.tsx from /features to /apps/web/modules following the pattern of keeping trpc-using components in /apps/web/modules - Fix prisma import to use named import: `import { prisma }` - Fix timezone bug in checkConflicts: use dayjs.utc() for consistent date boundaries regardless of server timezone - Fix toggleHoliday validation to include both current and next year holidays, matching the holidays displayed to users - Implement dependency injection pattern for holiday repository: - Create PrismaHolidayRepository with instance methods - Add HOLIDAY_REPOSITORY DI token and module - Update containers to load holiday repository module - Update calculateHolidayBlockedDates to use injected repository - Remove stale HOLIDAY_CACHE_DAYS env declaration (now a constant) - Update tests to mock repository instead of prisma directly * feat(holidays): improve UI responsiveness and add country flags - Add country flag emojis to holidays dropdown using Unicode regional indicator symbols - Handle edge cases for religious holidays (Hindu, Christian, etc.) which dont have 2-letter country codes - Increase country dropdown width to 180px for better readability - Make OOO/Holidays tabs responsive: use Select dropdown on mobile, ToggleGroup on desktop - Make + Add button responsive: show only + icon on mobile, full text on desktop - Fix PrismaHolidayRepository import to use @calcom/prisma for consistency with other repositories * feat(holidays): add contextual emojis for holidays - Add keyword-based emoji mapping for 80+ holidays - Display holiday emojis in styled containers on settings page - Add country flag emojis to dropdown using Unicode regional indicators - Use dynamic emojis on booking page unavailable dates * fix(holidays): address PR review feedback - Fix emoji ordering: move Chinese/Lunar New Year before generic new year to prevent incorrect match - Fix i18n: use t() instead of hardcoded text more in conflict warning - Fix a11y: use sr-only pattern for mobile button to maintain screen reader accessibility * fix failing test: packages/features/availability/lib/calculateHolidayBlockedDates.test.ts * fix failing test: packages/features/availability/lib/calculateHolidayBlockedDates.test.ts * fix failing tests and builds --- .../src/lib/modules/available-slots.module.ts | 2 + .../repositories/prisma-holiday.repository.ts | 13 ++ .../lib/services/user-availability.service.ts | 5 +- .../settings/my-account/holidays-view.tsx | 214 ++++++++++-------- .../my-account/out-of-office-view.tsx | 2 +- .../lib/calculateHolidayBlockedDates.test.ts | 96 ++++---- .../availability/lib/getUserAvailability.ts | 10 +- .../features/di/containers/AvailableSlots.ts | 2 + .../di/containers/GetUserAvailability.ts | 2 + .../di/modules/GetUserAvailability.ts | 1 + packages/features/di/modules/Holiday.ts | 17 ++ packages/features/di/tokens.ts | 2 + .../repositories/PrismaHolidayRepository.ts | 19 ++ .../CreateNewOutOfOfficeEntryButton.tsx | 4 +- .../outOfOffice/OutOfOfficeToggleGroup.tsx | 59 +++-- packages/lib/holidays/HolidayService.ts | 20 +- packages/lib/holidays/getHolidayEmoji.ts | 98 ++++++++ packages/platform/libraries/repositories.ts | 1 + packages/types/environment.d.ts | 2 - 19 files changed, 380 insertions(+), 189 deletions(-) create mode 100644 apps/api/v2/src/lib/repositories/prisma-holiday.repository.ts rename packages/features/settings/outOfOffice/HolidaysView.tsx => apps/web/modules/settings/my-account/holidays-view.tsx (52%) create mode 100644 packages/features/di/modules/Holiday.ts create mode 100644 packages/features/holidays/repositories/PrismaHolidayRepository.ts create mode 100644 packages/lib/holidays/getHolidayEmoji.ts diff --git a/apps/api/v2/src/lib/modules/available-slots.module.ts b/apps/api/v2/src/lib/modules/available-slots.module.ts index c9484b8407..49428b7e3c 100644 --- a/apps/api/v2/src/lib/modules/available-slots.module.ts +++ b/apps/api/v2/src/lib/modules/available-slots.module.ts @@ -1,6 +1,7 @@ import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository"; +import { PrismaHolidayRepository } from "@/lib/repositories/prisma-holiday.repository"; import { PrismaMembershipRepository } from "@/lib/repositories/prisma-membership.repository"; import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository"; @@ -23,6 +24,7 @@ import { Module } from "@nestjs/common"; imports: [PrismaWorkerModule], providers: [ PrismaOOORepository, + PrismaHolidayRepository, PrismaScheduleRepository, PrismaBookingRepository, PrismaSelectedSlotRepository, diff --git a/apps/api/v2/src/lib/repositories/prisma-holiday.repository.ts b/apps/api/v2/src/lib/repositories/prisma-holiday.repository.ts new file mode 100644 index 0000000000..44ae017ea6 --- /dev/null +++ b/apps/api/v2/src/lib/repositories/prisma-holiday.repository.ts @@ -0,0 +1,13 @@ +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { PrismaHolidayRepository as PrismaHolidayRepositoryLib } from "@calcom/platform-libraries/repositories"; +import type { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class PrismaHolidayRepository extends PrismaHolidayRepositoryLib { + constructor(private readonly dbWrite: PrismaWriteService) { + super(dbWrite.prisma as unknown as PrismaClient); + } +} + diff --git a/apps/api/v2/src/lib/services/user-availability.service.ts b/apps/api/v2/src/lib/services/user-availability.service.ts index 18796128b9..f48708351c 100644 --- a/apps/api/v2/src/lib/services/user-availability.service.ts +++ b/apps/api/v2/src/lib/services/user-availability.service.ts @@ -1,5 +1,6 @@ import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; +import { PrismaHolidayRepository } from "@/lib/repositories/prisma-holiday.repository"; import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; import { RedisService } from "@/modules/redis/redis.service"; import { Injectable } from "@nestjs/common"; @@ -12,13 +13,15 @@ export class UserAvailabilityService extends BaseUserAvailabilityService { oooRepoDependency: PrismaOOORepository, bookingRepository: PrismaBookingRepository, eventTypeRepository: PrismaEventTypeRepository, - redisService: RedisService + redisService: RedisService, + holidayRepository: PrismaHolidayRepository ) { super({ oooRepo: oooRepoDependency, bookingRepo: bookingRepository, eventTypeRepo: eventTypeRepository, redisClient: redisService, + holidayRepo: holidayRepository, }); } } diff --git a/packages/features/settings/outOfOffice/HolidaysView.tsx b/apps/web/modules/settings/my-account/holidays-view.tsx similarity index 52% rename from packages/features/settings/outOfOffice/HolidaysView.tsx rename to apps/web/modules/settings/my-account/holidays-view.tsx index 026affbab6..52063da3fb 100644 --- a/packages/features/settings/outOfOffice/HolidaysView.tsx +++ b/apps/web/modules/settings/my-account/holidays-view.tsx @@ -4,26 +4,33 @@ import { memo, useMemo, useCallback } from "react"; import dayjs from "@calcom/dayjs"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; +import { OutOfOfficeToggleGroup } from "@calcom/features/settings/outOfOffice/OutOfOfficeToggleGroup"; +import { getHolidayEmoji } from "@calcom/lib/holidays/getHolidayEmoji"; 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 { Select } from "@calcom/ui/components/form"; import { Switch } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; 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 (
-
); @@ -32,6 +39,15 @@ function HolidaysCTA() { type HolidayWithStatus = RouterOutputs["viewer"]["holidays"]["getUserSettings"]["holidays"][number]; type Country = { code: string; name: string }; +const getFlagEmoji = (countryCode: string): string | null => { + if (countryCode.length !== 2) return null; + const codePoints = countryCode + .toUpperCase() + .split("") + .map((char) => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); +}; + const CountrySelector = memo(function CountrySelector({ countries, value, @@ -47,11 +63,14 @@ const CountrySelector = memo(function CountrySelector({ const options = useMemo( () => [ - { value: "", label: t("no_holidays") }, - ...countries.map((country) => ({ - value: country.code, - label: country.name, - })), + { value: "", label: t("select_country") }, + ...countries.map((country) => { + const flag = getFlagEmoji(country.code); + return { + value: country.code, + label: flag ? `${flag} ${country.name}` : country.name, + }; + }), ], [countries, t] ); @@ -59,16 +78,18 @@ const CountrySelector = memo(function CountrySelector({ 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 }; + if (!country) return { value, label: value }; + const flag = getFlagEmoji(country.code); + return { value, label: flag ? `${flag} ${country.name}` : country.name }; }, [value, countries, t]); if (isLoading) { - return ; + return ; } return ( handleTabChange(option?.value ?? null)} + options={tabOptions} + /> + + {/* Desktop: Toggle group */} + + ); }; diff --git a/packages/lib/holidays/HolidayService.ts b/packages/lib/holidays/HolidayService.ts index 32e24e920c..455eeffc68 100644 --- a/packages/lib/holidays/HolidayService.ts +++ b/packages/lib/holidays/HolidayService.ts @@ -175,8 +175,16 @@ export class HolidayService { throw new Error("No holiday country selected"); } - const holidays = await this.getHolidaysForCountry(settings.countryCode); - if (!holidays.some((h) => h.id === holidayId)) { + // Validate against both current and next year holidays (matching getHolidaysWithStatus) + const currentYear = dayjs().year(); + const nextYear = currentYear + 1; + const [currentYearHolidays, nextYearHolidays] = await Promise.all([ + this.getHolidaysForCountry(settings.countryCode, currentYear), + this.getHolidaysForCountry(settings.countryCode, nextYear), + ]); + const allHolidays = [...currentYearHolidays, ...nextYearHolidays]; + + if (!allHolidays.some((h) => h.id === holidayId)) { throw new Error("Holiday not found for this country"); } @@ -211,8 +219,8 @@ export class HolidayService { } const dateRanges = holidayDates.map((h) => ({ - start: dayjs(h.date).startOf("day").toDate(), - end: dayjs(h.date).endOf("day").toDate(), + start: dayjs.utc(h.date).startOf("day").toDate(), + end: dayjs.utc(h.date).endOf("day").toDate(), })); const bookings = await HolidayRepository.findBookingsInDateRanges({ userId, dateRanges }); @@ -229,8 +237,8 @@ export class HolidayService { const conflicts: HolidayConflict[] = []; for (const holidayDate of holidayDates) { - const holidayStart = dayjs(holidayDate.date).startOf("day").valueOf(); - const holidayEnd = dayjs(holidayDate.date).endOf("day").valueOf(); + const holidayStart = dayjs.utc(holidayDate.date).startOf("day").valueOf(); + const holidayEnd = dayjs.utc(holidayDate.date).endOf("day").valueOf(); const conflictingBookings = bookingsWithTimestamps.filter( (booking) => booking.startTimestamp < holidayEnd && booking.endTimestamp > holidayStart diff --git a/packages/lib/holidays/getHolidayEmoji.ts b/packages/lib/holidays/getHolidayEmoji.ts new file mode 100644 index 0000000000..3fa487f625 --- /dev/null +++ b/packages/lib/holidays/getHolidayEmoji.ts @@ -0,0 +1,98 @@ +const HOLIDAY_EMOJI_MAP: Array<{ keywords: string[]; emoji: string }> = [ + { keywords: ["christmas", "xmas", "noรซl", "navidad", "natal", "weihnacht"], emoji: "๐ŸŽ„" }, + { keywords: ["chinese new year", "lunar new year", "spring festival"], emoji: "๐Ÿ‰" }, + { keywords: ["new year", "aรฑo nuevo", "nouvel an", "neujahr", "capodanno"], emoji: "๐ŸŽ†" }, + { keywords: ["boxing day", "st. stephen", "saint stephen", "stephen's day"], emoji: "๐ŸŽ" }, + { keywords: ["epiphany", "three kings", "reyes magos"], emoji: "๐Ÿ‘‘" }, + { keywords: ["easter", "pascua", "pรขques", "pasqua", "ostern"], emoji: "๐Ÿฃ" }, + { keywords: ["good friday", "viernes santo", "vendredi saint", "karfreitag"], emoji: "โœ๏ธ" }, + { keywords: ["ascension", "himmelfahrt", "ascensiรณn"], emoji: "โœ๏ธ" }, + { keywords: ["pentecost", "whit", "pfingsten", "pentecรดte"], emoji: "๐Ÿ•Š๏ธ" }, + { keywords: ["assumption", "assomption", "asunciรณn", "mariรค himmelfahrt"], emoji: "โœ๏ธ" }, + { keywords: ["palm sunday"], emoji: "๐ŸŒฟ" }, + { keywords: ["ash wednesday"], emoji: "โœ๏ธ" }, + { keywords: ["mother", "dรญa de la madre", "fรชte des mรจres", "muttertag"], emoji: "๐Ÿ’" }, + { keywords: ["father", "dรญa del padre", "fรชte des pรจres", "vatertag"], emoji: "๐Ÿ‘”" }, + { keywords: ["thanksgiving"], emoji: "๐Ÿฆƒ" }, + { keywords: ["halloween", "all saints", "todos los santos", "allerheiligen", "toussaint"], emoji: "๐ŸŽƒ" }, + { keywords: ["valentine", "san valentรญn", "saint valentin", "valentinstag"], emoji: "๐Ÿ’•" }, + { keywords: ["martin luther king"], emoji: "โœŠ" }, + { keywords: ["presidents", "prรคsidenten"], emoji: "๐Ÿ›๏ธ" }, + { keywords: ["juneteenth"], emoji: "โœŠ" }, + { keywords: ["columbus", "indigenous"], emoji: "๐ŸŒŽ" }, + { keywords: ["independence", "independencia", "4th of july", "fourth of july", "july 4"], emoji: "๐ŸŽ†" }, + { keywords: ["bastille", "14 juillet", "quatorze juillet"], emoji: "๐ŸŽ†" }, + { keywords: ["guy fawkes", "bonfire night"], emoji: "๐ŸŽ†" }, + { keywords: ["national day", "dรญa nacional", "fรชte nationale", "nationalfeiertag"], emoji: "๐Ÿ›๏ธ" }, + { keywords: ["republic day", "dรญa de la repรบblica", "tag der republik"], emoji: "๐Ÿ›๏ธ" }, + { keywords: ["constitution", "constituciรณn", "verfassung"], emoji: "๐Ÿ“œ" }, + { keywords: ["memorial", "remembrance", "veterans", "armistice", "volkstrauertag"], emoji: "๐ŸŽ–๏ธ" }, + { keywords: ["liberation", "liberaciรณn", "victory", "befreiung"], emoji: "๐Ÿ•Š๏ธ" }, + { keywords: ["revolution", "revoluciรณn"], emoji: "โœŠ" }, + { keywords: ["unity", "unification", "einheit", "german unity"], emoji: "๐Ÿค" }, + { keywords: ["flag day", "dรญa de la bandera"], emoji: "๐Ÿšฉ" }, + { keywords: ["labor", "labour", "workers", "trabajo", "travail", "may day", "tag der arbeit"], emoji: "๐Ÿ‘ท" }, + { keywords: ["diwali", "deepavali", "dipavali"], emoji: "๐Ÿช”" }, + { keywords: ["holi"], emoji: "๐ŸŽจ" }, + { keywords: ["dussehra", "dasara", "vijayadashami"], emoji: "๐Ÿน" }, + { keywords: ["ganesh", "ganesha", "vinayaka"], emoji: "๐Ÿ˜" }, + { keywords: ["navratri", "navaratri"], emoji: "๐Ÿ’ƒ" }, + { keywords: ["raksha bandhan", "rakhi"], emoji: "๐ŸŽ€" }, + { keywords: ["janmashtami", "krishna"], emoji: "๐Ÿชˆ" }, + { keywords: ["makar sankranti", "pongal", "lohri"], emoji: "๐Ÿช" }, + { keywords: ["ugadi", "gudi padwa"], emoji: "๐ŸŒธ" }, + { keywords: ["onam"], emoji: "๐Ÿ›ถ" }, + { keywords: ["eid al-fitr", "eid ul-fitr", "eid-ul-fitr"], emoji: "๐ŸŒ™" }, + { keywords: ["eid al-adha", "eid ul-adha", "eid-ul-adha", "bakrid"], emoji: "๐Ÿ‘" }, + { keywords: ["ramadan", "ramazan"], emoji: "๐ŸŒ™" }, + { keywords: ["muharram"], emoji: "๐Ÿ•Œ" }, + { keywords: ["milad", "mawlid", "prophet"], emoji: "๐Ÿ•Œ" }, + { keywords: ["hanukkah", "chanukah", "hanukka"], emoji: "๐Ÿ•Ž" }, + { keywords: ["passover", "pesach", "pessah"], emoji: "๐Ÿท" }, + { keywords: ["rosh hashanah", "rosh hashana"], emoji: "๐ŸŽ" }, + { keywords: ["yom kippur"], emoji: "๐Ÿ“–" }, + { keywords: ["sukkot", "succot"], emoji: "๐ŸŒฟ" }, + { keywords: ["purim"], emoji: "๐ŸŽญ" }, + { keywords: ["shavuot"], emoji: "๐Ÿ“œ" }, + { keywords: ["vesak", "buddha", "bodhi"], emoji: "๐Ÿชท" }, + { keywords: ["mid-autumn", "moon festival", "mooncake"], emoji: "๐Ÿฅฎ" }, + { keywords: ["dragon boat", "duanwu"], emoji: "๐Ÿฒ" }, + { keywords: ["qingming", "tomb sweeping"], emoji: "๐Ÿชฆ" }, + { keywords: ["golden week"], emoji: "๐ŸŒธ" }, + { keywords: ["obon", "bon festival"], emoji: "๐Ÿฎ" }, + { keywords: ["children's day", "kodomo no hi"], emoji: "๐ŸŽ" }, + { keywords: ["coming of age", "seijin no hi"], emoji: "๐Ÿ‘˜" }, + { keywords: ["carnival", "carnaval", "mardi gras"], emoji: "๐ŸŽญ" }, + { keywords: ["st. patrick", "saint patrick", "san patricio"], emoji: "โ˜˜๏ธ" }, + { keywords: ["oktoberfest"], emoji: "๐Ÿบ" }, + { keywords: ["summer solstice", "midsummer"], emoji: "โ˜€๏ธ" }, + { keywords: ["winter solstice"], emoji: "โ„๏ธ" }, + { keywords: ["spring equinox"], emoji: "๐ŸŒธ" }, + { keywords: ["autumn equinox", "fall equinox"], emoji: "๐Ÿ‚" }, + { keywords: ["bank holiday"], emoji: "๐Ÿฆ" }, + { keywords: ["public holiday"], emoji: "๐Ÿ“…" }, + { keywords: ["school", "education"], emoji: "๐Ÿ“š" }, + { keywords: ["election", "voting"], emoji: "๐Ÿ—ณ๏ธ" }, + { keywords: ["harvest", "cosecha"], emoji: "๐ŸŒพ" }, + { keywords: ["peace", "paz"], emoji: "๐Ÿ•Š๏ธ" }, + { keywords: ["women", "mujer", "femme"], emoji: "๐Ÿ‘ฉ" }, + { keywords: ["youth", "juventud"], emoji: "๐Ÿ‘ฆ" }, + { keywords: ["king", "queen", "royal", "coronation"], emoji: "๐Ÿ‘‘" }, + { keywords: ["birthday", "cumpleaรฑos", "anniversaire"], emoji: "๐ŸŽ‚" }, +]; + +const DEFAULT_HOLIDAY_EMOJI = "๐Ÿ“†"; + +export function getHolidayEmoji(holidayName: string): string { + const lowerName = holidayName.toLowerCase(); + + for (const { keywords, emoji } of HOLIDAY_EMOJI_MAP) { + if (keywords.some((kw) => lowerName.includes(kw))) { + return emoji; + } + } + + return DEFAULT_HOLIDAY_EMOJI; +} + +export { HOLIDAY_EMOJI_MAP, DEFAULT_HOLIDAY_EMOJI }; diff --git a/packages/platform/libraries/repositories.ts b/packages/platform/libraries/repositories.ts index f1897e02bc..8da6dcf9db 100644 --- a/packages/platform/libraries/repositories.ts +++ b/packages/platform/libraries/repositories.ts @@ -1,5 +1,6 @@ export { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository"; export { PrismaOOORepository } from "@calcom/features/ooo/repositories/PrismaOOORepository"; +export { PrismaHolidayRepository } from "@calcom/features/holidays/repositories/PrismaHolidayRepository"; export { ScheduleRepository as PrismaScheduleRepository } from "@calcom/features/schedules/repositories/ScheduleRepository"; export { BookingRepository as PrismaBookingRepository } from "@calcom/features/bookings/repositories/BookingRepository"; export { EventTypeRepository as PrismaEventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository"; diff --git a/packages/types/environment.d.ts b/packages/types/environment.d.ts index 04b3eba0a4..392e60ce67 100644 --- a/packages/types/environment.d.ts +++ b/packages/types/environment.d.ts @@ -6,8 +6,6 @@ declare namespace NodeJS { readonly GOOGLE_API_CREDENTIALS: string | undefined; /** Google Calendar API key for fetching public holiday calendars */ readonly GOOGLE_CALENDAR_API_KEY: string | undefined; - /** Number of days to cache holiday data from Google Calendar (default: 7) */ - readonly HOLIDAY_CACHE_DAYS: string | undefined; /** @deprecated use `NEXT_PUBLIC_WEBAPP_URL` */ readonly BASE_URL: string | undefined; /** @deprecated use `NEXT_PUBLIC_WEBAPP_URL` */