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
This commit is contained in:
Dhairyashil Shinde authored and GitHub committed 2025-12-16 00:40:04 +00:00
1 parent db2caa17ce
commit 5b9dd09f0d
19 files changed
+380 -189

No files matched your search

@@ -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,
@@ -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);
}
}
@@ -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,
});
}
}
@@ -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 (
<div className="flex gap-2">
<OutOfOfficeToggleGroup />
<Button color="primary" StartIcon="plus" className="invisible" aria-hidden="true" tabIndex={-1}>
{t("add")}
{/* Invisible placeholder to match OOO button width and prevent layout shift */}
<Button
color="primary"
size="base"
StartIcon="plus"
className="invisible flex items-center justify-between px-2 md:px-4"
aria-hidden="true"
tabIndex={-1}>
<span className="hidden md:inline">{t("add")}</span>
</Button>
</div>
);
@@ -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 <SkeletonText className="mt-1 h-10 w-full max-w-xs" />;
return <SkeletonText className="h-9 w-36" />;
}
return (
<Select
className="mt-1 w-full max-w-xs"
className="w-auto min-w-[180px]"
value={selectedOption}
onChange={(option) => onChange(option?.value || "")}
options={options}
@@ -85,15 +106,22 @@ const HolidayListItem = memo(function HolidayListItem({
onToggle: (holidayId: string, enabled: boolean) => void;
isToggling: boolean;
}) {
const formattedDate = holiday.date ? dayjs(holiday.date).format("D MMM YYYY") : null;
const formattedDate = holiday.date ? dayjs(holiday.date).format("D MMM, YYYY") : null;
const emoji = getHolidayEmoji(holiday.name);
return (
<div className="border-subtle flex items-center justify-between border-b px-4 py-4 last:border-b-0">
<div className="border-subtle flex items-center justify-between border-b px-5 py-4 last:border-b-0">
<div className="flex items-center gap-3">
<div className="text-2xl">📆</div>
<div className="bg-subtle flex h-10 w-10 items-center justify-center rounded-lg">
<span className="text-xl">{emoji}</span>
</div>
<div>
<p className="text-emphasis font-medium">{holiday.name}</p>
{formattedDate && <p className="text-subtle text-sm">{formattedDate}</p>}
<p className={holiday.enabled ? "text-emphasis font-medium" : "text-muted font-medium"}>
{holiday.name}
</p>
{formattedDate && (
<p className={holiday.enabled ? "text-subtle text-sm" : "text-muted text-sm"}>{formattedDate}</p>
)}
</div>
</div>
<Switch
@@ -105,35 +133,6 @@ const HolidayListItem = memo(function HolidayListItem({
);
});
function HolidaysList({
holidays,
onToggle,
togglingId,
}: {
holidays: HolidayWithStatus[];
onToggle: (holidayId: string, enabled: boolean) => void;
togglingId: string | null;
}) {
const { t } = useLocale();
if (holidays.length === 0) {
return <div className="text-subtle py-8 text-center">{t("no_holidays_found_for_country")}</div>;
}
return (
<div className="border-subtle divide-subtle divide-y rounded-md border">
{holidays.map((holiday) => (
<HolidayListItem
key={holiday.id}
holiday={holiday}
onToggle={onToggle}
isToggling={togglingId === holiday.id}
/>
))}
</div>
);
}
function ConflictWarning({
conflicts,
}: {
@@ -145,27 +144,28 @@ function ConflictWarning({
const totalBookings = conflicts.reduce((sum, c) => sum + c.bookings.length, 0);
const conflictMessage = (
<ul className="mt-1 space-y-1">
{conflicts.slice(0, 3).map((conflict) => (
<li key={conflict.holidayId}>
{conflict.holidayName} ({dayjs(conflict.date).format("D MMM")}) -{" "}
{t("booking_count", { count: conflict.bookings.length })}
</li>
))}
{conflicts.length > 3 && (
<li>{t("and_more_holidays_with_conflicts", { count: conflicts.length - 3 })}</li>
)}
</ul>
);
return (
<Alert
severity="warning"
title={t("holiday_booking_conflict_warning", { count: totalBookings })}
message={conflictMessage}
className="mb-4"
/>
<div className="bg-semantic-attention-subtle rounded-md px-3 py-2">
<div className="flex items-center gap-2">
<Icon name="triangle-alert" className="text-semantic-attention h-4 w-4 shrink-0" />
<p className="text-emphasis text-sm font-medium">
{t("holiday_booking_conflict_warning", { count: totalBookings })}:
</p>
<div className="flex flex-wrap items-center gap-1.5">
{conflicts.slice(0, 3).map((conflict, idx) => (
<span key={conflict.holidayId} className="text-default text-sm">
{conflict.holidayName} ({conflict.bookings.length})
{idx < Math.min(conflicts.length, 3) - 1 && ","}
</span>
))}
{conflicts.length > 3 && (
<span className="text-subtle text-sm">
+{conflicts.length - 3} {t("more")}
</span>
)}
</div>
</div>
</div>
);
}
@@ -267,45 +267,61 @@ export function HolidaysView() {
}
return (
<SettingsHeader title={t("holidays")} description={t("holidays_description")} CTA={<HolidaysCTA />}>
<SettingsHeader
title={t("out_of_office")}
description={t("out_of_office_description")}
CTA={<HolidaysCTA />}>
<div className="space-y-6">
<div className="border-subtle bg-default rounded-md border p-6">
<Label>{t("country_for_holidays")}</Label>
<CountrySelector
countries={countries || []}
value={settings?.countryCode || ""}
onChange={handleCountryChange}
isLoading={isLoadingCountries}
/>
</div>
{conflictsData?.conflicts && conflictsData.conflicts.length > 0 && (
<ConflictWarning conflicts={conflictsData.conflicts} />
)}
{settings?.countryCode ? (
<div>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-emphasis font-medium">
{t("holidays_list")} ({settings.holidays?.filter((h) => h.enabled).length || 0} {t("enabled")}
)
</h3>
<div className="border-subtle bg-muted overflow-hidden rounded-lg border p-5">
{/* Header with title and country selector */}
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-emphasis font-semibold">{t("holidays")}</h3>
<p className="text-subtle text-sm">{t("holidays_description")}</p>
</div>
<HolidaysList
holidays={settings.holidays || []}
onToggle={handleToggleHoliday}
togglingId={
toggleHolidayMutation.isPending ? toggleHolidayMutation.variables?.holidayId || null : null
}
<CountrySelector
countries={countries || []}
value={settings?.countryCode || ""}
onChange={handleCountryChange}
isLoading={isLoadingCountries}
/>
</div>
) : (
<EmptyScreen
Icon="calendar"
headline={t("no_holidays_selected")}
description={t("select_country_to_see_holidays")}
/>
)}
{/* Holidays list - inner container */}
{settings?.countryCode ? (
<div className="border-subtle bg-default overflow-hidden rounded-md border">
{settings.holidays && settings.holidays.length > 0 ? (
settings.holidays.map((holiday) => (
<HolidayListItem
key={holiday.id}
holiday={holiday}
onToggle={handleToggleHoliday}
isToggling={
toggleHolidayMutation.isPending &&
toggleHolidayMutation.variables?.holidayId === holiday.id
}
/>
))
) : (
<div className="text-subtle py-8 text-center text-sm">
{t("no_holidays_found_for_country")}
</div>
)}
</div>
) : (
<div className="bg-default flex flex-col items-center rounded-md py-14 text-center">
<div className="bg-emphasis mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<Icon name="calendar" className="text-default h-8 w-8" />
</div>
<h4 className="text-emphasis mb-1 font-medium">{t("no_holidays_selected")}</h4>
<p className="text-subtle text-sm">{t("select_country_to_see_holidays")}</p>
</div>
)}
</div>
</div>
</SettingsHeader>
);
@@ -6,7 +6,7 @@ import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
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 { HolidaysView } from "./holidays-view";
import OutOfOfficeEntriesList from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList";
import { OutOfOfficeTab } from "@calcom/features/settings/outOfOffice/OutOfOfficeToggleGroup";
@@ -1,8 +1,9 @@
import prismaMock from "../../../../tests/libs/__mocks__/prismaMock";
import { beforeEach, describe, expect, it, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import { getHolidayService } from "@calcom/lib/holidays";
import { UserAvailabilityService } from "./getUserAvailability";
vi.mock("@calcom/lib/holidays", () => ({
getHolidayService: vi.fn(() => ({
@@ -10,14 +11,24 @@ vi.mock("@calcom/lib/holidays", () => ({
})),
}));
import { getHolidayService } from "@calcom/lib/holidays";
// Helper to create working hours with proper Date types for startTime/endTime
// Times are stored as Date objects with only time component (1970-01-01)
const createWorkingHours = (days: number[]) => ({
days,
startTime: new Date("1970-01-01T09:00:00.000Z"), // 9 AM
endTime: new Date("1970-01-01T17:00:00.000Z"), // 5 PM
});
import { UserAvailabilityService } from "./getUserAvailability";
const mockHolidayRepo = {
findUserSettingsSelect: vi.fn(),
};
const mockDependencies = {
oooRepo: {} as never,
bookingRepo: {} as never,
redisClient: {} as never,
eventTypeRepo: {} as never,
holidayRepo: mockHolidayRepo as never,
};
describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
@@ -34,17 +45,17 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
});
it("should return empty object when user has no holiday settings", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue(null);
mockHolidayRepo.findUserSettingsSelect.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
[createWorkingHours([1, 2, 3, 4, 5])] // Monday to Friday, 9am-5pm
);
expect(prismaMock.userHolidaySettings.findUnique).toHaveBeenCalledWith({
where: { userId: 123 },
expect(mockHolidayRepo.findUserSettingsSelect).toHaveBeenCalledWith({
userId: 123,
select: {
countryCode: true,
disabledIds: true,
@@ -54,33 +65,25 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
});
it("should return empty object when user has no country selected", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
mockHolidayRepo.findUserSettingsSelect.mockResolvedValue({
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] }]
[createWorkingHours([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,
mockHolidayRepo.findUserSettingsSelect.mockResolvedValue({
countryCode: "US",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
mockHolidayService.getHolidayDatesInRange.mockResolvedValue([]);
@@ -89,7 +92,7 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
123,
new Date("2025-02-01"),
new Date("2025-02-28"),
[{ days: [1, 2, 3, 4, 5] }]
[createWorkingHours([1, 2, 3, 4, 5])]
);
// Dates are expanded to full day range (startOfDay to endOfDay in UTC)
@@ -108,13 +111,9 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
});
it("should return holiday data for dates that match user's working days", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
mockHolidayRepo.findUserSettingsSelect.mockResolvedValue({
countryCode: "US",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
// Wednesday, January 1, 2025 - New Year's Day
@@ -134,7 +133,7 @@ describe("UserAvailabilityService.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)
[createWorkingHours([1, 2, 3, 4, 5])] // Monday(1) to Friday(5) - includes Wednesday(3)
);
expect(result).toEqual({
@@ -142,19 +141,15 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
fromUser: null,
toUser: null,
reason: "New Year's Day",
emoji: "📆",
emoji: "🎆",
},
});
});
it("should skip holidays that fall on non-working days", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
mockHolidayRepo.findUserSettingsSelect.mockResolvedValue({
countryCode: "US",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
// Saturday, December 25, 2027 - Christmas Day
@@ -178,7 +173,7 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
123,
new Date("2027-12-01"),
new Date("2027-12-31"),
[{ days: [1, 2, 3, 4, 5] }]
[createWorkingHours([1, 2, 3, 4, 5])]
);
// Christmas 2027 is on Saturday (day 6), should be skipped
@@ -187,13 +182,9 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
});
it("should handle multiple holidays correctly", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
mockHolidayRepo.findUserSettingsSelect.mockResolvedValue({
countryCode: "US",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
mockHolidayService.getHolidayDatesInRange.mockResolvedValue([
@@ -221,7 +212,7 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
123,
new Date("2025-01-01"),
new Date("2025-01-31"),
[{ days: [1, 2, 3, 4, 5] }]
[createWorkingHours([1, 2, 3, 4, 5])]
);
expect(result).toEqual({
@@ -229,25 +220,21 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
fromUser: null,
toUser: null,
reason: "New Year's Day",
emoji: "📆",
emoji: "🎆",
},
"2025-01-20": {
fromUser: null,
toUser: null,
reason: "Martin Luther King Jr. Day",
emoji: "📆",
emoji: "✊",
},
});
});
it("should handle availability with multiple schedules", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
mockHolidayRepo.findUserSettingsSelect.mockResolvedValue({
countryCode: "US",
disabledIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
// Thursday, July 4, 2024 - Independence Day
@@ -269,8 +256,8 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
new Date("2024-07-01"),
new Date("2024-07-31"),
[
{ days: [1, 3] }, // Monday, Wednesday only
{ days: [2, 4] }, // Tuesday, Thursday - includes the holiday
createWorkingHours([1, 3]), // Monday, Wednesday only
createWorkingHours([2, 4]), // Tuesday, Thursday - includes the holiday
]
);
@@ -279,19 +266,15 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
fromUser: null,
toUser: null,
reason: "Independence Day",
emoji: "📆",
emoji: "🎆",
},
});
});
it("should respect disabled holidays", async () => {
prismaMock.userHolidaySettings.findUnique.mockResolvedValue({
id: 1,
userId: 123,
mockHolidayRepo.findUserSettingsSelect.mockResolvedValue({
countryCode: "US",
disabledIds: ["christmas_2025"],
createdAt: new Date(),
updatedAt: new Date(),
});
// The holiday service should receive the disabledIds and filter them out
@@ -312,7 +295,7 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
123,
new Date("2025-01-01"),
new Date("2025-12-31"),
[{ days: [1, 2, 3, 4, 5] }]
[createWorkingHours([1, 2, 3, 4, 5])]
);
// Verify disabledIds were passed to the service
@@ -329,9 +312,8 @@ describe("UserAvailabilityService.calculateHolidayBlockedDates", () => {
fromUser: null,
toUser: null,
reason: "New Year's Day",
emoji: "📆",
emoji: "🎆",
},
});
});
});
@@ -11,6 +11,7 @@ import {
} from "@calcom/features/busyTimes/lib/getBusyTimesFromLimits";
import { getBusyTimesService } from "@calcom/features/di/containers/BusyTimes";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import type { PrismaHolidayRepository } from "@calcom/features/holidays/repositories/PrismaHolidayRepository";
import type { PrismaOOORepository } from "@calcom/features/ooo/repositories/PrismaOOORepository";
import type { IRedisService } from "@calcom/features/redis/IRedisService";
import type { DateOverride, WorkingHours } from "@calcom/features/schedules/lib/date-ranges";
@@ -19,6 +20,7 @@ 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 { getHolidayEmoji } from "@calcom/lib/holidays/getHolidayEmoji";
import { HttpError } from "@calcom/lib/http-error";
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
@@ -26,7 +28,6 @@ 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,
@@ -165,6 +166,7 @@ export interface IUserAvailabilityService {
oooRepo: PrismaOOORepository;
bookingRepo: BookingRepository;
redisClient: IRedisService;
holidayRepo: PrismaHolidayRepository;
}
export class UserAvailabilityService {
@@ -709,8 +711,8 @@ export class UserAvailabilityService {
endDate: Date,
availability: GetUserAvailabilityParamsDTO["availability"]
): Promise<IOutOfOfficeData> {
const holidaySettings = await prisma.userHolidaySettings.findUnique({
where: { userId },
const holidaySettings = await this.dependencies.holidayRepo.findUserSettingsSelect({
userId,
select: {
countryCode: true,
disabledIds: true,
@@ -760,7 +762,7 @@ export class UserAvailabilityService {
fromUser: null,
toUser: null,
reason: holiday.name,
emoji: "📆",
emoji: getHolidayEmoji(holiday.name),
};
}
@@ -12,6 +12,7 @@ import { eventTypeRepositoryModule } from "../modules/EventType";
import { featuresRepositoryModule } from "../modules/Features";
import { filterHostsModule } from "../modules/FilterHosts";
import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
import { holidayRepositoryModule } from "../modules/Holiday";
import { noSlotsNotificationModule } from "../modules/NoSlotsNotification";
import { oooRepositoryModule } from "../modules/Ooo";
import { qualifiedHostsModule } from "../modules/QualifiedHosts";
@@ -32,6 +33,7 @@ container.load(DI_TOKENS.MEMBERSHIP_REPOSITORY_MODULE, membershipRepositoryModul
container.load(DI_TOKENS.USER_REPOSITORY_MODULE, userRepositoryModule);
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule);
container.load(DI_TOKENS.HOLIDAY_REPOSITORY_MODULE, holidayRepositoryModule);
container.load(DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY_MODULE, routingFormResponseRepositoryModule);
container.load(DI_TOKENS.FEATURES_REPOSITORY_MODULE, featuresRepositoryModule);
container.load(DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE_MODULE, checkBookingLimitsModule);
@@ -8,6 +8,7 @@ import { bookingRepositoryModule } from "../modules/Booking";
import { busyTimesModule } from "../modules/BusyTimes";
import { eventTypeRepositoryModule } from "../modules/EventType";
import { getUserAvailabilityModule } from "../modules/GetUserAvailability";
import { holidayRepositoryModule } from "../modules/Holiday";
import { oooRepositoryModule } from "../modules/Ooo";
const container = createContainer();
@@ -15,6 +16,7 @@ container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule);
container.load(DI_TOKENS.HOLIDAY_REPOSITORY_MODULE, holidayRepositoryModule);
container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule);
container.load(DI_TOKENS.BUSY_TIMES_SERVICE_MODULE, busyTimesModule);
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
@@ -10,4 +10,5 @@ getUserAvailabilityModule.bind(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE).toClass(
bookingRepo: DI_TOKENS.BOOKING_REPOSITORY,
eventTypeRepo: DI_TOKENS.EVENT_TYPE_REPOSITORY,
redisClient: DI_TOKENS.REDIS_CLIENT,
holidayRepo: DI_TOKENS.HOLIDAY_REPOSITORY,
} satisfies Record<keyof IUserAvailabilityService, symbol>);
+17
View File
@@ -0,0 +1,17 @@
import { DI_TOKENS } from "@calcom/features/di/tokens";
import { PrismaHolidayRepository } from "@calcom/features/holidays/repositories/PrismaHolidayRepository";
import { type Container, createModule } from "../di";
export const holidayRepositoryModule = createModule();
const token = DI_TOKENS.HOLIDAY_REPOSITORY;
const moduleToken = DI_TOKENS.HOLIDAY_REPOSITORY_MODULE;
holidayRepositoryModule.bind(token).toClass(PrismaHolidayRepository, [DI_TOKENS.PRISMA_CLIENT]);
export const moduleLoader = {
token,
loadModule: function (container: Container) {
container.load(moduleToken, holidayRepositoryModule);
},
};
+2
View File
@@ -55,6 +55,8 @@ export const DI_TOKENS = {
LUCKY_USER_SERVICE_MODULE: Symbol("LuckyUserServiceModule"),
HOST_REPOSITORY: Symbol("HostRepository"),
HOST_REPOSITORY_MODULE: Symbol("HostRepositoryModule"),
HOLIDAY_REPOSITORY: Symbol("HolidayRepository"),
HOLIDAY_REPOSITORY_MODULE: Symbol("HolidayRepositoryModule"),
ATTRIBUTE_REPOSITORY: Symbol("AttributeRepository"),
ATTRIBUTE_REPOSITORY_MODULE: Symbol("AttributeRepositoryModule"),
MEMBERSHIP_SERVICE: Symbol("MembershipService"),
@@ -0,0 +1,19 @@
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
export class PrismaHolidayRepository {
constructor(private prismaClient: PrismaClient) {}
async findUserSettingsSelect<T extends Prisma.UserHolidaySettingsSelect>({
userId,
select,
}: {
userId: number;
select: T;
}) {
return this.prismaClient.userHolidaySettings.findUnique({
where: { userId },
select,
});
}
}
@@ -32,12 +32,12 @@ const CreateNewOutOfOfficeEntryButton = ({
<Button
color="primary"
size={size ?? "base"}
className="flex items-center justify-between px-4"
className="flex items-center justify-between px-2 md:px-4"
StartIcon="plus"
onClick={onClick}
data-testid={rest["data-testid"]}
disabled={selectedTab === OutOfOfficeTab.TEAM && !hasTeamOOOAdminAccess}>
{t("add")}
<span className="sr-only md:not-sr-only md:inline">{t("add")}</span>
</Button>
);
};
@@ -1,11 +1,11 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useCompatSearchParams } from "@calcom/embed-core/src/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ToggleGroup } from "@calcom/ui/components/form";
import { Select, ToggleGroup } from "@calcom/ui/components/form";
export enum OutOfOfficeTab {
MINE = "mine",
@@ -31,22 +31,47 @@ export const OutOfOfficeToggleGroup = () => {
const selectedTab = searchParams?.get("type") ?? OutOfOfficeTab.MINE;
const toggleGroupOptions = [
{ value: OutOfOfficeTab.MINE, label: t("my_ooo") },
{ value: OutOfOfficeTab.TEAM, label: t("team_ooo") },
{ value: OutOfOfficeTab.HOLIDAYS, label: t("holidays") },
];
const tabOptions = useMemo(
() => [
{ value: OutOfOfficeTab.MINE, label: t("my_ooo") },
{ value: OutOfOfficeTab.TEAM, label: t("team_ooo") },
{ value: OutOfOfficeTab.HOLIDAYS, label: t("holidays") },
],
[t]
);
const handleTabChange = useCallback(
(value: string | null) => {
if (!value) return;
const newQuery = createQueryString("type", value);
router.push(`${pathname}?${newQuery}`);
},
[createQueryString, pathname, router]
);
const selectedOption = useMemo(
() => tabOptions.find((opt) => opt.value === selectedTab) ?? tabOptions[0],
[selectedTab, tabOptions]
);
return (
<ToggleGroup
className="hidden md:block"
defaultValue={selectedTab}
onValueChange={(value) => {
if (!value) return;
const newQuery = createQueryString("type", value);
router.push(`${pathname}?${newQuery}`);
}}
options={toggleGroupOptions}
/>
<>
{/* Mobile: Select dropdown */}
<div className="md:hidden">
<Select
className="w-[120px]"
value={selectedOption}
onChange={(option) => handleTabChange(option?.value ?? null)}
options={tabOptions}
/>
</div>
{/* Desktop: Toggle group */}
<ToggleGroup
className="hidden md:flex"
defaultValue={selectedTab}
onValueChange={handleTabChange}
options={tabOptions}
/>
</>
);
};
+14 -6
View File
@@ -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
+98
View File
@@ -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 };
@@ -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";
-2
View File
@@ -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` */