465 lines
13 KiB
TypeScript
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()],
|
|
};
|
|
}
|