Files
CapaKraken/packages/api/src/lib/holiday-availability.ts
T

465 lines
13 KiB
TypeScript

import { getPublicHolidays, type AbsenceDay } from "@capakraken/shared";
type VacationLike = {
startDate: Date;
endDate: Date;
type: string;
isHalfDay: boolean;
};
type HolidayAvailabilityInput = {
vacations: VacationLike[];
periodStart: Date;
periodEnd: Date;
countryCode?: string | null | undefined;
federalState?: string | null | undefined;
metroCityName?: string | null | undefined;
resolvedHolidayStrings?: string[] | undefined;
};
type HolidayAvailabilityResult = {
absenceDateStrings: string[];
publicHolidayStrings: string[];
absenceDays: AbsenceDay[];
};
export type CalendarHoliday = {
date: string;
name: string;
scope: "COUNTRY" | "STATE" | "CITY";
};
type CalendarScope = CalendarHoliday["scope"];
type HolidayCalendarEntryRecord = {
date: Date;
name: string;
isRecurringAnnual: boolean;
};
type HolidayCalendarRecord = {
id: string;
name: string;
scopeType: CalendarScope;
priority: number;
createdAt?: Date;
entries: HolidayCalendarEntryRecord[];
};
type HolidayResolverDb = {
[key: string]: unknown;
country?: {
findUnique: (args: any) => any;
};
metroCity?: {
findUnique: (args: any) => any;
};
holidayCalendar?: {
findMany: (args: any) => any;
};
};
type ResolvedHoliday = CalendarHoliday & {
calendarName: string;
priority: number;
sourceType: "BUILTIN" | "CUSTOM";
};
export function asHolidayResolverDb(db: unknown): HolidayResolverDb {
return db as HolidayResolverDb;
}
export function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
type CityHolidayRule = {
countryCode: string;
cityName: string;
resolveDates: (year: number) => string[];
};
const CITY_HOLIDAY_RULES: CityHolidayRule[] = [
{
countryCode: "DE",
cityName: "Augsburg",
resolveDates: (year) => [`${year}-08-08`],
},
];
const SCOPE_WEIGHT: Record<CalendarScope, number> = {
COUNTRY: 1,
STATE: 2,
CITY: 3,
};
function normalizeCityName(cityName?: string | null): string | null {
const normalized = cityName?.trim().toLowerCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function normalizeStateCode(stateCode?: string | null): string | null {
const normalized = stateCode?.trim().toUpperCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function resolveCalendarEntries(
calendars: HolidayCalendarRecord[],
periodStart: Date,
periodEnd: Date,
): ResolvedHoliday[] {
const startYear = periodStart.getUTCFullYear();
const endYear = periodEnd.getUTCFullYear();
const startIso = toIsoDate(periodStart);
const endIso = toIsoDate(periodEnd);
const resolved = new Map<string, ResolvedHoliday>();
for (const calendar of calendars) {
for (const entry of calendar.entries) {
const baseDate = new Date(entry.date);
for (let year = startYear; year <= endYear; year += 1) {
const effectiveDate = entry.isRecurringAnnual
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
: baseDate;
const key = toIsoDate(effectiveDate);
if (key < startIso || key > endIso) {
if (!entry.isRecurringAnnual) {
break;
}
continue;
}
const candidate: ResolvedHoliday = {
date: key,
name: entry.name,
scope: calendar.scopeType,
calendarName: calendar.name,
priority: calendar.priority,
sourceType: "CUSTOM",
};
const existing = resolved.get(key);
if (
!existing
|| SCOPE_WEIGHT[candidate.scope] > SCOPE_WEIGHT[existing.scope]
|| (
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
&& candidate.priority > existing.priority
)
|| (
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
&& candidate.priority === existing.priority
&& existing.sourceType === "BUILTIN"
)
) {
resolved.set(key, candidate);
}
if (!entry.isRecurringAnnual) {
break;
}
}
}
}
return [...resolved.values()].sort((left, right) => left.date.localeCompare(right.date));
}
function mergeResolvedHolidays(
builtInHolidays: CalendarHoliday[],
customHolidays: ResolvedHoliday[],
): ResolvedHoliday[] {
const merged = new Map<string, ResolvedHoliday>();
for (const holiday of builtInHolidays) {
merged.set(holiday.date, {
...holiday,
calendarName: "System",
priority: Number.MIN_SAFE_INTEGER,
sourceType: "BUILTIN",
});
}
for (const holiday of customHolidays) {
const existing = merged.get(holiday.date);
if (
!existing
|| SCOPE_WEIGHT[holiday.scope] > SCOPE_WEIGHT[existing.scope]
|| (
SCOPE_WEIGHT[holiday.scope] === SCOPE_WEIGHT[existing.scope]
&& holiday.priority >= existing.priority
)
) {
merged.set(holiday.date, holiday);
}
}
return [...merged.values()].sort((left, right) => left.date.localeCompare(right.date));
}
async function loadScopedHolidayCalendars(
db: HolidayResolverDb,
input: {
countryId?: string | null | undefined;
stateCode?: string | null | undefined;
metroCityId?: string | null | undefined;
},
): Promise<HolidayCalendarRecord[]> {
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
return [];
}
const stateCode = normalizeStateCode(input.stateCode);
const metroCityId = input.metroCityId?.trim() || null;
return db.holidayCalendar.findMany({
where: {
isActive: true,
countryId: input.countryId,
OR: [
{ scopeType: "COUNTRY" },
...(stateCode ? [{ scopeType: "STATE" as const, stateCode }] : []),
...(metroCityId ? [{ scopeType: "CITY" as const, metroCityId }] : []),
],
},
include: { entries: true },
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
});
}
export function getCalendarHolidayStrings(
periodStart: Date,
periodEnd: Date,
countryCode?: string | null,
federalState?: string | null,
metroCityName?: string | null,
): string[] {
return getCalendarHolidays(
periodStart,
periodEnd,
countryCode,
federalState,
metroCityName,
).map((holiday) => holiday.date);
}
export function getCalendarHolidays(
periodStart: Date,
periodEnd: Date,
countryCode?: string | null,
federalState?: string | null,
metroCityName?: string | null,
): CalendarHoliday[] {
const startYear = periodStart.getUTCFullYear();
const endYear = periodEnd.getUTCFullYear();
const holidays = new Map<string, CalendarHoliday>();
if (countryCode === "DE") {
for (let year = startYear; year <= endYear; year += 1) {
for (const holiday of getPublicHolidays(year, federalState ?? undefined)) {
if (holiday.date >= toIsoDate(periodStart) && holiday.date <= toIsoDate(periodEnd)) {
holidays.set(holiday.date, {
date: holiday.date,
name: holiday.name,
scope: holiday.federal ? "COUNTRY" : "STATE",
});
}
}
}
}
const normalizedCityName = normalizeCityName(metroCityName);
if (countryCode && normalizedCityName) {
for (const rule of CITY_HOLIDAY_RULES) {
if (
rule.countryCode === countryCode
&& normalizeCityName(rule.cityName) === normalizedCityName
) {
for (let year = startYear; year <= endYear; year += 1) {
for (const holidayDate of rule.resolveDates(year)) {
if (holidayDate >= toIsoDate(periodStart) && holidayDate <= toIsoDate(periodEnd)) {
holidays.set(holidayDate, {
date: holidayDate,
name: "Augsburger Friedensfest",
scope: "CITY",
});
}
}
}
}
}
}
return [...holidays.values()].sort((left, right) => left.date.localeCompare(right.date));
}
export async function getResolvedCalendarHolidays(
db: HolidayResolverDb,
input: {
periodStart: Date;
periodEnd: Date;
countryId?: string | null | undefined;
countryCode?: string | null | undefined;
federalState?: string | null | undefined;
metroCityId?: string | null | undefined;
metroCityName?: string | null | undefined;
},
): Promise<ResolvedHoliday[]> {
let countryCode = input.countryCode ?? null;
if (!countryCode && input.countryId && typeof db.country?.findUnique === "function") {
const country = await db.country.findUnique({
where: { id: input.countryId },
select: { code: true },
});
countryCode = country?.code ?? null;
}
let metroCityName = input.metroCityName ?? null;
if (!metroCityName && input.metroCityId && typeof db.metroCity?.findUnique === "function") {
const metroCity = await db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
});
metroCityName = metroCity?.name ?? null;
}
const builtIn = getCalendarHolidays(
input.periodStart,
input.periodEnd,
countryCode,
input.federalState,
metroCityName,
);
const calendars = await loadScopedHolidayCalendars(db, {
countryId: input.countryId,
stateCode: input.federalState,
metroCityId: input.metroCityId,
});
const custom = resolveCalendarEntries(calendars, input.periodStart, input.periodEnd);
return mergeResolvedHolidays(builtIn, custom);
}
export async function getResolvedCalendarHolidayStrings(
db: HolidayResolverDb,
input: {
periodStart: Date;
periodEnd: Date;
countryId?: string | null | undefined;
countryCode?: string | null | undefined;
federalState?: string | null | undefined;
metroCityId?: string | null | undefined;
metroCityName?: string | null | undefined;
},
): Promise<string[]> {
const holidays = await getResolvedCalendarHolidays(db, input);
return holidays.map((holiday) => holiday.date);
}
export function collectHolidayAvailability(
input: HolidayAvailabilityInput,
): HolidayAvailabilityResult {
const periodStartIso = toIsoDate(input.periodStart);
const periodEndIso = toIsoDate(input.periodEnd);
const publicHolidaySet = new Set(
input.resolvedHolidayStrings
? input.resolvedHolidayStrings.filter((date) => date >= periodStartIso && date <= periodEndIso)
: getCalendarHolidayStrings(
input.periodStart,
input.periodEnd,
input.countryCode,
input.federalState,
input.metroCityName,
),
);
const absenceDateSet = new Set<string>();
const absenceDayMap = new Map<string, AbsenceDay>();
for (const isoDate of publicHolidaySet) {
absenceDayMap.set(isoDate, {
date: new Date(`${isoDate}T00:00:00.000Z`),
type: "PUBLIC_HOLIDAY",
});
}
for (const vacation of input.vacations) {
if (vacation.type !== "PUBLIC_HOLIDAY") {
continue;
}
const overlapStart = new Date(
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
);
const overlapEnd = new Date(
Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
);
if (overlapStart > overlapEnd) {
continue;
}
const cursor = new Date(overlapStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(overlapEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const isoDate = toIsoDate(cursor);
publicHolidaySet.add(isoDate);
absenceDayMap.set(isoDate, {
date: new Date(cursor),
type: "PUBLIC_HOLIDAY",
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
});
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
for (const vacation of input.vacations) {
if (vacation.type === "PUBLIC_HOLIDAY") {
continue;
}
const overlapStart = new Date(
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
);
const overlapEnd = new Date(
Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
);
if (overlapStart > overlapEnd) {
continue;
}
const cursor = new Date(overlapStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(overlapEnd);
end.setUTCHours(0, 0, 0, 0);
const triggerType = vacation.type === "SICK" ? "SICK" : "VACATION";
while (cursor <= end) {
const isoDate = toIsoDate(cursor);
if (!publicHolidaySet.has(isoDate)) {
absenceDateSet.add(isoDate);
absenceDayMap.set(isoDate, {
date: new Date(cursor),
type: triggerType,
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
});
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
return {
absenceDateStrings: [...absenceDateSet].sort(),
publicHolidayStrings: [...publicHolidaySet].sort(),
absenceDays: [...absenceDayMap.values()],
};
}