* 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
275 lines
8.6 KiB
TypeScript
275 lines
8.6 KiB
TypeScript
import dayjs from "@calcom/dayjs";
|
|
import { HolidayRepository } from "@calcom/lib/server/repository/HolidayRepository";
|
|
|
|
import {
|
|
getHolidayServiceCachingProxy,
|
|
type CachedHoliday,
|
|
type HolidayServiceCachingProxy,
|
|
} from "./HolidayServiceCachingProxy";
|
|
import { CONFLICT_CHECK_MONTHS, GOOGLE_HOLIDAY_CALENDARS } from "./constants";
|
|
import type { Country, Holiday, HolidayWithStatus } from "./types";
|
|
|
|
export interface ConflictingBooking {
|
|
id: number;
|
|
title: string;
|
|
startTime: Date;
|
|
endTime: Date;
|
|
attendeeName: string | null;
|
|
}
|
|
|
|
export interface HolidayConflict {
|
|
holidayId: string;
|
|
holidayName: string;
|
|
date: string;
|
|
bookings: ConflictingBooking[];
|
|
}
|
|
|
|
export class HolidayService {
|
|
private cachingProxy: HolidayServiceCachingProxy;
|
|
private countriesCache: Country[] | null = null;
|
|
|
|
constructor(cachingProxy?: HolidayServiceCachingProxy) {
|
|
this.cachingProxy = cachingProxy || getHolidayServiceCachingProxy();
|
|
}
|
|
|
|
getSupportedCountries(): Country[] {
|
|
if (this.countriesCache) {
|
|
return this.countriesCache;
|
|
}
|
|
|
|
this.countriesCache = Object.entries(GOOGLE_HOLIDAY_CALENDARS).map(([code, config]) => ({
|
|
code,
|
|
name: config.name,
|
|
}));
|
|
|
|
return this.countriesCache;
|
|
}
|
|
|
|
isSupportedCountry(countryCode: string): boolean {
|
|
return countryCode in GOOGLE_HOLIDAY_CALENDARS;
|
|
}
|
|
|
|
async getUserSettings(
|
|
userId: number
|
|
): Promise<{ countryCode: string | null; holidays: HolidayWithStatus[] }> {
|
|
const settings = await HolidayRepository.findUserSettingsSelect({
|
|
userId,
|
|
select: { countryCode: true, disabledIds: true },
|
|
});
|
|
|
|
if (!settings?.countryCode) {
|
|
return { countryCode: null, holidays: [] };
|
|
}
|
|
|
|
const holidays = await this.getHolidaysWithStatus(settings.countryCode, settings.disabledIds);
|
|
return { countryCode: settings.countryCode, holidays };
|
|
}
|
|
|
|
async updateSettings(
|
|
userId: number,
|
|
countryCode: string | null,
|
|
resetDisabledHolidays: boolean
|
|
): Promise<{ countryCode: string | null; holidays: HolidayWithStatus[] }> {
|
|
if (countryCode && !this.isSupportedCountry(countryCode)) {
|
|
throw new Error("Invalid country code");
|
|
}
|
|
|
|
const settings = await HolidayRepository.upsertUserSettings({
|
|
userId,
|
|
countryCode,
|
|
resetDisabledHolidays,
|
|
});
|
|
|
|
if (settings.countryCode) {
|
|
const holidays = await this.getHolidaysWithStatus(settings.countryCode, settings.disabledIds);
|
|
return { countryCode: settings.countryCode, holidays };
|
|
}
|
|
|
|
return { countryCode: null, holidays: [] };
|
|
}
|
|
|
|
private cachedHolidayToHoliday(cached: CachedHoliday): Holiday {
|
|
return {
|
|
id: cached.eventId,
|
|
name: cached.name,
|
|
// Use UTC to ensure consistent date formatting regardless of server timezone
|
|
// Holiday dates are stored as UTC midnight (e.g., 2025-12-25T00:00:00.000Z)
|
|
date: dayjs(cached.date).utc().format("YYYY-MM-DD"),
|
|
year: cached.year,
|
|
};
|
|
}
|
|
|
|
async getHolidaysForCountry(countryCode: string, year?: number): Promise<Holiday[]> {
|
|
const targetYear = year || dayjs().year();
|
|
const cached = await this.cachingProxy.getHolidaysForCountry(countryCode, targetYear);
|
|
return cached.map((h) => this.cachedHolidayToHoliday(h));
|
|
}
|
|
|
|
async getHolidaysWithStatus(countryCode: string, disabledIds: string[]): Promise<HolidayWithStatus[]> {
|
|
const currentYear = dayjs().year();
|
|
const nextYear = currentYear + 1;
|
|
|
|
const [currentYearHolidays, nextYearHolidays] = await Promise.all([
|
|
this.cachingProxy.getHolidaysForCountry(countryCode, currentYear),
|
|
this.cachingProxy.getHolidaysForCountry(countryCode, nextYear),
|
|
]);
|
|
|
|
const allHolidays = [...currentYearHolidays, ...nextYearHolidays];
|
|
const disabledSet = new Set(disabledIds);
|
|
const today = dayjs().utc().startOf("day");
|
|
|
|
return allHolidays
|
|
.filter((h) => dayjs(h.date).utc().isAfter(today) || dayjs(h.date).utc().isSame(today))
|
|
.map((h) => ({
|
|
id: h.eventId,
|
|
name: h.name,
|
|
// Use UTC for consistent date formatting
|
|
date: dayjs(h.date).utc().format("YYYY-MM-DD"),
|
|
year: h.year,
|
|
enabled: !disabledSet.has(h.eventId),
|
|
}))
|
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
}
|
|
|
|
async getHolidayDatesInRange(
|
|
countryCode: string,
|
|
disabledIds: string[],
|
|
startDate: Date,
|
|
endDate: Date
|
|
): Promise<Array<{ date: string; holiday: Holiday }>> {
|
|
const disabledSet = new Set(disabledIds);
|
|
|
|
const holidays = await this.cachingProxy.getHolidaysInRange(countryCode, startDate, endDate);
|
|
|
|
return holidays
|
|
.filter((h) => !disabledSet.has(h.eventId))
|
|
.map((h) => ({
|
|
// Use UTC for consistent date formatting
|
|
date: dayjs(h.date).utc().format("YYYY-MM-DD"),
|
|
holiday: this.cachedHolidayToHoliday(h),
|
|
}))
|
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
}
|
|
|
|
async hasHolidaysInRange(
|
|
countryCode: string,
|
|
disabledIds: string[],
|
|
startDate: Date,
|
|
endDate: Date
|
|
): Promise<boolean> {
|
|
const holidays = await this.getHolidayDatesInRange(countryCode, disabledIds, startDate, endDate);
|
|
return holidays.length > 0;
|
|
}
|
|
|
|
async toggleHoliday(
|
|
userId: number,
|
|
holidayId: string,
|
|
enabled: boolean
|
|
): Promise<{ countryCode: string; holidays: HolidayWithStatus[] }> {
|
|
const settings = await HolidayRepository.findUserSettingsSelect({
|
|
userId,
|
|
select: { countryCode: true, disabledIds: true },
|
|
});
|
|
|
|
if (!settings?.countryCode) {
|
|
throw new Error("No holiday country selected");
|
|
}
|
|
|
|
// 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");
|
|
}
|
|
|
|
let disabledIds = [...settings.disabledIds];
|
|
if (enabled) {
|
|
disabledIds = disabledIds.filter((id) => id !== holidayId);
|
|
} else if (!disabledIds.includes(holidayId)) {
|
|
disabledIds.push(holidayId);
|
|
}
|
|
|
|
await HolidayRepository.updateDisabledIds({ userId, disabledIds });
|
|
|
|
const updatedHolidays = await this.getHolidaysWithStatus(settings.countryCode, disabledIds);
|
|
return { countryCode: settings.countryCode, holidays: updatedHolidays };
|
|
}
|
|
|
|
async checkConflicts(
|
|
userId: number,
|
|
countryCode: string,
|
|
disabledIds: string[]
|
|
): Promise<{ conflicts: HolidayConflict[] }> {
|
|
if (!countryCode) {
|
|
return { conflicts: [] };
|
|
}
|
|
|
|
const startDate = new Date();
|
|
const endDate = dayjs().add(CONFLICT_CHECK_MONTHS, "months").toDate();
|
|
|
|
const holidayDates = await this.getHolidayDatesInRange(countryCode, disabledIds, startDate, endDate);
|
|
if (holidayDates.length === 0) {
|
|
return { conflicts: [] };
|
|
}
|
|
|
|
const dateRanges = holidayDates.map((h) => ({
|
|
start: dayjs.utc(h.date).startOf("day").toDate(),
|
|
end: dayjs.utc(h.date).endOf("day").toDate(),
|
|
}));
|
|
|
|
const bookings = await HolidayRepository.findBookingsInDateRanges({ userId, dateRanges });
|
|
if (bookings.length === 0) {
|
|
return { conflicts: [] };
|
|
}
|
|
|
|
const bookingsWithTimestamps = bookings.map((b) => ({
|
|
...b,
|
|
startTimestamp: b.startTime.getTime(),
|
|
endTimestamp: b.endTime.getTime(),
|
|
}));
|
|
|
|
const conflicts: HolidayConflict[] = [];
|
|
|
|
for (const holidayDate of holidayDates) {
|
|
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
|
|
);
|
|
|
|
if (conflictingBookings.length > 0) {
|
|
conflicts.push({
|
|
holidayId: holidayDate.holiday.id,
|
|
holidayName: holidayDate.holiday.name,
|
|
date: holidayDate.date,
|
|
bookings: conflictingBookings.map((b) => ({
|
|
id: b.id,
|
|
title: b.title,
|
|
startTime: b.startTime,
|
|
endTime: b.endTime,
|
|
attendeeName: b.attendees[0]?.name || null,
|
|
})),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { conflicts };
|
|
}
|
|
}
|
|
|
|
let defaultService: HolidayService | null = null;
|
|
|
|
export function getHolidayService(): HolidayService {
|
|
if (!defaultService) {
|
|
defaultService = new HolidayService();
|
|
}
|
|
return defaultService;
|
|
}
|