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:
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+115
-99
@@ -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>);
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
Vendored
-2
@@ -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` */
|
||||
|
||||
Reference in new issue
Block a user